Beispiel #1
0
class Ilm200Tests(unittest.TestCase):
    """
    Tests for the Ilm200 IOC.
    """
    DEFAULT_SCAN_RATE = 1
    SLOW = "Slow"
    FAST = "Fast"
    LEVEL_TOLERANCE = 0.1

    FULL = 100.0
    LOW = 10.0
    FILL = 5.0

    RATE = "RATE"
    LEVEL = "LEVEL"
    TYPE = "TYPE"
    CURRENT = "CURR"

    @staticmethod
    def channel_range():
        number_of_channels = 3
        starting_index = 1
        return range(starting_index, starting_index + number_of_channels)

    def helium_channels(self):
        for i in self.channel_range():
            if self.ca.get_pv_value(self.ch_pv(i, self.TYPE)) != "Nitrogen":
                yield i

    @staticmethod
    def ch_pv(channel, pv):
        return "CH{}:{}".format(channel, pv)

    def setUp(self):
        self._lewis, self._ioc = get_running_lewis_and_ioc("ilm200", DEVICE_PREFIX)
        self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_wait_time=0.0)
        self.ca.assert_that_pv_exists("VERSION", timeout=30)
        self._lewis.backdoor_set_on_device("cycle", False)

        self._lewis.backdoor_run_function_on_device("set_cryo_type", (1, Ilm200ChannelTypes.NITROGEN))
        self._lewis.backdoor_run_function_on_device("set_cryo_type", (2, Ilm200ChannelTypes.HELIUM))
        self._lewis.backdoor_run_function_on_device("set_cryo_type", (3, Ilm200ChannelTypes.HELIUM_CONT))

    def set_level_via_backdoor(self, channel, level):
        self._lewis.backdoor_command(["device", "set_level", str(channel), str(level)])

    def set_helium_current_via_backdoor(self, channel, is_on):
        self._lewis.backdoor_command(["device", "set_helium_current", str(channel), str(is_on)])

    def check_state(self, channel, level, is_filling, is_low):
        self.ca.assert_that_pv_is_number(self.ch_pv(channel, self.LEVEL), level, self.LEVEL_TOLERANCE)
        self.ca.assert_that_pv_is(self.ch_pv(channel, "FILLING"), "Filling" if is_filling else "Not filling")
        self.ca.assert_that_pv_is(self.ch_pv(channel, "LOW"), "Low" if is_low else "Not low")

    def test_GIVEN_ilm200_THEN_has_version(self):
        self.ca.assert_that_pv_is_not("VERSION", "")
        self.ca.assert_that_pv_alarm_is("VERSION", self.ca.Alarms.NONE)

    def test_GIVEN_ilm200_THEN_each_channel_has_type(self):
        for i in self.channel_range():
            self.ca.assert_that_pv_is_not(self.ch_pv(i, self.TYPE), "Not in use")
            self.ca.assert_that_pv_alarm_is(self.ch_pv(i, self.TYPE), self.ca.Alarms.NONE)

    def set_and_check_level(self):
        for i in self.channel_range():
            level = ALARM_THRESHOLDS[i] + 10
            self.set_level_via_backdoor(i, level)
            self.ca.assert_that_pv_is_number(self.ch_pv(i, self.LEVEL), level, tolerance=0.1)
            self.ca.assert_that_pv_alarm_is(self.ch_pv(i, self.LEVEL), self.ca.Alarms.NONE)

    @skip_if_recsim("no backdoor in recsim")
    def test_GIVEN_ilm_200_THEN_can_read_level(self):
        self.set_and_check_level()

    @skip_if_recsim("no backdoor in recsim")
    def test_GIVEN_ilm_200_non_isobus_THEN_can_read_level(self):
        with self._ioc.start_with_macros({"USE_ISOBUS": "NO"}, pv_to_wait_for="VERSION"):
            self.set_and_check_level()

    @skip_if_recsim("Cannot do back door of dynamic behaviour in recsim")
    def test_GIVEN_ilm_200_WHEN_level_set_on_device_THEN_reported_level_matches_set_level(self):
        for i in self.channel_range():
            expected_level = i*12.3
            self.set_level_via_backdoor(i, expected_level)
            self.ca.assert_that_pv_is_number(self.ch_pv(i, self.LEVEL), expected_level, self.LEVEL_TOLERANCE)

    @skip_if_recsim("No dynamic behaviour recsim")
    def test_GIVEN_ilm_200_WHEN_is_cycling_THEN_channel_levels_change_over_time(self):
        self._lewis.backdoor_set_on_device("cycle", True)
        for i in self.channel_range():
            def not_equal(a, b):
                tolerance = self.LEVEL_TOLERANCE
                return abs(a-b)/(a+b+tolerance) > tolerance
            self.ca.assert_that_pv_value_over_time_satisfies_comparator(self.ch_pv(i, self.LEVEL), 2 * Ilm200Tests.DEFAULT_SCAN_RATE, not_equal)

    def test_GIVEN_ilm200_channel_WHEN_rate_change_requested_THEN_rate_changed(self):
        for i in self.channel_range():
            initial_rate = self.ca.get_pv_value(self.ch_pv(i, self.RATE))
            alternate_rate = self.SLOW if initial_rate == self.FAST else self.SLOW

            self.ca.assert_setting_setpoint_sets_readback(alternate_rate, self.ch_pv(i, self.RATE))
            self.ca.assert_setting_setpoint_sets_readback(initial_rate, self.ch_pv(i, self.RATE))

    def test_GIVEN_ilm200_channel_WHEN_rate_set_to_current_value_THEN_rate_unchanged(self):
        for i in self.channel_range():
            self.ca.assert_setting_setpoint_sets_readback(self.ca.get_pv_value(self.ch_pv(i, self.RATE)),
                                                          self.ch_pv(i, self.RATE))

    @skip_if_recsim("Cannot do back door of dynamic behaviour in recsim")
    def test_GIVEN_ilm200_WHEN_channel_full_THEN_not_filling_and_not_low(self):
        for i in self.channel_range():
            level = self.FULL
            self.set_level_via_backdoor(i, level)
            self.check_state(i, level, False, False)

    @skip_if_recsim("Cannot do back door of dynamic behaviour in recsim")
    def test_GIVEN_ilm200_WHEN_channel_low_but_auto_fill_not_triggered_THEN_not_filling_and_low(self):
        for i in self.channel_range():
            level = self.LOW - (self.LOW - self.FILL)/2  # Somewhere between fill and low
            self.set_level_via_backdoor(i, level)
            self.check_state(i, level, False, True)

    @skip_if_recsim("Cannot do back door of dynamic behaviour in recsim")
    def test_GIVEN_ilm200_WHEN_channel_low_but_and_auto_fill_triggered_THEN_filling_and_low(self):
        for i in self.channel_range():
            level = self.FILL/2
            self.set_level_via_backdoor(i, level)
            self.check_state(i, level, True, True)

    @skip_if_recsim("Cannot do back door of dynamic behaviour in recsim")
    def test_GIVEN_ilm200_WHEN_channel_low_THEN_alarm(self):
        for i in self.channel_range():
            level = self.FILL/2
            self.set_level_via_backdoor(i, level)
            self.ca.assert_that_pv_alarm_is(self.ch_pv(i, "LOW"), self.ca.Alarms.MINOR)

    @skip_if_recsim("Cannot do back door in recsim")
    def test_GIVEN_helium_channel_WHEN_helium_current_set_on_THEN_ioc_reports_current(self):
        for i in self.helium_channels():
            self.set_helium_current_via_backdoor(i, True)
            self.ca.assert_that_pv_is(self.ch_pv(i, self.CURRENT), "On")

    @skip_if_recsim("Cannot do back door in recsim")
    def test_GIVEN_helium_channel_WHEN_helium_current_set_off_THEN_ioc_reports_no_current(self):
        for i in self.helium_channels():
            self.set_helium_current_via_backdoor(i, False)
            self.ca.assert_that_pv_is(self.ch_pv(i, self.CURRENT), "Off")

    @skip_if_recsim("cannot do back door in recsim")
    def test_GIVEN_not_in_use_channel_THEN_being_in_neither_fast_nor_slow_mode_does_not_cause_alarm(self):
        self._lewis.backdoor_run_function_on_device("set_cryo_type", (1, Ilm200ChannelTypes.NOT_IN_USE))

        # Assert in neither fast nor slow mode
        self.ca.assert_that_pv_is(self.ch_pv(1, "STAT:RAW.B1"), "0")
        self.ca.assert_that_pv_is(self.ch_pv(1, "STAT:RAW.B2"), "0")

        # Assert that this does not cause an alarm
        self.ca.assert_that_pv_alarm_is(self.ch_pv(1, "RATE:ASSERT"), self.ca.Alarms.NONE)

    @skip_if_recsim("no backdoor in recsim")
    def test_GIVEN_level_reading_is_below_threshold_THEN_goes_into_alarm(self):
        for channel in self.channel_range():
            self.set_level_via_backdoor(channel, ALARM_THRESHOLDS[channel] + 0.1)
            self.ca.assert_that_pv_alarm_is(self.ch_pv(channel, "LEVEL"), self.ca.Alarms.NONE)

            self.set_level_via_backdoor(channel, ALARM_THRESHOLDS[channel] - 0.1)
            self.ca.assert_that_pv_alarm_is(self.ch_pv(channel, "LEVEL"), self.ca.Alarms.MAJOR)
Beispiel #2
0
class ZeroFieldTests(unittest.TestCase):
    """
    Tests for the muon zero field controller IOC.
    """
    def _set_simulated_measured_fields(self,
                                       fields,
                                       overload=False,
                                       wait_for_update=True):
        """
        Args:
            fields (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to the
              required fields
            overload (bool): whether to simulate the magnetometer being overloaded
            wait_for_update (bool): whether to wait for the statemachine to pick up the new readings
        """
        for axis in FIELD_AXES:
            self.magnetometer_ca.set_pv_value("SIM:DAQ:{}".format(axis),
                                              fields[axis],
                                              sleep_after_set=0)

        # Just overwrite the calculation to return a constant as we are not interested in testing the
        # overload logic in the magnetometer in these tests (that logic is tested separately).
        self.magnetometer_ca.set_pv_value("OVERLOAD:_CALC.CALC",
                                          "1" if overload else "0",
                                          sleep_after_set=0)

        if wait_for_update:
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_is("FIELD:{}".format(axis),
                                                  fields[axis])
                self.zfcntrl_ca.assert_that_pv_is("FIELD:{}:MEAS".format(axis),
                                                  fields[axis])

    def _set_user_setpoints(self, fields):
        """
        Args:
            fields (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to the
              required fields 
        """
        for axis in FIELD_AXES:
            self.zfcntrl_ca.set_pv_value("FIELD:{}:SP".format(axis),
                                         fields[axis],
                                         sleep_after_set=0)

    def _set_simulated_power_supply_currents(self,
                                             currents,
                                             wait_for_update=True):
        """
        Args:
            currents (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to
              the required currents
            wait_for_update (bool): whether to wait for the readback and setpoint readbacks to update
        """
        for axis in FIELD_AXES:
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR:SP".format(axis),
                                         currents[axis],
                                         sleep_after_set=0)

        if wait_for_update:
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_is(
                    "OUTPUT:{}:CURR".format(axis), currents[axis])
                self.zfcntrl_ca.assert_that_pv_is(
                    "OUTPUT:{}:CURR:SP:RBV".format(axis), currents[axis])

    def _set_simulated_power_supply_voltages(self,
                                             voltages,
                                             wait_for_update=True):
        """
        Args:
            voltages (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to
              the required voltages
            wait_for_update (bool): whether to wait for the readback and setpoint readbacks to update
        """
        for axis in FIELD_AXES:
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:VOLT:SP".format(axis),
                                         voltages[axis],
                                         sleep_after_set=0)

        if wait_for_update:
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_is(
                    "OUTPUT:{}:VOLT".format(axis), voltages[axis])
                self.zfcntrl_ca.assert_that_pv_is(
                    "OUTPUT:{}:VOLT:SP:RBV".format(axis), voltages[axis])

    def _assert_at_setpoint(self, status):
        """
        Args:
            status (string): value of AT_SETPOINT PV (either Yes, No or N/A)
        """
        self.zfcntrl_ca.assert_that_pv_is("AT_SETPOINT", status)

    def _assert_status(self, status):
        """
        Args:
            status (Tuple[str, str]): the controller status and error to assert.
        """
        name, expected_alarm = status

        # Special case - this alarm should be suppressed in manual mode. This is because, in manual mode, the
        # scientists will intentionally apply large fields (which overload the magnetometer), but they do not want
        # alarms for this case as it is a "normal" mode of operation.
        if name == Statuses.MAGNETOMETER_OVERLOAD[
                0] and self.zfcntrl_ca.get_pv_value(
                    "AUTOFEEDBACK") == "Manual":
            expected_alarm = self.zfcntrl_ca.Alarms.NONE

        self.zfcntrl_ca.assert_that_pv_is("STATUS", name)
        self.zfcntrl_ca.assert_that_pv_alarm_is("STATUS", expected_alarm)

    def _set_autofeedback(self, autofeedback):
        self.zfcntrl_ca.set_pv_value(
            "AUTOFEEDBACK", "Auto-feedback" if autofeedback else "Manual")

    def _set_scaling_factors(self, px, py, pz, fiddle):
        """
        Args:
            px (float): Amps per mG for the X axis.
            py (float): Amps per mG for the Y axis.
            pz (float): Amps per mG for the Z axis.
            fiddle (float): The feedback (sometimes called "fiddle") factor.
        """
        self.zfcntrl_ca.set_pv_value("P:X", px, sleep_after_set=0)
        self.zfcntrl_ca.set_pv_value("P:Y", py, sleep_after_set=0)
        self.zfcntrl_ca.set_pv_value("P:Z", pz, sleep_after_set=0)
        self.zfcntrl_ca.set_pv_value("P:FEEDBACK", fiddle, sleep_after_set=0)

    def _set_output_limits(self, lower_limits, upper_limits):
        """
        Args:
            lower_limits (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to
              the required output lower limits
            upper_limits (dict[AnyStr, float]): A dictionary with the same keys as FIELD_AXES and values corresponding to
              the required output upper limits
        """
        for axis in FIELD_AXES:
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR:SP.DRVL".format(axis),
                                         lower_limits[axis],
                                         sleep_after_set=0)
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR:SP.LOLO".format(axis),
                                         lower_limits[axis],
                                         sleep_after_set=0)
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR:SP.DRVH".format(axis),
                                         upper_limits[axis],
                                         sleep_after_set=0)
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR:SP.HIHI".format(axis),
                                         upper_limits[axis],
                                         sleep_after_set=0)

            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR.LOLO".format(axis),
                                         lower_limits[axis],
                                         sleep_after_set=0)
            self.zfcntrl_ca.set_pv_value("OUTPUT:{}:CURR.HIHI".format(axis),
                                         upper_limits[axis],
                                         sleep_after_set=0)

            self.zfcntrl_ca.set_pv_value(
                "OUTPUT:{}:CURR:SP:RBV.LOLO".format(axis),
                lower_limits[axis],
                sleep_after_set=0)
            self.zfcntrl_ca.set_pv_value(
                "OUTPUT:{}:CURR:SP:RBV.HIHI".format(axis),
                upper_limits[axis],
                sleep_after_set=0)

    @contextlib.contextmanager
    def _simulate_disconnected_magnetometer(self):
        """
        While this context manager is active, the magnetometer IOC will fail to take any new readings or process any PVs
        """
        self.magnetometer_ca.set_pv_value("DISABLE", 1, sleep_after_set=0)
        try:
            yield
        finally:
            self.magnetometer_ca.set_pv_value("DISABLE", 0, sleep_after_set=0)

    @contextlib.contextmanager
    def _simulate_invalid_magnetometer_readings(self):
        """
        While this context manager is active, any new readings from the magnetometer will be marked as INVALID
        """
        for axis in FIELD_AXES:
            self.magnetometer_ca.set_pv_value(
                "DAQ:{}:_RAW.SIMS".format(axis),
                self.magnetometer_ca.Alarms.INVALID,
                sleep_after_set=0)

        # Wait for RAW PVs to process
        for axis in FIELD_AXES:
            self.magnetometer_ca.assert_that_pv_alarm_is(
                "DAQ:{}:_RAW.SEVR".format(axis),
                self.magnetometer_ca.Alarms.INVALID)
        try:
            yield
        finally:
            for axis in FIELD_AXES:
                self.magnetometer_ca.set_pv_value(
                    "DAQ:{}:_RAW.SIMS".format(axis),
                    self.magnetometer_ca.Alarms.NONE,
                    sleep_after_set=0)
            # Wait for RAW PVs to process
            for axis in FIELD_AXES:
                self.magnetometer_ca.assert_that_pv_alarm_is(
                    "DAQ:{}:_RAW.SEVR".format(axis),
                    self.magnetometer_ca.Alarms.NONE)

    @contextlib.contextmanager
    def _simulate_invalid_power_supply(self):
        """
        While this context manager is active, the readback values from all power supplies will be marked as INVALID
        (this simulates the device not being plugged in, for example)
        """
        pvs_to_make_invalid = ("CURRENT", "_CURRENT:SP:RBV", "OUTPUTMODE",
                               "OUTPUTSTATUS", "VOLTAGE", "VOLTAGE:SP:RBV")

        for ca, pv in itertools.product(
            (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca),
                pvs_to_make_invalid):
            # 3 is the Enum value for an invalid alarm
            ca.set_pv_value("{}.SIMS".format(pv), 3, sleep_after_set=0)

        # Use a separate loop to avoid needing to wait for a 1-second scan 6 times.
        for ca, pv in itertools.product(
            (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca),
                pvs_to_make_invalid):
            ca.assert_that_pv_alarm_is(pv, ca.Alarms.INVALID)

        try:
            yield
        finally:
            for ca, pv in itertools.product(
                (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca),
                    pvs_to_make_invalid):
                ca.set_pv_value("{}.SIMS".format(pv), 0, sleep_after_set=0)

            # Use a separate loop to avoid needing to wait for a 1-second scan 6 times.
            for ca, pv in itertools.product(
                (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca),
                    pvs_to_make_invalid):
                ca.assert_that_pv_alarm_is(pv, ca.Alarms.NONE)

    @contextlib.contextmanager
    def _simulate_failing_power_supply_writes(self):
        """
        While this context manager is active, any writes to the power supply PVs will be ignored. This simulates the
        device being in local mode, for example. Note that this does not mark readbacks as invalid (for that, use
        _simulate_invalid_power_supply instead).
        """
        pvs = [
            "CURRENT:SP.DISP", "VOLTAGE:SP.DISP", "OUTPUTMODE:SP.DISP",
            "OUTPUTSTATUS:SP.DISP"
        ]

        for ca, pv in itertools.product(
            (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca), pvs):
            ca.set_pv_value(pv, 1, sleep_after_set=0)
        try:
            yield
        finally:
            for ca, pv in itertools.product(
                (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca), pvs):
                ca.set_pv_value(pv, 0, sleep_after_set=0)

    @contextlib.contextmanager
    def _simulate_measured_fields_changing_with_outputs(
            self, psu_amps_at_measured_zero):
        """
        Calculates and sets somewhat realistic simulated measured fields based on the current values of power supplies.

        Args:
            psu_amps_at_measured_zero: Dictionary containing the Amps of the power supplies when the measured field
              corresponds to zero. i.e. if the system is told to go to zero field, these are the power supply readings
              it will require to get there.
        """
        # Always start at zero current
        self._set_simulated_power_supply_currents({"X": 0, "Y": 0, "Z": 0})

        thread = multiprocessing.Process(target=_update_fields_continuously,
                                         args=(psu_amps_at_measured_zero, ))
        thread.start()
        try:
            yield
        finally:
            thread.terminate()

    def _wait_for_all_iocs_up(self):
        """
        Waits for the "primary" pv(s) from each ioc to be available
        """
        for ca in (self.x_psu_ca, self.y_psu_ca, self.z_psu_ca):
            ca.assert_that_pv_exists("CURRENT")
            ca.assert_that_pv_exists("CURRENT:SP")
            ca.assert_that_pv_exists("CURRENT:SP:RBV")

        for axis in FIELD_AXES:
            self.zfcntrl_ca.assert_that_pv_exists("FIELD:{}".format(axis))
            self.magnetometer_ca.assert_that_pv_exists(
                "CORRECTEDFIELD:{}".format(axis))

    def setUp(self):
        _, self._ioc = get_running_lewis_and_ioc(None, ZF_DEVICE_PREFIX)

        timeout = 20
        self.zfcntrl_ca = ChannelAccess(device_prefix=ZF_DEVICE_PREFIX,
                                        default_timeout=timeout)
        self.magnetometer_ca = ChannelAccess(
            device_prefix=MAGNETOMETER_DEVICE_PREFIX, default_timeout=timeout)
        self.x_psu_ca = ChannelAccess(default_timeout=timeout,
                                      device_prefix=X_KEPCO_DEVICE_PREFIX)
        self.y_psu_ca = ChannelAccess(default_timeout=timeout,
                                      device_prefix=Y_KEPCO_DEVICE_PREFIX)
        self.z_psu_ca = ChannelAccess(default_timeout=timeout,
                                      device_prefix=Z_KEPCO_DEVICE_PREFIX)

        self._wait_for_all_iocs_up()

        self.zfcntrl_ca.set_pv_value("TOLERANCE",
                                     STABILITY_TOLERANCE,
                                     sleep_after_set=0)
        self.zfcntrl_ca.set_pv_value("STATEMACHINE:LOOP_DELAY",
                                     LOOP_DELAY_MS,
                                     sleep_after_set=0)
        self._set_autofeedback(False)

        # Set the magnetometer calibration to the 3x3 identity matrix
        for x, y in itertools.product(range(1, 3 + 1), range(1, 3 + 1)):
            self.magnetometer_ca.set_pv_value("SENSORMATRIX:{}{}".format(x, y),
                                              1 if x == y else 0,
                                              sleep_after_set=0)

        self._set_simulated_measured_fields(ZERO_FIELD, overload=False)
        self._set_user_setpoints(ZERO_FIELD)
        self._set_simulated_power_supply_currents(ZERO_FIELD,
                                                  wait_for_update=True)
        self._set_scaling_factors(1, 1, 1, 1)
        self._set_output_limits(
            lower_limits={
                "X": DEFAULT_LOW_OUTPUT_LIMIT,
                "Y": DEFAULT_LOW_OUTPUT_LIMIT,
                "Z": DEFAULT_LOW_OUTPUT_LIMIT
            },
            upper_limits={
                "X": DEFAULT_HIGH_OUTPUT_LIMIT,
                "Y": DEFAULT_HIGH_OUTPUT_LIMIT,
                "Z": DEFAULT_HIGH_OUTPUT_LIMIT
            },
        )

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.NO_ERROR)

    def test_WHEN_ioc_is_started_THEN_it_is_not_disabled(self):
        self.zfcntrl_ca.assert_that_pv_is("DISABLE", "COMMS ENABLED")

    @parameterized.expand(parameterized_list(FIELD_AXES))
    def test_WHEN_manual_mode_and_any_readback_value_is_not_equal_to_setpoint_THEN_at_setpoint_field_is_na(
            self, _, axis_to_vary):
        fields = {"X": 10, "Y": 20, "Z": 30}
        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)

        # Set one of the parameters to a completely different value
        self.zfcntrl_ca.set_pv_value("FIELD:{}:SP".format(axis_to_vary),
                                     100,
                                     sleep_after_set=0)

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.NO_ERROR)

    def test_GIVEN_manual_mode_and_magnetometer_not_overloaded_WHEN_readback_values_are_equal_to_setpoints_THEN_at_setpoint_field_is_na(
            self):
        fields = {"X": 55, "Y": 66, "Z": 77}
        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.NO_ERROR)

    def test_GIVEN_manual_mode_and_within_tolerance_WHEN_magnetometer_is_overloaded_THEN_status_overloaded_and_setpoint_field_is_na(
            self):
        fields = {"X": 55, "Y": 66, "Z": 77}
        self._set_simulated_measured_fields(fields, overload=True)
        self._set_user_setpoints(fields)

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.MAGNETOMETER_OVERLOAD)

    def test_GIVEN_manual_mode_and_just_outside_tolerance_WHEN_magnetometer_is_overloaded_THEN_status_overloaded_and_setpoint_field_is_na(
            self):
        fields = {"X": 55, "Y": 66, "Z": 77}
        self._set_simulated_measured_fields(fields, overload=True)
        self._set_user_setpoints({
            k: v + 1.01 * STABILITY_TOLERANCE
            for k, v in six.iteritems(fields)
        })

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.MAGNETOMETER_OVERLOAD)

    def test_GIVEN_manual_mode_and_just_within_tolerance_WHEN_magnetometer_is_overloaded_THEN_status_overloaded_and_setpoint_field_is_na(
            self):
        fields = {"X": 55, "Y": 66, "Z": 77}
        self._set_simulated_measured_fields(fields, overload=True)
        self._set_user_setpoints({
            k: v + 0.99 * STABILITY_TOLERANCE
            for k, v in six.iteritems(fields)
        })

        self._assert_at_setpoint(AtSetpointStatuses.NA)
        self._assert_status(Statuses.MAGNETOMETER_OVERLOAD)

    def test_WHEN_magnetometer_ioc_does_not_respond_THEN_status_is_magnetometer_read_error(
            self):
        fields = {"X": 1, "Y": 2, "Z": 3}
        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)

        with self._simulate_disconnected_magnetometer():
            self._assert_status(Statuses.MAGNETOMETER_READ_ERROR)
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_alarm_is(
                    "FIELD:{}".format(axis), self.zfcntrl_ca.Alarms.INVALID)
                self.zfcntrl_ca.assert_that_pv_alarm_is(
                    "FIELD:{}:MEAS".format(axis),
                    self.zfcntrl_ca.Alarms.INVALID)

        # Now simulate recovery and assert error gets cleared correctly
        self._assert_status(Statuses.NO_ERROR)
        for axis in FIELD_AXES:
            self.zfcntrl_ca.assert_that_pv_alarm_is(
                "FIELD:{}".format(axis), self.zfcntrl_ca.Alarms.NONE)
            self.zfcntrl_ca.assert_that_pv_alarm_is(
                "FIELD:{}:MEAS".format(axis), self.zfcntrl_ca.Alarms.NONE)

    def test_WHEN_magnetometer_ioc_readings_are_invalid_THEN_status_is_magnetometer_invalid(
            self):
        fields = {"X": 1, "Y": 2, "Z": 3}
        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)

        with self._simulate_invalid_magnetometer_readings():
            self._assert_status(Statuses.MAGNETOMETER_DATA_INVALID)
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_alarm_is(
                    "FIELD:{}".format(axis), self.zfcntrl_ca.Alarms.INVALID)
                self.zfcntrl_ca.assert_that_pv_alarm_is(
                    "FIELD:{}:MEAS".format(axis),
                    self.zfcntrl_ca.Alarms.INVALID)

        # Now simulate recovery and assert error gets cleared correctly
        self._assert_status(Statuses.NO_ERROR)
        for axis in FIELD_AXES:
            self.zfcntrl_ca.assert_that_pv_alarm_is(
                "FIELD:{}".format(axis), self.zfcntrl_ca.Alarms.NONE)
            self.zfcntrl_ca.assert_that_pv_alarm_is(
                "FIELD:{}:MEAS".format(axis), self.zfcntrl_ca.Alarms.NONE)

    def test_WHEN_power_supplies_are_invalid_THEN_status_is_power_supplies_invalid(
            self):
        fields = {"X": 1, "Y": 2, "Z": 3}
        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)
        self._set_autofeedback(True)

        with self._simulate_invalid_power_supply():
            self._assert_at_setpoint(
                AtSetpointStatuses.TRUE
            )  # Invalid power supplies do not mark the field as "not at setpoint"
            self._assert_status(Statuses.PSU_INVALID)

        # Now simulate recovery and assert error gets cleared correctly
        self._assert_at_setpoint(AtSetpointStatuses.TRUE)
        self._assert_status(Statuses.NO_ERROR)

    def test_WHEN_power_supplies_writes_fail_THEN_status_is_power_supply_writes_failed(
            self):
        fields = {"X": 1, "Y": 2, "Z": 3}
        self._set_simulated_measured_fields(fields, overload=False)

        # For this test we need changing fields so that we can detect that the writes failed
        self._set_user_setpoints({
            k: v + 10 * STABILITY_TOLERANCE
            for k, v in six.iteritems(fields)
        })
        # ... and we also need large limits so that we see that the writes failed as opposed to a limits error
        self._set_output_limits(lower_limits={k: -999999
                                              for k in FIELD_AXES},
                                upper_limits={k: 999999
                                              for k in FIELD_AXES})
        self._set_autofeedback(True)

        with self._simulate_failing_power_supply_writes():
            self._assert_status(Statuses.PSU_WRITE_FAILED)

        # Now simulate recovery and assert error gets cleared correctly
        self._assert_status(Statuses.NO_ERROR)

    def test_GIVEN_measured_field_and_setpoints_are_identical_THEN_setpoints_remain_unchanged(
            self):
        fields = {"X": 5, "Y": 10, "Z": -5}
        outputs = {"X": -1, "Y": -2, "Z": -3}

        self._set_simulated_measured_fields(fields, overload=False)
        self._set_user_setpoints(fields)
        self._set_simulated_power_supply_currents(outputs,
                                                  wait_for_update=True)

        self._set_autofeedback(True)

        for axis in FIELD_AXES:
            self.zfcntrl_ca.assert_that_pv_is_number(
                "OUTPUT:{}:CURR".format(axis), outputs[axis], tolerance=0.0001)
            self.zfcntrl_ca.assert_that_pv_value_is_unchanged(
                "OUTPUT:{}:CURR".format(axis), wait=5)

    @parameterized.expand(
        parameterized_list([
            # If measured field is smaller than the setpoint, we want to adjust the output upwards to compensate
            (operator.sub, operator.gt, 1),
            # If measured field is larger than the setpoint, we want to adjust the output downwards to compensate
            (operator.add, operator.lt, 1),
            # If measured field is smaller than the setpoint, and A/mg is negative, we want to adjust the output downwards
            # to compensate
            (operator.sub, operator.lt, -1),
            # If measured field is larger than the setpoint, and A/mg is negative, we want to adjust the output upwards
            # to compensate
            (operator.add, operator.gt, -1),
            # If measured field is smaller than the setpoint, and A/mg is zero, then power supply output should remain
            # unchanged
            (operator.sub, operator.eq, 0),
            # If measured field is larger than the setpoint, and A/mg is zero, then power supply output should remain
            # unchanged
            (operator.add, operator.eq, 0),
        ]))
    def test_GIVEN_autofeedback_WHEN_measured_field_different_from_setpoints_THEN_power_supply_outputs_move_in_correct_direction(
            self, _, measured_field_modifier, output_comparator,
            scaling_factor):

        fields = {"X": 5, "Y": 0, "Z": -5}

        adjustment_amount = 10 * STABILITY_TOLERANCE  # To ensure that it is not considered stable to start with
        measured_fields = {
            k: measured_field_modifier(v, adjustment_amount)
            for k, v in six.iteritems(fields)
        }

        self._set_scaling_factors(scaling_factor,
                                  scaling_factor,
                                  scaling_factor,
                                  fiddle=1)
        self._set_simulated_measured_fields(measured_fields, overload=False)
        self._set_user_setpoints(fields)
        self._set_simulated_power_supply_currents({
            "X": 0,
            "Y": 0,
            "Z": 0
        },
                                                  wait_for_update=True)
        self._set_output_limits(lower_limits={k: -999999
                                              for k in FIELD_AXES},
                                upper_limits={k: 999999
                                              for k in FIELD_AXES})

        self._assert_status(Statuses.NO_ERROR)

        self._set_autofeedback(True)
        self._assert_at_setpoint(AtSetpointStatuses.FALSE)

        for axis in FIELD_AXES:
            self.zfcntrl_ca.assert_that_pv_value_over_time_satisfies_comparator(
                "OUTPUT:{}:CURR".format(axis),
                wait=5,
                comparator=output_comparator)

        # In this happy-path case, we shouldn't be hitting any long timeouts, so loop times should remain fairly quick
        self.zfcntrl_ca.assert_that_pv_is_within_range(
            "STATEMACHINE:LOOP_TIME", min_value=0, max_value=2 * LOOP_DELAY_MS)

    def test_GIVEN_output_limits_too_small_for_required_field_THEN_status_error_and_alarm(
            self):
        self._set_output_limits(
            lower_limits={
                "X": -0.1,
                "Y": -0.1,
                "Z": -0.1
            },
            upper_limits={
                "X": 0.1,
                "Y": 0.1,
                "Z": 0.1
            },
        )

        # The measured field is smaller than the setpoint, i.e. the output needs to go up to the limits
        self._set_simulated_measured_fields({"X": -1, "Y": -1, "Z": -1})
        self._set_user_setpoints(ZERO_FIELD)
        self._set_simulated_power_supply_currents(ZERO_FIELD)

        self._set_autofeedback(True)

        self._assert_status(Statuses.PSU_ON_LIMITS)
        for axis in FIELD_AXES:
            # Value should be on one of the limits
            self.zfcntrl_ca.assert_that_pv_is_one_of(
                "OUTPUT:{}:CURR:SP".format(axis), [-0.1, 0.1])
            # ...and in alarm
            self.zfcntrl_ca.assert_that_pv_alarm_is(
                "OUTPUT:{}:CURR:SP".format(axis), self.zfcntrl_ca.Alarms.MAJOR)

    def test_GIVEN_limits_wrong_way_around_THEN_appropriate_error_raised(self):
        # Set upper limits < lower limits
        self._set_output_limits(
            lower_limits={
                "X": 0.1,
                "Y": 0.1,
                "Z": 0.1
            },
            upper_limits={
                "X": -0.1,
                "Y": -0.1,
                "Z": -0.1
            },
        )
        self._set_autofeedback(True)
        self._assert_status(Statuses.INVALID_PSU_LIMITS)

    @parameterized.expand(
        parameterized_list([
            {
                "X": 45.678,
                "Y": 0.123,
                "Z": 12.345
            },
            {
                "X": 0,
                "Y": 0,
                "Z": 0
            },
            {
                "X": -45.678,
                "Y": -0.123,
                "Z": -12.345
            },
        ]))
    def test_GIVEN_measured_values_updating_realistically_WHEN_in_auto_mode_THEN_converges_to_correct_answer(
            self, _, psu_amps_at_zero_field):
        self._set_output_limits(lower_limits={k: -100
                                              for k in FIELD_AXES},
                                upper_limits={k: 100
                                              for k in FIELD_AXES})
        self._set_user_setpoints({"X": 0, "Y": 0, "Z": 0})
        self._set_simulated_power_supply_currents({"X": 0, "Y": 0, "Z": 0})

        # Set fiddle small to get a relatively slow response, which should theoretically be stable
        self._set_scaling_factors(0.001, 0.001, 0.001, fiddle=0.05)

        with self._simulate_measured_fields_changing_with_outputs(
                psu_amps_at_measured_zero=psu_amps_at_zero_field):
            self._set_autofeedback(True)
            for axis in FIELD_AXES:
                self.zfcntrl_ca.assert_that_pv_is_number(
                    "OUTPUT:{}:CURR:SP:RBV".format(axis),
                    psu_amps_at_zero_field[axis],
                    tolerance=STABILITY_TOLERANCE * 0.001,
                    timeout=60)
                self.zfcntrl_ca.assert_that_pv_is_number(
                    "FIELD:{}".format(axis),
                    0.0,
                    tolerance=STABILITY_TOLERANCE)

            self._assert_at_setpoint(AtSetpointStatuses.TRUE)
            self.zfcntrl_ca.assert_that_pv_value_is_unchanged("AT_SETPOINT",
                                                              wait=20)
            self._assert_status(Statuses.NO_ERROR)

    @parameterized.expand(parameterized_list(FIELD_AXES))
    def test_GIVEN_output_is_off_WHEN_autofeedback_switched_on_THEN_psu_is_switched_back_on(
            self, _, axis):
        self.zfcntrl_ca.assert_setting_setpoint_sets_readback(
            "Off", "OUTPUT:{}:STATUS".format(axis))
        self._set_autofeedback(True)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:{}:STATUS".format(axis),
                                          "On")

    @parameterized.expand(parameterized_list(FIELD_AXES))
    def test_GIVEN_output_mode_is_voltage_WHEN_autofeedback_switched_on_THEN_psu_is_switched_to_current_mode(
            self, _, axis):
        self.zfcntrl_ca.assert_setting_setpoint_sets_readback(
            "Voltage",
            "OUTPUT:{}:MODE".format(axis),
            expected_alarm=self.zfcntrl_ca.Alarms.MAJOR)
        self._set_autofeedback(True)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:{}:MODE".format(axis),
                                          "Current")

    @parameterized.expand(parameterized_list(FIELD_AXES))
    def test_GIVEN_output_is_off_and_cannot_write_to_psu_WHEN_autofeedback_switched_on_THEN_get_psu_write_error(
            self, _, axis):
        self.zfcntrl_ca.assert_setting_setpoint_sets_readback(
            "Off", "OUTPUT:{}:STATUS".format(axis))
        with self._simulate_failing_power_supply_writes():
            self._set_autofeedback(True)
            self._assert_status(Statuses.PSU_WRITE_FAILED)

        # Check it can recover when writes work again
        self._assert_status(Statuses.NO_ERROR)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:{}:STATUS".format(axis),
                                          "On")

    @parameterized.expand(parameterized_list(FIELD_AXES))
    def test_GIVEN_output_mode_is_voltage_and_cannot_write_to_psu_WHEN_autofeedback_switched_on_THEN_get_psu_write_error(
            self, _, axis):
        self.zfcntrl_ca.assert_setting_setpoint_sets_readback(
            "Voltage",
            "OUTPUT:{}:MODE".format(axis),
            expected_alarm=self.zfcntrl_ca.Alarms.MAJOR)

        with self._simulate_failing_power_supply_writes():
            self._set_autofeedback(True)
            self._assert_status(Statuses.PSU_WRITE_FAILED)

        # Check it can recover when writes work again
        self._assert_status(Statuses.NO_ERROR)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:{}:MODE".format(axis),
                                          "Current")

    @parameterized.expand(
        parameterized_list([
            (True, True),
            (False, True),
            (True, False),
            (False, False),
        ]))
    def test_GIVEN_magnetometer_overloaded_THEN_error_suppressed_if_in_manual_mode(
            self, _, autofeedback, overloaded):
        self._set_autofeedback(autofeedback)
        self._set_simulated_measured_fields(ZERO_FIELD,
                                            overload=overloaded,
                                            wait_for_update=True)
        self._assert_status(Statuses.MAGNETOMETER_OVERLOAD
                            if overloaded else Statuses.NO_ERROR)
        self.zfcntrl_ca.assert_that_pv_alarm_is(
            "STATUS", self.zfcntrl_ca.Alarms.MAJOR
            if overloaded and autofeedback else self.zfcntrl_ca.Alarms.NONE)

    def test_GIVEN_power_supply_voltage_limit_is_set_incorrectly_WHEN_going_into_auto_mode_THEN_correct_limits_applied(
            self):
        self._set_simulated_power_supply_voltages({"X": 0, "Y": 0, "Z": 0})

        self._set_autofeedback(True)

        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:X:VOLT:SP:RBV",
                                          X_KEPCO_VOLTAGE_LIMIT)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:Y:VOLT:SP:RBV",
                                          Y_KEPCO_VOLTAGE_LIMIT)
        self.zfcntrl_ca.assert_that_pv_is("OUTPUT:Z:VOLT:SP:RBV",
                                          Z_KEPCO_VOLTAGE_LIMIT)
class HelioxTests(unittest.TestCase):
    """
    Tests for the heliox IOC.
    """
    def setUp(self):
        self._lewis, self._ioc = get_running_lewis_and_ioc(
            EMULATOR_NAME, DEVICE_PREFIX)
        self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX,
                                default_timeout=10)
        self._lewis.backdoor_run_function_on_device("reset")

    def test_WHEN_ioc_is_started_THEN_it_is_not_disabled(self):
        self.ca.assert_that_pv_is("DISABLE", "COMMS ENABLED")

    @parameterized.expand(parameterized_list(TEST_TEMPERATURES))
    def test_WHEN_temperature_setpoint_is_set_THEN_setpoint_readback_updates(
            self, _, temp):
        self.ca.assert_setting_setpoint_sets_readback(
            temp, set_point_pv="TEMP:SP", readback_pv="TEMP:SP:RBV")

    @parameterized.expand(parameterized_list(TEST_TEMPERATURES))
    def test_WHEN_temperature_setpoint_is_set_THEN_actual_temperature_updates(
            self, _, temp):
        self.ca.assert_setting_setpoint_sets_readback(temp,
                                                      set_point_pv="TEMP:SP",
                                                      readback_pv="TEMP")

    @skip_if_recsim("Lewis backdoor is not available in recsim")
    def test_WHEN_temperature_fluctuates_between_stable_and_unstable_THEN_readback_updates(
            self):
        for stable in [True, False, True]:  # Check both transitions
            self._lewis.backdoor_set_on_device("temperature_stable", stable)
            self.ca.assert_that_pv_is("STABILITY",
                                      "Stable" if stable else "Unstable")

    @parameterized.expand(
        parameterized_list(itertools.product(CHANNELS, TEST_TEMPERATURES)))
    @skip_if_recsim("Lewis Backdoor not available in recsim")
    def test_WHEN_individual_channel_temperature_is_set_THEN_readback_updates(
            self, _, chan, temperature):
        self._lewis.backdoor_run_function_on_device(
            "backdoor_set_channel_temperature", [chan, temperature])
        self.ca.assert_that_pv_is_number("{}:TEMP".format(chan),
                                         temperature,
                                         tolerance=0.005)

    @parameterized.expand(
        parameterized_list(itertools.product(CHANNELS, TEST_TEMPERATURES)))
    @skip_if_recsim("Lewis Backdoor not available in recsim")
    def test_WHEN_individual_channel_temperature_setpoint_is_set_THEN_readback_updates(
            self, _, chan, temperature):
        self._lewis.backdoor_run_function_on_device(
            "backdoor_set_channel_temperature_sp", [chan, temperature])
        self.ca.assert_that_pv_is_number("{}:TEMP:SP:RBV".format(chan),
                                         temperature,
                                         tolerance=0.005)

    @parameterized.expand(parameterized_list(CHANNELS_WITH_STABILITY))
    @skip_if_recsim("Lewis backdoor not available in recsim")
    def test_WHEN_channel_statbility_is_set_via_backdoor_THEN_readback_updates(
            self, _, chan):
        for stability in [True, False, True]:  # Check both transitions
            self._lewis.backdoor_run_function_on_device(
                "backdoor_set_channel_stability", [chan, stability])
            self.ca.assert_that_pv_is("{}:STABILITY".format(chan),
                                      "Stable" if stability else "Unstable")

    @parameterized.expand(parameterized_list(CHANNELS_WITH_HEATER_AUTO))
    @skip_if_recsim("Lewis backdoor not available in recsim")
    def test_WHEN_channel_heater_auto_is_set_via_backdoor_THEN_readback_updates(
            self, _, chan):
        for heater_auto in [True, False, True]:  # Check both transitions
            self._lewis.backdoor_run_function_on_device(
                "backdoor_set_channel_heater_auto", [chan, heater_auto])
            self.ca.assert_that_pv_is("{}:HEATER:AUTO".format(chan),
                                      "On" if heater_auto else "Off")

    @parameterized.expand(
        parameterized_list(itertools.product(CHANNELS,
                                             TEST_HEATER_PERCENTAGES)))
    @skip_if_recsim("Lewis backdoor not available in recsim")
    def test_WHEN_individual_channel_heater_percentage_is_set_THEN_readback_updates(
            self, _, chan, percent):
        self._lewis.backdoor_run_function_on_device(
            "backdoor_set_channel_heater_percent", [chan, percent])
        self.ca.assert_that_pv_is_number("{}:HEATER:PERCENT".format(chan),
                                         percent,
                                         tolerance=0.005)

    @skip_if_recsim("Cannot properly simulate disconnected device in recsim")
    def test_WHEN_device_disconnected_THEN_temperature_goes_into_alarm(self):
        self.ca.assert_that_pv_alarm_is("TEMP", self.ca.Alarms.NONE)
        self._lewis.backdoor_set_on_device("connected", False)
        self.ca.assert_that_pv_alarm_is("TEMP", self.ca.Alarms.INVALID)

    @skip_if_recsim("Cannot properly simulate disconnected device in recsim")
    @slow_test
    def test_WHEN_device_disconnected_THEN_temperature_comms_error_stays_on_for_at_least_60s_afterwards(
            self):
        """
        Test is slow because the logic under test is checking whether any comms errors have occured in last 120 sec.
        """
        self.ca.assert_that_pv_alarm_is("TEMP", self.ca.Alarms.NONE)
        self.ca.assert_that_pv_is("REGEN:NO_RECENT_COMMS_ERROR",
                                  1,
                                  timeout=150)
        self._lewis.backdoor_set_on_device("connected", False)
        self.ca.assert_that_pv_alarm_is("TEMP", self.ca.Alarms.INVALID)
        # Should immediately indicate that there was an error
        self.ca.assert_that_pv_is("REGEN:NO_RECENT_COMMS_ERROR", 0)
        self._lewis.backdoor_set_on_device("connected", True)
        self.ca.assert_that_pv_alarm_is("TEMP", self.ca.Alarms.NONE)

        # Should stay unchanged for 120s but only assert that it doesn't change for 60 secs.
        self.ca.assert_that_pv_is("REGEN:NO_RECENT_COMMS_ERROR", 0)
        self.ca.assert_that_pv_value_is_unchanged(
            "REGEN:NO_RECENT_COMMS_ERROR", wait=60)

        # Make sure it does eventually clear (within a further 150s)
        self.ca.assert_that_pv_is("REGEN:NO_RECENT_COMMS_ERROR",
                                  1,
                                  timeout=150)

    @contextmanager
    def _simulate_helium_3_pot_empty(self):
        """
        Simulates the helium 3 pot being empty. In this state, the he3 pot temperature will drift towards 1.5K
        regardless of the current temperature setpoint.
        """
        self._lewis.backdoor_set_on_device("helium_3_pot_empty", True)
        try:
            yield
        finally:
            self._lewis.backdoor_set_on_device("helium_3_pot_empty", False)

    @skip_if_recsim(
        "Complex device behaviour (drifting) is not captured in recsim.")
    def test_GIVEN_helium_3_pot_is_empty_WHEN_temperature_stays_above_setpoint_for_coarse_time_THEN_regeneration_logic_detects_this(
            self):
        self.ca.assert_setting_setpoint_sets_readback(
            0.01, readback_pv="TEMP:SP:RBV", set_point_pv="TEMP:SP")
        self.ca.assert_that_pv_is("REGEN:TEMP_COARSE_CHECK",
                                  0,
                                  timeout=(HE3POT_COARSE_TIME + 10))

        with self._simulate_helium_3_pot_empty(
        ):  # Will cause temperature to drift to 1.5K
            self.ca.assert_that_pv_is("REGEN:TEMP_COARSE_CHECK",
                                      1,
                                      timeout=(HE3POT_COARSE_TIME + 10))

        self.ca.assert_that_pv_is("REGEN:TEMP_COARSE_CHECK", 0)

    @parameterized.expand(parameterized_list([0.3, 1.0, 1.234, 5.67, 12.34]))
    @skip_if_recsim(
        "Complex device behaviour (drifting) is not captured in recsim.")
    @slow_test
    def test_GIVEN_helium_3_pot_is_empty_WHEN_drifting_THEN_drift_rate_correct(
            self, _, value):
        self.ca.assert_setting_setpoint_sets_readback(
            0.01, readback_pv="TEMP:SP:RBV", set_point_pv="TEMP:SP")

        self._lewis.backdoor_set_on_device("drift_towards", 9999999999)
        self._lewis.backdoor_set_on_device(
            "drift_rate",
            value / 100)  # Emulator runs at 100x speed in framework

        with self._simulate_helium_3_pot_empty(
        ):  # Will cause temperature to drift upwards continuously
            self.ca.assert_that_pv_is_number(
                "REGEN:_CALCULATE_TEMP_DRIFT.VALB",
                value,
                timeout=(DRIFT_BUFFER_SIZE + 10),
                tolerance=0.05)
            self.ca.assert_that_pv_value_over_time_satisfies_comparator(
                "REGEN:_CALCULATE_TEMP_DRIFT.VALB",
                wait=DRIFT_BUFFER_SIZE,
                comparator=lambda initial, final: abs(
                    initial - final) < 0.05 and abs(value - final) < 0.05)
            self.ca.assert_that_pv_is("REGEN:TEMP_DRIFT_RATE", 1)

        # Assert that if the temperature stops drifting the check goes false (after potentially some delay)
        self.ca.assert_that_pv_is("REGEN:TEMP_DRIFT_RATE",
                                  0,
                                  timeout=(DRIFT_BUFFER_SIZE + 10))

    @parameterized.expand(parameterized_list(HELIOX_STATUSES))
    @skip_if_recsim("Lewis backdoor not available in recsim")
    def test_GIVEN_heliox_status_set_via_backdoor_THEN_status_record_updates(
            self, _, status):
        self._lewis.backdoor_set_on_device("status", status)
        self.ca.assert_that_pv_is("STATUS", status)

    @parameterized.expand(parameterized_list(HELIOX_STATUSES))
    @skip_if_recsim("Lewis backdoor not available in recsim")
    def test_GIVEN_heliox_status_set_via_backdoor_THEN_regeneration_low_temp_status_record_updates(
            self, _, status):
        self._lewis.backdoor_set_on_device("status", status)
        self.ca.assert_that_pv_is("REGEN:LOW_TEMP_MODE",
                                  1 if status == "Low Temp" else 0)

    @skip_if_recsim("Lewis backdoor not available in recsim")
    @slow_test
    def test_WHEN_all_regeneration_conditions_are_met_THEN_regeneration_required_pv_is_true(
            self):
        self._lewis.backdoor_run_function_on_device(
            "backdoor_set_channel_heater_auto", ["HE3SORB", True])
        self.ca.assert_that_pv_is("HE3SORB:HEATER:AUTO", "On")

        self._lewis.backdoor_run_function_on_device(
            "backdoor_set_channel_heater_percent", ["HE3SORB", 0.0])
        self.ca.assert_that_pv_is("HE3SORB:HEATER:PERCENT", 0.0)

        self._lewis.backdoor_set_on_device("status", "Low Temp")
        self.ca.assert_that_pv_is("STATUS", "Low Temp")

        self.ca.assert_that_pv_is("REGEN:NO_RECENT_COMMS_ERROR",
                                  1,
                                  timeout=150)

        self._lewis.backdoor_set_on_device("drift_towards", 9999999999)
        self._lewis.backdoor_set_on_device(
            "drift_rate",
            1.0 / 100)  # Emulator runs at 100x speed in framework

        with self._simulate_helium_3_pot_empty():
            self.ca.assert_that_pv_is("REGEN:REQUIRED",
                                      "Yes",
                                      timeout=(DRIFT_BUFFER_SIZE + 10))