diff --git a/src/devices/eeprom.py b/src/devices/eeprom.py index fb743bc..dfcd106 100644 --- a/src/devices/eeprom.py +++ b/src/devices/eeprom.py @@ -13,12 +13,10 @@ 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 self._tank_id = 1 - self._thermal_correction = 12 def get_google_sheet_interval(self, default): """ @@ -28,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 @@ -68,26 +58,12 @@ 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 """ 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..a2ba594 --- /dev/null +++ b/src/devices/thermal_control.py @@ -0,0 +1,163 @@ +""" +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 + self._amplitude = 0.0 + self._period_in_seconds = 0 + + def get_amplitude(self): + """ + Get the amplitude for the pH function. + """ + return self._amplitude + + def get_base_thermal_target(self): + """ + Get the base thermal target + """ + return self._base_thermal_target + + def get_current_thermal_target(self): + """ + Get the current thermal target + """ + return self._current_thermal_target + + def get_heat(self, default): + """ + Get the heat setting from EEPROM + """ + if self._heat is None: + return default + return self._heat + + def get_period_in_seconds(self): + """ + Get the period in seconds for the pH function. + """ + return self._period_in_seconds + + 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 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_thermal_function_type(self): + """ + Get the current thermal function type. + """ + 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): + """ + 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") + + 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.") + + 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 98f1fa9..a48b6d0 100644 --- a/src/devices/thermal_probe.py +++ b/src/devices/thermal_probe.py @@ -2,16 +2,85 @@ 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 """ self.eeprom = eeprom - self.correction = self.eeprom.get_thermal_correction(0.0) + self._correction = 12 + + self.history = [0.0] * self.HISTORY_SIZE + self.history_index = 0 + self.first_time = True + self.last_time = 0 + + def clear_thermal_correction(self): + """ + Clear the thermal correction value in EEPROM + """ + self._correction = 0 + + 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); + + 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() + 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 set_thermal_correction(self, value): + """ + Set the thermal correction value in EEPROM + """ + self._correction = value 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/main_menu.py b/src/ui_state/main_menu.py index 33cdab9..7449f00 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 @@ -31,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, ) @@ -59,6 +62,7 @@ def __init__(self, titrator): "View pH slope", "View PID", "View tank ID", + "View temp", "View temp cal", "View time", "View version", @@ -93,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 @@ -220,7 +225,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/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 aa05ed1..44a4028 100644 --- a/src/ui_state/set_menu/set_thermal_calibration.py +++ b/src/ui_state/set_menu/set_thermal_calibration.py @@ -2,10 +2,32 @@ 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) 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/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.py b/src/ui_state/view_menu/view_thermal.py new file mode 100644 index 0000000..3f4554a --- /dev/null +++ b/src/ui_state/view_menu/view_thermal.py @@ -0,0 +1,35 @@ +""" +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/src/ui_state/view_menu/view_thermal_correction.py b/src/ui_state/view_menu/view_thermal_correction.py index 978994e..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.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..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 @@ -93,23 +67,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_control_test.py b/tests/devices/thermal_control_test.py new file mode 100644 index 0000000..48f934a --- /dev/null +++ b/tests/devices/thermal_control_test.py @@ -0,0 +1,113 @@ +""" +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 + + 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: + 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 + + +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/devices/thermal_probe_test.py b/tests/devices/thermal_probe_test.py index c4ef5d7..a65999f 100644 --- a/tests/devices/thermal_probe_test.py +++ b/tests/devices/thermal_probe_test.py @@ -2,30 +2,71 @@ The file to test the ThermalProbe class """ -from unittest import mock +from unittest.mock import Mock, patch -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 + thermal_probe.set_thermal_correction(2.2) + assert thermal_probe.get_thermal_correction() == 2.2 -def test_thermal_probe_defaults_from_none(): - """ThermalProbe should read a numeric float from EEPROM.""" - eeprom = EEPROM() - eeprom._thermal_correction = None +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) - with mock.patch("src.devices.eeprom.EEPROM", return_value=eeprom): - thermal_probe = ThermalProbe(eeprom) + # 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 - assert thermal_probe.correction == 0.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/main_menu_test.py b/tests/ui_state/main_menu_test.py index 2ae56ef..fe1dff3 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,32 @@ 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() + 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) 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) 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) 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)