From e24b583b5a2a6883fd07867ba0924d8cef32815e Mon Sep 17 00:00:00 2001 From: Markus Kuhn Date: Fri, 22 Nov 2024 14:47:50 +0100 Subject: [PATCH] Squashed commit of the following: commit 0ee1ae15402845c516e4e990aca4cd063267703b Author: Markus Kuhn Date: Fri Nov 22 14:28:09 2024 +0100 FIX: SlowPwmDevice.cycle no longer depends in input ... within sensible limits: *Input.interval should be less than SlowPwmDevice.cycle, otherwise the PWM will react unproportionally (non linear). Too short cycle time may wear your relay contacts. I'd recommend semiconductor switches for short cycle times. After all its named SLOWPwmDevice! commit 9037c8a59139587e48d6aeb2f0513fc3dbafc1da Author: Markus Kuhn Date: Fri Nov 22 12:53:00 2024 +0100 FIX: SlowPwmDevice 100% showed as 50% in History Review of inheritance of SwitchDevice, SlowPwmDevice and AnalogDevice should be done! commit 5f2158dfa96d9c6da2df7552b2c8bea722d2935e Author: Markus Kuhn Date: Fri Nov 22 11:17:59 2024 +0100 Fixes and tuning of PidCtrl on real HW Remaining work: - refactor to use a PID class, e.g. m-lundberg/simple-pid pn github - debug the 50% ON state in SlowPwmDevice's history, should be 100% - relax interaction of AnalogInput.interval with SlowPwmDevice.cycle when connected with a PidCtrl. ATM interval MUST be slightly (!) shorter than cycle. - review log levels of PidCtrl and SlowPwmDevice commit f0592f77d56f25fe36bffc0585b68137b1371684 Author: Markus Kuhn <78373870+schwabix-1311@users.noreply.github.com> Date: Thu Nov 7 20:46:51 2024 +0100 ctrl_nodes.py still contained py3.9-style annotations commit 9388338079da39b07df64d42ed1460f294612b03 Author: Markus Kuhn Date: Thu Nov 7 20:37:26 2024 +0100 experimental backport of PidCtrl --- aquaPi/machineroom/__init__.py | 18 ++- aquaPi/machineroom/aux_nodes.py | 6 +- aquaPi/machineroom/ctrl_nodes.py | 85 ++++++++++- aquaPi/machineroom/in_nodes.py | 6 +- aquaPi/machineroom/out_nodes.py | 139 ++++++++++++++++-- .../static/spa/components/dashboard/comps.js | 2 + aquaPi/static/spa/i18n/locales/de.js | 1 + aquaPi/static/spa/i18n/locales/en.js | 2 +- aquaPi/static/spa/pages/Config.vue.js | 18 +-- run | 2 +- 10 files changed, 235 insertions(+), 44 deletions(-) diff --git a/aquaPi/machineroom/__init__.py b/aquaPi/machineroom/__init__.py index 3e040a9..6297658 100644 --- a/aquaPi/machineroom/__init__.py +++ b/aquaPi/machineroom/__init__.py @@ -6,9 +6,9 @@ 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 .out_nodes import SwitchDevice, SlowPwmDevice, AnalogDevice from .aux_nodes import ScaleAux, MinAux, MaxAux, AvgAux from .hist_nodes import History from .alert_nodes import Alert, AlertAbove, AlertBelow @@ -142,10 +142,14 @@ 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_o = SwitchDevice('Heizstab', wasser.id, - 'GPIO 12 out', inverted=1) + avg=1, interval=72) + #wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) + #wasser_o = SwitchDevice('Heizstab', wasser.id, + # 'GPIO 12 out', inverted=1) + wasser = PidCtrl('PID Temperatur', wasser_i.id, 25.0, + p_fact=1.5, i_fact=0.1, d_fact=0.) + wasser_o = SlowPwmDevice('Heizstab', wasser.id, + 'GPIO 12 out', inverted=1, cycle=70) wasser_i.plugin(self.bus) wasser.plugin(self.bus) wasser_o.plugin(self.bus) @@ -167,7 +171,7 @@ def create_default_nodes(self): # ... and history for a diagram t_history = History('Temperaturen', [wasser_i.id, wasser_i2.id, - wasser.id, #wasser_o.id, + wasser.id, wasser_o.id, coolspeed.id]) #, cool.id]) t_history.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..25263b2 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 @@ -209,6 +209,86 @@ 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 + + Output: + posts a series of PWM pulses + """ + data_range = DataRange.PERCENT + + def __init__(self, name: str, receives: str, setpoint: float, + p_fact: float = 1.0, i_fact: float = 0.05, d_fact: float = 0., + _cont: bool = False): + super().__init__(name, receives, _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._err_sum: float = 0 + self._err_old: float = 0 + self._tm_old: float = 0 + self.data: float = 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) + 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'], + _cont=True) + + def listen(self, msg): + if isinstance(msg, MsgData): + log.debug('PID got %s', msg) + now = time.time() + ta = now - self._tm_old + err = float(msg.data) - self.setpoint + if self._tm_old >= 1.: + self._err_sum = self._err_sum / 1 + err + p_dev = self.p_fact * err + i_dev = self.i_fact * ta * self._err_sum / 100 #?? + d_dev = self.d_fact / ta * (err - self._err_old) + val = p_dev + i_dev + d_dev + + log.warning('PID err %f, e-sum %f | P %+.1f%% / I %+.1f%% / D %+.1f %%, ', + err, self._err_sum, + 100 * p_dev, 100 * i_dev, 100 * d_dev) + self.data = min(max(0., 50. - val*100.), 100.) + log.brief('PID -> %f (%+.1f)', self.data, -val * 100) + self.post(MsgData(self.id, round(self.data, 4))) + + if self.data <= 0. or self.data >= 100.: + self._err_sum /= 2 + self._err_old = err + self._ta_old = ta + self._tm_old = now + + return super().listen(msg) + + def get_settings(self): + settings = super().get_settings() + settings.append(('setpoint', 'Sollwert [%s]' % self.unit, + self.setpoint, 'type="number" step="0.1"')) + settings.append(('p_fact', 'P Faktor', self.p_fact, 'type="number" min="-10" max="10" step="0.1"')) + settings.append(('i_fact', 'I Faktor', self.i_fact, 'type="number" min="-10" max="10" step="0.01"')) + settings.append(('d_fact', 'D Faktor', self.d_fact, 'type="number" min="-10" max="10" step="0.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 +358,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..534eb78 100644 --- a/aquaPi/machineroom/in_nodes.py +++ b/aquaPi/machineroom/in_nodes.py @@ -25,7 +25,7 @@ class InputNode(BusNode): """ ROLE = BusRole.IN_ENDP - def __init__(self, name, port, interval=0.5, _cont=False): + def __init__(self, name, port, interval, _cont=False): super().__init__(name, _cont=_cont) self._driver = None self._driver_opts = None @@ -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)) @@ -95,7 +95,7 @@ def get_settings(self): settings.append(('port', 'Input', self.port, 'type="text"')) settings.append(('interval', 'Leseintervall [s]', - self.interval, 'type="number" min="0.1" max="60" step="0.1"')) + self.interval, 'type="number" min="1" max="600" step="1"')) return settings diff --git a/aquaPi/machineroom/out_nodes.py b/aquaPi/machineroom/out_nodes.py index cfc7312..bbf9139 100644 --- a/aquaPi/machineroom/out_nodes.py +++ b/aquaPi/machineroom/out_nodes.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import logging +from threading import Thread +import time from .msg_bus import (BusListener, BusRole, DataRange, MsgData) from ..driver import (io_registry) @@ -16,8 +18,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,13 +52,13 @@ 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 %f|%r', self.name, self.data, inverted) def __getstate__(self): state = super().__getstate__() state.update(port=self.port) - state.update(inverted=self.inverted) + state.update(inverted=self._inverted) return state def __setstate__(self, state): @@ -86,15 +88,17 @@ 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: + if not self._inverted: self._driver.write(self.data) else: self._driver.write(not self.data) @@ -107,6 +111,121 @@ def get_settings(self): return settings +class SlowPwmDevice(DeviceNode): + """ An analog output to a binary GPIO pin or relay using slow PWM. + + Options: + name - unique name of this output node in UI + inputs - id of a single (!) input to receive data from + port - name of a IoRegistry port driver to drive output + inverted - swap the boolean interpretation for active low outputs + cycle - optional cycle time in sec for generated PWM + + Output: + drive output with PWM(input/100 * cycle), possibly inverted + """ + data_range = DataRange.BINARY + + def __init__(self, name, inputs, port, inverted=0, cycle=60., _cont=False): + super().__init__(name, inputs, _cont=_cont) + self.data = 50.0 + ##self.unit = '%' if self.data_range != DataRange.BINARY else '⏻' + self.cycle = float(cycle) + self._driver = None + self._port = None + self._inverted = int(inverted) + self._thread = None + self._thread_stop = False + self.port = port + self.set(self.data) + log.info('%s init to %f|%r|%r s', self.name, self.data, inverted, cycle) + + def __getstate__(self): + state = super().__getstate__() + state.update(cycle=self.cycle) + state.update(port=self.port) + state.update(inverted=self._inverted) + return state + + def __setstate__(self, state): + self.data = state['data'] + self.__init__(state['name'], state['inputs'], state['port'], + inverted=state['inverted'], cycle=state['cycle'], + _cont=True) + + @property + def port(self): + return self._port + + @port.setter + def port(self, port): + if self._driver: + io_registry.driver_destruct(self._port, self._driver) + if port: + self._driver = io_registry.driver_factory(port) + self._port = port + + @property + def inverted(self): + return self._inverted + + @inverted.setter + def inverted(self, inverted): + self._inverted = inverted + self.set(self.data) + + def listen(self, msg): + if isinstance(msg, MsgData): + self.set(float(msg.data)) + return super().listen(msg) + + def _pulse(self, hi_sec: float): + def toggle_and_wait(state: bool, end: float) -> bool: + start = time.time() + self._driver.write(state if not self._inverted else not state) + self.post(MsgData(self.id, 100 if state else 0)) + # avoid error accumulation by exact final sleep() + while time.time() < end - .1: + if self._thread_stop: + self._thread_stop = False + return False + time.sleep(.1) + time.sleep(end - time.time()) + log.debug(' _pulse needed %f instead of %f', + time.time() - start, end - start) + return True + + while True: + lead_edge = time.time() + if hi_sec > 0.1: + if not toggle_and_wait(True, lead_edge + hi_sec): + return + if hi_sec < self.cycle: + if not toggle_and_wait(False, lead_edge + self.cycle): + return + return + + def set(self, perc: float) -> None: + self.data: float = perc + + log.info('SlowPwmDevice %s: sets %.1f %% (%.3f of %f s)', + self.id, self.data, self.cycle * perc/100, self.cycle) + if self._thread: + self._thread_stop = True + self._thread.join() + self._thread = Thread(name='PIDpulse', target=self._pulse, + args=[self.data / 100 * self.cycle], daemon=True) + self._thread.start() + + def get_settings(self): + settings = super().get_settings() + settings.append(('cycle', 'PWM cycle time', self.cycle, + 'type="number" min="10" max="300" step="1"', + 'inverted', 'Inverted', self.inverted, + 'type="number" min="0" max="1"')) + return settings + + class AnalogDevice(DeviceNode): """ An analog output using PWM (or DAC), 0..100% input range is mapped to the pysical minimum...maximum range of this node. diff --git a/aquaPi/static/spa/components/dashboard/comps.js b/aquaPi/static/spa/components/dashboard/comps.js index 4dc8474..7de3e6d 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 = { @@ -306,6 +307,7 @@ const AnalogDevice = { extends: BusNode, } Vue.component('AnalogDevice', AnalogDevice) +Vue.component('SlowPwmDevice', AnalogDevice) const AuxNode = { extends: BusNode, 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 = { diff --git a/run b/run index 6e9ef67..32d90fb 100755 --- a/run +++ b/run @@ -24,4 +24,4 @@ if [[ ${reset_cfg} ]]; then rm "instance/${AQUAPI_CFG}"; fi export FLASK_APP=aquaPi export FLASK_ENV=development -nohup flask run --host "$(hostname -i|cut -d ' ' -f 1)" | tee run.log +nohup flask run --host "$(hostname -i|cut -d ' ' -f 1)"