PID/PWM for fish tank heater control

I have two heaters in my fish tank. Since the thermostats are very hard to synchronize I’ve added a THIRDREALITY Smart Dual Plug, Zigbee 2-in-1 and an Aquarium Thermometer, Smart Fish Tank Thermometer in combination to control the heaters.

I have both heaters set to 84° F and an automation that turns both outlets on when the temperature is below 80° F and off when it is above 80.01° F. This is working well, but has some overshoot, so the actual temperature varies from about 79°F to 81° F. (BTW, event monitoring didn’t seem to work, so my automation checks every 5 minutes).

To improve this I am thinking of using PWM techniques and/or a PID controller.

I’ve used PID control before, so I am good with that. A PID control should learn (by integration) how much baseline energy is required to offset heat loss to the room

The remaining problem is that the temperature control is binary (bang-bang control https://en.wikipedia.org/wiki/Bang–bang_control.) I am thinking of using slow timescale PWN methods to smooth this out. The idea is that I have a controller with a power setting from 0 to 100. Given this setting the heater (switches) turn on for P seconds, then turn off for (100 - P) seconds.

The PWM automation would directly control the heat, and the PID control would adjust the PWM setting.

I am trying to figure out a simple way to implement this PWM algorithm in HA. It would be simple in Python or C++ but not so much in YAML.

Has anyone worked on something like this before? Any tips?

Thanks,

-Christopher

There are PID adaptations available…
Plug this string into a search engine for a few:

site:community.home-assistant.io pid integration

Relays aren’t meant to be switched anywhere near that frequently or quickly. It won’t be a matter of if, but when they fail.

I have my aquariums controlled/monitored by an ESPHome controller I made myself, but for the heating piece I’m not much different than your setup. Standard aquarium heater (with the temp a bit higher than my setpoint) and a relay that controls it. I have them set with a 1 degree deadband as this seemed like a reasonable tradeoff between right temperature control and a reasonable number of relay cycles. I never get over or undershooting.

I’m wondering if you’d have better luck using the generic thermostat rather than your automation:

Are you sure that the checks are occurring every 5 minutes?

The “flat” sections in the graph seem to indicate that the checks are only occurring about once every hour - unless there is some kind of reporting/graphing issue going on.

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.