Skip to content

Commit

Permalink
Squashed commit of the following:
Browse files Browse the repository at this point in the history
commit 0ee1ae1
Author: Markus Kuhn <git@kuhn-clan.de>
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 9037c8a
Author: Markus Kuhn <git@kuhn-clan.de>
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 5f2158d
Author: Markus Kuhn <git@kuhn-clan.de>
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 f0592f7
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 9388338
Author: Markus Kuhn <git@kuhn-clan.de>
Date:   Thu Nov 7 20:37:26 2024 +0100

    experimental backport of PidCtrl
  • Loading branch information
schwabix-1311 committed Nov 22, 2024
1 parent bdbe551 commit e24b583
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 44 deletions.
18 changes: 11 additions & 7 deletions aquaPi/machineroom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions aquaPi/machineroom/aux_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
85 changes: 83 additions & 2 deletions aquaPi/machineroom/ctrl_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 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 @@ -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))
Expand All @@ -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
139 changes: 129 additions & 10 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 All @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
Loading

0 comments on commit e24b583

Please sign in to comment.