diff --git a/src/devices/eeprom.py b/src/devices/eeprom.py index dfcd106..21c268f 100644 --- a/src/devices/eeprom.py +++ b/src/devices/eeprom.py @@ -13,6 +13,7 @@ def __init__(self): The constructor function for the EEPROM class """ self._google_sheet_interval = 20 + self._ignore_bad_ph_slope = False self._kd_value = 36.0 self._ki_value = 28.0 self._kp_value = 20.0 @@ -26,6 +27,14 @@ def get_google_sheet_interval(self, default): return default return self._google_sheet_interval + def get_ignore_bad_ph_slope(self, default): + """ + Get the ignore bad pH slope setting from EEPROM + """ + if self._ignore_bad_ph_slope is None: + return default + return self._ignore_bad_ph_slope + def get_kd(self, default): """ Get the Kd value from EEPROM @@ -64,6 +73,12 @@ def set_google_sheet_interval(self, value): """ self._google_sheet_interval = value + def set_ignore_bad_ph_slope(self, value): + """ + Set the ignore bad pH slope setting in EEPROM + """ + self._ignore_bad_ph_slope = value + def set_kd(self, value): """ Set the Kd value in EEPROM diff --git a/src/devices/ph_calibration_warning.py b/src/devices/ph_calibration_warning.py new file mode 100644 index 0000000..119d688 --- /dev/null +++ b/src/devices/ph_calibration_warning.py @@ -0,0 +1,50 @@ +""" +The file to hold the PH Calibration Warning class +""" + +import time + +from src.ui_state.ui_state import UIState +from src.ui_state.view_menu.view_ph_calibration import ViewPHCalibration + + +class PHCalibrationWarning(UIState): + """ + Constructor for the PHCalibrationWarning class. + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator) + self.start_time = time.monotonic() + self.previous_state = previous_state + + def loop(self): + """ + Handle the blinking warning message and user response prompts. + """ + elapsed_time = (time.monotonic() - self.start_time) * 1000 + + if elapsed_time % 8000 < 5000: + if elapsed_time % 1000 < 700: + self.titrator.lcd.print("BAD CALIBRATION?", line=1) + else: + self.titrator.lcd.print("", line=1) + + slope_response = self.titrator.ph_probe.get_slope() + self.titrator.lcd.print(slope_response, line=2) + else: + self.titrator.lcd.print("A: Accept/ignore", line=1) + self.titrator.lcd.print("C: Clear calibra", line=2) + + def handle_key(self, key): + """ + Docstring for handle_key A and C + """ + if key == "A": + print("Setting ignore_bad_ph_slope to True") + self.titrator.ph_probe.eeprom.set_ignore_bad_ph_slope(True) + print("Ignore flag set. Returning to previous state.") + self._set_next_state(self.previous_state, True) + elif key == "C": + self.titrator.ph_probe.clear_calibration() + self._set_next_state(ViewPHCalibration(self.titrator, self), True) diff --git a/src/devices/ph_control.py b/src/devices/ph_control.py index ff5e74d..27c27bd 100644 --- a/src/devices/ph_control.py +++ b/src/devices/ph_control.py @@ -2,14 +2,148 @@ The file for the PH Control class """ +import time + class PHControl: """ The class for the PH Control """ - def __init__(self): + FLAT_TYPE = 0 + RAMP_TYPE = 1 + SINE_TYPE = 2 + + def __init__(self, titrator): """ The constructor function for the PH Control class """ + self.titrator = titrator self.use_pid = bool(True) + self._base_target_ph = 8.125 + self._current_target_ph = 8.5 + self._ph_function_type = PHControl.FLAT_TYPE # Default to 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_target_ph(self): + """ + Get the base target pH value + """ + return self._base_target_ph + + def get_current_target_ph(self): + """ + Get the current target pH value + """ + return self._current_target_ph + + def get_period_in_seconds(self): + """ + Get the period in seconds for the pH function. + """ + return self._period_in_seconds + + def get_ph_function_type(self): + """ + Get the current pH function type. + """ + return self._ph_function_type + + def get_ramp_time_end(self): + """ + Get the ramp time end in seconds. + """ + return ( + self._ramp_time_end_seconds + if self._ph_function_type != PHControl.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._ph_function_type != PHControl.FLAT_TYPE + else 0 + ) + + def set_amplitude(self, amplitude): + """ + Set the amplitude for the pH function. + """ + self._amplitude = amplitude + + def set_base_target_ph(self, target_ph): + """ + Set the base target pH value + """ + self._base_target_ph = target_ph + + def set_current_target_ph(self, target_ph): + """ + Set the current target pH value + """ + self._current_target_ph = target_ph + + def set_ph_function_type(self, function_type): + """ + Set the current pH function type. + """ + if function_type in ( + PHControl.FLAT_TYPE, + PHControl.RAMP_TYPE, + PHControl.SINE_TYPE, + ): + self._ph_function_type = function_type + else: + raise ValueError("Invalid pH function type") + + 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.ph_probe.get_ph_value() + self._ph_function_type = PHControl.RAMP_TYPE + else: + self._ramp_time_end_seconds = 0 + self._ph_function_type = PHControl.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._ph_function_type = PHControl.SINE_TYPE + else: + raise ValueError("Amp and period !> than 0.") diff --git a/src/devices/ph_probe_mock.py b/src/devices/ph_probe_mock.py new file mode 100644 index 0000000..df0b6d2 --- /dev/null +++ b/src/devices/ph_probe_mock.py @@ -0,0 +1,107 @@ +""" +The file for the Mock pH probe +""" + + +class PHProbe: + """ + Docstring for PHProbe + """ + + def __init__(self, eeprom): + """ + The constructor for the PHProbe class + """ + self.eeprom = eeprom + self._value = 3.125 + self._calibration_response = "?CAL,3" + self._slope_response = "99.7,100.3, -0.89" + self.slope_is_out_of_range = False + self._highpoint_calibration = None + self._lowpoint_calibration = None + self._midpoint_calibration = None + + def clear_calibration(self): + """ + Clear the calibration response string + """ + self.slope_is_out_of_range = False + self.eeprom.set_ignore_bad_ph_slope(False) + self._calibration_response = "" + + def get_calibration(self): + """ + Get the calibration response string + """ + return self._calibration_response + + def get_ph_value(self): + """ + Get the current pH value from the mock probe + """ + return self._value + + def get_slope(self): + """ + Get the slope response string + """ + return self._slope_response + + def set_ph_value(self, ph_value): + """ + Set the current pH value for the mock probe + """ + self._value = ph_value + + def set_highpoint_calibration(self, highpoint): + """ + Set the highpoint calibration value for the pH probe. + """ + self.slope_is_out_of_range = False + self.eeprom.set_ignore_bad_ph_slope(False) + self._highpoint_calibration = highpoint + buffer = f"Cal,High,{int(highpoint)}.{int(highpoint * 1000 + 0.5) % 1000}\r" + print(buffer) # Simulate sending the string to the Atlas Scientific product + print( + f"PHProbe::setHighpointCalibration({int(highpoint)}.{int(highpoint * 1000) % 1000})" + ) + + def set_lowpoint_calibration(self, lowpoint): + """ + Set the lowpoint calibration value for the pH probe. + """ + self.slope_is_out_of_range = False + self.eeprom.set_ignore_bad_ph_slope(False) + self._lowpoint_calibration = lowpoint + buffer = f"Cal,low,{int(lowpoint)}.{int(lowpoint * 1000 + 0.5) % 1000}\r" + print(buffer) # Simulate sending the string to the Atlas Scientific product + print( + f"PHProbe::setLowpointCalibration({int(lowpoint)}.{int(lowpoint * 1000) % 1000})" + ) + + def set_midpoint_calibration(self, midpoint): + """ + Set the midpoint calibration value for the pH probe. + """ + self.slope_is_out_of_range = False + self.eeprom.set_ignore_bad_ph_slope(False) + self._midpoint_calibration = midpoint + buffer = f"Cal,mid,{int(midpoint)}.{int(midpoint * 1000 + 0.5) % 1000}\r" + print(buffer) # Simulate sending the string to the Atlas Scientific product + print( + f"PHProbe::setMidpointCalibration({int(midpoint)}.{int(midpoint * 1000) % 1000})" + ) + + def should_warn_about_calibration(self): + """ + Determine if a calibration warning should be shown based on the slope and ignore settings. + """ + if not self.slope_is_out_of_range: + return False + if self.slope_is_out_of_range and not self.eeprom.get_ignore_bad_ph_slope( + False + ): + return True + if self.slope_is_out_of_range and self.eeprom.get_ignore_bad_ph_slope(False): + return False + return False diff --git a/src/titrator.py b/src/titrator.py index f71e009..cc7f1a1 100644 --- a/src/titrator.py +++ b/src/titrator.py @@ -10,13 +10,13 @@ Heater, Keypad, LiquidCrystal, - PHProbe, StirControl, SyringePump, TemperatureControl, TemperatureProbe, ) from src.devices.ph_control import PHControl +from src.devices.ph_probe_mock import PHProbe from src.devices.pid import PID from src.devices.sd import SD from src.devices.thermal_control import ThermalControl @@ -50,7 +50,7 @@ def __init__(self): self.pid = PID(self.eeprom) # Initialize PH Control - self.ph_control = PHControl() + self.ph_control = PHControl(self) # Initialize Thermal Control self.thermal_control = ThermalControl(self) @@ -68,7 +68,7 @@ def __init__(self): self.keypad = Keypad() # Initialize pH Probe - self.ph_probe = PHProbe() + self.ph_probe = PHProbe(self.eeprom) # Initialize Syringe Pump self.pump = SyringePump() diff --git a/src/ui_state/main_menu.py b/src/ui_state/main_menu.py index 7449f00..8204608 100644 --- a/src/ui_state/main_menu.py +++ b/src/ui_state/main_menu.py @@ -5,6 +5,7 @@ import time from src.devices.library import Keypad +from src.devices.ph_calibration_warning import PHCalibrationWarning from src.ui_state.set_menu.set_chill_or_heat import SetChillOrHeat from src.ui_state.set_menu.set_google_mins import SetGoogleSheetInterval from src.ui_state.set_menu.set_kd import SetKD @@ -30,6 +31,7 @@ ViewGoogleSheetInterval, ) from src.ui_state.view_menu.view_log_file import ViewLogFile +from src.ui_state.view_menu.view_ph import ViewPH 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 @@ -59,6 +61,7 @@ def __init__(self, titrator): "View free memory", "View Google mins", "View log file", + "View pH", "View pH slope", "View PID", "View tank ID", @@ -94,6 +97,7 @@ def __init__(self, titrator): ViewFreeMemory, # View Free Memory ViewGoogleSheetInterval, # View Google mins ViewLogFile, # View Log File + ViewPH, # View pH ViewPHCalibration, # View pH slope ViewPIDConstants, # View PID constants ViewTankID, # View Tank ID @@ -223,9 +227,42 @@ def idle(self): """ Displays the idle screen. """ - lcd = self.titrator.lcd - lcd.print("Idle Line 1", line=1) + # pH line (line 1) + if self.titrator.ph_probe.should_warn_about_calibration(): + self._set_next_state(PHCalibrationWarning(self.titrator, self), True) + + output = [" "] * 20 + ph_blink = self.titrator.ph_probe.slope_is_out_of_range and ( + (time.monotonic() + 1) % 2 == 0 + ) + output[0] = " " if ph_blink else "p" + output[1] = " " if ph_blink else "H" + output[2] = "=" if int(time.monotonic()) % 2 == 0 else " " + + ph_current = self.titrator.ph_probe.get_ph_value() + if ph_current < 10.0: + buffer = f"{ph_current:5.3f}" + else: + buffer = f"{ph_current:5.2f}" + output[3:8] = list(buffer[:5]) + + output[8] = " " + output[9] = "B" if self.titrator.ph_control.use_pid else " " + output[10] = " " + + ph_target = self.titrator.ph_control.get_current_target_ph() + if ph_target > 15 or ph_target < 0: + print("pH is out of range!") + + if ph_target < 10.0: + buffer = f"{ph_target:5.3f}" + else: + buffer = f"{ph_target:5.2f}" + output[11:16] = list(buffer[:5]) + + self.titrator.lcd.print("".join(output), line=1) + # Temperature line (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" diff --git a/src/ui_state/set_menu/set_ph_calibration.py b/src/ui_state/set_menu/set_ph_calibration.py index 6c060f6..7fc717e 100644 --- a/src/ui_state/set_menu/set_ph_calibration.py +++ b/src/ui_state/set_menu/set_ph_calibration.py @@ -2,6 +2,11 @@ The file to hold the PHCalibration class """ +from src.ui_state.set_menu.set_ph_calibration_mid import ( + PHCalibrationHigher, + PHCalibrationMid, + PHCalibrationOnly, +) from src.ui_state.ui_state import UIState @@ -9,3 +14,37 @@ class PHCalibration(UIState): """ This is a class for the PHCalibration state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + + def loop(self): + """ + The main loop for the PHCalibration state + """ + self.titrator.lcd.print("pH Cali: ", line=1) + self.titrator.lcd.print("1,2 or 3 point?", line=2) + + def handle_key(self, key): + """ + Handles key presses and updates the display accordingly. + """ + if key == "1": + self.titrator.lcd.print("1-pt pH calib...", line=2) + self.return_to_main_menu(ms_delay=3000) + self._set_next_state(PHCalibrationOnly(self.titrator, self), True) + + if key == "2": + self.titrator.lcd.print("2 Point Cali", line=2) + self._set_next_state(PHCalibrationHigher(self.titrator, self), True) + + if key == "3": + self.titrator.lcd.print("3 Point Cali", line=2) + self._set_next_state(PHCalibrationMid(self.titrator, self), True) + + if key == "4": + self._set_next_state(self.previous_state, True) + + if key == "D": + self.return_to_main_menu() diff --git a/src/ui_state/set_menu/set_ph_calibration_clear.py b/src/ui_state/set_menu/set_ph_calibration_clear.py index 8a44041..b1cb32c 100644 --- a/src/ui_state/set_menu/set_ph_calibration_clear.py +++ b/src/ui_state/set_menu/set_ph_calibration_clear.py @@ -2,10 +2,31 @@ The file to hold the PHCalibrationClear class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue +from src.ui_state.view_menu.view_ph_calibration import ViewPHCalibration -class ResetPHCalibration(UIState): +class ResetPHCalibration(UserValue): """ - This is a class for the PHCalibration state of the Tank Controller + This is a class for the ResetPHCalibration 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 pH Cali" + + def handle_key(self, key): + """ + Handles key presses and updates the display accordingly. + """ + if key == "A": + self.titrator.ph_probe.clear_calibration() + self._set_next_state(ViewPHCalibration(self.titrator, self), True) + else: + super().handle_key(key) diff --git a/src/ui_state/set_menu/set_ph_calibration_high.py b/src/ui_state/set_menu/set_ph_calibration_high.py new file mode 100644 index 0000000..9e3e51c --- /dev/null +++ b/src/ui_state/set_menu/set_ph_calibration_high.py @@ -0,0 +1,35 @@ +""" +The file to hold the Set pH Calibration Highpoint class +""" + +from src.ui_state.set_menu.set_ph_calibration_low import PHCalibrationLow +from src.ui_state.user_value import UserValue + + +class PHCalibrationHigh(UserValue): + """ + UI state to set the pH Calibration Highpoint. + Uses UserValue's keypad flow: implement get_label and save_value. + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._highpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "High buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Highpoint to the pH probe. + """ + self.titrator.ph_probe.set_highpoint_calibration(float(self.value)) + + self.titrator.lcd.print( + f"High = {self.titrator.ph_probe._highpoint_calibration}", line=2 + ) + self._set_next_state(PHCalibrationLow(self.titrator, self), True) diff --git a/src/ui_state/set_menu/set_ph_calibration_low.py b/src/ui_state/set_menu/set_ph_calibration_low.py new file mode 100644 index 0000000..0a6c502 --- /dev/null +++ b/src/ui_state/set_menu/set_ph_calibration_low.py @@ -0,0 +1,62 @@ +""" +The file to hold the Set pH Calibration Lowpoint class +""" + +from src.ui_state.user_value import UserValue + + +class PHCalibrationLower(UserValue): + """ + Docstring for PHCalibrationLower + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._lowpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Lower buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Lowpoint to the pH probe. + """ + self.titrator.ph_probe._lowpoint_calibration = float(self.value) + + self.titrator.lcd.print( + f"Low = {self.titrator.ph_probe._lowpoint_calibration}", line=2 + ) + self.return_to_main_menu(ms_delay=3000) + + +class PHCalibrationLow(UserValue): + """ + UI state to set the pH Calibration Lowpoint. + Uses UserValue's keypad flow: implement get_label and save_value. + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._lowpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Low buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Lowpoint to the pH probe. + """ + self.titrator.ph_probe.set_lowpoint_calibration(float(self.value)) + + self.titrator.lcd.print( + f"Low = {self.titrator.ph_probe._lowpoint_calibration}", line=2 + ) + self.return_to_main_menu(ms_delay=3000) diff --git a/src/ui_state/set_menu/set_ph_calibration_mid.py b/src/ui_state/set_menu/set_ph_calibration_mid.py new file mode 100644 index 0000000..8481404 --- /dev/null +++ b/src/ui_state/set_menu/set_ph_calibration_mid.py @@ -0,0 +1,93 @@ +""" +The file to hold the Set pH Calibration Midpoint class +""" + +from src.ui_state.set_menu.set_ph_calibration_high import PHCalibrationHigh +from src.ui_state.set_menu.set_ph_calibration_low import PHCalibrationLower +from src.ui_state.user_value import UserValue + + +class PHCalibrationOnly(UserValue): + """ + UI state to set the pH Calibration Midpoint. + Uses UserValue's keypad flow: implement get_label and save_value. + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._midpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Midpoint to the pH probe. + """ + self.titrator.ph_probe._midpoint_calibration = float(self.value) + + self.titrator.lcd.print( + f"Buffer = {self.titrator.ph_probe._midpoint_calibration}", line=2 + ) + self.return_to_main_menu(ms_delay=3000) + + +class PHCalibrationHigher(UserValue): + """ + Docstring for PHCalibrationHigher + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._midpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Higher buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Midpoint to the pH probe. + """ + self.titrator.ph_probe._midpoint_calibration = float(self.value) + + self.titrator.lcd.print( + f"Mid = {self.titrator.ph_probe._midpoint_calibration}", line=2 + ) + self._set_next_state(PHCalibrationLower(self.titrator, self), True) + + +class PHCalibrationMid(UserValue): + """ + UI state to set the pH Calibration Midpoint. + Uses UserValue's keypad flow: implement get_label and save_value. + """ + + def __init__(self, titrator, previous_state=None): + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.value = str(self.titrator.ph_probe._midpoint_calibration or "") + + def get_label(self): + """ + Returns the label for the user value input. + """ + return "Mid buffer pH" + + def save_value(self): + """ + Saves the entered pH Calibration Midpoint to the pH probe. + """ + self.titrator.ph_probe._midpoint_calibration = float(self.value) + + self.titrator.lcd.print( + f"Mid = {self.titrator.ph_probe._midpoint_calibration}", line=2 + ) + self._set_next_state(PHCalibrationHigh(self.titrator, self), True) diff --git a/src/ui_state/set_menu/set_ph_sine_wave.py b/src/ui_state/set_menu/set_ph_sine_wave.py index f0c2760..9edecec 100644 --- a/src/ui_state/set_menu/set_ph_sine_wave.py +++ b/src/ui_state/set_menu/set_ph_sine_wave.py @@ -2,10 +2,62 @@ The file to hold the Set PH Sine Wave class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class SetPHSineWave(UIState): +class SetPHSineWave(UserValue): """ This is a class for the SetPHSineWave state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + """ + Constructor for the SetPHSineWave class + """ + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.prompts = [ + "Set pH Mean:", + "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.ph_control.set_base_target_ph(self.values[0]) + self.titrator.ph_control.set_sine_amplitude_and_hours( + self.values[1], self.values[2] + ) + + ph_mean = f"New pH={self.values[0]:.3f}" + amplitude_and_period = f"A={self.values[1]:.3f} P={self.values[2]:.3f}" + self.titrator.lcd.print(ph_mean, 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_ph_target.py b/src/ui_state/set_menu/set_ph_target.py index 8a0ad2e..1023568 100644 --- a/src/ui_state/set_menu/set_ph_target.py +++ b/src/ui_state/set_menu/set_ph_target.py @@ -2,10 +2,59 @@ The file to hold the Set PH Target class """ -from src.ui_state.ui_state import UIState +from src.ui_state.user_value import UserValue -class SetPHTarget(UIState): +class SetPHTarget(UserValue): """ This is a class for the SetPHTarget state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + """ + Constructor for the SetPHTarget class + """ + super().__init__(titrator, previous_state) + self.previous_state = previous_state + self.prompts = [ + "Set pH Set Point:", + "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.ph_control.set_base_target_ph(self.values[0]) + self.titrator.ph_control.set_ramp_duration_hours(self.values[1]) + + ph_set_point = f"New pH={self.values[0]:.3f}" + ramp_hours = f"New ramp={self.values[1]:.3f}" + self.titrator.lcd.print(ph_set_point, line=1) + self.titrator.lcd.print(ramp_hours, 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_ph.py b/src/ui_state/view_menu/view_ph.py index 2ee14cd..8ce1a78 100644 --- a/src/ui_state/view_menu/view_ph.py +++ b/src/ui_state/view_menu/view_ph.py @@ -2,10 +2,107 @@ The file for the ViewPh class, which displays the buffer nominal pH value on the LCD. """ +import time + +from src.devices.library import Keypad from src.ui_state.ui_state import UIState -class ViewPh(UIState): +class ViewPH(UIState): """ - UI state to display the buffer nominal pH value + Show pH-related information, rotating every 3 seconds between two sets of displays. + + This mirrors the firmware SeePh behavior: + - for 3 seconds, show header and values. + - next 3 seconds, show pH function type and type variables. """ + + def __init__(self, titrator, previous_state=None): + """ + The constructor for the ViewPH class + """ + super().__init__(titrator) + self.previous_state = previous_state + self._start_time = time.monotonic() + + def loop(self): + """ + Rotate display between header/values and pH function/type variables every 3 seconds. + """ + elapsed = int(((time.monotonic() - self._start_time) / 3.0)) % 2 + if elapsed == 0: + self.load_header(line=1) + self.load_values(line=2) + else: + self.load_ph_function_type(line=1) + self.load_type_variables(line=2) + + def load_header(self, line): + """ + Write the header "Now Next Goal" to the specified line on the LCD. + """ + header = "Now Next Goal" + self.titrator.lcd.print(header, line) + + def load_values(self, line): + """ + Write the current pH, current target pH, and overall target pH values to the specified line on the LCD. + """ + current_ph = self.titrator.ph_probe.get_ph_value() + current_target_ph = self.titrator.ph_control.get_current_target_ph() + overall_target_ph = self.titrator.ph_control.get_base_target_ph() + + values = f"{current_ph:.2f} {current_target_ph:.3f} {overall_target_ph:.3f}" + self.titrator.lcd.print(values, line) + + def load_ph_function_type(self, line): + """ + Display the current pH function type on the LCD. + """ + ph_type = self.titrator.ph_control.get_ph_function_type() + type_mapping = { + self.titrator.ph_control.FLAT_TYPE: "flat", + self.titrator.ph_control.RAMP_TYPE: "ramp", + self.titrator.ph_control.SINE_TYPE: "sine", + } + type_str = type_mapping.get(ph_type, "????") + message = f"type: {type_str}" + self.titrator.lcd.print(message, line) + + def load_type_variables(self, line): + """ + Display the variables related to the current pH function type on the LCD. + """ + ph_type = self.titrator.ph_control.get_ph_function_type() + + if ph_type == self.titrator.ph_control.FLAT_TYPE: + message = "" + + elif ph_type == self.titrator.ph_control.RAMP_TYPE: + end_time = self.titrator.ph_control.get_ramp_time_end() + current_time = int(time.monotonic()) + time_left = max(0, end_time - current_time) + time_left_hours = time_left // 3600 + time_left_minutes = (time_left % 3600) // 60 + time_left_seconds = time_left % 60 + message = f"left: {time_left_hours}:{time_left_minutes}:{time_left_seconds}" + + elif ph_type == self.titrator.ph_control.SINE_TYPE: + period_in_seconds = self.titrator.ph_control.get_period_in_seconds() + period_hours = period_in_seconds / 3600.0 + amplitude = self.titrator.ph_control.get_amplitude() + message = f"p={period_hours:.3f} a={amplitude:.3f}" + + else: + # Default case + message = "Invalid type" + + # Display the message on the specified line + self.titrator.lcd.print(message, line) + + def handle_key(self, key): + """ + Handle key presses to return to the previous state. + """ + 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_ph_calibration.py b/src/ui_state/view_menu/view_ph_calibration.py index 5c66bdd..8031d19 100644 --- a/src/ui_state/view_menu/view_ph_calibration.py +++ b/src/ui_state/view_menu/view_ph_calibration.py @@ -2,6 +2,7 @@ The file to hold the View PH Calibration class """ +from src.devices.library import Keypad from src.ui_state.ui_state import UIState @@ -9,3 +10,24 @@ class ViewPHCalibration(UIState): """ This is a class for the ViewPHCalibration state of the Tank Controller """ + + def __init__(self, titrator, previous_state=None): + """ + The constructor for the ViewPHCalibration class + """ + super().__init__(titrator) + self.previous_state = previous_state + + def loop(self): + self.titrator.lcd.print(f"{self.titrator.ph_probe.get_calibration()}", line=1) + self.titrator.lcd.print(f"{self.titrator.ph_probe.get_slope()}", line=2) + + def handle_key(self, key): + """ + The handle_key function for the ViewGoogleSheetInterval class + """ + if key == Keypad.KEY_4: + self._set_next_state(self.previous_state, True) + + if key == Keypad.KEY_D: + self.return_to_main_menu() diff --git a/tests/devices/eeprom_test.py b/tests/devices/eeprom_test.py index 315872f..6e8443e 100644 --- a/tests/devices/eeprom_test.py +++ b/tests/devices/eeprom_test.py @@ -31,6 +31,32 @@ def test_set_google_sheet_interval(): assert eeprom.get_google_sheet_interval(65535) == 60 +def test_default_ignore_bad_ph_slope_value(): + """ + The function to test the default ignore_bad_ph_slope value + """ + eeprom = EEPROM() + assert eeprom.get_ignore_bad_ph_slope(True) is False + + +def test_save_ignore_bad_ph_slope_value(): + """ + The function to test setting the ignore_bad_ph_slope value + """ + eeprom = EEPROM() + eeprom._ignore_bad_ph_slope = True + assert eeprom.get_ignore_bad_ph_slope(False) is True + + +def test_set_ignore_bad_ph_slope(): + """ + The function to test setting the ignore_bad_ph_slope via setter + """ + eeprom = EEPROM() + eeprom.set_ignore_bad_ph_slope(True) + assert eeprom.get_ignore_bad_ph_slope(False) is True + + def test_pid_values(): """ The function to test the PID values diff --git a/tests/devices/ph_calibration_warning_test.py b/tests/devices/ph_calibration_warning_test.py new file mode 100644 index 0000000..c2136ca --- /dev/null +++ b/tests/devices/ph_calibration_warning_test.py @@ -0,0 +1,66 @@ +""" +Unit tests for the PHCalibrationWarning UI state. +""" + +from unittest import mock + +from src.devices.ph_calibration_warning import PHCalibrationWarning +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_ph_calibration import ViewPHCalibration + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch("src.devices.ph_probe_mock.PHProbe.get_slope", return_value="Slope: 99.7") +@mock.patch("src.devices.library.LiquidCrystal.print") +def test_loop_warning_message(print_mock, _mock_get_slope): + """ + Test the loop method to ensure the warning message and slope are displayed correctly. + """ + titrator = Titrator() + state = PHCalibrationWarning(titrator, MainMenu(titrator)) + + # Simulate the loop + state.loop() + + # Verify the warning message and slope are displayed + print_mock.assert_any_call("BAD CALIBRATION?", line=1) + print_mock.assert_any_call("Slope: 99.7", line=2) + + +def test_handle_key_a(): + """ + Test that pressing 'A' sets ignore_bad_ph_slope to True and returns to the previous state. + """ + titrator = Titrator() + state = PHCalibrationWarning(titrator, MockPreviousState(titrator)) + + state.handle_key("A") + assert titrator.ph_probe.eeprom.get_ignore_bad_ph_slope(True) is True + + assert isinstance(titrator.state, MockPreviousState) + + +def test_handle_key_c(): + """ + Test that pressing 'C' clears the calibration and transitions to ViewPHCalibration state. + """ + titrator = Titrator() + state = PHCalibrationWarning(titrator, MockPreviousState(titrator)) + + state.handle_key("C") + + assert titrator.ph_probe.get_calibration() == "" + assert titrator.ph_probe.slope_is_out_of_range is False + assert titrator.ph_probe.eeprom.get_ignore_bad_ph_slope(True) is False + + assert isinstance(titrator.state, ViewPHCalibration) diff --git a/tests/devices/ph_control_test.py b/tests/devices/ph_control_test.py index 58f6952..3be369f 100644 --- a/tests/devices/ph_control_test.py +++ b/tests/devices/ph_control_test.py @@ -2,14 +2,17 @@ The file to test the PH Control class """ +from unittest.mock import Mock + from src.devices.ph_control import PHControl -def test_uses_pid_by_default(): +def test_uses_pid_kby_default(): """ The function to test the default google_sheet_interval value """ - phcontrol = PHControl() + mock_titrator = Mock() + phcontrol = PHControl(mock_titrator) assert phcontrol.use_pid is True @@ -17,6 +20,92 @@ def test_set_use_pid_value(): """ The function to test setting the use_pid value """ - phcontrol = PHControl() + mock_titrator = Mock() + phcontrol = PHControl(mock_titrator) phcontrol.use_pid = False assert phcontrol.use_pid is False + + +def test_get_and_set_base_target_ph(): + """ + Test getting and setting the base target pH value. + """ + mock_titrator = Mock() + ph_control = PHControl(mock_titrator) + ph_control._base_target_ph = 1.125 + + assert ph_control.get_base_target_ph() == 1.125 + + ph_control.set_base_target_ph(7.5) + assert ph_control.get_base_target_ph() == 7.5 + + +def test_get_and_set_current_target_ph(): + """ + Test getting and setting the current target pH value. + """ + mock_titrator = Mock() + ph_control = PHControl(mock_titrator) + ph_control._current_target_ph = 2.2 + + assert ph_control.get_current_target_ph() == 2.2 + + ph_control.set_current_target_ph(6.8) + assert ph_control.get_current_target_ph() == 6.8 + + +def test_get_and_set_ph_function_type(): + """ + Test getting and setting the pH function type. + """ + mock_titrator = Mock() + ph_control = PHControl(mock_titrator) + + assert ph_control.get_ph_function_type() == PHControl.FLAT_TYPE + + ph_control.set_ph_function_type(PHControl.RAMP_TYPE) + assert ph_control.get_ph_function_type() == PHControl.RAMP_TYPE + + ph_control.set_ph_function_type(PHControl.SINE_TYPE) + assert ph_control.get_ph_function_type() == PHControl.SINE_TYPE + + try: + ph_control.set_ph_function_type(99) + except ValueError as err: + assert str(err) == "Invalid pH function type" + + +def test_set_ramp_duration_hours(): + """ + Test setting the ramp duration in hours. + """ + mock_titrator = Mock() + mock_titrator.ph_probe.get_ph_value = Mock(return_value=7.2) + ph_control = PHControl(mock_titrator) + + ph_control.set_ramp_duration_hours(2.5) + assert ph_control.get_ramp_time_start() > 0 + assert ph_control.get_ramp_time_end() > ph_control.get_ramp_time_start() + assert ph_control.get_ph_function_type() == PHControl.RAMP_TYPE + + ph_control.set_ramp_duration_hours(0) + assert ph_control.get_ramp_time_end() == 0 + assert ph_control.get_ph_function_type() == PHControl.FLAT_TYPE + + +def test_set_sine_amplitude_and_hours(): + """ + Test setting the sine amplitude and period in hours. + """ + mock_titrator = Mock() + ph_control = PHControl(mock_titrator) + + ph_control.set_sine_amplitude_and_hours(1.5, 4) + assert ph_control.get_amplitude() == 1.5 + assert ph_control.get_period_in_seconds() == 14400 # 4 hours in seconds + assert ph_control.get_ph_function_type() == PHControl.SINE_TYPE + + try: + ph_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/ph_probe_mock_test.py b/tests/devices/ph_probe_mock_test.py new file mode 100644 index 0000000..6efcf12 --- /dev/null +++ b/tests/devices/ph_probe_mock_test.py @@ -0,0 +1,77 @@ +""" +The file to test the PHProbe mock class +""" + +from unittest.mock import Mock + +from src.devices.ph_probe_mock import PHProbe + + +def test_get_and_set_ph_value(): + """ + Test getting and setting the pH value. + """ + mock_eeprom = Mock() + ph_probe = PHProbe(mock_eeprom) + ph_probe._value = 3.125 + assert ph_probe.get_ph_value() == 3.125 + + ph_probe.set_ph_value(7.5) + assert ph_probe.get_ph_value() == 7.5 + + +def test_get_calibration(): + """ + Test getting the calibration response string. + """ + mock_eeprom = Mock() + ph_probe = PHProbe(mock_eeprom) + ph_probe._calibration_response = "?CAL,3" + + assert ph_probe.get_calibration() == "?CAL,3" + + +def test_clear_calibration(): + """ + Test clearing the calibration response string. + """ + mock_eeprom = Mock() + ph_probe = PHProbe(mock_eeprom) + + ph_probe.slope_is_out_of_range = True + mock_eeprom.set_ignore_bad_ph_slope = Mock() + + ph_probe.clear_calibration() + + assert ph_probe.slope_is_out_of_range is False + mock_eeprom.set_ignore_bad_ph_slope.assert_called_once_with(False) + assert ph_probe.get_calibration() == "" + + +def test_get_slope(): + """ + Test getting the slope response string. + """ + mock_eeprom = Mock() + ph_probe = PHProbe(mock_eeprom) + ph_probe._slope_response = "99.7,100.3, -0.89" + + assert ph_probe.get_slope() == "99.7,100.3, -0.89" + + +def test_should_warn_about_calibration(): + """ + Test determining if a calibration warning should be shown. + """ + mock_eeprom = Mock() + ph_probe = PHProbe(mock_eeprom) + + ph_probe.slope_is_out_of_range = False + assert ph_probe.should_warn_about_calibration() is False + + ph_probe.slope_is_out_of_range = True + mock_eeprom.get_ignore_bad_ph_slope = Mock(return_value=False) + assert ph_probe.should_warn_about_calibration() is True + + mock_eeprom.get_ignore_bad_ph_slope = Mock(return_value=True) + assert ph_probe.should_warn_about_calibration() is False diff --git a/tests/ui_state/set_menu/set_ph_calibration_clear_test.py b/tests/ui_state/set_menu/set_ph_calibration_clear_test.py new file mode 100644 index 0000000..396d68a --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_calibration_clear_test.py @@ -0,0 +1,49 @@ +""" +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_ph_calibration_clear import ResetPHCalibration +from src.ui_state.view_menu.view_ph_calibration import ViewPHCalibration + + +@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() + titrator.ph_probe.clear_calibration = mock.Mock() + state = ResetPHCalibration(titrator) + + state.loop() + print_mock.assert_any_call("A: Clear pH Cali", line=1) + + state.handle_key("A") + titrator.ph_probe.clear_calibration.assert_called_once() + assert isinstance(titrator.state, ViewPHCalibration) + + +def test_set_calibration_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = ResetPHCalibration(titrator, MainMenu(titrator)) + + assert state.get_label() == "A: Clear pH Cali" + + +def test_handle_key_d(): + """ + The function to test the reset handle keys + """ + titrator = Titrator() + titrator.state = ResetPHCalibration(titrator, MainMenu(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_ph_calibration_high_test.py b/tests/ui_state/set_menu/set_ph_calibration_high_test.py new file mode 100644 index 0000000..0b226e0 --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_calibration_high_test.py @@ -0,0 +1,40 @@ +""" +The file to test the PHCalibrationHigh class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.set_menu.set_ph_calibration_high import PHCalibrationHigh +from src.ui_state.set_menu.set_ph_calibration_low import PHCalibrationLow +from src.ui_state.ui_state import UIState + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_high(print_mock): + """ + Test the PHCalibrationHigh state. + """ + titrator = Titrator() + state = PHCalibrationHigh(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("High buffer pH", line=1) + + state.value = "10.0" + state.save_value() + assert titrator.ph_probe._highpoint_calibration == 10.0 + print_mock.assert_any_call("High = 10.0", line=2) + + assert isinstance(titrator.state, PHCalibrationLow) + assert isinstance(titrator.state.previous_state, PHCalibrationHigh) diff --git a/tests/ui_state/set_menu/set_ph_calibration_low_test.py b/tests/ui_state/set_menu/set_ph_calibration_low_test.py new file mode 100644 index 0000000..2964502 --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_calibration_low_test.py @@ -0,0 +1,61 @@ +""" +The file to test the PHCalibrationLow 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_ph_calibration_low import ( + PHCalibrationLow, + PHCalibrationLower, +) +from src.ui_state.ui_state import UIState + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_lower(print_mock): + """ + Test the PHCalibrationLower state. + """ + titrator = Titrator() + state = PHCalibrationLower(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("Lower buffer pH", line=1) + + state.value = "3.5" + state.save_value() + assert titrator.ph_probe._lowpoint_calibration == 3.5 + print_mock.assert_any_call("Low = 3.5", line=2) + + assert isinstance(titrator.state.next_state, MainMenu) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_low(print_mock): + """ + Test the PHCalibrationLow state. + """ + titrator = Titrator() + state = PHCalibrationLow(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("Low buffer pH", line=1) + + state.value = "4.0" + state.save_value() + assert titrator.ph_probe._lowpoint_calibration == 4.0 + print_mock.assert_any_call("Low = 4.0", line=2) + + assert isinstance(titrator.state.next_state, MainMenu) diff --git a/tests/ui_state/set_menu/set_ph_calibration_mid_test.py b/tests/ui_state/set_menu/set_ph_calibration_mid_test.py new file mode 100644 index 0000000..1d4e82f --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_calibration_mid_test.py @@ -0,0 +1,85 @@ +""" +The file to test the PHCalibrationMid 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_ph_calibration_high import PHCalibrationHigh +from src.ui_state.set_menu.set_ph_calibration_low import PHCalibrationLower +from src.ui_state.set_menu.set_ph_calibration_mid import ( + PHCalibrationHigher, + PHCalibrationMid, + PHCalibrationOnly, +) +from src.ui_state.ui_state import UIState + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_only(print_mock): + """ + Test the PHCalibrationOnly state. + """ + titrator = Titrator() + state = PHCalibrationOnly(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("Buffer pH", line=1) + + state.value = "7.0" + state.save_value() + assert titrator.ph_probe._midpoint_calibration == 7.0 + print_mock.assert_any_call("Buffer = 7.0", line=2) + + assert isinstance(titrator.state.next_state, MainMenu) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_higher(print_mock): + """ + Test the PHCalibrationHigher state. + """ + titrator = Titrator() + state = PHCalibrationHigher(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("Higher buffer pH", line=1) + + state.value = "9.0" + state.save_value() + assert titrator.ph_probe._midpoint_calibration == 9.0 + print_mock.assert_any_call("Mid = 9.0", line=2) + + assert isinstance(titrator.state, PHCalibrationLower) + assert isinstance(titrator.state.previous_state, PHCalibrationHigher) + + +@mock.patch.object(LiquidCrystal, "print") +def test_ph_calibration_mid(print_mock): + """ + Test the PHCalibrationMid state. + """ + titrator = Titrator() + state = PHCalibrationMid(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("Mid buffer pH", line=1) + + state.value = "12.3" + state.save_value() + assert titrator.ph_probe._midpoint_calibration == 12.3 + print_mock.assert_any_call("Mid = 12.3", line=2) + + assert isinstance(titrator.state, PHCalibrationHigh) + assert isinstance(titrator.state.previous_state, PHCalibrationMid) diff --git a/tests/ui_state/set_menu/set_ph_calibration_test.py b/tests/ui_state/set_menu/set_ph_calibration_test.py new file mode 100644 index 0000000..8bf0442 --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_calibration_test.py @@ -0,0 +1,104 @@ +""" +The file to test the PHCalibration 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_ph_calibration import PHCalibration +from src.ui_state.set_menu.set_ph_calibration_mid import ( + PHCalibrationHigher, + PHCalibrationMid, + PHCalibrationOnly, +) +from src.ui_state.ui_state import UIState + + +class MockPreviousState(UIState): + """ + A mock previous state for testing purposes + """ + + def __init__(self, titrator): + super().__init__(titrator) + + +@mock.patch.object(LiquidCrystal, "print") +def test_one_point_calibration(print_mock): + """ + Test that entering '1' triggers 1-point calibration and transitions to PHCalibrationOnly. + """ + titrator = Titrator() + state = PHCalibration(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("pH Cali: ", line=1) + print_mock.assert_any_call("1,2 or 3 point?", line=2) + + state.handle_key("1") + print_mock.assert_any_call("1-pt pH calib...", line=2) + + assert isinstance(titrator.state, PHCalibrationOnly) + assert isinstance(titrator.state.previous_state, PHCalibration) + + +@mock.patch.object(LiquidCrystal, "print") +def test_two_point_calibration(print_mock): + """ + Test that entering '2' triggers 2-point calibration and transitions to PHCalibrationHigher. + """ + titrator = Titrator() + state = PHCalibration(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("pH Cali: ", line=1) + print_mock.assert_any_call("1,2 or 3 point?", line=2) + + state.handle_key("2") + print_mock.assert_any_call("2 Point Cali", line=2) + + assert isinstance(titrator.state, PHCalibrationHigher) + assert isinstance(titrator.state.previous_state, PHCalibration) + + +@mock.patch.object(LiquidCrystal, "print") +def test_three_point_calibration(print_mock): + """ + Test that entering '3' triggers 3-point calibration and transitions to PHCalibrationMid. + """ + titrator = Titrator() + state = PHCalibration(titrator, MockPreviousState(titrator)) + + state.loop() + print_mock.assert_any_call("pH Cali: ", line=1) + print_mock.assert_any_call("1,2 or 3 point?", line=2) + + state.handle_key("3") + print_mock.assert_any_call("3 Point Cali", line=2) + + assert isinstance(titrator.state, PHCalibrationMid) + assert isinstance(titrator.state.previous_state, PHCalibration) + + +def test_handle_key_4(): + """ + Test that entering '4' returns to the previous state. + """ + titrator = Titrator() + state = PHCalibration(titrator, MockPreviousState(titrator)) + + state.handle_key("4") + assert isinstance(titrator.state, MockPreviousState) + + +def test_handle_key_d(): + """ + Test that entering 'D' returns to the main menu. + """ + titrator = Titrator() + state = PHCalibration(titrator, MockPreviousState(titrator)) + + state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_ph_sine_wave_test.py b/tests/ui_state/set_menu/set_ph_sine_wave_test.py new file mode 100644 index 0000000..c88603f --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_sine_wave_test.py @@ -0,0 +1,96 @@ +""" +Test suite for the SetPHSineWave 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_ph_sine_wave import SetPHSineWave + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_ph_sine_wave_handle_key(print_mock): + """ + Test that handle_key processes key presses correctly. + """ + titrator = Titrator() + state = SetPHSineWave(titrator, MainMenu(titrator)) + + 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_ph_sine_wave_advances_substates(): + """ + Test that the state advances through substates correctly. + """ + titrator = Titrator() + state = SetPHSineWave(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_ph_sine_wave_valid_input(print_mock): + """ + Test that valid pH mean, amplitude, and period inputs are saved and displayed correctly. + """ + titrator = Titrator() + titrator.ph_control = mock.Mock() + state = SetPHSineWave(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.ph_control.set_base_target_ph.assert_called_once_with(7.5) + titrator.ph_control.set_sine_amplitude_and_hours.assert_called_once_with(2.0, 24.0) + + print_mock.assert_any_call("New pH=7.500", line=1) + print_mock.assert_any_call("A=2.000 P=24.000", line=2) + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_ph_sine_wave_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = SetPHSineWave(titrator, MainMenu(titrator)) + + assert state.get_label() == "Set pH Mean:" + 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 = SetPHSineWave(titrator, MainMenu(titrator)) + + state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/set_menu/set_ph_target_test.py b/tests/ui_state/set_menu/set_ph_target_test.py new file mode 100644 index 0000000..66afd52 --- /dev/null +++ b/tests/ui_state/set_menu/set_ph_target_test.py @@ -0,0 +1,88 @@ +""" +Test suite for the SetPHTarget 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_ph_target import SetPHTarget + + +@mock.patch.object(LiquidCrystal, "print") +def test_set_ph_target_handle_key(print_mock): + """ + Test that handle_key processes key presses correctly. + """ + titrator = Titrator() + state = SetPHTarget(titrator, MainMenu(titrator)) + + 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_ph_target_advances_substates(): + """ + Test that the state advances through substates correctly. + """ + titrator = Titrator() + state = SetPHTarget(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.ph_control = mock.Mock() + state = SetPHTarget(titrator, MainMenu(titrator)) + + state.value = "7.5" + state.save_value() + state.value = "2" + state.save_value() + + titrator.ph_control.set_base_target_ph.assert_called_once_with(7.5) + titrator.ph_control.set_ramp_duration_hours.assert_called_once_with(2.0) + + print_mock.assert_any_call("New pH=7.500", line=1) + print_mock.assert_any_call("New ramp=2.000", line=2) + assert isinstance(titrator.state.next_state, MainMenu) + + +def test_set_ph_target_get_label(): + """ + Test the label returned by get_label. + """ + titrator = Titrator() + state = SetPHTarget(titrator, MainMenu(titrator)) + + assert state.get_label() == "Set pH Set Point:" + 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 = SetPHTarget(titrator, MainMenu(titrator)) + + state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/view_menu/view_ph_calibration_test.py b/tests/ui_state/view_menu/view_ph_calibration_test.py new file mode 100644 index 0000000..5a6e625 --- /dev/null +++ b/tests/ui_state/view_menu/view_ph_calibration_test.py @@ -0,0 +1,57 @@ +""" +the file to test the View PH Calibration 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_ph_calibration import ViewPHCalibration + + +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_ph_calibration(print_mock): + """ + The function to test ViewPHCalibration's loop function + """ + state = ViewPHCalibration(Titrator(), MainMenu(Titrator())) + + state.loop() + + print_mock.assert_any_call(f"{state.titrator.ph_probe.get_calibration()}", line=1) + print_mock.assert_any_call(f"{state.titrator.ph_probe.get_slope()}", line=2) + + +def test_handle_key_4(): + """ + The function to test the back handle key + """ + titrator = Titrator() + + titrator.state = ViewPHCalibration(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 = ViewPHCalibration(titrator, MockPreviousState(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MainMenu) diff --git a/tests/ui_state/view_menu/view_ph_test.py b/tests/ui_state/view_menu/view_ph_test.py new file mode 100644 index 0000000..32d351a --- /dev/null +++ b/tests/ui_state/view_menu/view_ph_test.py @@ -0,0 +1,139 @@ +""" +The file to test the ViewPH class +""" + +from unittest import mock + +from src.devices.library import LiquidCrystal +from src.titrator import Titrator +from src.ui_state.ui_state import UIState +from src.ui_state.view_menu.view_ph import ViewPH + + +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_ph_shows_header_and_values(print_mock): + """ + Test that the ViewPH class displays the header and pH values in the first phase of the loop. + """ + titrator = Titrator() + titrator.ph_probe.get_ph_value = mock.Mock(return_value=7.5) + titrator.ph_control.get_current_target_ph = mock.Mock(return_value=8.0) + titrator.ph_control.get_base_target_ph = mock.Mock(return_value=8.5) + + state = ViewPH(titrator, MockPreviousState(titrator)) + state._start_time = 0.0 + with mock.patch( + "src.ui_state.view_menu.view_ph.time.monotonic", + return_value=1.0, + ): + state.loop() + + print_mock.assert_any_call("Now Next Goal", 1) + print_mock.assert_any_call("7.50 8.000 8.500", 2) + + +@mock.patch.object(LiquidCrystal, "print") +def test_view_ph_shows_function_flat_type(print_mock): + """ + Test that the ViewPH class displays the pH function type and no variables for FLAT_TYPE. + """ + titrator = Titrator() + titrator.ph_control.get_ph_function_type = mock.Mock( + return_value=titrator.ph_control.FLAT_TYPE + ) + + state = ViewPH(titrator, MockPreviousState(titrator)) + state._start_time = 0.0 + + with mock.patch("src.ui_state.view_menu.view_ph.time.monotonic", return_value=4.0): + state.loop() + + print_mock.assert_any_call("type: flat", 1) + print_mock.assert_any_call("", 2) + + +@mock.patch.object(LiquidCrystal, "print") +def test_view_ph_shows_function_ramp_type_and_variables(print_mock): + """ + Test that the ViewPH class displays the pH function type and variables in the second phase of the loop. + """ + titrator = Titrator() + titrator.ph_control.get_ph_function_type = mock.Mock( + return_value=titrator.ph_control.RAMP_TYPE + ) + titrator.ph_control.get_ramp_time_end = mock.Mock(return_value=4000) + + state = ViewPH(titrator, MockPreviousState(titrator)) + state._start_time = 0.0 + + with mock.patch("src.ui_state.view_menu.view_ph.time.monotonic", return_value=4.0): + state.loop() + + current_time = int(4.0) + time_left = 4000 - current_time + time_left_hours = time_left // 3600 + time_left_minutes = (time_left % 3600) // 60 + time_left_seconds = time_left % 60 + expected_message = ( + f"left: {time_left_hours}:{time_left_minutes}:{time_left_seconds}" + ) + + print_mock.assert_any_call("type: ramp", 1) + print_mock.assert_any_call(expected_message, 2) + + +@mock.patch.object(LiquidCrystal, "print") +def test_view_ph_shows_function_sine_type_and_variables(print_mock): + """ + Test that the ViewPH class displays the pH function type and variables for SINE_TYPE. + """ + titrator = Titrator() + titrator.ph_control.get_ph_function_type = mock.Mock( + return_value=titrator.ph_control.SINE_TYPE + ) + titrator.ph_control.get_period_in_seconds = mock.Mock(return_value=7200) # 2 hours + titrator.ph_control.get_amplitude = mock.Mock(return_value=0.5) + + state = ViewPH(titrator, MockPreviousState(titrator)) + state._start_time = 0.0 + + with mock.patch("src.ui_state.view_menu.view_ph.time.monotonic", return_value=4.0): + state.loop() + + period_hours = 7200 / 3600.0 + amplitude = 0.5 + expected_message = f"p={period_hours:.3f} a={amplitude:.3f}" + + print_mock.assert_any_call("type: sine", 1) + print_mock.assert_any_call(expected_message, 2) + + +def test_handle_key_4(): + """ + Test that pressing key 4 returns to the previous state. + """ + titrator = Titrator() + titrator.state = ViewPH(titrator, MockPreviousState(titrator)) + + titrator.state.handle_key("4") + assert isinstance(titrator.state, MockPreviousState) + + +def test_handle_key_d(): + """ + Test that pressing key D returns to the previous state. + """ + titrator = Titrator() + titrator.state = ViewPH(titrator, MockPreviousState(titrator)) + + titrator.state.handle_key("D") + assert isinstance(titrator.state, MockPreviousState)