Skip to content

Commit

Permalink
experimental backport of PidCtrl
Browse files Browse the repository at this point in the history
schwabix-1311 committed Nov 7, 2024

Verified

This commit was signed with the committer’s verified signature.
macmv Neil Macneale V
1 parent bdbe551 commit 9388338
Showing 9 changed files with 132 additions and 35 deletions.
5 changes: 3 additions & 2 deletions aquaPi/machineroom/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 3 additions & 3 deletions aquaPi/machineroom/aux_nodes.py
Original file line number Diff line number Diff line change
@@ -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))
114 changes: 111 additions & 3 deletions aquaPi/machineroom/ctrl_nodes.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion aquaPi/machineroom/in_nodes.py
Original file line number Diff line number Diff line change
@@ -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))
18 changes: 10 additions & 8 deletions aquaPi/machineroom/out_nodes.py
Original file line number Diff line number Diff line change
@@ -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:
1 change: 1 addition & 0 deletions aquaPi/static/spa/components/dashboard/comps.js
Original file line number Diff line number Diff line change
@@ -215,6 +215,7 @@ const MaximumCtrl = {
},
}
Vue.component('MaximumCtrl', MaximumCtrl)
Vue.component('PidCtrl', MaximumCtrl)


const SunCtrl = {
1 change: 1 addition & 0 deletions aquaPi/static/spa/i18n/locales/de.js
Original file line number Diff line number Diff line change
@@ -99,6 +99,7 @@ export default
history: 'Diagramm',
in_endp: 'Eingang',
out_endp: 'Ausgang',
alerts: 'Störung',
},
dataRange: {
default: {
2 changes: 1 addition & 1 deletion aquaPi/static/spa/i18n/locales/en.js
Original file line number Diff line number Diff line change
@@ -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: {
18 changes: 1 addition & 17 deletions aquaPi/static/spa/pages/Config.vue.js
Original file line number Diff line number Diff line change
@@ -49,23 +49,7 @@ const Config = {
<template v-if="node.inputs">
<v-sheet outlined class="ba-1 ml-7">
<h5>INPUTS:</h5>
<!-- {{ node.inputs }}-->
<ul>
<li v-for="item in node.inputs.sender">
<h3>
{{ nodeItem(item).name }}
<span class="font-weight-light">
[{{ nodeItem(item).id }}]
{{ nodeItem(item).identifier }}
| {{ nodeItem(item).type }}
| {{ nodeItem(item).role }}
| data: {{ nodeItem(item).data}} {{ nodeItem(item).unit}} {{ nodeItem(item).data_range }}
</span>
</h3>
<!-- {{ $store.getters['dashboard/node'](item) }} -->
</li>
</ul>
INPUTS: {{ node.inputs }}
</v-sheet>
</template>
</div>

0 comments on commit 9388338

Please sign in to comment.