I’ve solved this problem.
At a high level, I created a Pulse-Width Modulation (PWM) controller driven by a Proportional Integral Derivative (PID) controller. Conceptually the PWM controller turns the heat on for a variable percentage of an interval. A five minute interval is fast enough since the system has a very slow response time. The PID control is entirely defined by entity/helpers. There are pre-built integral and derivative helpers so this is straightforward. All the parameters are helpers that are exposed so they can be adjusted from the GUI.
Figure 1 shows the temperature history for a month. The large spikes are weekly water changes, when a portion of the dirty water is replaced with cold fresh water. There is some overshoot when the temperature comes back up, but it quickly settles down and stays almost entirely within 0.1 degrees of the desired temperature (78°F). Possibly, I could tune the PID contents to reduce this overshoot.
Figure 1. Temperature for past month.
Implementation Details
The entire automation is 34 lines of YAML:
description: ""
triggers:
- trigger: time_pattern
minutes: /5
conditions: []
actions:
- variables:
interval: 300
duty: "{{ (states('sensor.fish_temperature_heater_duty') | float(100.0)) / 100.0 }}"
duration: "{{ min(interval - 1, duty * interval) }}"
- if:
- condition: template
value_template: "{{ duration > 0 }}"
then:
- action: switch.turn_on
metadata: {}
target:
entity_id:
- switch.fish_tank_heaters_switch
- switch.fish_tank_heaters_switch_2
data: {}
- variables:
minutes: "{{ (duration / 60) | int }}"
seconds: "{{ (duration % 60) | int }}"
- delay: "{{ '00:{:02}:{:02}'.format(minutes, seconds) }}"
- action: switch.turn_off
metadata: {}
target:
entity_id:
- switch.fish_tank_heaters_switch
- switch.fish_tank_heaters_switch_2
data: {}
mode: single
There are a few important details.
The line:
duty: "{{ (states('sensor.fish_temperature_heater_duty') | float(100.0)) / 100.0 }}”
Forces the duty value to be a float, defaulting to 100.0% if for some reason it is “undefined”. The heaters are set at about 82°F, to provide a reasonable upper limit on the temperature. (Although the thermostats in the heaters don’t work as well.)
The five minute interval has to be hard-coded in two places, once in the trigger and once for the variable interval. I don’t know how to define the trigger with a controllable helper entity.
The duration value is computed carefully, so it can never exceed the interval which would cause one execution of the automation to hit the next execution. That should be OK because it runs as mode: single, but I didn’t want to depend on that. (Maybe I am just being paranoid, but experience developing reliable software has made me paranoid.)
I don’t really like using the delay action this way, but the alternative would be scheduling another script to turn off the heat, which would have been more complex. Maybe someone can suggest a good way to do that.
The duty is computed as a template helper with a percentage value.
{{
100 *
(
(states('sensor.fish_temperature_delta')|float * states('input_number.fish_temperature_delta_constant')|float / -100.0) +
(states('sensor.fish_temperature_error_integral')|float * states('input_number.fish_temperature_error_integral_constant')|float / -100.0) +
-(states('sensor.fish_temperature_derivative')|float * states(‘input_number.fish_temperature_derivative_constant')|float)
)
}}
The sensor.fish_temperature_delta is another template helper that returns the difference between the measured temperature and the desired temperature. A float construct is used to choose a low value if the thermometer probe fails to send a value.
The temperature integral and temperature derivative use the pre-built helper templates with the sensor.fish_temperature_delta as input. There is a sensor.fish_tank_temperature_defaulted entity, defined to return a reasonable low value (75°F) if the probe returns an undefined value. This is used for integration, to prevent integrating zero if the probe goes offline for a period.
{{ states('sensor.fish_tank_temperature') | float(75) }}
I’ve made a dashboard that shows all of this with controls for the PID constants, Figure 2.
Figure 2. PID Control Dashboard. The value “delta duty” -5.920 = 0.08 * -74.0. The duty value 12.0516 = -5.920 + 17.072 + 0.00.
One final note. While working on this, my original temperature probe failed and I replaced it with a better one. (The original probe used a complex cloud based integration which stopped sending values, even though the probe itself works. The new probe uses Zigbee which is local and more reliable.)
If I’ve forgotten to mention anything of importance, please ask. I found this to be an interesting project.