|
74 | 74 | ~DualPf4FilterBox |
75 | 75 | ~EpicsDescriptionMixin |
76 | 76 | ~KohzuSeqCtl_Monochromator |
| 77 | + ~ProcessController |
77 | 78 | ~Struck3820 |
78 | 79 |
|
79 | 80 | Internal routines |
|
121 | 122 |
|
122 | 123 | logger = logging.getLogger(__name__) |
123 | 124 |
|
124 | | -"""for convenience""" # TODO: contribute to ophyd? |
| 125 | +"""for convenience""" # TODO: contribute to ophyd? |
125 | 126 | SCALER_AUTOCOUNT_MODE = 1 |
126 | 127 |
|
127 | 128 |
|
@@ -1322,6 +1323,144 @@ class KohzuSeqCtl_Monochromator(Device): |
1322 | 1323 | crystal_type = Component(EpicsSignal, "BraggTypeMO") |
1323 | 1324 |
|
1324 | 1325 |
|
| 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 | + |
1325 | 1464 | class Struck3820(Device): |
1326 | 1465 | """Struck/SIS 3820 Multi-Channel Scaler (as used by USAXS)""" |
1327 | 1466 | start_all = Component(EpicsSignal, "StartAll") |
|
0 commit comments