diff --git a/aquaPi/machineroom/__init__.py b/aquaPi/machineroom/__init__.py index bc237ed..37111a8 100644 --- a/aquaPi/machineroom/__init__.py +++ b/aquaPi/machineroom/__init__.py @@ -6,7 +6,7 @@ import atexit from .msg_bus import MsgBus -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 @@ -97,10 +97,10 @@ def create_default_nodes(self) -> None: #REAL_CONFIG = False TEST_ALERT = False # True - TEST_PH = True + TEST_PH = False # True SIM_LIGHT = False # True DAWN_LIGHT = SIM_LIGHT and False # True - SIM_TEMP = False # True + SIM_TEMP = True COMPLEX_TEMP = SIM_TEMP and False if REAL_CONFIG: @@ -122,7 +122,7 @@ def create_default_nodes(self) -> None: # ... and history for a diagram history = History('Beleuchtung', - {light_schedule.id, light_c.id}) # , light_pwm.id]) + [light_schedule.id, light_c.id]) # , light_pwm.id]) history.plugin(self.bus) # single water temp sensor, switched relay @@ -150,9 +150,9 @@ def create_default_nodes(self) -> None: # ... and history for a diagram t_history = History('Temperaturen', - {wasser_i.id, wasser_i2.id, + [wasser_i.id, wasser_i2.id, wasser.id, # wasser_o.id, - coolspeed.id}) # , cool.id]) + coolspeed.id]) # , cool.id]) t_history.plugin(self.bus) adc_ph = AnalogInput('pH Sonde', 'ADC #1 in 3', 2.49, 'V', @@ -181,25 +181,26 @@ def create_default_nodes(self) -> None: # ... and history for a diagram ph_history = History('pH Verlauf', - {adc_ph.id, calib_ph.id, ph.id}) # , out_ph.id]) + [adc_ph.id, calib_ph.id, ph.id]) # , out_ph.id]) ph_history.plugin(self.bus) return if TEST_PH: adc_ph = AnalogInput('pH Sonde', 'ADC #1 in 3', 2.49, 'V', - avg=1, interval=30) + avg=1, interval=10) calib_ph = ScaleAux('pH Kalibrierung', adc_ph.id, 'pH', limit=(4.0, 10.0), points=[(2.99, 4.0), (2.51, 6.9)]) ph = MaximumCtrl('pH', calib_ph.id, 7.0) + #ph = PidCtrl('pH', calib_ph.id, 7.0) out_ph = SwitchDevice('CO2 Ventil', ph.id, 'GPIO 20 out') out_ph.plugin(self.bus) ph.plugin(self.bus) calib_ph.plugin(self.bus) adc_ph.plugin(self.bus) ph_history = History('pH Verlauf', - {adc_ph.id, calib_ph.id, ph.id, out_ph.id}) + [adc_ph.id, calib_ph.id, ph.id, out_ph.id]) ph_history.plugin(self.bus) if SIM_LIGHT: @@ -216,8 +217,8 @@ def create_default_nodes(self) -> None: light_pwm.plugin(self.bus) history = History('Licht', - {light_schedule.id, - light_c.id, light_pwm.id}) + [light_schedule.id, + light_c.id, light_pwm.id]) history.plugin(self.bus) else: dawn_schedule = ScheduleInput('Zeitplan 2', '* 22 * * *') @@ -233,20 +234,25 @@ def create_default_nodes(self) -> None: light_pwm.plugin(self.bus) history = History('Licht', - {light_schedule.id, dawn_schedule.id, - light_c.id, dawn_c.id, light_pwm.id}) + [light_schedule.id, dawn_schedule.id, + light_c.id, dawn_c.id, light_pwm.id]) history.plugin(self.bus) if SIM_TEMP: if not COMPLEX_TEMP: # single temp sensor -> temp ctrl -> relay wasser_i = AnalogInput('Wasser', 'DS1820 xA2E9C', 25.0, '°C') - wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) - wasser_o = SwitchDevice('Heizstab', wasser.id, 'GPIO 12 out') - wasser.plugin(self.bus) + #wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) + wasser_pid = PidCtrl('Temperatur', wasser_i.id, 25.0) + wasser_o = SwitchDevice('Heizstab', wasser_pid.id, 'GPIO 12 out') + wasser_pid.plugin(self.bus) wasser_o.plugin(self.bus) wasser_i.plugin(self.bus) + t_history = History('Temperaturen', + [wasser_i.id, wasser_pid.id, wasser_o.id]) + t_history.plugin(self.bus) + else: # 2 temp sensors -> average -> temp ctrl -> relay w1_temp = AnalogInput('T-Sensor 1', 'DS1820 xA2E9C', 25.0, '°C') @@ -277,8 +283,8 @@ def create_default_nodes(self) -> None: w_coolspeed.plugin(self.bus) t_history = History('Temperaturen', - {w1_temp.id, w2_temp.id, w_temp.id, - w_heat.id, w_cool.id}) + [w1_temp.id, w2_temp.id, w_temp.id, + w_heat.id, w_cool.id]) t_history.plugin(self.bus) if TEST_ALERT: diff --git a/aquaPi/machineroom/aux_nodes.py b/aquaPi/machineroom/aux_nodes.py index 07bd8e0..7fa3512 100644 --- a/aquaPi/machineroom/aux_nodes.py +++ b/aquaPi/machineroom/aux_nodes.py @@ -180,7 +180,7 @@ def listen(self, msg: Msg) -> bool: 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))) @@ -214,7 +214,7 @@ def listen(self, msg: Msg) -> bool: 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 self.post(MsgData(self.id, self.data)) return super().listen(msg) @@ -241,7 +241,7 @@ def listen(self, msg: Msg) -> bool: 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 ef25954..961ac54 100644 --- a/aquaPi/machineroom/ctrl_nodes.py +++ b/aquaPi/machineroom/ctrl_nodes.py @@ -210,6 +210,113 @@ def get_settings(self) -> list[tuple]: 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, receives: 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, 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.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) -> dict[str, Any]: + 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: dict[str, Any]) -> None: + log.debug('__SETstate__ %r', state) + self.data = state['data'] + PidCtrl.__init__(self, state['name'], state['receives'], 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: Msg) -> bool: + 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 diff --git a/aquaPi/machineroom/in_nodes.py b/aquaPi/machineroom/in_nodes.py index 57d70e1..9a3aeff 100644 --- a/aquaPi/machineroom/in_nodes.py +++ b/aquaPi/machineroom/in_nodes.py @@ -90,7 +90,7 @@ def _reader(self) -> None: 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 4e98e86..9e6bdac 100644 --- a/aquaPi/machineroom/out_nodes.py +++ b/aquaPi/machineroom/out_nodes.py @@ -19,8 +19,8 @@ class DeviceNode(BusListener, ABC): """ 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 @@ -55,7 +55,7 @@ def __init__(self, name: str, receives: str, port: str, ##self.unit = '%' if self.data_range != DataRange.BINARY else '⏻' self.port = port self.switch(self.data if _cont else False) - log.info('%s init to %r|%f|%f', self.name, _cont, self.data, inverted) + log.info('%s init to %r|%f|%r', self.name, _cont, self.data, inverted) def __getstate__(self) -> dict[str, Any]: state = super().__getstate__() @@ -96,12 +96,14 @@ def inverted(self, inverted: bool) -> None: def listen(self, msg: Msg) -> bool: 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: bool) -> None: - self.data: bool = on + 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 self._driver: diff --git a/aquaPi/static/spa/components/dashboard/comps.js b/aquaPi/static/spa/components/dashboard/comps.js index 163d14c..4c1626d 100644 --- a/aquaPi/static/spa/components/dashboard/comps.js +++ b/aquaPi/static/spa/components/dashboard/comps.js @@ -217,6 +217,7 @@ const MaximumCtrl = { }, } Vue.component('MaximumCtrl', MaximumCtrl) +Vue.component('PidCtrl', MaximumCtrl) const SunCtrl = {