Skip to content

Commit 0cb7812

Browse files
authored
Merge pull request #157 from BCDA-APS/ProcessController-156
add ProcessController Device (from USAXS)
2 parents a8fdf33 + f9c85e7 commit 0cb7812

File tree

1 file changed

+140
-1
lines changed

1 file changed

+140
-1
lines changed

apstools/devices.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
~DualPf4FilterBox
7575
~EpicsDescriptionMixin
7676
~KohzuSeqCtl_Monochromator
77+
~ProcessController
7778
~Struck3820
7879
7980
Internal routines
@@ -121,7 +122,7 @@
121122

122123
logger = logging.getLogger(__name__)
123124

124-
"""for convenience""" # TODO: contribute to ophyd?
125+
"""for convenience""" # TODO: contribute to ophyd?
125126
SCALER_AUTOCOUNT_MODE = 1
126127

127128

@@ -1322,6 +1323,144 @@ class KohzuSeqCtl_Monochromator(Device):
13221323
crystal_type = Component(EpicsSignal, "BraggTypeMO")
13231324

13241325

1326+
class ProcessController(Device):
1327+
"""
1328+
common parts of a process controller support
1329+
1330+
A process controller keeps a signal (a readback value such as
1331+
temperature, vacuum, himdity, etc.) as close as possible
1332+
to a target (set point) value. It has additional fields
1333+
that describe parameters specific to the controller such
1334+
as PID loop, on/off, applied controller power, and other
1335+
details.
1336+
1337+
This is a base class to standardize the few common terms
1338+
used to command and record the target and readback values
1339+
of a process controller.
1340+
1341+
Subclasses should redefine (override) `controller_name`,
1342+
``signal``, ``target``, and ``units`` such as the example below.
1343+
Also set values for ``tolerance``, ``report_interval_s``, and
1344+
``poll_s`` suitable for the specific controller used.
1345+
1346+
*Floats*: ``signal``, ``target`', and ``tolerance`` will be
1347+
considered as floating point numbers in the code.
1348+
1349+
It is assumed in "meth"`settled()` that: ``|signal - target| <= tolerance``.
1350+
Override this *property* method if a different decision is needed.
1351+
1352+
EXAMPLE::
1353+
1354+
class MyLinkam(ProcessController):
1355+
controller_name = "MyLinkam Controller"
1356+
signal = Component(EpicsSignalRO, "temp")
1357+
target = Component(EpicsSignal, "setLimit", kind="omitted")
1358+
units = Component(Signal, kind="omitted", value="C")
1359+
1360+
controller = MyLinkam("my:linkam:", name="controller")
1361+
RE(controller.wait_until_settled(timeout=10))
1362+
1363+
controller.record_signal()
1364+
print(f"{controller.controller_name} settled? {controller.settled}")
1365+
1366+
def rampUp_rampDown():
1367+
'''ramp temperature up, then back down'''
1368+
yield from controller.set_target(25, timeout=180)
1369+
controller.report_interval_s = 10 # change report interval to 10s
1370+
for i in range(10, 0, -1):
1371+
print(f"hold at {self.value:.2f}{self.units.value}, time remaining: {i}s")
1372+
yield from bps.sleep(1)
1373+
yield from controller.set_target(0, timeout=180)
1374+
1375+
RE(test_plan())
1376+
1377+
"""
1378+
1379+
controller_name = "ProcessController"
1380+
signal = Component(Signal) # override in subclass
1381+
target = Component(Signal, kind="omitted") # override in subclass
1382+
tolerance = Component(Signal, kind="omitted", value=1) # override in subclass
1383+
units = Component(Signal, kind="omitted", value="") # override in subclass
1384+
1385+
report_interval_s = 5 # time between reports during loop, s
1386+
poll_s = 0.02 # time to wait during polling loop, s
1387+
1388+
def record_signal(self):
1389+
"""write signal to the console"""
1390+
msg = f"{self.controller_name} signal: {self.value:.2f}{self.units.value}"
1391+
print(msg)
1392+
return msg
1393+
1394+
def set_target(self, target, wait=True, timeout=None, timeout_fail=False):
1395+
"""change controller to new signal set point"""
1396+
yield from bps.mv(self.target, target)
1397+
1398+
msg = f"Set {self.controller_name} target to {target:.2f}{self.units.value}"
1399+
print(msg)
1400+
1401+
if wait:
1402+
yield from self.wait_until_settled(
1403+
timeout=timeout,
1404+
timeout_fail=timeout_fail)
1405+
1406+
@property
1407+
def value(self):
1408+
"""shortcut to self.signal.value"""
1409+
return self.signal.value
1410+
1411+
@property
1412+
def settled(self):
1413+
"""Is signal close enough to target?"""
1414+
diff = abs(self.signal.get() - self.target.value)
1415+
return diff <= self.tolerance
1416+
1417+
def wait_until_settled(self, timeout=None, timeout_fail=False):
1418+
"""
1419+
plan: wait for controller signal to reach target within tolerance
1420+
"""
1421+
# see: https://stackoverflow.com/questions/2829329/catch-a-threads-exception-in-the-caller-thread-in-python
1422+
t0 = time.time()
1423+
_st = DeviceStatus(self.signal)
1424+
1425+
if self.settled:
1426+
# just in case signal already at target
1427+
_st._finished(success=True)
1428+
else:
1429+
started = False
1430+
1431+
def changing_cb(*args, **kwargs):
1432+
if started and self.settled:
1433+
_st._finished(success=True)
1434+
1435+
token = self.signal.subscribe(changing_cb)
1436+
started = True
1437+
report = 0
1438+
while not _st.done and not self.settled:
1439+
elapsed = time.time() - t0
1440+
if timeout is not None and elapsed > timeout:
1441+
_st._finished(success=self.settled)
1442+
msg = f"{self.controller_name} Timeout after {elapsed:.2f}s"
1443+
msg += f", target {self.target.value:.2f}{self.units.value}"
1444+
msg += f", now {self.signal.get():.2f}{self.units.value}"
1445+
print(msg)
1446+
if timeout_fail:
1447+
raise TimeoutError(msg)
1448+
continue
1449+
if elapsed >= report:
1450+
report += self.report_interval_s.value
1451+
msg = f"Waiting {elapsed:.1f}s"
1452+
msg += f" to reach {self.target.value:.2f}{self.units.value}"
1453+
msg += f", now {self.signal.get():.2f}{self.units.value}"
1454+
print(msg)
1455+
yield from bps.sleep(self.poll_s)
1456+
1457+
self.signal.unsubscribe(token)
1458+
1459+
self.record_signal()
1460+
elapsed = time.time() - t0
1461+
print(f"Total time: {elapsed:.3f}s, settled:{_st.success}")
1462+
1463+
13251464
class Struck3820(Device):
13261465
"""Struck/SIS 3820 Multi-Channel Scaler (as used by USAXS)"""
13271466
start_all = Component(EpicsSignal, "StartAll")

0 commit comments

Comments
 (0)