diff --git a/.travis.yml b/.travis.yml index 6f4c2e21..402ff29a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ env: - secure: "FhNkkbod0Wc/zUf9cTvwziAYHcjfte2POf+hoVSmC+v/RcYKCNCo+mGGMhF9F4KyC2nzvulfzow7YXoswZqav4+TEEu+mpuPaGlf9aqp8V61eij8MVTwonzQEYmHAy3KatwXxyvvhQpfj3gOuDVolfOg2MtNZi6QERES4E1sjOn714fx2HkVxqH2Y8/PF/FzzGeJaRlVaVci0EdIJ5Ss5c5SjO6JGgxj4hzhTPHjTaLjdLHlVhuB9Yatl80zbhGriljLcDQTHmoSODwBpAh5YLDUZq6B9vomaNB9Hb3e0D5gItjOdj53v6AsHU8LkncZMvsgJgh2sZZqMO6nkpHcYPwJgbPbKd3RtVlk6Kg/tvKQk0rMcxl5fFFeD2i9POnANg/xJsKN6yAEY3kaRwQtajQmlcicSa/wdwv9NhUTtBmA/mnyzxHbQXrB0bEc2P2QVu7U8en6dWaOAqc1VCMrWIhp2ADNWb7JZhYj70TgmExIU3UH8qlMb6dyx50SJUE9waJj3fiiZVkjh+E568ZRSMvL9n+bLlFt4uDT4AysSby6cj+zjfNViKFstTAqjyd5VJEvCoUu73vNzWEiWFtEvKKVL1P3pbLN/G3aSSJMa5fc1o+2lRUwdwNNOOdH6iKBDZGNpE8nGDlTP2b2dhFyEt8nICKJhbgU208jhyyH8Vk=" script: + - export OPHYD_CONTROL_LAYER=caproto - coverage run -m pytest # Run the tests and check for test coverage. - coverage report -m # Generate test coverage report. - codecov # Upload the report to codecov. diff --git a/nslsii/iocs/lakeshore_control.py b/nslsii/iocs/lakeshore_control.py new file mode 100644 index 00000000..232fc8ab --- /dev/null +++ b/nslsii/iocs/lakeshore_control.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +from caproto.server import pvproperty, PVGroup +from caproto import ChannelType + + +class ControlRecord(PVGroup): + + def __init__(self, prefix, *, indx, ioc, **kwargs): + super().__init__(prefix, **kwargs) + self._indx = indx + self.ioc = ioc + + _false_true_states = ['False', 'True'] + + # PVPositioner required attributes + + _rb_val = 0. + + setpoint = pvproperty(value=_rb_val, + dtype=ChannelType.DOUBLE, + name='}}T-SP') + readback = pvproperty(value=_rb_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}T-RB') + + _done_val = 0. + + done = pvproperty(value='False', + read_only=False, + enum_strings=_false_true_states, + dtype=ChannelType.ENUM, + name='}}Sts:Ramp-Sts') + + # top level attributes + + _heater_range_val = 0. + _heater_status_val = 0. + + heater_range = pvproperty(value=_heater_range_val, + dtype=ChannelType.DOUBLE, + name='}}Val:Range-Sel') + heater_status = pvproperty(value=_heater_status_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Err:Htr-Sts') + + _mode_val = 0. + _enable_val = 0. + _target_channel_val = '' + + mode = pvproperty(value=_mode_val, + dtype=ChannelType.DOUBLE, + name='}}Mode-Sel') + enable = pvproperty(value=_enable_val, + dtype=ChannelType.DOUBLE, + name='}}Enbl-Sel') + target_channel = pvproperty(value=_target_channel_val, + dtype=ChannelType.STRING, + name='}}Out-Sel') + + # ramp attributes + + _ramp_enable_val = 0. + _ramp_rate_val = 5. # degree/s + + ramp_enable = pvproperty(value=_ramp_enable_val, + dtype=ChannelType.DOUBLE, + name='}}Enbl:Ramp-Sel') + + ramp_rate_rb = pvproperty(value=_ramp_rate_val, + dtype=ChannelType.DOUBLE, + name='}}Val:Ramp-RB') + ramp_rate_sp = pvproperty(value=_ramp_rate_val, + dtype=ChannelType.DOUBLE, + name='}}Val:Ramp-SP') + + # PID loop parameters + + _pid_proportional_val = 0. + _pid_integral_val = 0. + _pid_derivative_val = 0. + + pid_proportional_rb = pvproperty(value=_pid_proportional_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Gain:P-RB') + pid_proportional_sp = pvproperty(value=_pid_proportional_val, + dtype=ChannelType.DOUBLE, + name='}}Gain:P-SP') + + pid_integral_rb = pvproperty(value=_pid_integral_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Gain:I-RB') + pid_integral_sp = pvproperty(value=_pid_integral_val, + dtype=ChannelType.DOUBLE, + name='}}Gain:I-SP') + + pid_derivative_rb = pvproperty(value=_pid_derivative_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Gain:D-RB') + pid_derivative_sp = pvproperty(value=_pid_derivative_val, + dtype=ChannelType.DOUBLE, + name='}}Gain:D-SP') + + # output parameters + + _out_current_val = 0. + _out_man_current_val = 0. + _out_max_current_val = 0. + _out_resistance_val = 0. + + out_current = pvproperty(value=_out_current_val, + dtype=ChannelType.DOUBLE, + name='}}Out-I') + + out_man_current_rb = pvproperty(value=_out_man_current_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Out:Man-RB') + out_man_current_sp = pvproperty(value=_out_man_current_val, + dtype=ChannelType.DOUBLE, + name='}}Out:Man-SP') + + out_max_current_rb = pvproperty(value=_out_max_current_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Out:MaxI-RB') + out_max_current_sp = pvproperty(value=_out_max_current_val, + dtype=ChannelType.DOUBLE, + name='}}Out:MaxI-SP') + + out_resistance_rb = pvproperty(value=_out_resistance_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Out:R-RB') + out_resistance_sp = pvproperty(value=_out_resistance_val, + dtype=ChannelType.DOUBLE, + name='}}Out:R-SP') + + # Putter/Getter Methods + + @setpoint.putter + async def setpoint(self, instance, value): + + # select channel + prefix = self.ioc.prefix.replace('{', '{'*2) + channel = self._target_channel_val + t_k = f'{prefix}-Chan:{channel}' + if t_k in self.ioc.groups: + pass + else: + return instance.value + t_v = self.ioc.groups[t_k] + + # apply cmd + indx = self._indx + cmd = f'{value},{indx}' + await t_v.cmd.write(value=cmd) + + self._rb_val = value + return value + + @done.getter + async def done(self, instance): + return self._done_val + + @target_channel.getter + async def target_channel(self, instance): + return self._target_channel_val + + @target_channel.putter + async def target_channel(self, instance, value): + self._target_channel_val = value + return value + + @ramp_rate_rb.getter + async def ramp_rate_rb(self, instance): + return self._ramp_rate_val + + @ramp_rate_sp.putter + async def ramp_rate_sp(self, instance, value): + self._ramp_rate_val = value + return value + + @pid_proportional_rb.getter + async def pid_proportional_rb(self, instance): + return self._pid_proportional_val + + @pid_proportional_sp.putter + async def pid_proportional_sp(self, instance, value): + self._pid_proportional_val = value + return value + + @pid_integral_rb.getter + async def pid_integral_rb(self, instance): + return self._pid_integral_val + + @pid_integral_sp.putter + async def pid_integral_sp(self, instance, value): + self._pid_integral_val = value + return value + + @pid_derivative_rb.getter + async def pid_derivative_rb(self, instance): + return self._pid_derivative_val + + @pid_derivative_sp.putter + async def pid_derivative_sp(self, instance, value): + self._pid_derivative_val = value + return value + + @out_man_current_rb.getter + async def out_man_current_rb(self, instance): + return self._out_man_current_val + + @out_man_current_sp.putter + async def out_man_current_sp(self, instance, value): + self._out_man_current_val = value + return value + + @out_max_current_rb.getter + async def out_max_current_rb(self, instance): + return self._out_max_current_val + + @out_max_current_sp.putter + async def out_max_current_sp(self, instance, value): + self._out_max_current_val = value + return value + + @out_resistance_rb.getter + async def out_resistance_rb(self, instance): + return self._out_resistance_val + + @out_resistance_sp.putter + async def out_resistance_sp(self, instance, value): + self._out_resistance_val = value + return value diff --git a/nslsii/iocs/lakeshore_ioc_sim.py b/nslsii/iocs/lakeshore_ioc_sim.py new file mode 100644 index 00000000..288a6aae --- /dev/null +++ b/nslsii/iocs/lakeshore_ioc_sim.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +from caproto.server import PVGroup, ioc_arg_parser, run + +from nslsii.iocs.lakeshore_temperature import TemperatureRecord +from nslsii.iocs.lakeshore_control import ControlRecord + + +class LakeshoreIOC(PVGroup): + """ + Simulates a Lakeshore IOC. + """ + + def __init__(self, prefix, *, groups, **kwargs): + super().__init__(prefix, **kwargs) + self.groups = groups + + +def create_ioc(prefix, temperatures, controls, **ioc_options): + + groups = {} + + ioc = LakeshoreIOC(prefix, groups=groups, **ioc_options) + + for t in temperatures: + t_prefix = f'{prefix}-Chan:{t}' + print('t_prefix:', t_prefix) + groups[t_prefix] = TemperatureRecord(t_prefix, indx=t, ioc=ioc) + + for c in controls: + c_prefix = f'{prefix}-Out:{c}' + print('c_prefix:', c_prefix) + groups[c_prefix] = ControlRecord(c_prefix, indx=c, ioc=ioc) + + for prefix, group in groups.items(): + ioc.pvdb.update(**group.pvdb) + + return ioc + + +if __name__ == '__main__': + + ioc_options, run_options = ioc_arg_parser( + default_prefix='test:{{{{', + desc='Lakeshore IOC.') + + temperatures = ['A', 'B', 'C', 'D'] + controls = [1, 2] + + ioc = create_ioc(temperatures=temperatures, + controls=controls, + **ioc_options) + + run(ioc.pvdb, **run_options) diff --git a/nslsii/iocs/lakeshore_temperature.py b/nslsii/iocs/lakeshore_temperature.py new file mode 100644 index 00000000..f8f814c0 --- /dev/null +++ b/nslsii/iocs/lakeshore_temperature.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +from caproto.server import pvproperty, PVGroup +from caproto import ChannelType + +import numpy as np + +from threading import Lock + + +class TemperatureRecord(PVGroup): + + def __init__(self, prefix, *, indx, ioc, **kwargs): + super().__init__(prefix, **kwargs) + self._indx = indx + self.ioc = ioc + + _false_true_states = ['False', 'True'] + _step_size = 1 # degree + + _T_val = 273.15 + _V_val = 0. + _status_val = 0. + _display_name = 'Lakeshore T' + _alarm_high_val = 400.0 + _alarm_low_val = 200.0 + + putter_lock = Lock() + + T = pvproperty(value=_T_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}T-I') + + T_celsius = pvproperty(value=_T_val - 273.15, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}T:C-I') + V = pvproperty(value=_V_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}Val:Sens-I') + status = pvproperty(value=_status_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}T-Sts') + display_name_rb = pvproperty(value=_display_name, + read_only=True, + dtype=ChannelType.STRING, + name='}}T:Name-RB') + display_name_sp = pvproperty(value=_display_name, + dtype=ChannelType.STRING, + name='}}T:Name-SP') + + alarm_high = pvproperty(value='False', + enum_strings=_false_true_states, + dtype=ChannelType.ENUM, + name='}}Alrm:High-Sts') + alarm_low = pvproperty(value='False', + enum_strings=_false_true_states, + dtype=ChannelType.ENUM, + name='}}Alrm:Low-Sts') + + _T_lim_val = 0. + + T_lim_rb = pvproperty(value=_T_lim_val, + read_only=True, + dtype=ChannelType.DOUBLE, + name='}}T:Lim-RB') + T_lim_sp = pvproperty(value=_T_lim_val, + dtype=ChannelType.DOUBLE, + name='}}T:Lim-SP') + + # Methods + + _velocity = 1. + _step_size = 0.1 + + cmd = pvproperty(value='', + dtype=ChannelType.STRING, + name='}}Cmd') + + def exp_sin_func(self, p0, p1, dwell, j, v): + omega = np.pi + setpoint = p1 + Tvar = setpoint*0.1 + K = (p1 - p0)/v/2.5 + dt = dwell*(j+1) + return Tvar*np.exp(-dt / K) * np.sin(omega * dt) + setpoint + + def linear_func(self, p0, dp, j): + return p0 + dp*(j+1) + + @cmd.startup + async def cmd(self, instance, async_lib): + instance.ev = async_lib.library.Event() + instance.async_lib = async_lib + + @cmd.putter + async def cmd(self, instance, cmd): + + cmd_list = cmd.split(',') # value, ctrl indx + value = float(cmd_list[0]) + ctrl_indx = int(cmd_list[1]) + + # check alarm high + + if value >= self._alarm_high_val: + await self.alarm_high.write(value=1) + return instance.value + else: + await self.alarm_high.write(value=0) + + # check alarm low + + if value <= self._alarm_low_val: + await self.alarm_low.write(value=1) + return instance.value + else: + await self.alarm_low.write(value=0) + + # check lock + + if self.putter_lock.locked() is True: + return instance.value + else: + self.putter_lock.acquire() + + # select the lakeshore control + + prefix = self.ioc.prefix.replace('{', '{'*2) + c_k = f'{prefix}-Out:{ctrl_indx}' + ctrl = self.ioc.groups[c_k] + + # update the temp and ctrl readbacks + + await ctrl.done.write(value=0) + + p0 = self._T_val + dp = self._step_size + v = ctrl._ramp_rate_val + dwell = dp/v + N = max(1, int((value - p0) / self._step_size)) + + for j in range(N): + # new_value = p0 + self._step_size*(j+1) + # new_value = self.linear_func(p0, dp, j) + new_value = self.exp_sin_func(p0, value, dwell, j, v) + await instance.async_lib.library.sleep(dwell) + await self.T.write(value=new_value) + await self.T_celsius.write(value=(new_value - 273.15)) + await ctrl.readback.write(value=new_value) + + self._T_val = value + await ctrl.done.write(value=1) + + self.putter_lock.release() + return value diff --git a/nslsii/tests/lakeshore_test.py b/nslsii/tests/lakeshore_test.py new file mode 100644 index 00000000..f640fb3b --- /dev/null +++ b/nslsii/tests/lakeshore_test.py @@ -0,0 +1,262 @@ +import os +import pytest +import subprocess +import sys +import time + +from collections import OrderedDict +from ophyd import (Component, DynamicDeviceComponent, Device, + EpicsSignal, EpicsSignalRO, PVPositioner) + +from caproto.sync.client import read + + +def create_device_from_components(name, *, docstring=None, + default_read_attrs=None, + default_configuration_attrs=None, + base_class=Device, class_kwargs=None, + **components): + + if docstring is None: + docstring = f'{name} Device' + + if not isinstance(base_class, tuple): + base_class = (base_class, ) + + if class_kwargs is None: + class_kwargs = {} + + clsdict = OrderedDict( + __doc__=docstring, + _default_read_attrs=default_read_attrs, + _default_configuration_attrs=default_configuration_attrs + ) + + for attr, component in components.items(): + if not isinstance(component, Component): + raise ValueError(f'Attribute {attr} is not a Component. ' + f'It is of type {type(component).__name__}') + + clsdict[attr] = component + + return type(name, base_class, clsdict, **class_kwargs) + + +def lakeshore336(name='Lakeshore336', temperatures=['A', 'B', 'C', 'D'], + controls=[1, 2], docstring=None, + default_read_attrs=None, default_configuration_attrs=None): + + def _set_fields(fields, cls, prefix, field_prefix='', **kwargs): + '''A function that generates the component dictionaries for fields.''' + out_dict = OrderedDict() + for field in fields: + suffix = f'{prefix}{field}' + out_dict[f'{field_prefix}{field}'] = Component(cls, suffix, + **kwargs) + return out_dict + + class _Temperature(Device): + + T = Component(EpicsSignalRO, '}T-I') + T_celsius = Component(EpicsSignalRO, '}T:C-I') + V = Component(EpicsSignalRO, '}Val:Sens-I') + status = Component(EpicsSignalRO, '}T-Sts', kind='config') + display_name = Component(EpicsSignal, '}T:Name-RB', + write_pv='}T:Name-SP', kind='omitted') + + alarm = DynamicDeviceComponent( + {'high': (EpicsSignalRO, '}Alrm:High-Sts', {'kind': 'config'}), + 'low': (EpicsSignalRO, '}Alrm:Low-Sts', {'kind': 'config'})}, + kind='config') + + T_limit = Component(EpicsSignal, '}T:Lim-RB', write_pv='}T:Lim-SP', + kind='omitted') + + class _Control(PVPositioner): + + # PVPositioner required attributes + setpoint = Component(EpicsSignal, '}T-SP') + readback = Component(EpicsSignalRO, '}T-RB') + + done = Component(EpicsSignalRO, '}Sts:Ramp-Sts', kind='omitted') + + # top level attributes + heater_range = Component(EpicsSignal, '}Val:Range-Sel', kind='config') + heater_status = Component(EpicsSignalRO, '}Err:Htr-Sts', + kind='omitted') + mode = Component(EpicsSignal, '}Mode-Sel', kind='config') + enable = Component(EpicsSignal, '}Enbl-Sel', kind='config') + target_channel = Component(EpicsSignal, '}Out-Sel', kind='config') + + # ramp attributes + ramp = DynamicDeviceComponent( + {'enabled': (EpicsSignal, '}Enbl:Ramp-Sel', {'kind': 'config'}), + 'rate': (EpicsSignal, '}Val:Ramp-RB', + {'write_pv': '}Val:Ramp-SP', 'kind': 'config'})}, + kind='config') + + # PID loop parameters + pid = DynamicDeviceComponent( + {'proportional': (EpicsSignal, '}Gain:P-RB', + {'write_pv': '}Gain:P-SP', 'kind': 'config'}), + 'integral': (EpicsSignal, '}Gain:I-RB', + {'write_pv': '}Gain:I-SP', 'kind': 'config'}), + 'derivative': (EpicsSignal, '}Gain:D-RB', + {'write_pv': '}Gain:D-SP', 'kind': 'config'})}, + kind='config') + + # output parameters + output = DynamicDeviceComponent( + {'current': (EpicsSignal, '}Out-I', {}), + 'manual_current': (EpicsSignal, '}Out:Man-RB', + {'write_pv': '}Out:Man-SP'}), + 'max_current': (EpicsSignal, '}Out:MaxI-RB', + {'write_pv': '}Out:MaxI-SP', 'kind': 'config'}), + 'resistance': (EpicsSignal, '}Out:R-RB', + {'write_pv': '}Out:R-SP', 'kind': 'config'})}) + + temp_components = _set_fields(temperatures, _Temperature, '-Chan:') + output_components = _set_fields(controls, _Control, '-Out:', + field_prefix='out') + + components = { + 'temp': Component(create_device_from_components('temp', + **temp_components), + ''), + 'ctrl': Component(create_device_from_components('ctrl', + **output_components), + '')} + + new_class = create_device_from_components( + name, docstring=docstring, default_read_attrs=default_read_attrs, + default_configuration_attrs=default_configuration_attrs, + base_class=Device, **components) + + return new_class + + +@pytest.fixture(scope='class') +def ioc_sim(request): + + # setup code + + stdout = subprocess.PIPE + stdin = None + + ioc_process = subprocess.Popen([sys.executable, '-m', + 'nslsii.iocs.lakeshore_ioc_sim'], + stdout=stdout, stdin=stdin, + env=os.environ) + + print(f'nslsii.iocs.lakeshore_ioc_sim is now running') + + time.sleep(5) + + MyLakeshore336 = lakeshore336() + tc = MyLakeshore336('test:{', name='temp_controller') + + time.sleep(5) + + request.cls.tc = tc + + yield + + # teardown code + + ioc_process.terminate() + + +@pytest.mark.usefixtures('ioc_sim') +class TestIOC: + + def test_caproto_level(self): + + t_rb = read('test:{-Chan:A}T:C-I') + assert t_rb.data[0] == 0.0 + + c_sp = read('test:{-Out:1}T-SP') + assert c_sp.data[0] == 0.0 + + c_rb = read('test:{-Out:1}T-RB') + assert c_rb.data[0] == 0.0 + + def test_device_level(self): + + res = self.tc.temp.A.T_celsius.get() + assert res == 0.0 + + res = self.tc.ctrl.out1.setpoint.get() + assert res == 0.0 + + res = self.tc.ctrl.out1.target_channel.get() + assert res == '' + + def test_target_channel(self): + + self.tc.ctrl.out1.target_channel.put('A') + res = self.tc.ctrl.out1.target_channel.get() + assert res == 'A' + + def test_alarm_low(self): + + new_value = 150.0 + + res = self.tc.temp.A.alarm.low.get() + assert res == 0 + + self.tc.ctrl.out1.target_channel.put('A') + self.tc.ctrl.out1.setpoint.put(new_value) + + res = self.tc.temp.A.alarm.low.get() + assert res == 1 + + def test_alarm_high(self): + + new_value = 450.0 + + res = self.tc.temp.A.alarm.high.get() + assert res == 0 + + self.tc.ctrl.out1.target_channel.put('A') + self.tc.ctrl.out1.setpoint.put(new_value) + + res = self.tc.temp.A.alarm.high.get() + assert res == 1 + + def test_1_A(self): + + value = self.tc.temp.A.T.get() + new_value = value + 10 + + rate = self.tc.ctrl.out1.ramp.rate.get() + t = (new_value-value)/rate + + self.tc.ctrl.out1.target_channel.put('A') + self.tc.ctrl.out1.setpoint.put(new_value) + + time.sleep(t+1) + + res = self.tc.ctrl.out1.done.get() + assert res == 1 + + res = self.tc.temp.A.T.get() + assert res == new_value + + def test_2_B(self): + + value = self.tc.temp.B.T.get() + new_value = value + 10 + + rate = self.tc.ctrl.out2.ramp.rate.get() + t = (new_value-value)/rate + + self.tc.ctrl.out2.target_channel.put('B') + self.tc.ctrl.out2.setpoint.put(new_value) + + time.sleep(t+1) + + res = self.tc.ctrl.out2.done.get() + assert res == 1 + + res = self.tc.temp.B.T.get() + assert res == new_value diff --git a/nslsii/tests/temperature_controllers_test.py b/nslsii/tests/temperature_controllers_test.p similarity index 96% rename from nslsii/tests/temperature_controllers_test.py rename to nslsii/tests/temperature_controllers_test.p index 33b7978b..21aedfd6 100644 --- a/nslsii/tests/temperature_controllers_test.py +++ b/nslsii/tests/temperature_controllers_test.p @@ -6,6 +6,7 @@ import os import sys import pytest +import time @pytest.fixture @@ -27,13 +28,14 @@ def test_Eurotherm(RE): # Start up an IOC based on the thermo_sim device in caproto.ioc_examples ioc_process = subprocess.Popen([sys.executable, '-m', - 'caproto.tests.example_runner', 'caproto.ioc_examples.thermo_sim'], stdout=stdout, stdin=stdin, env=os.environ) print(f'caproto.ioc_examples.thermo_sim is now running') + time.sleep(5) + # Wrap the rest in a try-except to ensure the ioc is killed before exiting try: euro = Eurotherm('thermo:', name='euro') diff --git a/nslsii/iocs/tests/test_epstwostate_ioc.py b/nslsii/tests/test_epstwostate_ioc.py similarity index 100% rename from nslsii/iocs/tests/test_epstwostate_ioc.py rename to nslsii/tests/test_epstwostate_ioc.py