diff --git a/services/tunefbx/current/app/pyproject.toml b/services/tunefbx/current/app/pyproject.toml index 04d77bf..1d0f61b 100644 --- a/services/tunefbx/current/app/pyproject.toml +++ b/services/tunefbx/current/app/pyproject.toml @@ -5,6 +5,7 @@ description = "AGEBD-TUNEFBX Service" requires-python = "==3.10.*" dependencies = [ "agebd", + "numpy>=2.2.6", ] [dependency-groups] diff --git a/services/tunefbx/current/app/src/agebd_tunefbx/service.py b/services/tunefbx/current/app/src/agebd_tunefbx/service.py index 9e58ff5..fda5c79 100644 --- a/services/tunefbx/current/app/src/agebd_tunefbx/service.py +++ b/services/tunefbx/current/app/src/agebd_tunefbx/service.py @@ -1,12 +1,12 @@ -import time +from time import perf_counter +import numpy as np from epics import dbr -from agebd.pv import LocalPVLink +from agebd.pv import get_pv_class from agebd.service.base import BaseService from agebd.service.pvs import BasePVs from agebd.utils import printgetversion -from agebd.pv import get_pv_class __version__ = printgetversion(__file__) @@ -14,36 +14,86 @@ PV = get_pv_class() class PVs(BasePVs): - # Option 1: ## Service specific PVs from dedicated IOC - my_pv1 = PV("AGEBD-TUNEFBX:AO") # TODO: is this a good PV to use? + kp = PV("AGEBD-TUNEFBX:CONTROL-KP.VAL") + ki = PV("AGEBD-TUNEFBX:CONTROL-KI.VAL") + kd = PV("AGEBD-TUNEFBX:CONTROL-KD.VAL") + qx_ref = PV("AGEBD-TUNEFBX:CONTROL-QX.VAL") + qx_okrng = PV("AGEBD-TUNEFBX:CONTROL-QX-OKRNG.VAL") + qx_drvh = PV("AGEBD-TUNEFBX:CONTROL-QXMAX.VAL") + qx_drvl = PV("AGEBD-TUNEFBX:CONTROL-QXMIN.VAL") + mincur = PV("AGEBD-TUNEFBX:CONTROL-MIN-CURR.VAL") + dq = PV("AGEBD-TUNEFBX:DELTAQ.VAL") + dq_ulim = PV("AGEBD-TUNEFBX:DELTAQ-ULIM.VAL") + dq_llim = PV("AGEBD-TUNEFBX:DELTAQ-LLIM.VAL") + check1 = PV("AGEBD-TUNEFBX:CHECK1.VAL") + check2 = PV("AGEBD-TUNEFBX:CHECK2.VAL") + check3 = PV("AGEBD-TUNEFBX:CHECK3.VAL") + check4 = PV("AGEBD-TUNEFBX:CHECK4.VAL") + check5 = PV("AGEBD-TUNEFBX:CHECK5.VAL") + check6 = PV("AGEBD-TUNEFBX:CHECK6.VAL") + check7 = PV("AGEBD-TUNEFBX:CHECK7.VAL") + check8 = PV("AGEBD-TUNEFBX:CHECK8.VAL") + + # Output PVs + qx_stepdec = PV("AGEBD-TUNEBUMP:QX-STEPDOWN.VAL") + qx_stepinc = PV("AGEBD-TUNEBUMP:QX-STEPUP.VAL") + qx_stepsiz = PV("AGEBD-TUNEBUMP:QX-STEPSIZE.VAL") ## Service specific PVs from other IOCs - # ... + inj_active = PV("AGEBD-PARAMS:INJECTION-ACTIVE.VAL") + dump_valve = PV("ARS01-VVPG-0210:PLC_STATUS.VAL") + hor_pinger_trig = PV("AGETI-CVME-MASTER-TMA:Evt-SR-Pinger-H-Ena-Sel.VAL") + ver_pinger_trig = PV("AGETI-CVME-MASTER-TMA:Evt-SR-Pinger-V-Ena-Sel.VAL") + bpm_qx_pilot_int = PV("ARS07-DBPM-0840:PILOT-INT-SWITCH.VAL") # must be: 0 No Pilot + bpm_qx_pilot_ext = PV("ARS07-DBPM-0840:PILOT-EXT-SWITCH.VAL") # must be: 0 No Pilot + bpm_qx_chopper = PV("ARS07-DBPM-0840:CHOPPER-MODE.VAL") # must be: 0 Periodic + cur = PV("ARS07-DPCT-0000:CURR.VAL") def __init__(self, service_name: str, pv_factory=PV): super().__init__(service_name, pv_factory) PV = pv_factory - # TODO: can we do this cleaner? Maybe mechanism for - # settings initial values in dev mode (e.g. ini file) - if isinstance(self.onoff, LocalPVLink): - self.onoff.value = True - - # Option 2: - ## Service specific PVs from dedicated IOC - self.my_pv2 = PV( - "AGEBD-TUNEFBX:BO" - ) # TODO: is this a good PV to use? - - ## Service specific PVs from other IOCs - # ... - - # TODO: class attribute names usually lowercase # usually it is advisable to trigger the mainloop of a service on changes of certain PVs: - self.CallbackPV = PV( - "AGEBD-TUNEFBX:CALLBACK", auto_monitor=dbr.DBE_VALUE - ) # TODO: ok? + self.CallbackPV = PV("AGEBD-TUNEBUMP:QX.VAL", auto_monitor=dbr.DBE_VALUE) + + +class PID: + # Proportional-Integral-Derivative (PID) controller class + # PID setup procedure: + # 1. set integral and derivative gains ki, kd to 0 + # 2. set proportional gain kp to low, stable value + # 3. increase kp and create a disturbance, e.g., by changing the setpoint + # 4. increase kp until feedback parameter oscillates + # 5. set kp to 40 % of the critical value triggering oscillation + # 6. slowly increase ki to eliminate the steady state error + # 7. adjust it until setpoint is reached accurately without overshoot + # 8. tune kd to minimize oscillations and overshoots (caution: high kd can amplify noise) + + def __init__(self, kp, ki, kd, setpoint): + + # Proportional Gain >> The "present" term. It provides a response proportional to the current error. Increasing kp makes the system respond faster, but if pushed too high, it will cause violent overshoot and oscillation. + self.kp = kp + # Integral Gain >> The "past" term. It accumulates the errors over time to eliminate steady-state error. However, high ki values can cause sluggishness or instability (e.g., integral windup). + self.ki = ki + # Derivative Gain >> The "future" term. It measures the rate of change of the error, acting as a damping factor to reduce overshoot and smooth out rapid, unwanted movements + self.kd = kd + self.setpoint = setpoint + self.integral = 0 + self.prev_error = 0 + + def update(self, measurement, dt): + + error = self.setpoint - measurement + self.integral += error * dt + derivative = (error - self.prev_error) / dt if dt > 0 else 0 + + self.prev_error = error + + # Calculate the final output using Kp, Ki, and Kd which is written to actuator + output = (self.kp * error) + (self.ki * self.integral) + (self.kd * derivative) + + return output class Service(BaseService[PVs]): @@ -56,17 +106,103 @@ class Service(BaseService[PVs]): ): super().__init__(name, pvs, version, sleep_interval) - # TODO: What is this for? - self.path = "sls/bd/bin" + self.control = 0.0 + self.dq_llim = 0.0 + self.dq_ulim = 0.0 + + self.qx_okrng = pvs.qx_okrng.get() + + self.PID = PID(pvs.kp.get(), pvs.ki.get(), pvs.kd.get(), pvs.qx_ref.get()) + # start stop watch + self.t0 = perf_counter() def update(self): + pvs = self.pvs # when callback is fired, this code will be executed if self.CallbackFired: ####################################################################################### ####################################################################################### - # here goes all the code that your service - # is supposed to be running when the callback is fired - time.sleep(1) + # here goes all the code that your service is supposed to be + # running every time when the callback is fired + + # update PID settings + self.PID.setpoint = pvs.qx_ref.get() + self.PID.kp = pvs.kp.get() + self.PID.ki = pvs.ki.get() + self.PID.kd = pvs.kd.get() + + self.qx_okrng = pvs.qx_okrng.get() + + ############ + ## CHECKS ## + ############ + # 1. measured tune within drivelimits + self.check1 = ( + pvs.qx_drvl.get() < self.CallbackValue and self.CallbackValue < pvs.qx_drvh.get() + ) + pvs.check1.put(self.check1) + + # 2. measured tune outside ok range limits + self.check2 = self.CallbackValue < ( + self.PID.setpoint - self.qx_okrng + ) or self.CallbackValue > (self.PID.setpoint + self.qx_okrng) + pvs.check2.put(self.check2) + + # 3. injection not active + self.check3 = not pvs.inj_active.get() + pvs.check3.put(self.check3) + + # 4. stored beam current above threshold + self.check4 = pvs.cur.get() > pvs.mincur.get() + pvs.check4.put(self.check4) + + # 5. bpm setup correctly for tune measurement + self.check5 = ( + pvs.bpm_qx_pilot_int.get() == 0 + and pvs.bpm_qx_pilot_ext.get() == 0 + and pvs.bpm_qx_chopper.get() == 1 + ) + pvs.check5.put(self.check5) + + # 6. pingers are on + self.check6 = pvs.hor_pinger_trig.get() and pvs.ver_pinger_trig.get() + pvs.check6.put(self.check6) + + # 7. dt corresponds to 3.125 Hz, i.e., min. 2 consecutive valid measurements + t1 = perf_counter() + self.dt = t1 - self.t0 + self.t0 = t1 + self.check7 = self.dt < 1.1 / 3.125 + pvs.check7.put(self.check7) + + # check all checks before PID call + self.checkall = ( + self.check1 + and self.check2 + and self.check3 + and self.check4 + and self.check5 + and self.check6 + and self.check7 + ) + + if self.checkall: + # 8. PID calculated step within limits + self.control = self.PID.update(self.CallbackValue, self.dt) + self.dq_llim = pvs.dq_llim.get() + self.dq_ulim = pvs.dq_ulim.get() + + self.check8 = self.control > self.dq_llim and self.control < self.dq_ulim + pvs.check8.put(self.check8) + + if self.check8: + pvs.dq.put(self.control) + pvs.qx_stepsiz.put(np.abs(self.control)) + if self.control < 0: + pvs.qx_stepdec.put(1) + else: + pvs.qx_stepinc.put(1) + ####################################################################################### ####################################################################################### @@ -77,17 +213,9 @@ class Service(BaseService[PVs]): ####################################################################################### # here goes all the code that your service # is supposed to be running in any case, e.g., - # update some pvs/vals - my_pv1_random_val = str(time.clock_gettime_ns(0))[-1] - self.pvs.my_pv1.put(my_pv1_random_val) - # self.pvs.my_pv2.put("MY_PV2 value") - - # get some pvs/vals - self.val1 = self.pvs.my_pv1.get() - self.val2 = self.pvs.my_pv2.get() # maybe sleep to limit the update loop execution rate - time.sleep(1) + # sleep(1) ####################################################################################### ####################################################################################### diff --git a/services/tunefbx/current/app/uv.lock b/services/tunefbx/current/app/uv.lock index 92c43fe..d4cd5b5 100644 --- a/services/tunefbx/current/app/uv.lock +++ b/services/tunefbx/current/app/uv.lock @@ -30,6 +30,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "agebd" }, + { name = "numpy" }, ] [package.dev-dependencies] @@ -40,7 +41,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "agebd", editable = "../../../../packages/agebd" }] +requires-dist = [ + { name = "agebd", editable = "../../../../packages/agebd" }, + { name = "numpy", specifier = ">=2.2.6" }, +] [package.metadata.requires-dev] dev = [