Better estimated remaining time

This commit is contained in:
2026-08-07 16:51:00 +02:00
parent 988cb7958c
commit 16e3325f8f
2 changed files with 73 additions and 31 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ class MicrowaveScreen:
if cooking_state.paused: if cooking_state.paused:
self.h_centered_text("Paused", 40, 1) self.h_centered_text("Paused", 40, 1)
elif cooking_state.state != CookingStates.DONE: 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 minutes = remaining_time // 60
seconds = remaining_time % 60 seconds = remaining_time % 60
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1) self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
+72 -30
View File
@@ -3,6 +3,7 @@ import time
class CookingState: class CookingState:
TEMPERATURE_TOLERANCE = 1.0 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): 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 self.cook_time = cook_time
@@ -27,6 +28,9 @@ class CookingState:
self._last_refresh_signature = None self._last_refresh_signature = None
self._stirred = False self._stirred = False
# Moving average filter for heating rate (°C / sec)
self._smoothed_heating_rate = 0.0
def set_temperature_provider(self, temperature_provider): def set_temperature_provider(self, temperature_provider):
self.temperature_provider = temperature_provider self.temperature_provider = temperature_provider
@@ -35,7 +39,7 @@ class CookingState:
def set_refresh_callback(self, callback): def set_refresh_callback(self, callback):
self.on_refresh = callback self.on_refresh = callback
def set_pause_callback(self, callback): def set_pause_callback(self, callback):
self.on_pause = callback self.on_pause = callback
@@ -45,9 +49,9 @@ class CookingState:
self.paused = True self.paused = True
self._pause_started_at = time.time() self._pause_started_at = time.time()
# self._notify_refresh(force=True)
if self.on_pause: if self.on_pause:
self.on_pause(self) self.on_pause(self)
def unpause(self): def unpause(self):
if not self.paused: if not self.paused:
return return
@@ -56,16 +60,15 @@ class CookingState:
if self._pause_started_at is not None: if self._pause_started_at is not None:
self._paused_duration += now - self._pause_started_at self._paused_duration += now - self._pause_started_at
# self._pause_started_at = None
self.paused = False self.paused = False
# self._notify_refresh(force=True)
def toggle_pause(self): def toggle_pause(self):
if self.paused: if self.paused:
self.unpause() self.unpause()
else: else:
self.pause() self.pause()
self.on_pause(self) if self.on_pause:
self.on_pause(self)
def set_state(self, state): def set_state(self, state):
if self.state == state: if self.state == state:
@@ -90,20 +93,56 @@ class CookingState:
def get_remaining_time_estimation(self) -> float: def get_remaining_time_estimation(self) -> float:
elapsed_time = self.get_elapsed_time() 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) 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 return timer_remaining
if self.current_dish_temp >= self.target_temp: # 2. Prevent hitting 00:00 before stirring trigger:
return timer_remaining # 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() heating_rate = self._estimate_heating_rate()
if heating_rate <= 0: temp_needed = self.target_temp - self.current_dish_temp
return timer_remaining
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate if heating_rate > 0.01:
return max(timer_remaining, max(0.0, target_remaining)) 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): def _read_temperatures(self):
if self.temperature_provider is None: if self.temperature_provider is None:
@@ -118,27 +157,29 @@ class CookingState:
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)") 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: 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 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 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): def _notify_state_change(self):
if self.on_state_change is None: if self.on_state_change is None:
return return
self.on_state_change(self) self.on_state_change(self)
def _notify_refresh(self, force=False): def _notify_refresh(self, force=False):
@@ -177,21 +218,22 @@ class CookingState:
now = time.time() now = time.time()
elapsed_time = self.get_elapsed_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 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.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE 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.state = CookingStates.STIRRING_REQUIRED
self.pause() 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.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: if self.state != previous_state:
self._notify_state_change() self._notify_state_change()