Skip to content

Commit

Permalink
Fixes and tuning of PidCtrl on real HW
Browse files Browse the repository at this point in the history
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
  • Loading branch information
schwabix-1311 committed Nov 22, 2024
1 parent f0592f7 commit 5f2158d
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 65 deletions.
15 changes: 9 additions & 6 deletions aquaPi/machineroom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .msg_bus import MsgBus, BusRole
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
Expand Down Expand Up @@ -142,11 +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)
avg=1, interval=72)
#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_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)
Expand All @@ -168,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)

Expand Down
85 changes: 29 additions & 56 deletions aquaPi/machineroom/ctrl_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,102 +217,75 @@ class PidCtrl(ControllerNode):
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,
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, inputs, _cont=_cont)
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
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)
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
ta = now - self._tm_old
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, ',
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,
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
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"'))
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"'))
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


Expand Down
4 changes: 2 additions & 2 deletions aquaPi/machineroom/in_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
72 changes: 72 additions & 0 deletions aquaPi/machineroom/out_nodes.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -109,6 +111,76 @@ def get_settings(self):
return settings


class SlowPwmDevice(SwitchDevice):
""" 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
"""
def __init__(self, name, inputs, port, inverted=0, cycle=60., _cont=False):
super().__init__(name, inputs, port, inverted, _cont=_cont)
self.cycle = float(cycle)
self.data = 50.0
self._thread = None
#self._thread_stop = False
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)
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)

def listen(self, msg):
if isinstance(msg, MsgData):
log.brief('SlowPwmDevice %s: got %f %%', self.name, msg.data)
self.set(float(msg.data))
return super().listen(msg)

def _pulse(self, dur: float):
log.brief(' PID pulse: %s -> %f s', self.name, round(dur, 1))
if dur > 0.1:
log.brief(' PID on')
self._driver.write(True if not self._inverted else False)
self.post(MsgData(self.id, 100))
time.sleep(dur)
if dur < self.cycle:
log.brief(' PID off')
self._driver.write(False if not self._inverted else True)
self.post(MsgData(self.id, 0))
log.brief(' PID pulse: done')
return

def set(self, perc: float) -> None:
self.data: float = perc

log.error('SlowPwmDevice %s: sets %.1f', self.id, self.data)
#if self._thread:
# self._thread_stop = True
# log.brief(' PID pulse: stopping ...')
# self._thread.join()
# log.brief(' PID pulse: ... stopeed')
self._thread = Thread(name='PIDpulse', target=self._pulse, args=[self.data / 100. * self.cycle], daemon=True).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"')) # FIXME 'class="uk-checkbox" type="checkbox" checked' fixes appearance, but result is always False )
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.
Expand Down
1 change: 1 addition & 0 deletions aquaPi/static/spa/components/dashboard/comps.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ const AnalogDevice = {
extends: BusNode,
}
Vue.component('AnalogDevice', AnalogDevice)
Vue.component('SlowPwmDevice', AnalogDevice)

const AuxNode = {
extends: BusNode,
Expand Down
2 changes: 1 addition & 1 deletion run
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

0 comments on commit 5f2158d

Please sign in to comment.