From e9f879a40eea4b05a9b57e5fbb46f7cb99ce3896 Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Wed, 31 Dec 2025 10:59:22 -0800 Subject: [PATCH 1/9] develop thermal probe actions branch --- src/devices/eeprom.py | 9 ------- src/devices/thermal_probe.py | 14 +++++++++- .../view_menu/view_thermal_correction.py | 2 +- tests/devices/eeprom_test.py | 17 ------------ tests/devices/thermal_probe_test.py | 27 ++++++------------- 5 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/devices/eeprom.py b/src/devices/eeprom.py index fb743bc..4e0ff0d 100644 --- a/src/devices/eeprom.py +++ b/src/devices/eeprom.py @@ -18,7 +18,6 @@ def __init__(self): self._ki_value = 28.0 self._kp_value = 20.0 self._tank_id = 1 - self._thermal_correction = 12 def get_google_sheet_interval(self, default): """ @@ -68,14 +67,6 @@ def get_tank_id(self, default): return default return self._tank_id - def get_thermal_correction(self, default): - """ - Get the thermal correction value from EEPROM - """ - if self._thermal_correction is None: - return default - return float(self._thermal_correction) - def set_google_sheet_interval(self, value): """ Set the google sheet interval in EEPROM diff --git a/src/devices/thermal_probe.py b/src/devices/thermal_probe.py index 98f1fa9..c3fa495 100644 --- a/src/devices/thermal_probe.py +++ b/src/devices/thermal_probe.py @@ -14,4 +14,16 @@ def __init__(self, eeprom): """ self.eeprom = eeprom - self.correction = self.eeprom.get_thermal_correction(0.0) + self._correction = 12 + + def get_thermal_correction(self): + """ + Get the thermal correction value from EEPROM + """ + return float(self._correction) + + def set_thermal_correction(self, value): + """ + Set the thermal correction value in EEPROM + """ + self._correction = value diff --git a/src/ui_state/view_menu/view_thermal_correction.py b/src/ui_state/view_menu/view_thermal_correction.py index 978994e..097668a 100644 --- a/src/ui_state/view_menu/view_thermal_correction.py +++ b/src/ui_state/view_menu/view_thermal_correction.py @@ -23,7 +23,7 @@ def loop(self): The loop function for the ViewThermalCorrection class """ self.titrator.lcd.print("Temp Cal Offset:", line=1) - self.titrator.lcd.print(f"{self.titrator.thermal_probe.correction}", line=2) + self.titrator.lcd.print(f"{self.titrator.thermal_probe.get_thermal_correction()}", line=2) def handle_key(self, key): """ diff --git a/tests/devices/eeprom_test.py b/tests/devices/eeprom_test.py index 1ef5752..9d1b814 100644 --- a/tests/devices/eeprom_test.py +++ b/tests/devices/eeprom_test.py @@ -93,23 +93,6 @@ def test_set_pid_values(): assert eeprom.get_kd(0.0) == 35.0 -def test_thermal_correction_value(): - """ - The function to test the default thermal_correction_address value - """ - eeprom = EEPROM() - assert eeprom.get_thermal_correction(0.0) == 12 - - -def test_save_thermal_correction_value(): - """ - The function to test setting the thermal_correction_address value - """ - eeprom = EEPROM() - eeprom._thermal_correction = 2.5 - assert eeprom.get_thermal_correction(0.0) == 2.5 - - def test_tank_id_value(): """ The function to test the default tank_id value diff --git a/tests/devices/thermal_probe_test.py b/tests/devices/thermal_probe_test.py index c4ef5d7..435deff 100644 --- a/tests/devices/thermal_probe_test.py +++ b/tests/devices/thermal_probe_test.py @@ -2,30 +2,19 @@ The file to test the ThermalProbe class """ -from unittest import mock +from unittest.mock import Mock -from src.devices.eeprom import EEPROM from src.devices.thermal_probe import ThermalProbe -def test_thermal_probe_reads_values_from_eeprom(): +def test_get_and_set_thermal_correction(): """ The function to test the creation of a ThermalProbe """ - eeprom = EEPROM() - eeprom._thermal_correction = 1.1 - with mock.patch("src.devices.eeprom.EEPROM", return_value=eeprom): - thermal_probe = ThermalProbe(eeprom) + mock_eeprom = Mock() + thermal_probe = ThermalProbe(mock_eeprom) + thermal_probe._correction = 1.1 + assert thermal_probe.get_thermal_correction() == 1.1 - assert thermal_probe.correction == 1.1 - - -def test_thermal_probe_defaults_from_none(): - """ThermalProbe should read a numeric float from EEPROM.""" - eeprom = EEPROM() - eeprom._thermal_correction = None - - with mock.patch("src.devices.eeprom.EEPROM", return_value=eeprom): - thermal_probe = ThermalProbe(eeprom) - - assert thermal_probe.correction == 0.0 + thermal_probe.set_thermal_correction(2.2) + assert thermal_probe.get_thermal_correction() == 2.2 From 276e5f6fbc832650ec4bce47285cc6dfb48c1958 Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Wed, 31 Dec 2025 19:34:14 -0800 Subject: [PATCH 2/9] Add set calibration and clear --- src/devices/thermal_probe.py | 6 +++++ .../set_menu/set_thermal_calibration.py | 22 ++++++++++++++-- .../set_menu/set_thermal_calibration_clear.py | 25 +++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/devices/thermal_probe.py b/src/devices/thermal_probe.py index c3fa495..3ebff60 100644 --- a/src/devices/thermal_probe.py +++ b/src/devices/thermal_probe.py @@ -22,6 +22,12 @@ def get_thermal_correction(self): """ return float(self._correction) + def clear_thermal_correction(self): + """ + Clear the thermal correction value in EEPROM + """ + self._correction = 0 + def set_thermal_correction(self, value): """ Set the thermal correction value in EEPROM diff --git a/src/ui_state/set_menu/set_thermal_calibration.py b/src/ui_state/set_menu/set_thermal_calibration.py index aa05ed1..e213a69 100644 --- a/src/ui_state/set_menu/set_thermal_calibration.py +++ b/src/ui_state/set_menu/set_thermal_calibration.py @@ -2,10 +2,28 @@ The file to hold the Set Thermal Calibration class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class SetThermalCalibration(UIState): +class SetThermalCalibration(UserValue): """ This is a class for the SetThermalCalibration state of the Tank Controller """ + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.thermal_probe.get_thermal_correction()) + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Real Temperature" + + def save_value(self): + """ + Saves the thermal calibration value to the thermal probe. + """ + self.titrator.thermal_probe.set_thermal_correction(self.value) + self.titrator.lcd.print(f"New correction={self.titrator.thermal_probe.get_thermal_correction()}", line=2) + self.return_to_main_menu(ms_delay=3000) diff --git a/src/ui_state/set_menu/set_thermal_calibration_clear.py b/src/ui_state/set_menu/set_thermal_calibration_clear.py index dcc7abd..285bf78 100644 --- a/src/ui_state/set_menu/set_thermal_calibration_clear.py +++ b/src/ui_state/set_menu/set_thermal_calibration_clear.py @@ -2,10 +2,31 @@ The file to hold the ThermalCalibrationClear class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class ResetThermalCalibration(UIState): +class ResetThermalCalibration(UserValue): """ This is a class for the ThermalCalibration state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "A: Clear TempCal" + + def handle_key(self, key): + """ + Handles key presses and updates the display accordingly. + """ + if key == "A": + self.titrator.thermal_probe.clear_thermal_correction() + self.titrator.lcd.print("Cleared TempCali", line=1) + self.return_to_main_menu(ms_delay=3000) + else: + super().handle_key(key) From dd809f7ec72721b3e6bc97a0e2a83cbeb536a913 Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Sat, 3 Jan 2026 23:29:18 -0800 Subject: [PATCH 3/9] add thermal control and related actions --- src/devices/eeprom.py | 15 -- src/devices/thermal_control.py | 132 ++++++++++++++++++ src/devices/thermal_probe.py | 51 +++++++ src/titrator.py | 4 + src/ui_state/set_menu/set_chill_or_heat.py | 8 +- .../set_menu/set_thermal_calibration.py | 6 +- src/ui_state/set_menu/set_thermal_target.py | 48 ++++++- .../view_menu/view_thermal_correction.py | 4 +- tests/devices/eeprom_test.py | 26 ---- tests/devices/thermal_control_test.py | 92 ++++++++++++ tests/devices/thermal_probe_test.py | 54 ++++++- .../set_menu/set_chill_or_heat_test.py | 6 +- 12 files changed, 393 insertions(+), 53 deletions(-) create mode 100644 src/devices/thermal_control.py create mode 100644 tests/devices/thermal_control_test.py diff --git a/src/devices/eeprom.py b/src/devices/eeprom.py index 4e0ff0d..dfcd106 100644 --- a/src/devices/eeprom.py +++ b/src/devices/eeprom.py @@ -13,7 +13,6 @@ def __init__(self): The constructor function for the EEPROM class """ self._google_sheet_interval = 20 - self._heat = bool(True) self._kd_value = 36.0 self._ki_value = 28.0 self._kp_value = 20.0 @@ -27,14 +26,6 @@ def get_google_sheet_interval(self, default): return default return self._google_sheet_interval - def get_heat(self, default): - """ - Get the heat setting from EEPROM - """ - if self._heat is None: - return default - return self._heat - def get_kd(self, default): """ Get the Kd value from EEPROM @@ -73,12 +64,6 @@ def set_google_sheet_interval(self, value): """ self._google_sheet_interval = value - def set_heat(self, value): - """ - Set the heat setting in EEPROM - """ - self._heat = value - def set_kd(self, value): """ Set the Kd value in EEPROM diff --git a/src/devices/thermal_control.py b/src/devices/thermal_control.py new file mode 100644 index 0000000..969d61b --- /dev/null +++ b/src/devices/thermal_control.py @@ -0,0 +1,132 @@ +""" +A file for the ThermalControl class +""" + +import time + + +class ThermalControl: + """ + The class for the ThermalControl + """ + + FLAT_TYPE = 0 + RAMP_TYPE = 1 + SINE_TYPE = 2 + + def __init__(self, titrator): + """ + The constructor function for the ThermalControl class + """ + self.titrator = titrator + self._heat = bool(True) + self._base_thermal_target = 78 + self._current_thermal_target = 67 + self._thermal_function_type = ThermalControl.FLAT_TYPE + self._ramp_time_start_seconds = 0 + self._ramp_time_end_seconds = 0 + self._ramp_initial_value = 0.0 + + def get_heat(self, default): + """ + Get the heat setting from EEPROM + """ + if self._heat is None: + return default + return self._heat + + def set_heat(self, value): + """ + Set the heat setting in EEPROM + """ + self._heat = value + + def get_base_thermal_target(self): + """ + Get the base thermal target + """ + return self._base_thermal_target + + def set_base_thermal_target(self, value): + """ + Set the base thermal target + """ + self._base_thermal_target = value + + def get_current_thermal_target(self): + """ + Get the current thermal target + """ + return self._current_thermal_target + + def set_current_thermal_target(self, value): + """ + Set the current thermal target + """ + self._current_thermal_target = value + + def get_thermal_function_type(self): + """ + Get the current thermal function type. + """ + return self._thermal_function_type + + def set_thermal_function_type(self, function_type): + """ + Set the current thermal function type. + """ + if function_type in ( + ThermalControl.FLAT_TYPE, + ThermalControl.RAMP_TYPE, + ThermalControl.SINE_TYPE, + ): + self._thermal_function_type = function_type + else: + raise ValueError("Invalid thermal function type") + + def get_ramp_time_start(self): + """ + Get the ramp time start in seconds. + """ + return ( + self._ramp_time_start_seconds + if self._thermal_function_type != ThermalControl.FLAT_TYPE + else 0 + ) + + def get_ramp_time_end(self): + """ + Get the ramp time end in seconds. + """ + return ( + self._ramp_time_end_seconds + if self._thermal_function_type != ThermalControl.FLAT_TYPE + else 0 + ) + + def set_ramp_duration_hours(self, new_ph_ramp_duration): + """ + Set the ramp duration in hours. If the duration is greater than 0, configure ramp parameters; + otherwise, set the function type to FLAT_TYPE. + """ + if new_ph_ramp_duration > 0: + current_ramp_time = ( + self._ramp_time_end_seconds - self._ramp_time_start_seconds + ) + current_ramp_time_str = f"{current_ramp_time:.3f}" + new_ramp_duration_str = f"{new_ph_ramp_duration:.3f}" + print( + f"Change ramp time from {current_ramp_time_str} to {new_ramp_duration_str}" + ) + + self._ramp_time_start_seconds = int(time.monotonic()) + self._ramp_time_end_seconds = self._ramp_time_start_seconds + int( + new_ph_ramp_duration * 3600 + ) + + self._ramp_initial_value = self.titrator.thermal_probe.get_running_average() + self._thermal_function_type = ThermalControl.RAMP_TYPE + else: + self._ramp_time_end_seconds = 0 + self._thermal_function_type = ThermalControl.FLAT_TYPE + print("Set ramp time to 0") diff --git a/src/devices/thermal_probe.py b/src/devices/thermal_probe.py index 3ebff60..8811c19 100644 --- a/src/devices/thermal_probe.py +++ b/src/devices/thermal_probe.py @@ -2,12 +2,16 @@ The file for the ThermalProbe class """ +import time + class ThermalProbe: """ The class for the ThermalProbe """ + HISTORY_SIZE = 10 + def __init__(self, eeprom): """ The constructor function for the ThermalProbe class @@ -16,6 +20,11 @@ def __init__(self, eeprom): self._correction = 12 + self.history = [0.0] * self.HISTORY_SIZE + self.history_index = 0 + self.first_time = True + self.last_time = 0 + def get_thermal_correction(self): """ Get the thermal correction value from EEPROM @@ -33,3 +42,45 @@ def set_thermal_correction(self, value): Set the thermal correction value in EEPROM """ self._correction = value + + def get_uncorrected_running_average(self): + """ + Calculate the uncorrected running average of temperature readings. + """ + current_time = time.time() # Get current time in seconds + if ( + self.first_time or self.last_time + 1 <= current_time + ): # Check if 1 second has passed + temperature = self.get_raw_temperature() + if self.first_time: + # Initialize the history buffer with the first temperature reading + self.history = [temperature] * self.HISTORY_SIZE + self.first_time = False + + # Update the history buffer with the new temperature reading + self.history_index = (self.history_index + 1) % self.HISTORY_SIZE + self.history[self.history_index] = temperature + self.last_time = current_time + + # Calculate the average of the history buffer, ignoring unused slots + valid_readings = self.history[: self.history_index + 1] + return sum(valid_readings) / len(valid_readings) + + def get_running_average(self): + """ + Return the corrected running average within the range of 00.00-99.99 + """ + temperature = self.get_uncorrected_running_average() + self._correction + if temperature < 0.0: + temperature = 0.0 + elif temperature > 99.99: + temperature = 99.99 + return temperature + + def get_raw_temperature(self): + """ + Simulate reading the raw temperature from a sensor. + In a real implementation, this method would interface with hardware. + """ + # Placeholder for actual sensor reading logic + return 25.0 # return thermo.temperature(RTDnominal, refResistor); diff --git a/src/titrator.py b/src/titrator.py index 34f193c..f71e009 100644 --- a/src/titrator.py +++ b/src/titrator.py @@ -19,6 +19,7 @@ from src.devices.ph_control import PHControl from src.devices.pid import PID from src.devices.sd import SD +from src.devices.thermal_control import ThermalControl from src.devices.thermal_probe import ThermalProbe from src.ui_state.main_menu import MainMenu from src.version import VERSION @@ -51,6 +52,9 @@ def __init__(self): # Initialize PH Control self.ph_control = PHControl() + # Initialize Thermal Control + self.thermal_control = ThermalControl(self) + # Initialize Thermal Probe self.thermal_probe = ThermalProbe(self.eeprom) diff --git a/src/ui_state/set_menu/set_chill_or_heat.py b/src/ui_state/set_menu/set_chill_or_heat.py index 3971d27..72a2ed2 100644 --- a/src/ui_state/set_menu/set_chill_or_heat.py +++ b/src/ui_state/set_menu/set_chill_or_heat.py @@ -21,7 +21,7 @@ def loop(self): """ self.titrator.lcd.print("1:Chill; 9:Heat", line=1) - if self.titrator.eeprom.get_heat(True): + if self.titrator.thermal_control.get_heat(True): self.titrator.lcd.print("Currently: Heat", line=2) else: self.titrator.lcd.print("Currently: Chill", line=2) @@ -31,17 +31,17 @@ def handle_key(self, key): Handle key presses to return to the previous state. """ if key == Keypad.KEY_1: - self.titrator.eeprom.set_heat(False) + self.titrator.thermal_control.set_heat(False) self.titrator.lcd.print("Use chiller", line=2) self.return_to_main_menu(ms_delay=3000) if key == Keypad.KEY_9: - self.titrator.eeprom.set_heat(True) + self.titrator.thermal_control.set_heat(True) self.titrator.lcd.print("Use heater", line=2) self.return_to_main_menu(ms_delay=3000) if key == Keypad.KEY_A: - if self.titrator.eeprom.get_heat(True): + if self.titrator.thermal_control.get_heat(True): self.titrator.lcd.print("Use heater", line=2) else: self.titrator.lcd.print("Use chiller", line=2) diff --git a/src/ui_state/set_menu/set_thermal_calibration.py b/src/ui_state/set_menu/set_thermal_calibration.py index e213a69..44a4028 100644 --- a/src/ui_state/set_menu/set_thermal_calibration.py +++ b/src/ui_state/set_menu/set_thermal_calibration.py @@ -9,6 +9,7 @@ class SetThermalCalibration(UserValue): """ This is a class for the SetThermalCalibration state of the Tank Controller """ + def __init__(self, titrator, previous_state=None): super().__init__(titrator, previous_state) self.previous_state = previous_state @@ -25,5 +26,8 @@ def save_value(self): Saves the thermal calibration value to the thermal probe. """ self.titrator.thermal_probe.set_thermal_correction(self.value) - self.titrator.lcd.print(f"New correction={self.titrator.thermal_probe.get_thermal_correction()}", line=2) + self.titrator.lcd.print( + f"New correction={self.titrator.thermal_probe.get_thermal_correction()}", + line=2, + ) self.return_to_main_menu(ms_delay=3000) diff --git a/src/ui_state/set_menu/set_thermal_target.py b/src/ui_state/set_menu/set_thermal_target.py index 93b6901..97a162b 100644 --- a/src/ui_state/set_menu/set_thermal_target.py +++ b/src/ui_state/set_menu/set_thermal_target.py @@ -2,10 +2,54 @@ The file to hold the Set Thermal Target class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class SetThermalTarget(UIState): +class SetThermalTarget(UserValue): """ This is a class for the SetThermalTarget state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + """ + Constructor for the SetThermalTarget class + """ + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.prompts = ["Set Temperature", "Set ramp hours:"] + self.values = [0.0] * 2 + self.sub_state = 0 + + def get_label(self): + """ + Returns the label for the user value input. + """ + return self.prompts[self.sub_state] + + def save_value(self): + """ + Saves the entered value for the current sub-state and advances to the next sub-state. + """ + self.values[self.sub_state] = float(self.value) + self.sub_state += 1 + + if self.sub_state < len(self.values): + self.value = "" + else: + self.titrator.thermal_control.set_base_thermal_target(self.values[0]) + self.titrator.thermal_control.set_ramp_duration_hours(self.values[1]) + + self.titrator.lcd.print(f"New Temp={self.values[0]:.2f}", line=1) + self.titrator.lcd.print(f"New Ramp={self.values[1]:.2f}", line=2) + + self.return_to_main_menu(ms_delay=3000) + + def handle_key(self, key): + """ + Handles key presses and updates the display accordingly. + """ + if key == "A" and self.value not in ("", "."): + self.save_value() + self.value = "" + else: + super().handle_key(key) diff --git a/src/ui_state/view_menu/view_thermal_correction.py b/src/ui_state/view_menu/view_thermal_correction.py index 097668a..6b2cb61 100644 --- a/src/ui_state/view_menu/view_thermal_correction.py +++ b/src/ui_state/view_menu/view_thermal_correction.py @@ -23,7 +23,9 @@ def loop(self): The loop function for the ViewThermalCorrection class """ self.titrator.lcd.print("Temp Cal Offset:", line=1) - self.titrator.lcd.print(f"{self.titrator.thermal_probe.get_thermal_correction()}", line=2) + self.titrator.lcd.print( + f"{self.titrator.thermal_probe.get_thermal_correction()}", line=2 + ) def handle_key(self, key): """ diff --git a/tests/devices/eeprom_test.py b/tests/devices/eeprom_test.py index 9d1b814..315872f 100644 --- a/tests/devices/eeprom_test.py +++ b/tests/devices/eeprom_test.py @@ -31,32 +31,6 @@ def test_set_google_sheet_interval(): assert eeprom.get_google_sheet_interval(65535) == 60 -def test_default_heat_value(): - """ - The function to test the default heat value - """ - eeprom = EEPROM() - assert eeprom.get_heat(False) is True - - -def test_save_heat_value(): - """ - The function to test setting the heat value - """ - eeprom = EEPROM() - eeprom._heat = False - assert eeprom.get_heat(True) is False - - -def test_set_heat(): - """ - The function to test setting the heat via setter - """ - eeprom = EEPROM() - eeprom.set_heat(False) - assert eeprom.get_heat(True) is False - - def test_pid_values(): """ The function to test the PID values diff --git a/tests/devices/thermal_control_test.py b/tests/devices/thermal_control_test.py new file mode 100644 index 0000000..013de8a --- /dev/null +++ b/tests/devices/thermal_control_test.py @@ -0,0 +1,92 @@ +""" +The file to test the PH Control class +""" + +from unittest.mock import Mock + +from src.devices.thermal_control import ThermalControl + + +def test_default_heat_value(): + """ + The function to test the default heat value + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + assert thermal_control.get_heat(False) is True + + +def test_save_heat_value(): + """ + The function to test setting the heat value + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + thermal_control._heat = False + assert thermal_control.get_heat(True) is False + + +def test_set_heat(): + """ + The function to test setting the heat via setter + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + thermal_control.set_heat(False) + assert thermal_control.get_heat(True) is False + + +def test_get_and_set_base_thermal_target(): + """ + Test getting and setting the base thermal target value. + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + thermal_control.set_base_thermal_target(75.0) + assert thermal_control.get_base_thermal_target() == 75.0 + + +def test_get_and_set_current_thermal_target(): + """ + Test getting and setting the current thermal target value. + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + thermal_control.set_current_thermal_target(68.5) + assert thermal_control.get_current_thermal_target() == 68.5 + + +def test_get_and_set_thermal_function_type(): + """ + Test getting and setting the thermal function type. + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + + assert thermal_control.get_thermal_function_type() == ThermalControl.FLAT_TYPE + + thermal_control.set_thermal_function_type(ThermalControl.RAMP_TYPE) + assert thermal_control.get_thermal_function_type() == ThermalControl.RAMP_TYPE + + try: + thermal_control.set_thermal_function_type(99) + except ValueError as err: + assert str(err) == "Invalid thermal function type" + + +def test_set_ramp_duration_hours(): + """ + Test setting the ramp duration in hours. + """ + mock_titrator = Mock() + mock_titrator.thermal_probe.get_running_average = Mock(return_value=70.0) + thermal_control = ThermalControl(mock_titrator) + + thermal_control.set_ramp_duration_hours(3.0) + assert thermal_control.get_ramp_time_start() > 0 + assert thermal_control.get_ramp_time_end() > thermal_control.get_ramp_time_start() + assert thermal_control.get_thermal_function_type() == ThermalControl.RAMP_TYPE + + thermal_control.set_ramp_duration_hours(0) + assert thermal_control.get_ramp_time_end() == 0 + assert thermal_control.get_thermal_function_type() == ThermalControl.FLAT_TYPE diff --git a/tests/devices/thermal_probe_test.py b/tests/devices/thermal_probe_test.py index 435deff..a65999f 100644 --- a/tests/devices/thermal_probe_test.py +++ b/tests/devices/thermal_probe_test.py @@ -2,7 +2,7 @@ The file to test the ThermalProbe class """ -from unittest.mock import Mock +from unittest.mock import Mock, patch from src.devices.thermal_probe import ThermalProbe @@ -18,3 +18,55 @@ def test_get_and_set_thermal_correction(): thermal_probe.set_thermal_correction(2.2) assert thermal_probe.get_thermal_correction() == 2.2 + + +def test_clear_thermal_correction(): + """ + Test clearing the thermal correction value. + """ + mock_eeprom = Mock() + thermal_probe = ThermalProbe(mock_eeprom) + thermal_probe.set_thermal_correction(5.0) + assert thermal_probe.get_thermal_correction() == 5.0 + + thermal_probe.clear_thermal_correction() + assert thermal_probe.get_thermal_correction() == 0.0 + + +@patch("src.devices.thermal_probe.ThermalProbe.get_raw_temperature", return_value=30.0) +def test_get_uncorrected_running_average(_mock_get_raw_temperature): + """ + Test calculating the uncorrected running average. + """ + mock_eeprom = Mock() + thermal_probe = ThermalProbe(mock_eeprom) + + # Simulate multiple readings + for _ in range(thermal_probe.HISTORY_SIZE): + thermal_probe.get_uncorrected_running_average() + + # The running average should equal the raw temperature since all values are the same + assert thermal_probe.get_uncorrected_running_average() == 30.0 + + +@patch("time.time", side_effect=[0, 1, 2, 3, 4]) # Simulate time in seconds +@patch( + "src.devices.thermal_probe.ThermalProbe.get_raw_temperature", + side_effect=[25.0, 30.0, 35.0, 35.0, 35.0], +) +def test_get_running_average(_mock_get_raw_temperature, _mock_time): + """ + Test calculating the corrected running average. + """ + mock_eeprom = Mock() + thermal_probe = ThermalProbe(mock_eeprom) + thermal_probe.set_thermal_correction(5.0) + + # Simulate multiple readings + thermal_probe.get_uncorrected_running_average() # First reading: 25.0 + thermal_probe.get_uncorrected_running_average() # Second reading: 30.0 + thermal_probe.get_uncorrected_running_average() # Third reading: 35.0 + + # The running average should be the average of [25.0, 30.0, 35.0] + correction + expected_average = (25.0 + 30.0 + 35.0) / 3 + 5.0 + assert thermal_probe.get_running_average() == expected_average diff --git a/tests/ui_state/set_menu/set_chill_or_heat_test.py b/tests/ui_state/set_menu/set_chill_or_heat_test.py index e867afc..4db7bcd 100644 --- a/tests/ui_state/set_menu/set_chill_or_heat_test.py +++ b/tests/ui_state/set_menu/set_chill_or_heat_test.py @@ -32,7 +32,7 @@ def test_set_heat(print_mock): print_mock.assert_any_call("1:Chill; 9:Heat", line=1) state.handle_key("9") - assert titrator.ph_control.use_pid is True + assert titrator.thermal_control.get_heat(False) is True print_mock.assert_any_call("Use heater", line=2) assert isinstance(titrator.state, Wait) @@ -51,7 +51,7 @@ def test_set_chill(print_mock): print_mock.assert_any_call("1:Chill; 9:Heat", line=1) state.handle_key("1") - assert titrator.eeprom.get_heat(True) is False + assert titrator.thermal_control.get_heat(True) is False print_mock.assert_any_call("Use chiller", line=2) assert isinstance(titrator.state, Wait) @@ -72,7 +72,7 @@ def test_handle_key_a(print_mock): state.handle_key("A") print_mock.assert_any_call("Use heater", line=2) - assert titrator.eeprom.get_heat(True) is True + assert titrator.thermal_control.get_heat(True) is True assert isinstance(titrator.state, Wait) assert isinstance(titrator.state.next_state, MainMenu) From dd2041070e6e5e51d1711e670dbdd82f21564b84 Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Mon, 5 Jan 2026 10:41:05 -0800 Subject: [PATCH 4/9] add set therm sine and make thermal related tests --- src/devices/thermal_control.py | 31 ++++++ .../set_menu/set_thermal_sine_wave.py | 51 ++++++++- tests/devices/thermal_control_test.py | 21 ++++ .../{set_tank_id.py => set_tank_id_test.py} | 0 .../set_thermal_calibration_clear_test.py | 52 +++++++++ .../set_menu/set_thermal_calibration_test.py | 70 ++++++++++++ .../set_menu/set_thermal_sine_wave_test.py | 101 ++++++++++++++++++ .../set_menu/set_thermal_target_test.py | 91 ++++++++++++++++ 8 files changed, 415 insertions(+), 2 deletions(-) rename tests/ui_state/set_menu/{set_tank_id.py => set_tank_id_test.py} (100%) create mode 100644 tests/ui_state/set_menu/set_thermal_calibration_clear_test.py create mode 100644 tests/ui_state/set_menu/set_thermal_calibration_test.py create mode 100644 tests/ui_state/set_menu/set_thermal_sine_wave_test.py create mode 100644 tests/ui_state/set_menu/set_thermal_target_test.py diff --git a/src/devices/thermal_control.py b/src/devices/thermal_control.py index 969d61b..301956b 100644 --- a/src/devices/thermal_control.py +++ b/src/devices/thermal_control.py @@ -26,6 +26,8 @@ def __init__(self, titrator): self._ramp_time_start_seconds = 0 self._ramp_time_end_seconds = 0 self._ramp_initial_value = 0.0 + self._amplitude = 0.0 + self._period_in_seconds = 0 def get_heat(self, default): """ @@ -130,3 +132,32 @@ def set_ramp_duration_hours(self, new_ph_ramp_duration): self._ramp_time_end_seconds = 0 self._thermal_function_type = ThermalControl.FLAT_TYPE print("Set ramp time to 0") + + def get_amplitude(self): + """ + Get the amplitude for the pH function. + """ + return self._amplitude + + def set_amplitude(self, amplitude): + """ + Set the amplitude for the pH function. + """ + self._amplitude = amplitude + + def get_period_in_seconds(self): + """ + Get the period in seconds for the pH function. + """ + return self._period_in_seconds + + def set_sine_amplitude_and_hours(self, amplitude, period_in_hours): + """ + Set the amplitude and period (in hours) for the sine wave pH function. + """ + if amplitude > 0 and period_in_hours > 0: + self._amplitude = amplitude + self._period_in_seconds = int(period_in_hours * 3600) + self._thermal_function_type = ThermalControl.SINE_TYPE + else: + raise ValueError("Amp and period !> than 0.") diff --git a/src/ui_state/set_menu/set_thermal_sine_wave.py b/src/ui_state/set_menu/set_thermal_sine_wave.py index 64d1dc4..cb7bc0b 100644 --- a/src/ui_state/set_menu/set_thermal_sine_wave.py +++ b/src/ui_state/set_menu/set_thermal_sine_wave.py @@ -2,10 +2,57 @@ The file to hold the Set Thermal Sine Wave class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class SetThermalSineWave(UIState): +class SetThermalSineWave(UserValue): """ This is a class for the SetThermalSineWave state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + """ + Constructor for the SetThermalTarget class + """ + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.prompts = ["Set T Set Point", "Set Amplitude:", "Set Period hrs:"] + self.values = [0.0] * 3 + self.sub_state = 0 + + def get_label(self): + """ + Returns the label for the user value input. + """ + return self.prompts[self.sub_state] + + def save_value(self): + """ + Saves the entered value for the current sub-state and advances to the next sub-state. + """ + self.values[self.sub_state] = float(self.value) + self.sub_state += 1 + + if self.sub_state < len(self.values): + self.value = "" + else: + self.titrator.thermal_control.set_base_thermal_target(self.values[0]) + self.titrator.thermal_control.set_sine_amplitude_and_hours( + self.values[1], self.values[2] + ) + temperature = f"New Temp={self.values[0]:.2f}" + amplitude_and_period = f"A={self.values[1]:.2f} P={self.values[2]:.3f}" + self.titrator.lcd.print(temperature, line=1) + self.titrator.lcd.print(amplitude_and_period, line=2) + + self.return_to_main_menu(ms_delay=3000) + + def handle_key(self, key): + """ + Handles key presses and updates the display accordingly. + """ + if key == "A" and self.value not in ("", "."): + self.save_value() + self.value = "" + else: + super().handle_key(key) diff --git a/tests/devices/thermal_control_test.py b/tests/devices/thermal_control_test.py index 013de8a..48f934a 100644 --- a/tests/devices/thermal_control_test.py +++ b/tests/devices/thermal_control_test.py @@ -68,6 +68,9 @@ def test_get_and_set_thermal_function_type(): thermal_control.set_thermal_function_type(ThermalControl.RAMP_TYPE) assert thermal_control.get_thermal_function_type() == ThermalControl.RAMP_TYPE + thermal_control.set_thermal_function_type(ThermalControl.SINE_TYPE) + assert thermal_control.get_thermal_function_type() == ThermalControl.SINE_TYPE + try: thermal_control.set_thermal_function_type(99) except ValueError as err: @@ -90,3 +93,21 @@ def test_set_ramp_duration_hours(): thermal_control.set_ramp_duration_hours(0) assert thermal_control.get_ramp_time_end() == 0 assert thermal_control.get_thermal_function_type() == ThermalControl.FLAT_TYPE + + +def test_set_sine_amplitude_and_hours(): + """ + Test setting the sine amplitude and period in hours. + """ + mock_titrator = Mock() + thermal_control = ThermalControl(mock_titrator) + + thermal_control.set_sine_amplitude_and_hours(1.5, 4) + assert thermal_control.get_amplitude() == 1.5 + assert thermal_control.get_period_in_seconds() == 14400 + assert thermal_control.get_thermal_function_type() == ThermalControl.SINE_TYPE + + try: + thermal_control.set_sine_amplitude_and_hours(-1, 4) + except ValueError as err: + assert str(err) == "Amp and period !> than 0." diff --git a/tests/ui_state/set_menu/set_tank_id.py b/tests/ui_state/set_menu/set_tank_id_test.py similarity index 100% rename from tests/ui_state/set_menu/set_tank_id.py rename to tests/ui_state/set_menu/set_tank_id_test.py diff --git a/tests/ui_state/set_menu/set_thermal_calibration_clear_test.py b/tests/ui_state/set_menu/set_thermal_calibration_clear_test.py new file mode 100644 index 0000000..f43be99 --- /dev/null +++ b/tests/ui_state/set_menu/set_thermal_calibration_clear_test.py @@ -0,0 +1,52 @@ +""" +The file to test the ResetPHCalibration class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.main_menu import MainMenu +from src.ui_state.set_menu.set_thermal_calibration_clear import ( + ResetThermalCalibration, +) + + +@mock.patch.object(LiquidCrystal, "print") +def test_reset_ph_calibration(print_mock): + """ + Test that pressing 'A' clears pH calibration and transitions to ViewPHCalibration. + """ + titrator = Titrator() + state = ResetThermalCalibration(titrator, MainMenu(titrator)) + titrator.thermal_probe.clear_thermal_correction = mock.Mock() + + state.loop() + print_mock.assert_any_call("A: Clear TempCal", line=1) + + state.handle_key("A") + titrator.thermal_probe.clear_thermal_correction.assert_called_once() + print_mock.assert_any_call("Cleared TempCali", line=1) + + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_calibration_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = ResetThermalCalibration(titrator, MainMenu(titrator)) + + assert state.get_label() == "A: Clear TempCal" + + +def test_handle_key_d(): + """ + The function to test the reset handle keys + """ + titrator = Titrator() + titrator.state = ResetThermalCalibration(titrator, MainMenu(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_thermal_calibration_test.py b/tests/ui_state/set_menu/set_thermal_calibration_test.py new file mode 100644 index 0000000..3c1929c --- /dev/null +++ b/tests/ui_state/set_menu/set_thermal_calibration_test.py @@ -0,0 +1,70 @@ +""" +The file to test the SetThermalCalibration class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.main_menu import MainMenu +from src.ui_state.set_menu.set_thermal_calibration import SetThermalCalibration + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_thermal_calibration_valid_input(print_mock): + """ + Unittest that entering a valid tank ID sets EEPROM and shows confirmation. + """ + titrator = Titrator() + titrator.thermal_probe.set_thermal_correction(2.5) + state = SetThermalCalibration(titrator, MainMenu(titrator)) + assert state.value == "2.5" + + state.save_value() + assert titrator.thermal_probe.get_thermal_correction() == 2.5 + print_mock.assert_any_call("New correction=2.5", line=2) + + assert isinstance(titrator.state.next_state, MainMenu) + + +@mock.patch.object(LiquidCrystal, "print") +def test_user_thermal_calibration_string_input(print_mock): + """ + Test entering a tank ID value through the UserValue interface. + """ + titrator = Titrator() + state = SetThermalCalibration(titrator, MainMenu(titrator)) + + state.handle_key("C") + assert state.value == "" + state.handle_key("1") + assert state.value == "1" + state.handle_key("0") + assert state.value == "10" + state.handle_key("A") + + assert titrator.thermal_probe.get_thermal_correction() == 10 + print_mock.assert_any_call("New correction=10.0", line=2) + + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_thermal_calibration_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = SetThermalCalibration(titrator, MainMenu(titrator)) + + assert state.get_label() == "Real Temperature" + + +def test_handle_key_d(): + """ + The function to test the reset handle keys + """ + titrator = Titrator() + titrator.state = SetThermalCalibration(titrator, MainMenu(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_thermal_sine_wave_test.py b/tests/ui_state/set_menu/set_thermal_sine_wave_test.py new file mode 100644 index 0000000..71df1b3 --- /dev/null +++ b/tests/ui_state/set_menu/set_thermal_sine_wave_test.py @@ -0,0 +1,101 @@ +""" +Test file for the Set Thermal Sine Wave UI state +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.main_menu import MainMenu +from src.ui_state.set_menu.set_thermal_sine_wave import SetThermalSineWave + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_thermal_sine_wave_handle_key(print_mock): + """ + Test that handle_key processes key presses correctly. + """ + titrator = Titrator() + state = SetThermalSineWave(titrator, MainMenu(titrator)) + + state.loop() + print_mock.assert_any_call("Set T Set Point", line=1) + + state.handle_key("7") + state.handle_key("A") + + assert state.sub_state == 1 + assert state.values[0] == 7 + + state.loop() + print_mock.assert_any_call("Set Amplitude:", line=1) + print_mock.assert_any_call("", style="center", line=2) + + +def test_set_thermal_sine_wave_advances_substates(): + """ + Test that the state advances through substates correctly. + """ + titrator = Titrator() + state = SetThermalSineWave(titrator, MainMenu(titrator)) + + state.value = "7.5" + state.save_value() + assert state.sub_state == 1 + state.value = "2.0" + state.save_value() + assert state.sub_state == 2 + state.value = "24.0" + state.save_value() + assert state.sub_state == 3 + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_thermal_sine_wave_valid_input(print_mock): + """ + Test that valid pH mean, amplitude, and period inputs are saved and displayed correctly. + """ + titrator = Titrator() + titrator.thermal_control = mock.Mock() + state = SetThermalSineWave(titrator, MainMenu(titrator)) + + state.value = "7.5" + state.save_value() + state.value = "2.0" + state.save_value() + state.value = "24.0" + state.save_value() + + titrator.thermal_control.set_base_thermal_target.assert_called_once_with(7.5) + titrator.thermal_control.set_sine_amplitude_and_hours.assert_called_once_with( + 2.0, 24.0 + ) + + print_mock.assert_any_call("New Temp=7.50", line=1) + print_mock.assert_any_call("A=2.00 P=24.000", line=2) + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_thermal_sine_wave_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = SetThermalSineWave(titrator, MainMenu(titrator)) + + assert state.get_label() == "Set T Set Point" + state.sub_state = 1 + assert state.get_label() == "Set Amplitude:" + state.sub_state = 2 + assert state.get_label() == "Set Period hrs:" + + +def test_handle_key_d(): + """ + Test that entering 'D' returns to the main menu. + """ + titrator = Titrator() + state = SetThermalSineWave(titrator, MainMenu(titrator)) + + state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_thermal_target_test.py b/tests/ui_state/set_menu/set_thermal_target_test.py new file mode 100644 index 0000000..8794c8d --- /dev/null +++ b/tests/ui_state/set_menu/set_thermal_target_test.py @@ -0,0 +1,91 @@ +""" +Test suite for the SetThermalTarget class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.main_menu import MainMenu +from src.ui_state.set_menu.set_thermal_target import SetThermalTarget + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_thermal_target_handle_key(print_mock): + """ + Test that handle_key processes key presses correctly. + """ + titrator = Titrator() + state = SetThermalTarget(titrator, MainMenu(titrator)) + + state.loop() + print_mock.assert_any_call("Set Temperature", line=1) + + state.handle_key("7") + state.handle_key("A") + assert state.sub_state == 1 + assert state.values[0] == 7 + + state.loop() + print_mock.assert_any_call("Set ramp hours:", line=1) + print_mock.assert_any_call("", style="center", line=2) + + +def test_set_thermal_target_advances_substates(): + """ + Test that the state advances through substates correctly. + """ + titrator = Titrator() + state = SetThermalTarget(titrator, MainMenu(titrator)) + + state.value = "7.5" + state.save_value() + assert state.sub_state == 1 + state.value = "2" + state.save_value() + assert state.sub_state == 2 + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_ph_target_valid_input(print_mock): + """ + Test that valid pH target and ramp inputs are saved and displayed correctly. + """ + titrator = Titrator() + titrator.thermal_control = mock.Mock() + state = SetThermalTarget(titrator, MainMenu(titrator)) + + state.value = "7.5" + state.save_value() + state.value = "2" + state.save_value() + + titrator.thermal_control.set_base_thermal_target.assert_called_once_with(7.5) + titrator.thermal_control.set_ramp_duration_hours.assert_called_once_with(2.0) + + print_mock.assert_any_call("New Temp=7.50", line=1) + print_mock.assert_any_call("New Ramp=2.00", line=2) + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_thermal_target_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = SetThermalTarget(titrator, MainMenu(titrator)) + + assert state.get_label() == "Set Temperature" + state.sub_state = 1 + assert state.get_label() == "Set ramp hours:" + + +def test_handle_key_d(): + """ + Test that entering 'D' returns to the main menu. + """ + titrator = Titrator() + state = SetThermalTarget(titrator, MainMenu(titrator)) + + state.handle_key("D") + assert isinstance(titrator.state, MainMenu) From bcc72290a7840a5497293ebc850d57e207ad8b5b Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Mon, 5 Jan 2026 13:31:17 -0800 Subject: [PATCH 5/9] add thermal line 2 to Idle --- src/ui_state/main_menu.py | 24 +++++++++++++++++++++- tests/ui_state/main_menu_test.py | 35 +++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/ui_state/main_menu.py b/src/ui_state/main_menu.py index 33cdab9..bd663e8 100644 --- a/src/ui_state/main_menu.py +++ b/src/ui_state/main_menu.py @@ -2,6 +2,8 @@ The file for the MainMenu class """ +import time + from src.devices.library import Keypad from src.ui_state.set_menu.set_chill_or_heat import SetChillOrHeat from src.ui_state.set_menu.set_google_mins import SetGoogleSheetInterval @@ -220,7 +222,27 @@ def idle(self): """ lcd = self.titrator.lcd lcd.print("Idle Line 1", line=1) - lcd.print("Idle Line 2", line=2) + + thermal_control = self.titrator.thermal_control + temperature = self.titrator.thermal_probe.get_running_average() + status = "h" if thermal_control.get_heat(True) else "c" + + output = [" "] * 20 + output[0] = "T" + output[1] = "=" if int(time.monotonic()) % 2 == 0 else " " + + buffer = f"{temperature:5.2f}" + output[2:7] = list(buffer[:5]) + + output[7] = " " + output[8] = status + output[9] = " " + + thermal_target = thermal_control.get_current_thermal_target() + buffer = f"{thermal_target:5.2f}" + output[10:15] = list(buffer[:5]) + + self.titrator.lcd.print("".join(output), line=2) def loop(self): """ diff --git a/tests/ui_state/main_menu_test.py b/tests/ui_state/main_menu_test.py index 2ae56ef..8ac9c5a 100644 --- a/tests/ui_state/main_menu_test.py +++ b/tests/ui_state/main_menu_test.py @@ -145,11 +145,6 @@ def test_view_list(print_mock): """ main_menu = MainMenu(Titrator()) - main_menu.loop() - print_mock.assert_any_call("Idle Line 1", line=1) - print_mock.assert_any_call("Idle Line 2", line=2) - print_mock.reset_mock() - main_menu.handle_key("6") main_menu.loop() print_mock.assert_any_call("View settings", line=1) @@ -172,11 +167,6 @@ def test_change_list(print_mock): """ main_menu = MainMenu(Titrator()) - main_menu.loop() - print_mock.assert_any_call("Idle Line 1", line=1) - print_mock.assert_any_call("Idle Line 2", line=2) - print_mock.reset_mock() - main_menu.handle_key("6") main_menu.handle_key("8") main_menu.loop() @@ -191,3 +181,28 @@ def test_change_list(print_mock): main_menu.handle_key("8") main_menu.loop() print_mock.assert_any_call(label, line=1) + + +@mock.patch("src.devices.library.LiquidCrystal.print") +@mock.patch("src.titrator.ThermalControl.get_current_thermal_target") +@mock.patch("src.titrator.ThermalControl.get_heat") +@mock.patch("src.titrator.ThermalProbe.get_running_average") +def test_idle_thermal_display( + mock_get_running_average, + mock_get_heat, + mock_get_current_thermal_target, + mock_lcd_print, +): + """ + Test that the thermal portion of the idle method displays the correct information. + """ + main_menu = MainMenu(Titrator()) + + mock_get_running_average.return_value = 22.75 + mock_get_heat.return_value = True + mock_get_current_thermal_target.return_value = 25.50 + + main_menu.loop() + mock_lcd_print.assert_any_call( + "T=22.75 h 25.50 " or "T 22.75 h 25.50 ", line=2 + ) From 155cdf694696e40b10e9fd3e9ce540b99113ed51 Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Mon, 5 Jan 2026 13:39:28 -0800 Subject: [PATCH 6/9] fix thermal main menu test --- tests/ui_state/main_menu_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ui_state/main_menu_test.py b/tests/ui_state/main_menu_test.py index 8ac9c5a..c67dfbf 100644 --- a/tests/ui_state/main_menu_test.py +++ b/tests/ui_state/main_menu_test.py @@ -203,6 +203,6 @@ def test_idle_thermal_display( mock_get_current_thermal_target.return_value = 25.50 main_menu.loop() - mock_lcd_print.assert_any_call( - "T=22.75 h 25.50 " or "T 22.75 h 25.50 ", line=2 - ) + expected_outputs = ["T=22.75 h 25.50 ", "T 22.75 h 25.50 "] + line_2_calls = [call.args[0] for call in mock_lcd_print.call_args_list if call.kwargs.get("line") == 2] + assert any(output in expected_outputs for output in line_2_calls) From c1cec4a9fe1d7c14f0edcec9547be6b2ab1e8b8f Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Fri, 9 Jan 2026 10:43:46 -0800 Subject: [PATCH 7/9] format --- tests/ui_state/main_menu_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ui_state/main_menu_test.py b/tests/ui_state/main_menu_test.py index c67dfbf..fe1dff3 100644 --- a/tests/ui_state/main_menu_test.py +++ b/tests/ui_state/main_menu_test.py @@ -204,5 +204,9 @@ def test_idle_thermal_display( main_menu.loop() expected_outputs = ["T=22.75 h 25.50 ", "T 22.75 h 25.50 "] - line_2_calls = [call.args[0] for call in mock_lcd_print.call_args_list if call.kwargs.get("line") == 2] + line_2_calls = [ + call.args[0] + for call in mock_lcd_print.call_args_list + if call.kwargs.get("line") == 2 + ] assert any(output in expected_outputs for output in line_2_calls) From 842bcc2d48d0bf0c1f331705dd405f9f9bd5635a Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Wed, 14 Jan 2026 23:11:11 -0800 Subject: [PATCH 8/9] reorder device functions alphabetically and new view temp feature --- src/devices/thermal_control.py | 116 +++++++++--------- src/devices/thermal_probe.py | 52 ++++---- src/ui_state/main_menu.py | 3 + src/ui_state/view_menu/view_thermal.py | 37 ++++++ tests/ui_state/view_menu/view_thermal_test.py | 61 +++++++++ 5 files changed, 185 insertions(+), 84 deletions(-) create mode 100644 src/ui_state/view_menu/view_thermal.py create mode 100644 tests/ui_state/view_menu/view_thermal_test.py diff --git a/src/devices/thermal_control.py b/src/devices/thermal_control.py index 301956b..a2ba594 100644 --- a/src/devices/thermal_control.py +++ b/src/devices/thermal_control.py @@ -29,19 +29,11 @@ def __init__(self, titrator): self._amplitude = 0.0 self._period_in_seconds = 0 - def get_heat(self, default): - """ - Get the heat setting from EEPROM - """ - if self._heat is None: - return default - return self._heat - - def set_heat(self, value): + def get_amplitude(self): """ - Set the heat setting in EEPROM + Get the amplitude for the pH function. """ - self._heat = value + return self._amplitude def get_base_thermal_target(self): """ @@ -49,42 +41,35 @@ def get_base_thermal_target(self): """ return self._base_thermal_target - def set_base_thermal_target(self, value): - """ - Set the base thermal target - """ - self._base_thermal_target = value - def get_current_thermal_target(self): """ Get the current thermal target """ return self._current_thermal_target - def set_current_thermal_target(self, value): + def get_heat(self, default): """ - Set the current thermal target + Get the heat setting from EEPROM """ - self._current_thermal_target = value + if self._heat is None: + return default + return self._heat - def get_thermal_function_type(self): + def get_period_in_seconds(self): """ - Get the current thermal function type. + Get the period in seconds for the pH function. """ - return self._thermal_function_type + return self._period_in_seconds - def set_thermal_function_type(self, function_type): + def get_ramp_time_end(self): """ - Set the current thermal function type. + Get the ramp time end in seconds. """ - if function_type in ( - ThermalControl.FLAT_TYPE, - ThermalControl.RAMP_TYPE, - ThermalControl.SINE_TYPE, - ): - self._thermal_function_type = function_type - else: - raise ValueError("Invalid thermal function type") + return ( + self._ramp_time_end_seconds + if self._thermal_function_type != ThermalControl.FLAT_TYPE + else 0 + ) def get_ramp_time_start(self): """ @@ -96,15 +81,35 @@ def get_ramp_time_start(self): else 0 ) - def get_ramp_time_end(self): + def get_thermal_function_type(self): """ - Get the ramp time end in seconds. + Get the current thermal function type. """ - return ( - self._ramp_time_end_seconds - if self._thermal_function_type != ThermalControl.FLAT_TYPE - else 0 - ) + return self._thermal_function_type + + def set_amplitude(self, amplitude): + """ + Set the amplitude for the pH function. + """ + self._amplitude = amplitude + + def set_base_thermal_target(self, value): + """ + Set the base thermal target + """ + self._base_thermal_target = value + + def set_current_thermal_target(self, value): + """ + Set the current thermal target + """ + self._current_thermal_target = value + + def set_heat(self, value): + """ + Set the heat setting in EEPROM + """ + self._heat = value def set_ramp_duration_hours(self, new_ph_ramp_duration): """ @@ -133,24 +138,6 @@ def set_ramp_duration_hours(self, new_ph_ramp_duration): self._thermal_function_type = ThermalControl.FLAT_TYPE print("Set ramp time to 0") - def get_amplitude(self): - """ - Get the amplitude for the pH function. - """ - return self._amplitude - - def set_amplitude(self, amplitude): - """ - Set the amplitude for the pH function. - """ - self._amplitude = amplitude - - def get_period_in_seconds(self): - """ - Get the period in seconds for the pH function. - """ - return self._period_in_seconds - def set_sine_amplitude_and_hours(self, amplitude, period_in_hours): """ Set the amplitude and period (in hours) for the sine wave pH function. @@ -161,3 +148,16 @@ def set_sine_amplitude_and_hours(self, amplitude, period_in_hours): self._thermal_function_type = ThermalControl.SINE_TYPE else: raise ValueError("Amp and period !> than 0.") + + def set_thermal_function_type(self, function_type): + """ + Set the current thermal function type. + """ + if function_type in ( + ThermalControl.FLAT_TYPE, + ThermalControl.RAMP_TYPE, + ThermalControl.SINE_TYPE, + ): + self._thermal_function_type = function_type + else: + raise ValueError("Invalid thermal function type") diff --git a/src/devices/thermal_probe.py b/src/devices/thermal_probe.py index 8811c19..a48b6d0 100644 --- a/src/devices/thermal_probe.py +++ b/src/devices/thermal_probe.py @@ -25,29 +25,42 @@ def __init__(self, eeprom): self.first_time = True self.last_time = 0 - def get_thermal_correction(self): - """ - Get the thermal correction value from EEPROM - """ - return float(self._correction) - def clear_thermal_correction(self): """ Clear the thermal correction value in EEPROM """ self._correction = 0 - def set_thermal_correction(self, value): + def get_raw_temperature(self): """ - Set the thermal correction value in EEPROM + Simulate reading the raw temperature from a sensor. + In a real implementation, this method would interface with hardware. """ - self._correction = value + # Placeholder for actual sensor reading logic + return 25.0 # return thermo.temperature(RTDnominal, refResistor); + + def get_running_average(self): + """ + Return the corrected running average within the range of 00.00-99.99 + """ + temperature = self.get_uncorrected_running_average() + self._correction + if temperature < 0.0: + temperature = 0.0 + elif temperature > 99.99: + temperature = 99.99 + return temperature + + def get_thermal_correction(self): + """ + Get the thermal correction value from EEPROM + """ + return float(self._correction) def get_uncorrected_running_average(self): """ Calculate the uncorrected running average of temperature readings. """ - current_time = time.time() # Get current time in seconds + current_time = time.time() if ( self.first_time or self.last_time + 1 <= current_time ): # Check if 1 second has passed @@ -66,21 +79,8 @@ def get_uncorrected_running_average(self): valid_readings = self.history[: self.history_index + 1] return sum(valid_readings) / len(valid_readings) - def get_running_average(self): - """ - Return the corrected running average within the range of 00.00-99.99 - """ - temperature = self.get_uncorrected_running_average() + self._correction - if temperature < 0.0: - temperature = 0.0 - elif temperature > 99.99: - temperature = 99.99 - return temperature - - def get_raw_temperature(self): + def set_thermal_correction(self, value): """ - Simulate reading the raw temperature from a sensor. - In a real implementation, this method would interface with hardware. + Set the thermal correction value in EEPROM """ - # Placeholder for actual sensor reading logic - return 25.0 # return thermo.temperature(RTDnominal, refResistor); + self._correction = value diff --git a/src/ui_state/main_menu.py b/src/ui_state/main_menu.py index bd663e8..7449f00 100644 --- a/src/ui_state/main_menu.py +++ b/src/ui_state/main_menu.py @@ -33,6 +33,7 @@ from src.ui_state.view_menu.view_ph_calibration import ViewPHCalibration from src.ui_state.view_menu.view_pid_constants import ViewPIDConstants from src.ui_state.view_menu.view_tank_id import ViewTankID +from src.ui_state.view_menu.view_thermal import ViewThermal from src.ui_state.view_menu.view_thermal_correction import ( ViewThermalCorrection, ) @@ -61,6 +62,7 @@ def __init__(self, titrator): "View pH slope", "View PID", "View tank ID", + "View temp", "View temp cal", "View time", "View version", @@ -95,6 +97,7 @@ def __init__(self, titrator): ViewPHCalibration, # View pH slope ViewPIDConstants, # View PID constants ViewTankID, # View Tank ID + ViewThermal, # View Temperature ViewThermalCorrection, # View Thermal Correction ViewTime, # View Time ViewVersion, # View Version diff --git a/src/ui_state/view_menu/view_thermal.py b/src/ui_state/view_menu/view_thermal.py new file mode 100644 index 0000000..3d80b16 --- /dev/null +++ b/src/ui_state/view_menu/view_thermal.py @@ -0,0 +1,37 @@ +""" +The file to hold the View Thermal class +""" + +from src.devices.library import Keypad +from src.ui_state.ui_state import UIState + + +class ViewThermal(UIState): + """ + This is a class for the ViewThermal state of the Tank Controller + """ + + def __init__(self, titrator, previous_state=None): + """ + The constructor for the ViewThermal class + """ + super().__init__(titrator) + self.previous_state = previous_state + + def loop(self): + """ + The loop function for the ViewThermal class + """ + self.titrator.lcd.print("Avg Raw", line=1) + average = self.titrator.thermal_probe.get_running_average() + raw = self.titrator.thermal_probe.get_raw_temperature() + self.titrator.lcd.print( + f"{average} {raw}", line=2 + ) + + def handle_key(self, key): + """ + The handle_key function for the ViewThermal class + """ + if key in [Keypad.KEY_4, Keypad.KEY_D]: + self._set_next_state(self.previous_state, True) diff --git a/tests/ui_state/view_menu/view_thermal_test.py b/tests/ui_state/view_menu/view_thermal_test.py new file mode 100644 index 0000000..d73f2cc --- /dev/null +++ b/tests/ui_state/view_menu/view_thermal_test.py @@ -0,0 +1,61 @@ +""" +The file to test the View Thermal class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.main_menu import MainMenu +from src.ui_state.ui_state import UIState +from src.ui_state.view_menu.view_thermal import ViewThermal + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch.object(LiquidCrystal, "print") +def test_view_thermal_loop(print_mock): + """ + The function to test ViewThermal's loop function + """ + titrator = Titrator() + titrator.thermal_probe.get_running_average = mock.Mock(return_value="25.5") + titrator.thermal_probe.get_raw_temperature = mock.Mock(return_value="26.0") + + state = ViewThermal(titrator, MainMenu(titrator)) + + state.loop() + + print_mock.assert_any_call("Avg Raw", line=1) + print_mock.assert_any_call("25.5 26.0", line=2) + + +def test_handle_key_4(): + """ + The function to test the back handle key + """ + titrator = Titrator() + + titrator.state = ViewThermal(titrator, MockPreviousState(titrator)) + + titrator.state.handle_key("4") + assert isinstance(titrator.state, MockPreviousState) + + +def test_handle_key_d(): + """ + The function to test the reset handle keys + """ + titrator = Titrator() + + titrator.state = ViewThermal(titrator, MockPreviousState(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MockPreviousState) From 0b65cf58d88fda05035d9c9fb2a126bda4352a8c Mon Sep 17 00:00:00 2001 From: Samuel Nguyen Date: Wed, 14 Jan 2026 23:12:13 -0800 Subject: [PATCH 9/9] format --- src/ui_state/view_menu/view_thermal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ui_state/view_menu/view_thermal.py b/src/ui_state/view_menu/view_thermal.py index 3d80b16..3f4554a 100644 --- a/src/ui_state/view_menu/view_thermal.py +++ b/src/ui_state/view_menu/view_thermal.py @@ -25,9 +25,7 @@ def loop(self): self.titrator.lcd.print("Avg Raw", line=1) average = self.titrator.thermal_probe.get_running_average() raw = self.titrator.thermal_probe.get_raw_temperature() - self.titrator.lcd.print( - f"{average} {raw}", line=2 - ) + self.titrator.lcd.print(f"{average} {raw}", line=2) def handle_key(self, key): """