Close

Temperature Controller Completed

A project log for MycoHub

A comprehensive mycology platform; a clean box, a lab incubator, a "smart" spawn and fruiting chamber with online data tracking for research

jonathanJonathan 09/07/2021 at 00:110 Comments

What does the Temperature Controller do:

Drops or raises the internal temperature of MycoBox to the set-point temperature and then maintains that temperature.

Video Demonstration:

How it works in plain terms

The temperature controller subtracts the measured temperature from the set-point temperature to get the error; which is the number of degrees celsius the chamber is from the set-point temperature. The error value is run through a function that ‘decides’ how the Temperature Controller’s actuators should respond to the update value. When the error is a negative value the AC will be used, when it’s a positive value the heater will be used. Both the AC and heater have 3 modes; stopped, idle, and active. When a session begins the actuators are in stopped mode. If the chamber is more than .6 degree celsius too hot the AC will turn on, and if the chamber is more than .6 degree celsius too cold the heater will turn on. Once an actuator has switched on it is in active mode, and it will continue till it has lowered or raised the error to be within 0.2 of 0. At this point the actuator will remain on and switch to idle mode. If the error value remains within .2 of 0 for 3 cycles idle mode will end, if the error pushes back out of range before three cycles complete then idle mode will reset and keep watching. Idle mode is to make sure that the actuator is shutting off when the chamber temperature has truly reached the set-point and will maintain for some time without an actuator’s input. If the error cannot remain at least within .2 of 0 for three cycles, then the actuator will remain on and keep holding the chamber temperature nearly at set-point. The actuator is shut off once the error is stable for 3 cycles and idle mode switches to stopped mode. Now the temperature controller will watch and wait for the chamber temperature to move more than .6 degree celsius out of range, + or -, and then it will turn on the appropriate actuator to bring the temperature back to set-point. Note: I’m dropping the threshold from .6 to a lower value so temp is maintained closer to set-point. I point out why further along in the video analysis

Video Analysis

The thermocouple sensor is usually one to three degrees ahead of the DHT22 sensors 1 and 2 when the chamber temperature is first being set. I’ve noticed that this is because the thermocouple is secured nearly directly in front of the AC port and is cooling faster than the DHT22s that are secured on the opposite side of the box. If there is a large error between the set-point temperature and the actual temperature, then the thermocouple may drop two to three degrees celsius ahead of the DHT22s as the AC works to drop the temperature. You’ll notice this within the first 5 minutes of the video, as the precise temperature reading drops to 23.5 degrees while the DHT22s are both at 25.10 degrees.

The AC stops when the DHT22 sensors reach 24.40 and 24.80 degrees celsisu, and the thermocouple reaches 22.50 degrees. When the AC stops you’ll notice the DHT22 sensors’ readings continue to drop to 24.30 and 24.50 degrees, and the thermocouple’s readings actually begin to rise to 24 degrees because the AC is no longer blowing directly on the thermocouple and the chamber temperature is evening out to approximately the set-point. The measured value for the temperature is a weighted average of the DHT22s and the thermocouple, and the thermocouple is given more weight than the individual DHT22s. 

Once the set-point is reached the AC switches from active to idle, and then finally stopped mode. From there it will wait for the temperature to exceed more than 0.6 from the set-point temperature before switching the AC back on and into active mode. You can see this at 8:03 minutes into the video, and the sensor readings are 24.50, 24.6, and 24.75. It took about 3 minutes for the AC to switch back on after it stopped the first time in the video. I will add some more insulation to the ceiling of the MycoBox, and seal the sensor port leading into the box, so the chamber will maintain its temperature for even longer. 

Code Overview:

This section outlines how the code is working. It begins with the new_session function. 

function newSession(config) {
    return new Promise ( (resolve)=> {
        const session_state = get('session_state');
        if (!session_state.active_session) {
            set_environment_config(config)
                .then(update_environment_state())
                .then(set_session_state('active_session', true))
                .then(environment_manager())
                .then(resolve())
                .catch(err => console.log(`Error Caught: new_session: ${err}`))
            
        } else {
            throw new Error('There is already an active session')
        }
    });
}

new_session starts an environment manager session with the configuration from the web application. The function first uses the submitted configuration form to initialize the session by setting the global configuration that runs an environment manager session. The global environment configuration is set with the values entered into the configuration form by the user. Then the session state is set to active and the correct stage in the session is set to true, which at the start is spawn running. The global environment state is set with initial sensor readings and then finally the environment manager function is called.

/**
 * Responsibilities: Calls Promises 
 * i. coordinates through three session stages
 * ii. maintain PID states
 * iii. call each EM PID
 */
const environment_manager = () => {
    console.log('METHOD CALL: environment_manager')
    // #1. Validate the session is still active and THEN
    validate_active_session()
        .then((validation) => {
            console.log('Validation Results: ' + validation)
            // #2. Process the current session_state, and don't do anything until its done; not sure why it's async

            // #3. calculate measured and generated a pid_config WHEN valid env_state returned
            if (validation) {
                console.log('Environment Manager Has Validated Session')
                update_environment_state()
                    .then(run_pid_controllers()
                        .then(data => {
                            // if there is any data returned do whatever with it here
                            // otherwise recall environment_manager, because the session must still be active
                            console.log('#############################################################################')
                            console.log('Update Value Returned | ' + data + ' | Recalling ENV MANAGER')
                            console.log('#############################################################################')

                            setTimeout(() => {
                                console.log('**************************** Waited 2 Seconds ****************************')
                                return environment_manager();
                            }, 4000);
                        }))

            }
            if (!validation) {
                resolve('Session has ended')
            }
        })
}

The environment_manager calls for the latest sensor readings of the environment, then passes the environment state to the four environment controllers; temperature, humidity, ventilation, and circulation. These 4 controllers are managed by the run_pid_controllers function.

/**
 * Validate the current environment state, then calculate the measured values and call each controller with its
 * respective configuration object
 * @param {*} env_state 
 * @returns { temp, humidity, co2 }  
 */
const run_pid_controllers = () => {
    console.log('Running PID Controllers now ------------------------------------------------------------------------')
    return new Promise((resolve) => {
        validate_env_state()
            .then(validation => {
                console.log(validation['env_state'])
                if (validation.validation) {
                    console.log('$$$$$$$$$$$$ The Environment State Was Validated $$$$$$$$$$$$')
                    const measured = calculate_measured(validation.env_state);
                    // =========================================================================================================
                    // todo: check for session stage (sr, pi, fr) 
                    // generate config for each controller: add the other controller functions for this
                    get_state()
                        .then(state => {
                            console.log(state)
                            const config = temp_pid_controller_config(measured, state[0].spawn_running, state[2].temperature)
                            console.log('Call Each PID');
                            return update_temperature(config)
                        })
                        .then(results => resolve(results))
                }
            })
    })
}

Run PID controllers validates that the environment state is filled out with all the necessary data, it will continually recall for sensor readings till the environment state is validated. Next it will calculate a measured temperature and humidity from the environment state by taking a weighted average of the 4 internal temperature readings and the 3 internal humidity readings (note: sensor 3 is down right now, I’m only using DHT22 #1 and #2). Next a configuration object is created for each controller class and all 4 controllers will be called with the data they need to start and command their respective actuators; however right now I’ve only completed the Temperature Controller

Temperature Controller Code

/**
 * Temperature Controller: PID and Overrides
 * design of PID greatly influenced by https://github.com/Philmod/node-pid-controller/blob/master/lib/index.js 
 * & https://gist.github.com/DzikuVx/f8b146747c029947a996b9a3b070d5e7
 * ----------------------
 * How this is going to work: 
 * Could be that the controller calls it everytime with env_sate, and runs the function with the previous report
*/

const { set_pid_state, get, set_actuator_state } = require('../../globals/globals');
const { TempPidController } = require('../../services/environment.manager/temperature.pid.service');
const { s2r1_off, s2r1_on, s1r1_on } = require('../../cli_control_panel/relay');

/**
 * Run PID:
 * Run the PID service with the latest measured value, and any configuration updates
 * @param {
 *  settings: {
 *    kp,
 *    ki,
 *    kd,
 *    iLimit  
 *  },
 *  previousReport: {
 *    integralOfError,
 *    lastError,
 *    lastTime
 *  }
 *  incomingReport: {
 *    setPoint,
 *    measured
 *  } 
 * } config the previous (or initial) report & the incoming
 */
const update_temperature = (config) => {
    // initialize the controller
    const tempController = new TempPidController(config);
    // update the actuator
    const value = tempController.update();
    console.log('The calculated Update Value')
    console.log(value);
    set_pid_state('temperature', tempController.report())
    temp_actuator_controller(value)
    return value
}

/**
 * Create TemperauturePidController config
 * Todo: move this to the temperaturePidController
 */
const temp_pid_controller_config = (measured, env_config, pid_state) => {
    console.log('Method Call: temp_pid_controller_config')
    const config = {
        settings: {
            kp: 1,
            ki: 0.005,
            kd: 0.005,
        },
        pid_state: {
            integralOfError: pid_state.integralOfError,
            lastError: pid_state.lastError,
            lastTime: pid_state.lastTime,
        },
        incoming_report: {
            setPoint: env_config.temperature,
            measured: measured.temperature
        }
    }
    return config
}

/**
 * Override:
 * Purpose: Manually switch the acuator on or off if OVERRIDE is true
 * Description: Commands the selected actuator to turn on/off regardless of the global context (EnvModel, SystemStatus, ...)
 * @param {string, string} command {actuator: '', status: ''}
 */
const override = (command) => {
    switch (command.actuator) {
        case 'AC':
            if (command.status === 'ON') s1r1_on();
            if (command.status === 'OFF') s1r1_off();
            break;
        case 'HEATER':
            if (command.status === 'ON') s2r1_on();
            if (command.status === 'OFF') s2r1_off();
            break;

        default:
            break;
    }
}

/**
 * Temp Actuator Controller
 * if the update value is greater or equal than +/- 1 from 0 for 3 consecutive readings, then turn on the appropriate acuator and enter 'set mode'
 * once the update value hits within 0.2 of zero or switches positive/negative, stop and don't start until more than or equal to +/-1 from 0 again
 * actuator will stay on till update value can reach .2 of 0, and then it'll switch on again once the update is > or = +/- 1 for 3 consecutive readings
 */
const temp_actuator_controller = (update) => {
    // #2. Check the actuator state
    get('actuators_state')
        // The switch on threshold (st) should be a variable
        .then(state => {
            if (state.ac.active) {
                // check if update is within -0.2 from 0
                const idle = idle_check(update, false)
                console.log('Temp Actuator Controller: Active ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
                switch (idle) {
                    case 1: // AC switches to idle
                        set_actuator_state('ac', 'active', false).then(set_actuator_state('ac', 'idle', 1))
                        break;

                    case 2: // AC stays ON
                        set_actuator_state('ac', 'active', true)
                        break;

                    default:
                        break;
                }
            }
            if (state.ac.stopped) {
                // Start if more than .5 or .7 off not 1
                console.log('temp_actuator_controller: Stopped^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^')
                const stopped = remain_stopped_check(update, false)
                switch (stopped) {
                    case 1: // AC stays OFF
                        console.log('AC Remain Stopped')

                        break;

                    case 2: // AC switches ON
                        console.log('AC Switching Active')
                        set_actuator_state('ac', 'stopped', false).then(set_actuator_state('ac', 'active', true).then(() => s2r1_on()))
                        break;

                    default:
                        break;
                }
            }
            if (state.ac.idle > 0) {
                console.log('temp_acutator_controller: Idle ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ' + state.ac.idle)
                const idle = idle_check(update, false)
                console.log('IDLE CHECK CODE: ' + idle)
                switch (idle) {
                    case 2: // switch back to active
                        console.log('AC Switching Back to Active')
                        set_actuator_state('ac', 'idle', 0).then(set_actuator_state('ac', 'active', true))
                        break;

                    case 1: // increment up or switch to stopped and turn off the ac
                        if (state.ac.idle >= 3) {
                            console.log('AC Switching OFF From Idle ')
                            set_actuator_state('ac', 'idle', 0).then(set_actuator_state('ac', 'stopped', true)).then(() => s2r1_off())
                        } else {
                            console.log('Incrementing Idle:')
                            const increment = (state.ac.idle + 1)
                            console.log('Increment: ' + increment)
                            set_actuator_state('ac', 'idle', increment)
                        }
                        break;

                    default:
                        break;
                }
            }
            // Heater Protocol
            if (state.heater.active) {

            }
            if (state.heater.stopped) {

            }
            if (state.heater.idle) {

            }
        })
}

/**
 * 
 * @param {*} update 
 * @returns
 * 1: AC switches to idle
 * 2: AC stays ON
 * 3: Heater stays OFF
 * 4: Heater turn ON
 */
const idle_check = (update, sign) => {
    console.log('Idle Check Starting')
    // check if within .2 for sign
    switch (sign) {
        // positive sign
        case true:
            console.log('Idle Check: Positive Sign')
            // within .2 of zero
            if (update <= 0.2) {
                console.log('Within 0.2')
                return 3
            }
            // more than .2 from zero
            if (update > 0.2) {
                console.log('Outside 0.2')
                return 4
            }
            break;
        // negative sign
        case false:
            console.log('Idle Check: Negative Sign')
            // within -.2 of zer0
            if (update > -0.2) {
                console.log('Within 0.2')
                return 1
            }
            // less than -.2
            if (update < -0.2) {
                console.log('Outside 0.2')
                return 2
            }
            break;

        // default
        default:
            break;
    }
}

/**
 * 
 * @param {*} update 
 * @returns
 * 1: AC stay OFF
 * 2: AC turn ON
 * 3: Heater stay OFF
 * 4: Heater turn ON
 */
//NOTE: the comparator value should be a parameter?
const remain_stopped_check = (update, sign) => {
    // check if within .2 for sign
    switch (sign) {
        // positive sign
        case true:
            console.log('Remain Stopped Check: Positive Sign')
            // within 1 of zero
            if (update <= .6) {
                console.log('Update is Less than .5 greater than 0')
                return 3
            }
            // more than 1 from zero
            if (update > 0.6) {
                console.log('Update is Greater than 1 from 0')
                return 4
            }
            break;
        // negative sign
        case false:
            console.log('Remain Stopped Check: Negative Sign')
            // within -1 of zer0
            if (update > -0.6) {
                console.log('Within 1')
                return 1
            }
            // more than -1 from zero
            if (update < -0.6) {
                console.log('Outside 1')
                return 2
            }
            break;
        // default
        default:
            break;
    }
}


module.exports = {
    update_temperature,
    temp_pid_controller_config,
    override
}


And here is the temperature.pid.service class used by the temperature controller

/**
 * PID Controller Class
 * @param {number} kp proportional gain
 * @param {number} ki integral gain
 * @param {number} kd derivative gain
 * @param {min: number, max: number} iLimit limits for the integral
 * ----------------------------------------------------------------------------------------
 * Steps:
 * #1. create a new controller: e.g. let tempCtr = new TempPidController(0.25,0.01,0.01)
 * #2. create the set-point: e.g. tempCtr.setPoint(21)
 * #3. read the updated environment model every time it's available; flag indicating updated
 */
 class TempPidController {
    constructor(config) {
        // saturation has been reached if these limits are hit and clamping should happen
        let defaultIntegralLimit = { min: -10, max: 10}
        // Set PID weights (gain)
        this.kp = config.settings.kp || 1;
        this.ki = config.settings.ki || 0.1;
        this.kd = config.settings.kd || 0.1;
        // init properties for the integral of error
        this.integralLimit = config.settings.iLimit || defaultIntegralLimit;
        this.integralOfError = config.pid_state.integralOfError;
        this.lastError = config.pid_state.lastError;
        this.lastTime = config.pid_state.lastTime;
        // init the set point
        this.setPoint = config.incoming_report.setPoint;
        this.measured = config.incoming_report.measured;
        console.log('This: TempPidController Properties')
        console.log(this)
    }

    update() {
        // #1. find the cycle-time (dt) and update lastTime
        const {dt, currentTime} = this.calculate_dt(this.lastTime)
        let D;
        this.lastTime = currentTime;
        // #2. calculate the error: setpoint - measured
        const err = this.setPoint - this.measured;
        this.lastError = err;
        // #3. calculate P => kp * err
        const P = this.kp * err;
        // #4. calculate It => It + (ki * error * dt)
        this.integralOfError += (this.ki * err * dt)
        // #5. limit the It
        if (this.integralOfError > this.integralLimit.max) this.integralOfError = this.integralLimit.max;
        if (this.integralOfError < this.integralLimit.min) this.integralOfError = this.integralLimit.min;
        // #6. calculate D => kd * (err - lastErr) / dt
        dt === 0 ? D = 0 : D = this.kd * (err - this.lastError) / dt;
        return P 
    }

    // set the global pid state for this controller
    report() {
        return {
            integralOfError: this.integralOfError,
            lastError: this.lastError,
            lastTime: this.lastTime
        }
    }

    reset () {
        this.integralOfError = 0;
        this.lastError = 0;
        this.lastTime = 0;
    }
    // calculate_dt
    calculate_dt(lastTime) {
        const currentTime = Date.now();
        let dt;
        if (lastTime === 0) {
            dt = 0;
        } else {
            dt = (currentTime - lastTime) / 1000;
        }
        return {dt, currentTime};
    }
    // clamp_check
    clamp_check() {
        
    }
}

module.exports = {
    TempPidController,
}

The humidity, Ventilation, and Circulation controllers will be true PID controllers, however the Temperature controller is just a P controller as described above. This is because the air conditioner runs at 11 amps, and I do not want to use a AC phase control module to throttle its current, instead I only switch the ac on or off. The Controllers are initialized with the settings for the PID, the previous PID data, as well as the set-point and current measured value. The controllers calculate their update value and then pass it to their actuator_controller, which decides how to respond to the update value. Once each controller has run, the environment manager recalls itself, and it will continue this loop till the session has progressed through all three stages and ends.

*Note: You may have noticed, if anyone cares to read this that closely, that the TempPidController class calculated the integral and derivate, but only returns the P. This is because the TempPidController is setup to be a PID controller but I realized after the fact I just need it to use the proportional error. The other controllers will use the same code structure, however they will return the P + I + D as their update value. Also you may have noticed I have not filled in the code for the heater in the temp_actuator_controller function of the temperature.controller.js file. I will be filling that in tonight. 

Additional functionality coming soon:

One other feature I'm considering to add to the temperature controller is a ventilation alternative to the AC or heater. If the external temperature is at or beyond the set-point temperature the intake fan can be switched on to adjust the chamber temperature. This option depends on the stage of the session and pretty much could not be used in the spawn running stage when the CO2 levels need to be high.

Discussions