Skip to content

Commit

Permalink
a fun detour: experimental PID controller
Browse files Browse the repository at this point in the history
- FIX: History inputs should be lists, to make their order in graphs
  prediuctable. Sets can be used with unpredictable order though.

- To allow a PID controller, all nodes now post unchanged results too.
  This may become the recommended behaviour.

- PidCtrl can now be used instead of MinimumCtrl. It can produce analog
  output for PWM or a 50% threshold switching in Binary devices, or it
  can be changed to produce slow PWM output of 0/100, if its set to
  DataRange.BINARY.
  • Loading branch information
schwabix-1311 committed Nov 7, 2024
1 parent 0c99f0e commit b5992d9
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 29 deletions.
42 changes: 24 additions & 18 deletions aquaPi/machineroom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand All @@ -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 * * *')
Expand All @@ -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')
Expand Down Expand Up @@ -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:
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 @@ -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)))
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
107 changes: 107 additions & 0 deletions aquaPi/machineroom/ctrl_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion aquaPi/machineroom/in_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 9 additions & 7 deletions aquaPi/machineroom/out_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__()
Expand Down Expand Up @@ -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:
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 @@ -217,6 +217,7 @@ const MaximumCtrl = {
},
}
Vue.component('MaximumCtrl', MaximumCtrl)
Vue.component('PidCtrl', MaximumCtrl)


const SunCtrl = {
Expand Down

0 comments on commit b5992d9

Please sign in to comment.