From 9388338079da39b07df64d42ed1460f294612b03 Mon Sep 17 00:00:00 2001 From: Markus Kuhn Date: Thu, 7 Nov 2024 20:37:26 +0100 Subject: [PATCH] experimental backport of PidCtrl --- aquaPi/machineroom/__init__.py | 5 +- aquaPi/machineroom/aux_nodes.py | 6 +- aquaPi/machineroom/ctrl_nodes.py | 114 +++++++++++++++++- aquaPi/machineroom/in_nodes.py | 2 +- aquaPi/machineroom/out_nodes.py | 18 +-- .../static/spa/components/dashboard/comps.js | 1 + aquaPi/static/spa/i18n/locales/de.js | 1 + aquaPi/static/spa/i18n/locales/en.js | 2 +- aquaPi/static/spa/pages/Config.vue.js | 18 +-- 9 files changed, 132 insertions(+), 35 deletions(-) diff --git a/aquaPi/machineroom/__init__.py b/aquaPi/machineroom/__init__.py index 3e040a9..51deb41 100644 --- a/aquaPi/machineroom/__init__.py +++ b/aquaPi/machineroom/__init__.py @@ -6,7 +6,7 @@ import atexit from .msg_bus import MsgBus, BusRole -from .ctrl_nodes import MinimumCtrl, MaximumCtrl, SunCtrl, FadeCtrl +from .ctrl_nodes import MinimumCtrl, MaximumCtrl, PidCtrl, SunCtrl, FadeCtrl from .in_nodes import AnalogInput, ScheduleInput from .out_nodes import SwitchDevice, AnalogDevice from .aux_nodes import ScaleAux, MinAux, MaxAux, AvgAux @@ -143,7 +143,8 @@ def create_default_nodes(self): # single water temp sensor, switched relay wasser_i = AnalogInput('Wasser', 'DS1820 xA2E9C', 25.0, '°C', avg=3, interval=30) - wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) + #wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) + wasser = PidCtrl('PID Temperatur', wasser_i.id, 25.0) wasser_o = SwitchDevice('Heizstab', wasser.id, 'GPIO 12 out', inverted=1) wasser_i.plugin(self.bus) diff --git a/aquaPi/machineroom/aux_nodes.py b/aquaPi/machineroom/aux_nodes.py index 35d7feb..f01571d 100644 --- a/aquaPi/machineroom/aux_nodes.py +++ b/aquaPi/machineroom/aux_nodes.py @@ -171,7 +171,7 @@ def listen(self, msg): for k in self.values: val += self.values[k] / len(self.values) - if (self.data != val): + if (self.data != val) or True: self.data = val log.info('AvgAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, round(self.data, 4))) @@ -205,7 +205,7 @@ def listen(self, msg): for k in self.values: val = min(val, self.values[k]) val = round(val, 4) - if self.data != val: + if self.data != val or True: self.data = val ## log.info('MinAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, self.data)) @@ -233,7 +233,7 @@ def listen(self, msg): for k in self.values: val = max(val, self.values[k]) val = round(val, 4) - if self.data != val: + if self.data != val or True: self.data = val log.info('MaxAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, self.data)) diff --git a/aquaPi/machineroom/ctrl_nodes.py b/aquaPi/machineroom/ctrl_nodes.py index 43ed1da..702b93b 100644 --- a/aquaPi/machineroom/ctrl_nodes.py +++ b/aquaPi/machineroom/ctrl_nodes.py @@ -111,7 +111,7 @@ def listen(self, msg): elif float(msg.data) >= (self.threshold + self.hysteresis / 2): new_val = 0.0 - if (self.data != new_val) or True: # WAR a startup problem + if (self.data != new_val) or True: #FIXME WAR a startup problem log.debug('MinimumCtrl: %d -> %d', self.data, new_val) self.data = new_val @@ -182,7 +182,7 @@ def listen(self, msg): elif float(msg.data) <= (self.threshold - self.hysteresis / 2): new_val = 0.0 - if (self.data != new_val) or True: # WAR a startup problem + if (self.data != new_val) or True: #FIXME WAR a startup problem log.debug('MaximumCtrl: %d -> %d', self.data, new_val) self.data = new_val @@ -198,7 +198,7 @@ def listen(self, msg): self.post(MsgData(self.id, self.data)) return super().listen(msg) - def get_settings(self): + def get_settings(self) -> list[tuple]: limits = get_unit_limits(self.unit) settings = super().get_settings() @@ -209,6 +209,113 @@ def get_settings(self): return settings +class PidCtrl(ControllerNode): + """ An experimental PID controller producing a slow PWM + + Options: + name - unique name of this controller node in UI + receives - id of a single (!) input to receive measurements from + setpoint - the target value + p_fact/i_fact,d_fact - the PID factors + sample - the ratio of sensor reads to 1 PID output + + Output: + posts a series of PWM pulses + """ + data_range = DataRange.PERCENT + #data_range = DataRange.BINARY + + def __init__(self, name: str, inputs: str, setpoint: float, + p_fact: float = 1.0, i_fact: float = .1, d_fact: float = 0.1, + sample: int = 10, + _cont: bool = False): + super().__init__(name, inputs, _cont=_cont) + self.setpoint: float = setpoint + self.p_fact: float = p_fact + self.i_fact: float = i_fact + self.d_fact: float = d_fact + self.sample: int = sample + self._err_sum: float = 0 + self._err_pre: float = 0 + self._ta_pre: float = 0 + self._t_pre: float = 0 + self._cnt: int = 0 + + def __getstate__(self): + state = super().__getstate__() + state.update(setpoint=self.setpoint) + state.update(p_fact=self.p_fact) + state.update(i_fact=self.i_fact) + state.update(d_fact=self.d_fact) + state.update(sample=self.sample) + return state + + def __setstate__(self, state): + log.debug('__SETstate__ %r', state) + self.data = state['data'] + PidCtrl.__init__(self, state['name'], state['inputs'], state['setpoint'], + p_fact=state['p_fact'], i_fact=state['i_fact'], d_fact=state['d_fact'], + sample=state['sample'], + _cont=True) + + def _pulse(self, dur: float, perc: float): + log.info(' PID ON: %d %% -> %f s', perc, round(dur * perc / 100, 1)) + if perc: + self.post(MsgData(self.id, 100)) + time.sleep(dur * perc / 100) + log.info(' PID off') + if perc < 100: + self.post(MsgData(self.id, 0)) + return + + def listen(self, msg): + if isinstance(msg, MsgData): + log.debug('PID got %s', msg) + now = time.time() + ta = now - self._t_pre + err = float(msg.data) - self.setpoint + if self._ta_pre: + err_sum = self._err_sum + err + val = self.p_fact * err \ + + self.i_fact * ta * err_sum \ + + self.d_fact / ta * (err - self._err_pre) + + log.debug('PID err %f, e-sum %f, p %f / i %f / d %f, ', + err, self._err_sum, + self.p_fact * err, + self.i_fact * ta * err_sum, + self.d_fact / ta * (err - self._err_pre)) + self.data = min(max(0., 50. - val*10), 100.) + if self.data > 0.0 and self.data < 100.: + self._err_sum = err_sum + else: + log.debug('clipped') + + self._cnt += 1 + if self._cnt % self.sample == 0: + log.info('PID -> %f (%f)', self.data, val) + if self.data_range == DataRange.PERCENT: + self.post(MsgData(self.id, round(self.data, 4))) + #self.post(MsgData(self.id, 100 if self.data >= 50 else 0)) + else: + Thread(name='PIDpulse', target=self._pulse, args=[9 * ta, self.data], daemon=True).start() + self._err_pre = err + self._ta_pre = ta + self._t_pre = now + + return super().listen(msg) + + def get_settings(self) -> list[tuple]: + settings = super().get_settings() + settings.append(('setpoint', 'Sollwert [%s]' % self.unit, + self.setpoint, 'type="number"')) + settings.append(('p_fact', 'P Faktor', self.p_fact, 'type="number" min="0" max="10" step="0.1"')) + settings.append(('i_fact', 'I Faktor', self.i_fact, 'type="number" min="0" max="10" step="0.1"')) + settings.append(('d_fact', 'D Faktor', self.d_fact, 'type="number" min="0" max="10" step="0.1"')) + settings.append(('sample', 'Samples', self.sample, 'type="number" min="1" max="25" step="1"')) + return settings + + class FadeCtrl(ControllerNode): """ Single channel linear fading controller, usable for light (dusk/dawn). A change of input value will start a ramp from current to new @@ -278,6 +385,7 @@ def listen(self, msg): log.debug('_fader %f -> %f', self.data, self.target) self._fader_thread = Thread(name=self.id, target=self._fader, daemon=True) self._fader_thread.start() + return super().listen(msg) def _fader(self): """ This fader uses constant steps of 0.1% unless this would be >10 steps/sec diff --git a/aquaPi/machineroom/in_nodes.py b/aquaPi/machineroom/in_nodes.py index 1f17f10..db2f1db 100644 --- a/aquaPi/machineroom/in_nodes.py +++ b/aquaPi/machineroom/in_nodes.py @@ -78,7 +78,7 @@ def _reader(self): try: val = self.read() self.alert = None - if self.data != val: + if self.data != val or True: self.data = val log.brief('%s: read %f', self.id, self.data) self.post(MsgData(self.id, self.data)) diff --git a/aquaPi/machineroom/out_nodes.py b/aquaPi/machineroom/out_nodes.py index cfc7312..273a793 100644 --- a/aquaPi/machineroom/out_nodes.py +++ b/aquaPi/machineroom/out_nodes.py @@ -16,8 +16,8 @@ class DeviceNode(BusListener): """ Base class for OUT_ENDP such as relay, PWM, GPIO pins. Receives float input from listened sender. - The interpretation is device specific, recommendation is - to follow pythonic truth testing to avoid surprises. + Binary devices should use a threashold of 50 or pythonic + truth testing, whatever is more intuitive for each dev. """ ROLE = BusRole.OUT_ENDP @@ -50,8 +50,8 @@ def __init__(self, name, inputs, port, inverted=0, _cont=False): self._inverted = int(inverted) ##self.unit = '%' if self.data_range != DataRange.BINARY else '⏻' self.port = port - self.switch(self.data if _cont else 0) - log.info('%s init to %r|%f|%f', self.name, _cont, self.data, inverted) + self.switch(self.data if _cont else False) + log.info('%s init to %r|%f|%r', self.name, _cont, self.data, inverted) def __getstate__(self): state = super().__getstate__() @@ -86,12 +86,14 @@ def inverted(self, inverted): def listen(self, msg): if isinstance(msg, MsgData): - if self.data != bool(msg.data): - self.switch(msg.data) + #if self.data != bool(msg.data): + data = (msg.data > 50.) + if self.data != data: + self.switch(data) return super().listen(msg) - def switch(self, on): - self.data = 100 if bool(on) else 0 + def switch(self, state: bool) -> None: + self.data: bool = state log.info('SwitchDevice %s: turns %s', self.id, 'ON' if self.data else 'OFF') if not self.inverted: diff --git a/aquaPi/static/spa/components/dashboard/comps.js b/aquaPi/static/spa/components/dashboard/comps.js index 4dc8474..4f4f9e7 100644 --- a/aquaPi/static/spa/components/dashboard/comps.js +++ b/aquaPi/static/spa/components/dashboard/comps.js @@ -215,6 +215,7 @@ const MaximumCtrl = { }, } Vue.component('MaximumCtrl', MaximumCtrl) +Vue.component('PidCtrl', MaximumCtrl) const SunCtrl = { diff --git a/aquaPi/static/spa/i18n/locales/de.js b/aquaPi/static/spa/i18n/locales/de.js index 991a82c..be72396 100644 --- a/aquaPi/static/spa/i18n/locales/de.js +++ b/aquaPi/static/spa/i18n/locales/de.js @@ -99,6 +99,7 @@ export default history: 'Diagramm', in_endp: 'Eingang', out_endp: 'Ausgang', + alerts: 'Störung', }, dataRange: { default: { diff --git a/aquaPi/static/spa/i18n/locales/en.js b/aquaPi/static/spa/i18n/locales/en.js index 59a8352..54df655 100644 --- a/aquaPi/static/spa/i18n/locales/en.js +++ b/aquaPi/static/spa/i18n/locales/en.js @@ -90,7 +90,6 @@ export default } } } - }, misc: { @@ -100,6 +99,7 @@ export default history: 'Diagram', in_endp: 'Input', out_endp: 'Output', + alerts: 'Alert', }, dataRange: { default: { diff --git a/aquaPi/static/spa/pages/Config.vue.js b/aquaPi/static/spa/pages/Config.vue.js index 1e78e57..77f936b 100644 --- a/aquaPi/static/spa/pages/Config.vue.js +++ b/aquaPi/static/spa/pages/Config.vue.js @@ -49,23 +49,7 @@ const Config = {