Sunday, May 24, 2020

Thermistors_Triple Python Program on a Raspberry Pi

Here's a video of one of my home projects:

Note: to view it in high resolution, after starting it, click on the YouTube logo to watch it on a YouTube page.

In this video, we measured how the surface temperature of a ceramic coffee mug changes after hot coffee is poured in. Three temperature sensors are taped to the mug in three different places. Sensor 0 is on the side of the mug near the bottom, Sensor 1 is near the rim, and Sensor 2 is on the handle. The sensor is a thermistor ( which is a tiny resistor that changes resistance strongly with temperature. Measurement accuracy and resolution is plus or minus 0.01 degree Celsius.

The hardware in the video is a Raspberry Pi 4B credit-card-sized computer running Raspbian Linux, a Pi-16ADC voltage-measuring HAT board screwed on top, and a few resistors and capacitors that I soldered onto the HAT that form the sensor circuit. I bought the Raspberry Pi and HAT at

I wrote a Python program that commands and reads from the ADC chip on the HAT via the I2C communication bus. I used a very cool and powerful Python user interface (UI) library and live-updating web-server called REMI ( The smartphone in the video, using its web browser app, displays the live measurements on a numeric display and on a strip chart drawn using the Matplotlib library ( Black is Sensor 0, Red is Sensor 1, and Blue is Sensor 2. The display update rate is once per second.

Here's the Python code:

# Version 0.5, 30-May-2020
# Copyright © 2020 Joe Czapski
# Contact: xejgyczlionautomeasuretigercom replace lion with
# an at sign and tiger with a dot.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# See the License for the specific language governing permissions and
# limitations under the License.

# This program was written to run on a Raspberry Pi 4B computer running
# Raspbian Linux, with a Pi-16ADC voltage-measuring HAT board screwed on top,
# and with resistors and capacitors soldered onto the HAT to form
# the sensor circuit. The program commands and reads from the LTC2497
# ADC chip on the HAT via the I2C communication bus.
# This program uses a UI library and live-updating web-server engine
# called REMI (
# The program's structure is based on usage examples from the
# REMI codebase. The strip chart is drawn using the Matplotlib library
# (

# Comments that begin with [DEV] describe potential improvements that can be made.

import math
import threading
from smbus import SMBus
import io
import time
from time import sleep
import remi
from remi.gui import *
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg

Nsensors = 3   #number of sensors to poll continuously
i2caddr = 0x76   #ADC board jumper positions
adc_ch = [0xB0, 0xB8, 0xB1]   #ADC voltage input channel addresses
#reference resistors are metal-film resistors with TC<0.5ohm/degC:
Rref = [4976.4, 5003.9, 4994.8]   #values in ohms
vplus = 5.0   #use nominal value of supply +5V to the ADC board, not actual value
max_counts = 16777215.0   #2^24 - 1
readlenbytes = 6   #I2C bus read data block length
buswait = 0.17   #seconds to wait between I2C actions
update_interval_sec = 1.0
xspan = 30.0   #strip chart X axis span in seconds
buffer_length = 250
yAuto = True   #autorange the Y axis?
ymin = 20.0; ymax = 70.0   #initial Y axis range, and fixed if yAuto is False
plotColors = ['black', 'red', 'blue']
#Steinhart–Hart equation coefficients for TDK NTC Thermistor B57863S0103F040:
coeffA = 1.12532e-3; coeffB = 2.34873e-4; coeffC = 8.59509e-8

#FUNCTION CtoF() converts Celcius to Fahrenheit
def CtoF(degrees):
    return 1.8 * degrees + 32.0

#CLASS CircBuff
#This class implements a continuous wrap-around buffer to hold and serve out
#the temperature and time data for the strip chart.
#Two instances of CircBuff are spawned for each ADC channel used.
#If for example Nsensors = 3, then 6 CircBuff instances are spawned.
#[DEV] Within this class, need to implement locking of all methods per instance, to prevent
#issues arising from multiple threads simultaneously acting on a buffer.
class CircBuff:
    def __init__(self, length):
        self.buflen = length
        self.buffer = [0.0] * self.buflen
        self.ptr = 0
        self.full = False
    def add(self, value):
        self.buffer[self.ptr] = value
        self.ptr += 1
        if self.ptr >= self.buflen:
            self.ptr = 0
            self.full = True
    def size(self):
        if self.full:
            return self.buflen
            return self.ptr
    def read(self, count=-1):
        s = self.size()
        p = self.ptr
        b = self.buffer.copy()  #grab snapshot of buffer to ignore colliding changes
        if self.full:
            if p == 0:
                rlist = b
                rlist = b[p:] + b[0: p]
            rlist = b[0: p]
        if count >= 0 and count < self.buflen:
            return rlist[max(s - count, 0):]
            return rlist
    def readtimespan(self, tspan):
        tlist =
        tstart = tlist[-1] - tspan
        i = -1
        for t in tlist:
            i += 1
            if t > tstart:
        return tlist[i:]
    def readlast(self):
        if self.ptr == 0:
            return self.buffer[self.buflen - 1]
            return self.buffer[self.ptr - 1]
    def clear(self):
        self.ptr = 0
        self.full = False

#CLASS MatplotImage
#This class is taken intact from REMI example programs for drawing graphs.
#It contains the interface functions for displaying a Matplotlib graph image
#onto the web page. This latest version is flicker-free.
class MatplotImage(Image):
    ax = None
    app_instance = None #the application instance used to send updates
    def __init__(self, **kwargs):
        super(MatplotImage, self).__init__("/%s/get_image_data?index=0" % str(id(self)), **kwargs)
        self._fig = Figure(figsize=(8, 4)) = self._fig.add_subplot(111)

    def search_app_instance(self, node):
        if issubclass(node.__class__, remi.server.App):
            return node
        if not hasattr(node, "get_parent"):
            return None
        return self.search_app_instance(node.get_parent()) 

    def update(self, *args):
        if self.app_instance==None:
            self.app_instance = self.search_app_instance(self)
            if self.app_instance==None:
            url = '/%(id)s/get_image_data?index=%(frame_index)s';
            xhr = null;
            xhr = new XMLHttpRequest();
  'GET', url, true);
            xhr.responseType = 'blob'
            xhr.onload = function(e){
                urlCreator = window.URL || window.webkitURL;
                imageUrl = urlCreator.createObjectURL(this.response);
                document.getElementById('%(id)s').src = imageUrl;
            """ % {'id': self.identifier, 'frame_index': str(time.time())})

    def get_image_data(self, index=0):
        Image.set_image(self, '/%(id)s/get_image_data?index=%(frame_index)s'% {'id': self.identifier, 'frame_index': str(time.time())})
            data = None
            canv = FigureCanvasAgg(self._fig)
            buf = io.BytesIO()
            canv.print_figure(buf, format='png')
            data =
            headers = {'Content-type': 'image/png', 'Cache-Control': 'no-cache'}
            return [data, headers]
        except Exception:
        return None, None

#CLASS Thermistors
#This class is the main program structured as a REMI App object.
class Thermistors(remi.App):

    #FUNCTION read_temperature() reads a channel's ADC counts via the I2C bus
    #and then calculates voltage then temperature.
    def read_temperature(self, ch_index):
        self.i2cbus.write_byte(i2caddr, adc_ch[ch_index])
        readarray = self.i2cbus.read_i2c_block_data(i2caddr, adc_ch[ch_index], readlenbytes)
        counts = readarray[0]*65536.0 + readarray[1]*256.0 + readarray[2]
        volts = vplus * counts / max_counts
        Rt = volts * Rref[ch_index] / (vplus - volts)  #calculate thermistor resistance
        lnR = math.log(Rt)
        invT = coeffA + coeffB*lnR + coeffC*lnR*lnR*lnR  #Steinhart–Hart equation
        T = 1.0/invT - 273.0   #convert inverse Kelvin to Celcius
        return T
    #FUNCTION daq_process() is the data acquisition process running as a separate thread.
    #Each ADC channel takes 300 to 400 milliseconds to access and read.
    #Temperature readings are written to the circular buffers that were allocated at start-up.
    #[DEV] Provide an option for this function to also write readings to a file.
    def daq_process(self):
        while self.daq_running:
            for i in range(0, Nsensors):
                while self.daq_paused:
                if not self.daq_running:
                t = time.time() - self.reftime   #capture timestamp before the T reading
                self.ydata[i].add(self.read_temperature(i))   #read T
                self.xdata[i].add(t)   #write timestamp after T is done reading

    def __init__(self, *args, **kwargs):
        if not 'editing_mode' in kwargs.keys():
            super(Thermistors, self).__init__(*args, static_file_path={'my_res': './res/'})

    #FUNCTION idle() is called by the REMI engine once per display update interval.
    #This function's job is to update the display of the temperature values.
    def idle(self):
        allChannelsIn = True
        for i in range(0, Nsensors):
            n = self.ydata[i].size()
            if n > 0:
                T = self.ydata[i].readlast()   #get the latest T reading from the DAQ thread
                if not self.unitIsCelcius:
                    T = CtoF(T)   #convert Celcius to Fahrenheit
                Tstr = f'{T:.2f}'   #display value to 2 decimal places
                allChannelsIn = False
                Tstr = '-.--'
        #Generate the strip chart if all sensors have at least one measured sample in.
        if allChannelsIn:
            x2a = []; y1a = []; y2a = []
    #Clear the previous plots. Unfortunately, this clears the axis labels, too.
            for i in range(0, Nsensors):
                xlist = self.xdata[i].readtimespan(xspan + 1.0)  #read the X data (time) from the buffer
                ylist = self.ydata[i].read(len(xlist))  #read the Y data (temperature) from the buffer
                if not self.unitIsCelcius:
                    ylist = [CtoF(y) for y in ylist]
      , ylist, color=plotColors[i])  #plot the data using this sensor's color
            #Calculate the X and Y axes limits, (x1, x2) and (y1, y2)
            x2 = max(x2a)
            if x2 < xspan:
                x2 = xspan
                x1 = 0.0
                x1 = x2 - xspan
            if yAuto:
                y1 = math.floor(min(y1a)); y2 = math.ceil(max(y2a))
            elif self.unitIsCelcius:
                y1 = ymin; y2 = ymax
                y1 = math.floor(CtoF(ymin)); y2 = math.ceil(CtoF(ymax))
            #Generate the new graph and update the display
  'Time (s)',
                ylabel='Temperature (deg ' + ('C' if self.unitIsCelcius else 'F') + ')',
                autoscale_on=False, xlim=(x1, x2), ylim=(y1, y2),
                title='Three Temperatures vs. Time')

    #FUNCTION main() is called by the REMI engine once at program start.
    def main(self):
        self.xdata = []; self.ydata = []
        for i in range(0, Nsensors):   #allocate all the data buffers
        self.textbox = ['textTvalue0', 'textTvalue1', 'textTvalue2']
        self.daq_running = True
        self.daq_paused = False
        self.unitIsCelcius = True
        self.reftime = time.time()
        self.i2cbus = SMBus(1)   #initialize connection to the I2C bus
        t = threading.Thread(target=self.daq_process)
        t.start()   #start the DAQ Process thread
        return Thermistors.construct_ui(self)

    #FUNCTION construct_ui() consists mostly of Python code generated by the REMI Editor,
    #which you can use to layout the web page and its indicators, text, images, and controls.
    def construct_ui(self):
        mainContainer = Container()
        mainContainer.attr_editor_newclass = False
        mainContainer.css_background_color = "rgb(236,246,204)"
        mainContainer.css_border_color = "rgb(25,31,37)"
        mainContainer.css_border_style = "solid"
        mainContainer.css_border_width = "5%"
        mainContainer.css_height = "240.0px"
        mainContainer.css_left = "10.0px"
        mainContainer.css_margin = "0px"
        mainContainer.css_position = "absolute"
        mainContainer.css_top = "15px"
        mainContainer.css_width = "315.0px"
        mainContainer.variable_name = "mainContainer"
        textTvalue0 = Label()
        textTvalue0.attr_editor_newclass = False
        textTvalue0.css_align_content = "stretch"
        textTvalue0.css_align_items = "center"
        textTvalue0.css_align_self = "center"
        textTvalue0.css_color = "rgb(0,0,0)"
        textTvalue0.css_font_size = "50px"
        textTvalue0.css_font_weight = "bold"
        textTvalue0.css_height = "75px"
        textTvalue0.css_justify_content = "center"
        textTvalue0.css_left = "15.0px"
        textTvalue0.css_margin = "0px"
        textTvalue0.css_position = "absolute"
        textTvalue0.css_text_align = "center"
        textTvalue0.css_top = "15px"
        textTvalue0.css_width = "180.0px"
        textTvalue0.text = "-.--"
        textTvalue0.variable_name = "textTvalue0"
        btnDegUnit = Button()
        btnDegUnit.attr_editor_newclass = False
        btnDegUnit.css_font_size = "40px"
        btnDegUnit.css_height = "75.0px"
        btnDegUnit.css_left = "225px"
        btnDegUnit.css_margin = "0px"
        btnDegUnit.css_position = "absolute"
        btnDegUnit.css_top = "15px"
        btnDegUnit.css_width = "75.0px"
        btnDegUnit.text = "C"
        btnDegUnit.variable_name = "btnDegUnit"
        textTvalue1 = Label()
        textTvalue1.attr_editor_newclass = False
        textTvalue1.css_align_items = "center"
        textTvalue1.css_align_self = "center"
        textTvalue1.css_color = "rgb(255,0,0)"
        textTvalue1.css_font_size = "50px"
        textTvalue1.css_font_weight = "bold"
        textTvalue1.css_height = "75.0px"
        textTvalue1.css_justify_content = "center"
        textTvalue1.css_left = "15.0px"
        textTvalue1.css_margin = "0px"
        textTvalue1.css_position = "absolute"
        textTvalue1.css_text_align = "center"
        textTvalue1.css_top = "90px"
        textTvalue1.css_width = "180.0px"
        textTvalue1.text = "-.--"
        textTvalue1.variable_name = "textTvalue1"
        graphContainer = Container()
        graphContainer.attr_editor_newclass = False
        graphContainer.css_border_color = "rgb(25,31,37)"
        graphContainer.css_border_style = "solid"
        graphContainer.css_border_width = "1%"
        graphContainer.css_height = "215.0px"
        graphContainer.css_left = "0.0px"
        graphContainer.css_margin = "0px"
        graphContainer.css_position = "absolute"
        graphContainer.css_top = "255px"
        graphContainer.css_width = "340.0px"
        graphContainer.variable_name = "graphContainer"
        textTvalue2 = Label()
        textTvalue2.attr_editor_newclass = False
        textTvalue2.css_align_items = "center"
        textTvalue2.css_align_self = "center"
        textTvalue2.css_color = "rgb(0,0,255)"
        textTvalue2.css_font_size = "50px"
        textTvalue2.css_font_weight = "bold"
        textTvalue2.css_height = "75px"
        textTvalue2.css_justify_content = "center"
        textTvalue2.css_left = "15px"
        textTvalue2.css_margin = "0px"
        textTvalue2.css_position = "absolute"
        textTvalue2.css_text_align = "center"
        textTvalue2.css_top = "165.0px"
        textTvalue2.css_width = "180px"
        textTvalue2.text = "-.--"
        textTvalue2.variable_name = "textTvalue2"
        btnEndProcess = Button()
        btnEndProcess.attr_editor_newclass = False
        btnEndProcess.css_height = "45.0px"
        btnEndProcess.css_left = "210.0px"
        btnEndProcess.css_margin = "0px"
        btnEndProcess.css_position = "absolute"
        btnEndProcess.css_top = "495.0px"
        btnEndProcess.css_width = "90.0px"
        btnEndProcess.text = "End Server Process"
        btnEndProcess.variable_name = "btnEndProcess"
        btnClearChart = Button()
        btnClearChart.attr_editor_newclass = False
        btnClearChart.css_height = "45px"
        btnClearChart.css_left = "15.0px"
        btnClearChart.css_margin = "0px"
        btnClearChart.css_position = "absolute"
        btnClearChart.css_top = "495.0px"
        btnClearChart.css_width = "90px"
        btnClearChart.text = "Clear Chart"
        btnClearChart.variable_name = "btnClearChart"
        self.mpl = MatplotImage(width=340, height=200)['margin'] = '0px''Time (s)', ylabel='Temperature (deg C)',
            autoscale_on=False, xlim=(0.0, xspan), ylim=(ymin, ymax),
            title='Three Temperatures vs. Time')
        self.mainContainer = mainContainer
        return self.mainContainer
    def onclick_btnDegUnit(self, emitter):
        self.unitIsCelcius = not self.unitIsCelcius
        if self.unitIsCelcius:
            unitSymbol = 'C'
            unitSymbol = 'F'
    def onclick_btnClearChart(self, emitter):
        self.daq_paused = True   #Pause DAQ thread and wait for it to actually pause
        self.reftime = time.time()
        for i in range(0, Nsensors):
        self.daq_paused = False   #Unpause DAQ thread now that buffers, pointers, and flags are fully cleared
    #If the 'End Server Process' button is clicked, first stop the DAQ thread, wait,
    #then end the program. It might be a good idea depending on future usage
    #to require an admin password in order to end the program.
    def onclick_btnEndProcess(self, emitter):
        self.daq_running = False
        self.daq_paused = False

#configuration hash set below is just for the REMI UI Editor
configuration = {'config_project_name': 'Thermistors', 'config_resourcepath': './res/'}

#The REMI start() function is called below to start serving the web page.
localmode = True
if localmode:
    ipaddr = ''; show_browser = True
    ipaddr = ''; show_browser = False

if __name__ == "__main__":
    remi.start(Thermistors, address=ipaddr, port=8081, update_interval=update_interval_sec,
          multiple_instance=False, enable_file_cache=False, start_browser=show_browser)