class InstronStressRigTests(unittest.TestCase):
    """
    Tests for the Instron IOC.
    """
    def _change_channel(self, name):
        # Setpoint is zero-indexed
        self.ca.set_pv_value("CHANNEL:SP", CHANNELS[name] - 1)
        self.ca.assert_that_pv_is("CHANNEL.RVAL", CHANNELS[name])
        self.ca.assert_that_pv_alarm_is("CHANNEL", self.ca.Alarms.NONE)
        self.ca.assert_that_pv_is("CHANNEL:GUI", name)
        self.ca.assert_that_pv_alarm_is("CHANNEL:GUI", self.ca.Alarms.NONE)

    def setUp(self):
        self._lewis, self._ioc = get_running_lewis_and_ioc(
            "instron_stress_rig", DEVICE_PREFIX)

        self.ca = ChannelAccess(15, device_prefix=DEVICE_PREFIX)
        self.ca.assert_that_pv_exists("CHANNEL", timeout=30)

        # Can't use lewis backdoor commands in recsim
        # All of the below commands apply to devsim only.
        if not IOCRegister.uses_rec_sim:
            # Reinitialize the emulator state
            self._lewis.backdoor_command(["device", "reset"])

            self._lewis.backdoor_set_on_device("status", 7680)

            for index, chan_type2 in enumerate((3, 2, 4)):
                self._lewis.backdoor_command([
                    "device", "set_channel_param",
                    str(index + 1), "channel_type",
                    str(chan_type2)
                ])

            self.ca.assert_that_pv_is("CHANNEL:SP.ZRST", "Position")

            self._change_channel("Position")

            # Ensure the rig is stopped
            self._lewis.backdoor_command(["device", "movement_type", "0"])
            self.ca.assert_that_pv_is("GOING", "NO")

            # Ensure stress area and strain length are sensible values (i.e. not zero)
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "2", "area", "10"])
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "3", "length", "10"])
            self.ca.assert_that_pv_is_number("STRESS:AREA",
                                             10,
                                             tolerance=0.001)
            self.ca.assert_that_pv_is_number("STRAIN:LENGTH",
                                             10,
                                             tolerance=0.001)

            # Ensure that all the scales are sensible values (i.e. not zero)
            for index, channel in enumerate(POS_STRESS_STRAIN, 1):
                self._lewis.backdoor_command(
                    ["device", "set_channel_param",
                     str(index), "scale", "10"])
                self.ca.assert_that_pv_is_number(channel + ":SCALE",
                                                 10,
                                                 tolerance=0.001)

            # Always set the waveform generator to run for lots of cycles so it only stops if we want it to
            self.ca.set_pv_value(quart_prefixed("CYCLE:SP"), LOTS_OF_CYCLES)

    @skip_if_recsim("In rec sim we can not set the code easily")
    def test_WHEN_the_rig_has_no_error_THEN_the_status_is_ok(self):
        self._lewis.backdoor_set_on_device("status", 7680)

        self.ca.assert_that_pv_is("STAT:DISP", "System OK")
        self.ca.assert_that_pv_alarm_is("STAT:DISP", ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim we can not set the code easily")
    def test_WHEN_the_rig_has_other_no_error_THEN_the_status_is_ok(self):
        self._lewis.backdoor_set_on_device("status", 0)

        self.ca.assert_that_pv_is("STAT:DISP", "System OK")
        self.ca.assert_that_pv_alarm_is("STAT:DISP", ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim we can not set the code easily")
    def test_WHEN_the_rig_has_error_THEN_the_status_is_emergency_stop_pushed(
            self):
        code_and_errors = [([0, 1, 1, 0], "Emergency stop pushed"),
                           ([0, 0, 0, 1], "Travel limit exceeded"),
                           ([0, 0, 1, 0], "Power amplifier too hot"),
                           ([0, 1, 1, 0], "Emergency stop pushed"),
                           ([0, 1, 0, 1], "Invalid status from rig"),
                           ([0, 1, 0, 0], "Invalid status from rig"),
                           ([0, 1, 1, 0], "Emergency stop pushed"),
                           ([0, 1, 1, 1], "Oil too hot"),
                           ([1, 0, 0, 0], "Oil level too low"),
                           ([1, 0, 0, 1], "Motor too hot"),
                           ([1, 0, 1, 0], "Oil pressure too high"),
                           ([1, 0, 1, 1], "Oil pressure too low"),
                           ([1, 1, 0, 0], "Manifold/pump blocked"),
                           ([1, 1, 0, 1], "Oil level going too low"),
                           ([1, 1, 1, 0], "Manifold low pressure")]

        for code, error in code_and_errors:
            code_val = code[0] * 2**12
            code_val += code[1] * 2**11
            code_val += code[2] * 2**10
            code_val += code[3] * 2**9

            self._lewis.backdoor_set_on_device("status", code_val)

            self.ca.assert_that_pv_is("STAT:DISP",
                                      error,
                                      msg="code set {0} = {code_val}".format(
                                          code, code_val=code_val))
            self.ca.assert_that_pv_alarm_is("STAT:DISP",
                                            ChannelAccess.Alarms.MAJOR)

    def test_WHEN_the_rig_is_initialized_THEN_it_is_not_going(self):
        self.ca.assert_that_pv_is("GOING", "NO")

    def test_WHEN_the_rig_is_initialized_THEN_it_is_not_panic_stopping(self):
        self.ca.assert_that_pv_is("PANIC:SP", "READY")

    def test_WHEN_the_rig_is_initialized_THEN_it_is_not_stopping(self):
        self.ca.assert_that_pv_is("STOP:SP", "READY")

    def test_that_the_rig_is_not_normally_in_control_mode(self):
        self.ca.assert_that_pv_is("STOP:SP", "READY")

    def test_WHEN_init_sequence_run_THEN_waveform_ramp_is_set_the_status_is_ok(
            self):
        for chan in POS_STRESS_STRAIN:
            self.ca.set_pv_value("{0}:RAMP:WFTYP:SP".format(chan), 0)

        self.ca.set_pv_value("INIT", 1)

        for chan in POS_STRESS_STRAIN:
            self.ca.assert_that_pv_is("{0}:RAMP:WFTYP".format(chan),
                                      RAMP_WAVEFORM_TYPES[3])

    def _switch_to_position_channel_and_change_setpoint(self):

        # It has to be big or the set point will be reached before the test completes
        _big_set_point = 999999999999

        # Select position as control channel
        self._change_channel("Position")
        # Change the setpoint so that movement can be started
        self.ca.set_pv_value("POS:SP", _big_set_point)
        self.ca.assert_that_pv_is_number("POS:SP", _big_set_point, tolerance=1)
        self.ca.assert_that_pv_is_number("POS:SP:RBV",
                                         _big_set_point,
                                         tolerance=1)

    @skip_if_recsim("Dynamic behaviour not captured in RECSIM")
    def test_WHEN_going_and_then_stopping_THEN_going_pv_reflects_the_expected_state(
            self):
        self.ca.assert_that_pv_is("GOING", "NO")
        self._switch_to_position_channel_and_change_setpoint()
        self.ca.set_pv_value("MOVE:GO:SP", 1)
        self.ca.assert_that_pv_is("GOING", "YES")
        self.ca.set_pv_value("STOP:SP", 1)
        self.ca.assert_that_pv_is("GOING", "NO")
        self.ca.set_pv_value("STOP:SP", 0)

    @skip_if_recsim("Dynamic behaviour not captured in RECSIM")
    def test_WHEN_going_and_then_panic_stopping_THEN_going_pv_reflects_the_expected_state(
            self):
        self.ca.assert_that_pv_is("GOING", "NO")
        self._switch_to_position_channel_and_change_setpoint()
        self.ca.set_pv_value("MOVE:GO:SP", 1)
        self.ca.assert_that_pv_is("GOING", "YES")
        self.ca.set_pv_value("PANIC:SP", 1)
        self.ca.assert_that_pv_is("GOING", "NO")
        self.ca.set_pv_value("PANIC:SP", 0)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_arbitrary_command_Q22_is_sent_THEN_the_response_is_a_status_code(
            self):
        self.ca.set_pv_value("ARBITRARY:SP", "Q22")
        # Assert that the response to Q22 is a status code
        self.ca.assert_that_pv_is_within_range("ARBITRARY",
                                               min_value=0,
                                               max_value=65535)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_arbitrary_command_Q300_is_sent_THEN_the_response_is_a_number_between_1_and_3(
            self):
        self.ca.set_pv_value("ARBITRARY:SP", "Q300")
        # Assert that the response to Q300 is between 1 and 3
        self.ca.assert_that_pv_is_within_range("ARBITRARY",
                                               min_value=1,
                                               max_value=3)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_arbitrary_command_C4_is_sent_THEN_Q4_gives_back_the_value_that_was_just_set(
            self):
        def _set_and_check(value):
            # Put the record into a non-alarm state. This is needed so that we can wait until the record is in alarm
            # later, when we do a command which (expectedly) puts the record into a timeout alarm.
            self.ca.set_pv_value("ARBITRARY:SP", "Q4,1")
            self.ca.assert_that_pv_alarm_is("ARBITRARY", self.ca.Alarms.NONE)

            self.ca.set_pv_value("ARBITRARY:SP", "C4,1," + str(value))
            self.ca.assert_that_pv_is("ARBITRARY:SP", "C4,1," + str(value))
            # No response from arbitrary command causes record to be TIMEOUT INVALID - this is expected.
            self.ca.assert_that_pv_alarm_is("ARBITRARY",
                                            self.ca.Alarms.INVALID)

            self.ca.set_pv_value("ARBITRARY:SP", "Q4,1")
            self.ca.assert_that_pv_is_number("ARBITRARY",
                                             value,
                                             tolerance=0.001,
                                             timeout=60)

        for v in [0, 1, 0]:
            _set_and_check(v)

    def test_WHEN_control_channel_is_requested_THEN_an_allowed_value_is_returned(
            self):
        self.ca.assert_that_pv_is_one_of("CHANNEL", CHANNELS.keys())

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_control_channel_setpoint_is_requested_THEN_it_is_one_of_the_allowed_values(
            self):
        self.ca.assert_that_pv_is_one_of("CHANNEL:SP", CHANNELS.keys())

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_the_control_channel_is_set_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        for channel in CHANNELS.keys():
            # change channel function contains the relevant assertions.
            self._change_channel(channel)

    def test_WHEN_the_step_time_for_various_channels_is_set_as_an_integer_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        for chan, val in [("POS", 123), ("STRESS", 456), ("STRAIN", 789)]:
            pv_name = chan + ":STEP:TIME"
            self.ca.assert_setting_setpoint_sets_readback(val, pv_name)

    def test_WHEN_the_step_time_for_various_channels_is_set_as_a_float_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        for chan, val in [("POS", 111.111), ("STRESS", 222.222),
                          ("STRAIN", 333.333)]:
            pv_name = chan + ":STEP:TIME"
            self.ca.assert_setting_setpoint_sets_readback(val, pv_name)

    def test_WHEN_the_ramp_waveform_for_a_channel_is_set_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        pv_name = "{0}:RAMP:WFTYP"
        for chan in POS_STRESS_STRAIN:
            for set_value, return_value in enumerate(RAMP_WAVEFORM_TYPES):
                self.ca.assert_setting_setpoint_sets_readback(
                    set_value,
                    pv_name.format(chan),
                    expected_value=return_value)

    def test_WHEN_the_ramp_amplitude_for_a_channel_is_set_as_an_integer_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        for chan in POS_STRESS_STRAIN:
            for val in [0, 10, 1000, 1000000]:
                pv_name = chan + ":RAW:SP"
                pv_name_rbv = pv_name + ":RBV"
                self.ca.assert_setting_setpoint_sets_readback(
                    val, readback_pv=pv_name_rbv, set_point_pv=pv_name)

    def test_WHEN_the_ramp_amplitude_for_a_channel_is_set_as_a_float_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        for chan in POS_STRESS_STRAIN:
            for val in [1.0, 5.5, 1.000001, 9.999999, 10000.1]:
                pv_name = chan + ":RAW:SP"
                pv_name_rbv = pv_name + ":RBV"
                self.ca.assert_setting_setpoint_sets_readback(
                    val, readback_pv=pv_name_rbv, set_point_pv=pv_name)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_the_setpoint_for_a_channel_is_set_THEN_the_readback_contains_the_value_that_was_just_set(
            self):
        def _set_and_check(chan, value):
            self.ca.set_pv_value(chan + ":SP", value)
            self.ca.assert_that_pv_is_number(chan + ":SP",
                                             value,
                                             tolerance=0.001)
            self.ca.assert_that_pv_is_number(chan + ":SP:RBV",
                                             value,
                                             tolerance=0.05,
                                             timeout=30)

        for chan in POS_STRESS_STRAIN:
            for i in [1.0, 123.456, 555.555, 1000]:
                _set_and_check(chan, i)

    def test_WHEN_channel_tolerance_is_set_THEN_it_changes_limits_on_SP_RBV(
            self):
        for chan in POS_STRESS_STRAIN:
            sp_val = 1
            self.ca.set_pv_value(chan + ":SP", sp_val)
            for val in [0.1, 1.0, 2.5]:
                pv_name = chan + ":TOLERANCE"
                pv_name_high = chan + ":SP:RBV.HIGH"
                pv_name_low = chan + ":SP:RBV.LOW"
                self.ca.assert_setting_setpoint_sets_readback(
                    val,
                    readback_pv=pv_name_high,
                    set_point_pv=pv_name,
                    expected_value=val + sp_val,
                    expected_alarm=None)
                self.ca.assert_setting_setpoint_sets_readback(
                    val,
                    readback_pv=pv_name_low,
                    set_point_pv=pv_name,
                    expected_value=sp_val - val,
                    expected_alarm=None)

    @skip_if_recsim("Alarms not properly emulated in recsim")
    def test_GIVEN_a_big_tolerance_WHEN_the_setpoint_is_set_THEN_the_setpoint_has_no_alarms(
            self):
        def _set_and_check(chan, value):
            self.ca.set_pv_value(chan + ":SP", value)
            self.ca.set_pv_value(chan + ":TOLERANCE", 9999)
            self.ca.assert_that_pv_alarm_is(chan + ":SP:RBV",
                                            ChannelAccess.Alarms.NONE)

        for chan in POS_STRESS_STRAIN:
            for i in [0.123, 567]:
                _set_and_check(chan, i)

    @skip_if_recsim("Alarms not properly emulated in recsim")
    def test_GIVEN_a_tolerance_of_minus_one_WHEN_the_setpoint_is_set_THEN_the_setpoint_readback_has_alarms(
            self):
        def _set_and_check(chan, value):
            self.ca.set_pv_value(chan + ":SP", value)
            self.ca.set_pv_value(chan + ":TOLERANCE", -1)
            self.ca.assert_that_pv_alarm_is(chan + ":SP:RBV",
                                            ChannelAccess.Alarms.MINOR)

        for chan in POS_STRESS_STRAIN:
            for i in [0.234, 789]:
                _set_and_check(chan, i)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_ioc_gets_a_raw_position_reading_from_the_device_THEN_it_is_converted_correctly(
            self):

        for chan_scale in [0.1, 10.0]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "1", "scale",
                 str(chan_scale)])
            self.ca.assert_that_pv_is("POS:SCALE", chan_scale)

            for raw_value in [0, 123]:
                self._lewis.backdoor_command([
                    "device", "set_channel_param", "1", "value",
                    str(raw_value)
                ])
                self.ca.assert_that_pv_is_number("POS:RAW",
                                                 raw_value,
                                                 tolerance=0.01)
                self.ca.assert_that_pv_is_number("POS",
                                                 raw_value * chan_scale * 1000,
                                                 tolerance=(0.01 * chan_scale *
                                                            1000))

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_ioc_gets_a_raw_stress_reading_from_the_device_THEN_it_is_converted_correctly(
            self):

        for chan_area in [0.1, 10.0]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "2", "area",
                 str(chan_area)])
            self.ca.assert_that_pv_is("STRESS:AREA", chan_area)

            for chan_scale in [0.1, 10.0]:
                self._lewis.backdoor_command([
                    "device", "set_channel_param", "2", "scale",
                    str(chan_scale)
                ])
                self.ca.assert_that_pv_is("STRESS:SCALE", chan_scale)

                for raw_value in [0, 123]:
                    self._lewis.backdoor_command([
                        "device", "set_channel_param", "2", "value",
                        str(raw_value)
                    ])
                    self.ca.assert_that_pv_is("STRESS:RAW", raw_value)
                    self.ca.assert_that_pv_is(
                        "STRESS", raw_value * chan_scale * (1.0 / chan_area))

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_strain_length_updates_on_device_THEN_pv_updates(self):
        for value in [1, 123]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "3", "length",
                 str(value)])
            self.ca.assert_that_pv_is("STRAIN:LENGTH", value)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_ioc_gets_a_raw_strain_reading_from_the_device_THEN_it_is_converted_correctly(
            self):
        for chan_scale in [0.1, 10.0]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "3", "scale",
                 str(chan_scale)])

            for chan_length in [0.1, 10.0]:
                self._lewis.backdoor_command([
                    "device", "set_channel_param", "3", "length",
                    str(chan_length)
                ])

                for raw_value in [0, 0.001]:
                    self._lewis.backdoor_command([
                        "device", "set_channel_param", "3", "value",
                        str(raw_value)
                    ])

                    self.ca.assert_that_pv_is("STRAIN:SCALE", chan_scale)
                    self.ca.assert_that_pv_is("STRAIN:LENGTH", chan_length)
                    self.ca.assert_that_pv_is("STRAIN:RAW", raw_value)

                    self.ca.assert_that_pv_is(
                        "STRAIN",
                        (raw_value * chan_scale * 100000 * (1 / chan_length)))

    def test_WHEN_the_area_setpoint_is_set_THEN_the_area_readback_updates(
            self):
        def _set_and_check(value):
            self.ca.set_pv_value("STRESS:AREA:SP", value)
            self.ca.assert_that_pv_is_number("STRESS:AREA",
                                             value,
                                             tolerance=0.01)
            self.ca.assert_that_pv_alarm_is("STRESS:AREA",
                                            ChannelAccess.Alarms.NONE)

        for val in [0.234, 789]:
            _set_and_check(val)

    def test_WHEN_the_area_setpoint_is_set_THEN_the_diameter_readback_updates(
            self):
        def _set_and_check(value):
            self.ca.set_pv_value("STRESS:AREA:SP", value)
            self.ca.assert_that_pv_is_number("STRESS:DIAMETER",
                                             (2 * math.sqrt(value / math.pi)),
                                             tolerance=0.01)
            self.ca.assert_that_pv_alarm_is("STRESS:DIAMETER",
                                            ChannelAccess.Alarms.NONE)

        for val in [0.234, 789]:
            _set_and_check(val)

    def test_WHEN_the_diameter_setpoint_is_set_THEN_the_diameter_readback_updates(
            self):
        def _set_and_check(value):
            self.ca.set_pv_value("STRESS:DIAMETER:SP", value)
            self.ca.assert_that_pv_is_number("STRESS:DIAMETER",
                                             value,
                                             tolerance=0.0005)
            self.ca.assert_that_pv_alarm_is("STRESS:DIAMETER",
                                            ChannelAccess.Alarms.NONE)

        for val in [0.234, 789]:
            _set_and_check(val)

    def test_WHEN_the_diameter_setpoint_is_set_THEN_the_area_readback_updates(
            self):
        def _set_and_check(value):
            self.ca.set_pv_value("STRESS:DIAMETER:SP", value)
            self.ca.assert_that_pv_is_number("STRESS:AREA",
                                             ((value / 2.0)**2 * math.pi),
                                             tolerance=0.0005)
            self.ca.assert_that_pv_alarm_is("STRESS:AREA",
                                            ChannelAccess.Alarms.NONE)

        for val in [0.234, 789]:
            _set_and_check(val)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_a_position_setpoint_is_set_THEN_it_is_converted_correctly(
            self):
        for scale in [2.34, 456.78]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "1", "scale",
                 str(scale)])
            self.ca.assert_that_pv_is("POS:SCALE", scale)

            for val in [1.23, 123.45]:
                self.ca.set_pv_value("POS:SP", val)
                self.ca.assert_that_pv_is_number("POS:RAW:SP",
                                                 val * (1.0 / 1000.0) *
                                                 (1 / scale),
                                                 tolerance=0.0000000001)
                self.ca.assert_that_pv_alarm_is("POS:RAW:SP",
                                                ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_a_stress_setpoint_is_set_THEN_it_is_converted_correctly(
            self):

        for area in [789, 543.21]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "2", "area",
                 str(area)])
            self.ca.assert_that_pv_is("STRESS:AREA", area)

            for chan_scale in [2.34, 456.78]:
                self._lewis.backdoor_command([
                    "device", "set_channel_param", "2", "scale",
                    str(chan_scale)
                ])
                self.ca.assert_that_pv_is("STRESS:SCALE", chan_scale)

                for val in [1.23, 123.45]:
                    self.ca.set_pv_value("STRESS:SP", val)
                    self.ca.assert_that_pv_is_number("STRESS:RAW:SP",
                                                     val * (1 / chan_scale) *
                                                     area,
                                                     tolerance=0.0000000001)
                    self.ca.assert_that_pv_alarm_is("STRESS:RAW:SP",
                                                    ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_a_strain_setpoint_is_set_THEN_it_is_converted_correctly(
            self):

        for length in [789, 543.21]:
            self._lewis.backdoor_command(
                ["device", "set_channel_param", "3", "length",
                 str(length)])
            self.ca.assert_that_pv_is("STRAIN:LENGTH", length)

            for chan_scale in [2.34, 456.78]:
                self._lewis.backdoor_command([
                    "device", "set_channel_param", "3", "scale",
                    str(chan_scale)
                ])
                self.ca.assert_that_pv_is("STRAIN:SCALE", chan_scale)

                for val in [1.23, 123.45]:
                    self.ca.set_pv_value("STRAIN:SP", val)
                    self.ca.assert_that_pv_is_number("STRAIN:RAW:SP",
                                                     val * (1 / chan_scale) *
                                                     length * (1.0 / 100000.0),
                                                     tolerance=0.0000000001)
                    self.ca.assert_that_pv_alarm_is("STRAIN:RAW:SP",
                                                    ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_the_channel_type_updates_on_the_device_THEN_the_pv_updates(
            self):

        for chan_name, chan_num in [("POS", 1), ("STRESS", 2), ("STRAIN", 3)]:
            for value_1, value_2, return_value_1, return_value_2 in [
                (0, 1, "Standard transducer", "Unrecognized"),
                (1, 10, "User transducer", "Ext. waveform generator")
            ]:

                self._lewis.backdoor_command([
                    "device", "set_channel_param",
                    str(chan_num), "transducer_type",
                    str(value_1)
                ])
                self._lewis.backdoor_command([
                    "device", "set_channel_param",
                    str(chan_num), "channel_type",
                    str(value_2)
                ])
                self.ca.assert_that_pv_is("" + chan_name + ":TYPE:STANDARD",
                                          return_value_1)
                self.ca.assert_that_pv_is("" + chan_name + ":TYPE",
                                          return_value_2)

    def test_WHEN_waveform_type_abs_set_on_axes_THEN_all_axes_are_set(self):
        def _set_and_check(set_value, return_value):
            self.ca.set_pv_value("AXES:RAMP:WFTYP:SP", set_value)
            for chan in POS_STRESS_STRAIN:
                self.ca.assert_that_pv_is("{0}:RAMP:WFTYP".format(chan),
                                          return_value)
                self.ca.assert_that_pv_alarm_is("{0}:RAMP:WFTYP".format(chan),
                                                ChannelAccess.Alarms.NONE)

        for set_value, return_value in enumerate(RAMP_WAVEFORM_TYPES):
            _set_and_check(set_value, return_value)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_channel_fails_check_THEN_channel_mbbi_record_is_invalid_and_has_tag_disabled(
            self):

        for index, (chan_name, type, index_as_name,
                    channel_as_name) in enumerate(
                        zip(POS_STRESS_STRAIN, (1, 1, 1), ("ZR", "ON", "TW"),
                            ("Position", "Stress", "Strain"))):

            self._lewis.backdoor_command([
                "device", "set_channel_param",
                str(index + 1), "channel_type",
                str(type)
            ])

            self.ca.assert_that_pv_is("" + chan_name + ":TYPE:CHECK", "FAIL")
            self.ca.assert_that_pv_is("CHANNEL:SP.{}ST".format(index_as_name),
                                      "{0} - disabled".format(channel_as_name))

            self.ca.set_pv_value("CHANNEL:SP", index)

            self.ca.assert_that_pv_alarm_is("CHANNEL:SP",
                                            ChannelAccess.Alarms.INVALID)

    @skip_if_recsim("In rec sim this test fails")
    def test_WHEN_channel_succeeds_check_THEN_channel_mbbi_record_is_invalid_and_has_tag_disabled(
            self):
        self.ca.set_pv_value("CHANNEL:SP", 3)
        for index, (chan_name, type, index_as_name,
                    channel_as_name) in enumerate(
                        zip(POS_STRESS_STRAIN, (3, 2, 4), ("ZR", "ON", "TW"),
                            ("Position", "Stress", "Strain"))):

            self._lewis.backdoor_command([
                "device", "set_channel_param",
                str(index + 1), "channel_type",
                str(type)
            ])

            self.ca.assert_that_pv_is("" + chan_name + ":TYPE:CHECK",
                                      "PASS",
                                      timeout=30)

            self.ca.assert_that_pv_is("CHANNEL:SP.{}ST".format(index_as_name),
                                      channel_as_name)
            self.ca.assert_that_pv_is("CHANNEL:SP.{}SV".format(index_as_name),
                                      ChannelAccess.Alarms.NONE)

            self.ca.set_pv_value("CHANNEL:SP", index)
            self.ca.assert_that_pv_alarm_is("CHANNEL:SP",
                                            ChannelAccess.Alarms.NONE)

    @skip_if_recsim("In rec sim we can not disconnect the device from the IOC")
    def test_WHEN_the_rig_is_not_connected_THEN_the_status_has_alarm(self):
        self._lewis.backdoor_set_on_device("status", None)

        self.ca.assert_that_pv_alarm_is("STAT:DISP",
                                        ChannelAccess.Alarms.INVALID)

    # Waveform tests

    def check_running_state(self, status, running, continuing, timeout=None):
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), status, timeout)
        self.ca.assert_that_pv_is(wave_prefixed("RUNNING"),
                                  "Running" if running else "Not running",
                                  timeout)
        self.ca.assert_that_pv_is(
            wave_prefixed("CONTINUING"),
            "Continuing" if continuing else "Not continuing", timeout)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_stopped_WHEN_it_is_started_THEN_it_is_running(
            self):
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_stopped_WHEN_it_is_aborted_THEN_it_is_stopped(
            self):
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("ABORT"), 1)
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_stopped_WHEN_it_is_stopped_THEN_it_is_stopped(
            self):
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("STOP"), 1)
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_running_WHEN_it_is_started_THEN_it_is_running(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_RUNNING])
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_running_WHEN_it_is_aborted_THEN_it_is_aborted(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_RUNNING])
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)
        self.ca.set_pv_value(wave_prefixed("ABORT"), 1)
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_running_WHEN_it_is_stopped_THEN_it_is_finishing_and_then_stops_within_5_seconds(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_RUNNING])
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)
        self.ca.set_pv_value(wave_prefixed("STOP"), 1)
        self.check_running_state(status="Finishing",
                                 running=True,
                                 continuing=False)
        self.check_running_state(status="Stopped",
                                 running=False,
                                 continuing=False,
                                 timeout=5)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_aborted_WHEN_it_is_started_THEN_it_is_running_and_keeps_running(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_ABORTED])
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)

        # We need to make sure it can keep running for a few scans. The IOC could feasibly stop the generator shortly
        # after it is started
        time.sleep(5)
        self.check_running_state(status="Running",
                                 running=True,
                                 continuing=True)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_aborted_WHEN_it_is_aborted_THEN_it_is_aborted(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_ABORTED])
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("ABORT"), 1)
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_aborted_WHEN_it_is_stopped_THEN_it_is_aborted(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_ABORTED])
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)
        self.ca.set_pv_value(wave_prefixed("STOP"), 1)
        self.check_running_state(status="Aborted",
                                 running=False,
                                 continuing=False)

    def test_WHEN_waveform_type_is_set_THEN_the_device_reports_it_has_changed(
            self):
        for index, wave_type in enumerate([
                "Sine", "Triangle", "Square", "Haversine", "Havetriangle",
                "Haversquare", "Sensor", "Aux", "Sawtooth"
        ]):
            self.ca.assert_setting_setpoint_sets_readback(
                index, wave_prefixed("TYPE"), expected_value=wave_type)

    @skip_if_recsim("Recsim record does not handle multiple channels ")
    def test_GIVEN_multiple_channels_WHEN_waveform_frequency_is_set_THEN_the_device_is_updated_to_that_value(
            self):
        expected_values = [123.456, 789.012, 345.678]
        assert len(expected_values) == NUMBER_OF_CHANNELS

        # Do this as two separate loops so that we can verify that all 3 channel values are stored independently
        for device_channel in range(NUMBER_OF_CHANNELS):
            self.ca.set_pv_value("CHANNEL:SP.VAL", device_channel)
            self.ca.set_pv_value(wave_prefixed("FREQ:SP"),
                                 expected_values[device_channel])

        for device_channel in range(NUMBER_OF_CHANNELS):
            self.ca.set_pv_value("CHANNEL:SP.VAL", device_channel)
            self.ca.assert_that_pv_is(wave_prefixed("FREQ"),
                                      expected_values[device_channel])

    @unstable_test()
    @skip_if_recsim("Conversion factors initialized to 0")
    def test_GIVEN_multiple_channels_WHEN_waveform_amplitude_is_set_THEN_the_device_is_updated_to_that_value_with_channel_conversion_factor_applied(
            self):
        input_values = [123.4, 567.8, 91.2]
        conversion_factors = [
            float(self.ca.get_pv_value("POS:SCALE")) * 1000,
            float(self.ca.get_pv_value("STRESS:SCALE")) /
            float(self.ca.get_pv_value("STRESS:AREA")),
            float(self.ca.get_pv_value("STRAIN:SCALE")) * 100000 *
            float(self.ca.get_pv_value("STRAIN:LENGTH"))
        ]

        for i in range(len(conversion_factors)):
            self.assertNotEqual(0, conversion_factors[i],
                                "Factor {} was zero".format(i))

        expected_values = [
            input_values[i] / conversion_factors[i]
            for i in range(NUMBER_OF_CHANNELS)
        ]
        assert len(expected_values) == len(conversion_factors) == len(
            input_values) == NUMBER_OF_CHANNELS

        # Do this as two separate loops so that we can verify that all 3 channel values are stored independently
        for device_channel in range(NUMBER_OF_CHANNELS):
            self.ca.set_pv_value("CHANNEL:SP.VAL", device_channel)
            self.ca.set_pv_value(wave_prefixed("AMP:SP"),
                                 input_values[device_channel])
            self.ca.assert_that_pv_is(wave_prefixed("AMP"),
                                      expected_values[device_channel])
            self.ca.assert_that_pv_is(wave_prefixed("AMP:SP:RBV"),
                                      input_values[device_channel])

        for device_channel in range(NUMBER_OF_CHANNELS):
            self.ca.set_pv_value("CHANNEL:SP.VAL", device_channel)
            self.ca.assert_that_pv_is(wave_prefixed("AMP"),
                                      expected_values[device_channel])
            self.ca.assert_that_pv_is(wave_prefixed("AMP:SP:RBV"),
                                      input_values[device_channel])

    @skip_if_recsim("RECSIM does not capture dynamic behaviour")
    def test_WHEN_the_quarter_counter_is_off_THEN_the_number_of_counts_is_and_remains_zero(
            self):
        self.ca.set_pv_value(quart_prefixed("OFF"), 1)
        self.ca.assert_that_pv_is("QUART", 0)
        self.ca.assert_that_pv_is("QUART", 0, timeout=5)

    @skip_if_recsim("Status more complicated than RECSIM can handle")
    def test_WHEN_the_quarter_counter_is_armed_THEN_the_status_is_armed(self):
        self.ca.set_pv_value(quart_prefixed("ARM"), 1)
        self.ca.assert_that_pv_is(quart_prefixed("STATUS"), "Armed")

    @skip_if_recsim("Counting part of dynamic device behaviour")
    def test_WHEN_the_waveform_generator_is_started_THEN_the_quarter_counter_starts_counting_and_keeps_increasing(
            self):
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.ca.assert_that_pv_value_is_increasing("QUART", 5)

    @skip_if_recsim("Status more complicated than RECSIM can handle")
    def test_WHEN_the_quarter_counter_is_armed_THEN_the_number_of_quarts_never_exceeds_the_requested_maximum(
            self):
        cycles = 5
        self.ca.set_pv_value(quart_prefixed("CYCLE:SP"), cycles)
        self.ca.assert_that_pv_is(quart_prefixed("SP"), cycles * 4)
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        while self.ca.get_pv_value(quart_prefixed("STATUS")) == "Armed":
            self.assertLessEqual(float(self.ca.get_pv_value("QUART") / 4.0),
                                 cycles)
        self.ca.assert_that_pv_is(quart_prefixed("STATUS"), "Tripped")

    def test_GIVEN_the_waveform_generator_is_stopped_WHEN_instructed_to_hold_THEN_status_is_stopped(
            self):
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Stopped")
        self.ca.set_pv_value(wave_prefixed("HOLD"), 1)
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Stopped")

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_running_WHEN_instructed_to_hold_THEN_status_is_holding(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_RUNNING])
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Running")
        self.ca.set_pv_value(wave_prefixed("HOLD"), 1)
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Holding")

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_holding_WHEN_instructed_to_hold_THEN_status_is_holding(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_HOLDING])
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Holding")
        self.ca.set_pv_value(wave_prefixed("HOLD"), 1)
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Holding")

    @skip_if_recsim("No backdoor in LewisNone")
    def test_GIVEN_the_waveform_generator_is_finishing_WHEN_instructed_to_hold_THEN_status_is_finishing(
            self):
        self._lewis.backdoor_command(
            ["device", "set_waveform_state", WAVEFORM_FINISHING])
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Finishing")
        self.ca.set_pv_value(wave_prefixed("HOLD"), 1)
        self.ca.assert_that_pv_is(wave_prefixed("STATUS"), "Finishing")

    def verify_channel_abs(self, expected_value):
        self.ca.assert_that_pv_is("POS:RAMP:WFTYP", expected_value)
        self.ca.assert_that_pv_is("STRAIN:RAMP:WFTYP", expected_value)
        self.ca.assert_that_pv_is("STRESS:RAMP:WFTYP", expected_value)

    def test_WHEN_the_waveform_generator_is_started_THEN_every_axis_is_set_to_ramp(
            self):
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.verify_channel_abs(RAMP_WAVEFORM_TYPES[0])

    def test_WHEN_the_waveform_generator_is_stopped_THEN_every_axis_is_set_to_absolute_ramp(
            self):
        self.ca.set_pv_value(wave_prefixed("STOP"), 1)
        self.verify_channel_abs(RAMP_WAVEFORM_TYPES[3])

    @skip_if_recsim("Different statuses don't interact in RECSIM")
    def test_WHEN_the_waveform_generator_is_started_THEN_the_quarter_counter_is_armed(
            self):
        self.ca.set_pv_value(wave_prefixed("START"), 1)
        self.ca.assert_that_pv_is(quart_prefixed("STATUS"), "Armed")

    def test_WHEN_the_max_cyles_is_set_THEN_the_readback_matches_setpoint(
            self):
        value = 7
        self.ca.assert_setting_setpoint_sets_readback(
            value=value,
            set_point_pv=quart_prefixed("CYCLE:SP"),
            readback_pv=quart_prefixed("CYCLE:SP:RBV"))
Ejemplo n.º 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 IpsTests(unittest.TestCase):
    """
    Tests for the Ips IOC.
    """
    def setUp(self):
        self._lewis, self._ioc = get_running_lewis_and_ioc(EMULATOR_NAME, DEVICE_PREFIX)
        # Some changes happen on the order of HEATER_WAIT_TIME seconds. Use a significantly longer timeout
        # to capture a few heater wait times plus some time for PVs to update.
        self.ca = ChannelAccess(device_prefix=DEVICE_PREFIX, default_timeout=HEATER_WAIT_TIME*10)

        # Wait for some critical pvs to be connected.
        for pv in ["MAGNET:FIELD:PERSISTENT", "FIELD", "FIELD:SP:RBV", "HEATER:STATUS"]:
            self.ca.assert_that_pv_exists(pv)

        # Ensure in the correct mode
        self.ca.set_pv_value("CONTROL:SP", "Remote & Unlocked")
        self.ca.set_pv_value("ACTIVITY:SP", "To Setpoint")

        # Don't run reset as the sudden change of state confuses the IOC's state machine. No matter what the initial
        # state of the device the SNL should be able to deal with it.
        # self._lewis.backdoor_run_function_on_device("reset")

        self.ca.set_pv_value("HEATER:WAITTIME", HEATER_WAIT_TIME)

        self.ca.set_pv_value("FIELD:RATE:SP", 10)
        # self.ca.assert_that_pv_is_number("FIELD:RATE:SP", 10)

        self.ca.process_pv("FIELD:SP")

        # Wait for statemachine to reach "at field" state before every test.
        self.ca.assert_that_pv_is("STATEMACHINE", "At field")

    def tearDown(self):
        # Wait for statemachine to reach "at field" state after every test.
        self.ca.assert_that_pv_is("STATEMACHINE", "At field")

        self.assertEqual(self._lewis.backdoor_get_from_device("quenched"), False)

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

    def _assert_field_is(self, field, check_stable=False):
        self.ca.assert_that_pv_is_number("FIELD", field, tolerance=TOLERANCE)
        self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE)
        if check_stable:
            self.ca.assert_that_pv_value_is_unchanged("FIELD", wait=30)
            self.ca.assert_that_pv_is_number("FIELD", field, tolerance=TOLERANCE, timeout=10)
            self.ca.assert_that_pv_is_number("FIELD:USER", field, tolerance=TOLERANCE, timeout=10)

    def _assert_heater_is(self, heater_state):
        self.ca.assert_that_pv_is("HEATER:STATUS:SP", "On" if heater_state else "Off")
        if heater_state:
            self.ca.assert_that_pv_is("HEATER:STATUS", "On",)
        else:
            self.ca.assert_that_pv_is_one_of("HEATER:STATUS", HEATER_OFF_STATES)

    def _set_and_check_persistent_mode(self, mode):
        self.ca.assert_setting_setpoint_sets_readback("YES" if mode else "NO", "PERSISTENT")

    @parameterized.expand(val for val in parameterized_list(TEST_VALUES))
    def test_GIVEN_persistent_mode_enabled_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_ramps_to_zero(self, _, val):

        self._set_and_check_persistent_mode(True)

        # Field in the magnet already from persistent mode.
        persistent_field = float(self.ca.get_pv_value("MAGNET:FIELD:PERSISTENT"))

        # Set the new field. This will cause all of the following events based on the state machine.
        self.ca.set_pv_value("FIELD:SP", val)

        # PSU should be ramped to match the persistent field inside the magnet
        self._assert_field_is(persistent_field)
        self.ca.assert_that_pv_is("ACTIVITY", "To Setpoint")

        # Then it is safe to turn on the heater
        self._assert_heater_is(True)

        # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up
        # after being set.
        self._assert_field_is(val)

        # Now that the correct current is in the magnet, the SNL should turn the heater off
        self._assert_heater_is(False)

        # Now that the heater is off, can ramp down the PSU to zero (SNL waits some time for heater to be off before
        # ramping PSU to zero)
        self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE)  # PSU field
        self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE)  # Persistent field
        self.ca.assert_that_pv_is_number("FIELD:USER", val, tolerance=TOLERANCE)  # User field should be tracking persistent field here
        self.ca.assert_that_pv_is("ACTIVITY", "To Zero")

        # ...And the magnet should now be in the right state!
        self.ca.assert_that_pv_is("STATEMACHINE", "At field")
        self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE)

        # "User" field should take the value put in the setpoint, even when the actual field provided by the supply
        # drops to zero
        self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE)  # PSU field
        self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE)  # Persistent field
        self.ca.assert_that_pv_is_number("FIELD:USER", val, tolerance=TOLERANCE)  # User field should be tracking persistent field here

    @parameterized.expand(val for val in parameterized_list(TEST_VALUES))
    def test_GIVEN_non_persistent_mode_WHEN_magnet_told_to_go_to_field_setpoint_THEN_goes_to_that_setpoint_and_psu_does_not_ramp_to_zero(self, _, val):

        self._set_and_check_persistent_mode(False)

        # Field in the magnet already from persistent mode.
        persistent_field = float(self.ca.get_pv_value("MAGNET:FIELD:PERSISTENT"))

        # Set the new field. This will cause all of the following events based on the state machine.
        self.ca.set_pv_value("FIELD:SP", val)

        # PSU should be ramped to match the persistent field inside the magnet (if there was one)
        self._assert_field_is(persistent_field)

        # Then it is safe to turn on the heater (the heater is explicitly switched on and we wait for it even if it
        # was already on out of an abundance of caution).
        self._assert_heater_is(True)

        # Assert that value gets passed to device by SNL. SNL waits 30s for the heater to cool down/warm up
        # after being set.
        self._assert_field_is(val)

        # ...And the magnet should now be in the right state!
        self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", val, tolerance=TOLERANCE)

        # And the PSU should remain stable providing the required current/field
        self.ca.assert_that_pv_is("STATEMACHINE", "At field")
        self._assert_field_is(val, check_stable=True)

    @contextmanager
    def _backdoor_magnet_quench(self, reason="Test framework quench"):
        self._lewis.backdoor_run_function_on_device("quench", [reason])
        try:
            yield
        finally:
            # Get back out of the quenched state. This is because the tearDown method checks that magnet has not
            # quenched.
            self._lewis.backdoor_run_function_on_device("unquench")
            # Wait for IOC to notice quench state has gone away
            self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.NONE)

    @parameterized.expand(field for field in parameterized_list(TEST_VALUES))
    def test_GIVEN_magnet_quenches_while_at_field_THEN_ioc_displays_this_quench_in_statuses(self, _, field):

        self._set_and_check_persistent_mode(False)
        self.ca.set_pv_value("FIELD:SP", field)
        self._assert_field_is(field)
        self.ca.assert_that_pv_is("STATEMACHINE", "At field")

        with self._backdoor_magnet_quench():
            self.ca.assert_that_pv_is("STS:SYSTEM:FAULT", "Quenched")
            self.ca.assert_that_pv_alarm_is("STS:SYSTEM:FAULT", self.ca.Alarms.MAJOR)
            self.ca.assert_that_pv_is("CONTROL", "Auto-Run-Down")
            self.ca.assert_that_pv_alarm_is("CONTROL", self.ca.Alarms.MAJOR)

            # The trip field should be the field at the point when the magnet quenched.
            self.ca.assert_that_pv_is_number("FIELD:TRIP", field, tolerance=TOLERANCE)

            # Field should be set to zero by emulator (mirroring what the field ought to do in the real device)
            self.ca.assert_that_pv_is_number("FIELD", 0, tolerance=TOLERANCE)
            self.ca.assert_that_pv_is_number("FIELD:USER", 0, tolerance=TOLERANCE)
            self.ca.assert_that_pv_is_number("MAGNET:FIELD:PERSISTENT", 0, tolerance=TOLERANCE)

    @parameterized.expand(val for val in parameterized_list(TEST_VALUES))
    def test_WHEN_inductance_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val):
        self._lewis.backdoor_set_on_device("inductance", val)
        self.ca.assert_that_pv_is_number("MAGNET:INDUCTANCE", val, tolerance=TOLERANCE)

    @parameterized.expand(val for val in parameterized_list(TEST_VALUES))
    def test_WHEN_measured_current_set_via_backdoor_THEN_value_in_ioc_updates(self, _, val):
        self._lewis.backdoor_set_on_device("measured_current", val)
        self.ca.assert_that_pv_is_number("MAGNET:CURR:MEAS", val, tolerance=TOLERANCE)

    @parameterized.expand(val for val in parameterized_list(TEST_SWEEP_RATES))
    def test_WHEN_sweep_rate_set_THEN_sweep_rate_on_ioc_updates(self, _, val):
        self.ca.set_pv_value("FIELD:RATE:SP", val)
        self.ca.assert_that_pv_is_number("FIELD:RATE:SP", val, tolerance=TOLERANCE)
        self.ca.assert_that_pv_is_number("FIELD:RATE", val, tolerance=TOLERANCE)
        self.ca.assert_that_pv_alarm_is("FIELD:RATE", self.ca.Alarms.NONE)

    @parameterized.expand(activity_state for activity_state in parameterized_list(ACTIVITY_STATES))
    @unstable_test()
    def test_WHEN_activity_set_via_backdoor_to_clamped_THEN_alarm_major_ELSE_no_alarm(self, _, activity_state):
        self.ca.set_pv_value("ACTIVITY", activity_state)
        if activity_state == "Clamped":
            self.ca.assert_that_pv_alarm_is("ACTIVITY", "MAJOR")
        else:
            self.ca.assert_that_pv_alarm_is("ACTIVITY", "NO_ALARM")

    @parameterized.expand(control_command for control_command in parameterized_list(CONTROL_COMMANDS_WITH_VALUES))
    def test_WHEN_control_command_value_set_THEN_remote_unlocked_set(self, _, control_pv, set_value):
        self.ca.set_pv_value("CONTROL", "Local & Locked")
        self.ca.set_pv_value(control_pv, set_value)
        self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked")

    @parameterized.expand(control_pv for control_pv in parameterized_list(CONTROL_COMMANDS_WITHOUT_VALUES))
    def test_WHEN_control_command_processed_THEN_remote_unlocked_set(self, _, control_pv):
        self.ca.set_pv_value("CONTROL", "Local & Locked")
        self.ca.process_pv(control_pv)
        self.ca.assert_that_pv_is("CONTROL", "Remote & Unlocked")