From f904e7909580a63ea3b7d5db5ab938344d2c63a9 Mon Sep 17 00:00:00 2001 From: Benjamin Labrecque Date: Fri, 17 Jul 2026 08:40:51 +0200 Subject: [PATCH] feat: migrate tunefby python service --- services/tunefby/current/app/pyproject.toml | 1 + .../current/app/src/agebd_tunefby/service.py | 201 ++++++++++++++---- services/tunefby/current/app/uv.lock | 6 +- 3 files changed, 169 insertions(+), 39 deletions(-) diff --git a/services/tunefby/current/app/pyproject.toml b/services/tunefby/current/app/pyproject.toml index b3dd565..cdd7fc7 100644 --- a/services/tunefby/current/app/pyproject.toml +++ b/services/tunefby/current/app/pyproject.toml @@ -5,6 +5,7 @@ description = "AGEBD-TUNEFBY Service" requires-python = "==3.10.*" dependencies = [ "agebd", + "numpy>=2.2.6", ] [dependency-groups] diff --git a/services/tunefby/current/app/src/agebd_tunefby/service.py b/services/tunefby/current/app/src/agebd_tunefby/service.py index 736310e..160777d 100644 --- a/services/tunefby/current/app/src/agebd_tunefby/service.py +++ b/services/tunefby/current/app/src/agebd_tunefby/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-TUNEFBY:AO") # TODO: is this a good PV to use? + kp = PV("AGEBD-TUNEFBY:CONTROL-KP.VAL") + ki = PV("AGEBD-TUNEFBY:CONTROL-KI.VAL") + kd = PV("AGEBD-TUNEFBY:CONTROL-KD.VAL") + qy_ref = PV("AGEBD-TUNEFBY:CONTROL-QY.VAL") + qy_okrng = PV("AGEBD-TUNEFBY:CONTROL-QY-OKRNG.VAL") + qy_drvh = PV("AGEBD-TUNEFBY:CONTROL-QYMAX.VAL") + qy_drvl = PV("AGEBD-TUNEFBY:CONTROL-QYMIN.VAL") + mincur = PV("AGEBD-TUNEFBY:CONTROL-MIN-CURR.VAL") + dq = PV("AGEBD-TUNEFBY:DELTAQ.VAL") + dq_ulim = PV("AGEBD-TUNEFBY:DELTAQ-ULIM.VAL") + dq_llim = PV("AGEBD-TUNEFBY:DELTAQ-LLIM.VAL") + check1 = PV("AGEBD-TUNEFBY:CHECK1.VAL") + check2 = PV("AGEBD-TUNEFBY:CHECK2.VAL") + check3 = PV("AGEBD-TUNEFBY:CHECK3.VAL") + check4 = PV("AGEBD-TUNEFBY:CHECK4.VAL") + check5 = PV("AGEBD-TUNEFBY:CHECK5.VAL") + check6 = PV("AGEBD-TUNEFBY:CHECK6.VAL") + check7 = PV("AGEBD-TUNEFBY:CHECK7.VAL") + check8 = PV("AGEBD-TUNEFBY:CHECK8.VAL") + + # Output PVs + qy_stepdec = PV("AGEBD-TUNEBUMP:QY-STEPDOWN.VAL") + qy_stepinc = PV("AGEBD-TUNEBUMP:QY-STEPUP.VAL") + qy_stepsiz = PV("AGEBD-TUNEBUMP:QY-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") + cur = PV("ARS07-DPCT-0000:CURR.VAL") + bpm_qy_pilot_int = PV("ARS07-DBPM-5530:PILOT-INT-SWITCH.VAL") # must be: 0 No Pilot + bpm_qy_pilot_ext = PV("ARS07-DBPM-5530:PILOT-EXT-SWITCH.VAL") # must be: 0 No Pilot + bpm_qy_chopper = PV("ARS07-DBPM-5530:CHOPPER-MODE.VAL") # must be: 0 Periodic 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-TUNEFBY: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-TUNEFBY:CALLBACK", auto_monitor=dbr.DBE_VALUE - ) # TODO: ok? + self.CallbackPV = PV("AGEBD-TUNEBUMP:QY.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,100 @@ class Service(BaseService[PVs]): ): super().__init__(name, pvs, version, sleep_interval) - # TODO: What is this for? - self.path = "sls/bd/bin" + self.qy_okrng = pvs.qy_okrng.get() + + self.PID = PID(pvs.kp.get(), pvs.ki.get(), pvs.kd.get(), pvs.qy_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.qy_ref.get() + self.PID.kp = pvs.kp.get() + self.PID.ki = pvs.ki.get() + self.PID.kd = pvs.kd.get() + + self.qy_okrng = pvs.qy_okrng.get() + + ############ + ## CHECKS ## + ############ + # 1. measured tune within drivelimits + self.check1 = ( + pvs.qy_drvl.get() < self.CallbackValue and self.CallbackValue < pvs.qy_drvh.get() + ) + pvs.check1.put(self.check1) + + # 2. measured tune outside ok range limits + self.check2 = self.CallbackValue < ( + self.PID.setpoint - self.qy_okrng + ) or self.CallbackValue > (self.PID.setpoint + self.qy_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_qy_pilot_int.get() == 0 + and pvs.bpm_qy_pilot_ext.get() == 0 + and pvs.bpm_qy_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.qy_stepsiz.put(np.abs(self.control)) + if self.control < 0: + pvs.qy_stepdec.put(1) + else: + pvs.qy_stepinc.put(1) + ####################################################################################### ####################################################################################### @@ -77,17 +210,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/tunefby/current/app/uv.lock b/services/tunefby/current/app/uv.lock index ee3af41..c4be5c0 100644 --- a/services/tunefby/current/app/uv.lock +++ b/services/tunefby/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 = [