Friday, January 13, 2017

Simple electronic thermometer using STM32F030 and thermocouple. Part 1.

It has been a while since I blogged about my little thermometer project. Meanwhile I did some progress, but as usually put it aside because more urgent matters. Newertheless, lets try to continue.

I will try to split my progress into a number of smaller blogs, starting from basics and hoping to get some working results.

Thermocouples

As defined in wikipedia:

A thermocouple is an electrical device consisting of two dissimilar conductors forming electrical junctions at differing temperatures. A thermocouple produces a temperature-dependent voltage as a result of the thermoelectric effect, and this voltage can be interpreted to measure temperature. Thermocouples are a widely used type of temperature sensor.

So theoretically my digital thermometer shall perform a simple algorithm:

  1. Measure voltage value provided by thermocouple
  2. Take an empiric curve provided by NIST and do reverse lookup (from Volts to degrees Celsius) to figure out the temperature.

Not that easy!

Cold Junction Compensation

The web contains great number of explanations about the cold junction problem. For example the TI guide provides some insight.

Basically, thermocouple has two ends. One end is used to measure the target temperature it is called the Hot Junction. The other end is normally connected to a thermometer and is called the Cold Junction. The problem is that the Cold Junction forms kind of parasitic thermocouple with leads connected to the thermometer. Since the thermometer itself has non-zero temperature it provides distortion of the Hot Junction measurements.

The empiric curves provided by NIST assume the Cold Junction at temperature zero degrees Celsius. Thus, some action is needed to reverse the distortion provided by the Cold Junction.

Normally the problem solved by putting temperature sensor near the thermocouple connection. I will use a thermistor (see my previous blog) to figure out the temperature of the Cold Junction.

Now my theoretical thermometer's algorithm is more complicated:

  1. Measure voltage value provided by thermocouple. Lets call it $V_{therm}$
  2. Measure temperature at Cold Junction provided by thermistor. Lets call it $T_{CJ}$
  3. Use NIST curves to lookup distortion voltage corresponding to $T_{CJ}$. I will designate this value as $V_{CJ} = V_K(T_{CJ})$. Here $V_K()$ is an approximation of the Celsius to Volts curve for K-Type thermocouple. For other types of thermocouples it is same calculation but curves are different.
  4. Now it is possible to calculate the undistorted voltage at Hot Junction: $V_{HJ} = V_{therm} - V_{CJ}$. The same value we would measure with Cold Junction at zero degrees Celsius.
  5. Use reverse function $V^{-1}_K()$ to figure out temperature at Hot Junction: $T_{HJ} = V^{-1}_K(V_{HJ}) = V^{-1}_K(V_{therm} - V_{CJ}) = V^{-1}_K(V_{therm} - V_K(T_{CJ}))$

Better theoretically, but there are still couple of technical problems... More will come.

Friday, September 23, 2016

Making PCBs at home

Last month I have switched from all my on-going project to something I wanted to try for a very long time. I have started making my own PCBs.

I will not describe my process here, but my reference is a set of amazing videos by Dmitry Dementiev:
https://www.youtube.com/watch?v=hKJiR35kjpI
https://www.youtube.com/watch?v=El_O8IMgWUk
https://www.youtube.com/watch?v=yPS-ldzMQRI

It is all in Russian. There are similar English videos on youtube, but I followed Dimitry's since he describes things really well. If you don't understand Russian and still want to know how to do it - let me know, I will try to do some translation.

Anyway, here is my first home made PCB.
UV-Timer PCB immediately after etching
UV-Timer PCB immediately after etching

UV-Timer PCB with photoresist removed
UV-Timer PCB with photoresist removed

It is a modified version of Dmitry's timer for UV LED lamp.


Here is my schematics:
There are few changes compared with Dmitry's schematic:
  1. I have put another MOSFET and controlling resistors
  2. I removed controlling buttons. All control is done from rotary encoder
  3. I have wrote the firmware from scratch. Just for fun.
All the code and schematics are at https://github.com/kloper/uv-timer

A few more photos of the timer being built:

Board fully populated
Bottom side with a few patches for a first version (fixed on github)
Fully populated board

Very first working version of firmware

Thursday, August 18, 2016

Atmel-ICE Durability Upgrade

I have bought Atmel-ICE for playing with AVR chips at home. I am using the same device at work for two years. Works flawlessly, but there are two big problems with this device, both mechanical:
  1.  It has 2x5 1.27mm pitch connectors for AVR and SAM chips. Atmel supplies connector cables if you buy full package but those are too fragile for my taste. Also they are lacking standard 2x3 and 2x5 2.57mm connectors found on most Arduinos.
  2. The USB connector is simply soldered on the board. No additional mechanical strengthening. Connector on my device at work has been broken off after an year of careful use.
So I bought a plain Atmel-ICE PCB, without useless cables and case and made a little durability upgrade from the very beginning. 

The 1.27mm pitch connector cables are relatively expensive. So I have bought 2x5 1.27mm female connector and made an adapter out of old IDE connector cable from PC and plain 2.57mm 2x5 female connector. 

The adapter is connected to a standard 2x3 SPI/PDI/dWire connector and 2x5 JTAG connectors.

The USB part is also made from spare USB connectors and old IDE cable from PC. I found Type-B cables more durable, so I have made micro to Type-B connector and soldered the latter directly on the prototype board. Hopefully I will not need to unplug the fragile micro-USB from the PCB.

Works fine meanwhile. 





Saturday, August 13, 2016

Tip Of the Day: Converting IPython notebook into a blogger post

If you like me and want to convert IPython notebook into blogger post:
  1. Start from instructions in this blog
  2. If you need your markdown Latex and other formula to work, continue to this stackexchange answer.
Many thanks to the authors for information!

Messing with Thermistors

Note: This is a IPython notebook that I converted to HTML especially for blogger. The original is on thermo github repository.

Intro

I want my electronic thermometer to measure temperatures higher than 300C. This basically means that I can't use a single thermistor or temperature sensor (e.g LM35). Those are limited to 200-300C max.
I will use a cheap thermocouple. It will probably be less precise, but it is OK for my needs. The question is how to deal with cold junction compensation?
The simplest solution would be using IC like MAX6675. But my goal is not a simplest solution, but learning electronics and STM32 programming. I can always revert to MAX6675 if my own solution fails.
The hard way requires thermocouple voltage amplifier and cold junction compensation. I will leave thermocouple amplification for another blog. For now I will concentrate on a relatively simple problem of obtaining cold junction temperature.
The idea came from EEVBlog #419 where Dave taking apart the Fluke thermometer. That puppy has two massive thermocouple terminals with thermistor between them.
Further dealing with thermocouples is a subject for another blog, but for now I will deal with the thermistor.

Thermistors

Thermistor is a resistor that changes its resistance depending on temperature. Thermistors are cheap and come in different flavors of size, precision, resistance, etc...
Important Note: When buying on Ebay ensure that seller provides resistance ($R_0$) at known temperature ($T_0$) and Beta value ($B $). I didn't paid attention when I bought my, but I was lucky enough to find that info in the order information and the values were suitable for my project.
Also, another important characteristic is the tolerance.
I have got $R_0=100KOhm$, $T_0=25C$ and $B=3950$. And the tolerance is 5%.

Connecting to MCU

The next question was how do I connect that thing to my MCU? The idea came again from Adafruit learning site. Schematics is very simple:
Thermistor
Here the COLDJT is directly connected to ADC_IN0 on MCU.
The COLDJT voltage is always between ground and $V_{cc}$ thus it always fits ADC range accepted by STM32F030. But will it be wide enough to have enough precision?
This depends on values of TP1 and R1.

Thermistor Resistance Range

Note: All (or most) the python code below is from thermo github repository
First of all lets try to figure out thermistor resistance range. My thermistor will be used for measuring of thermocouple cold junction. It will be mounted inside thermometer box and used almost always indoor. Thus, it is more than safe to assume its target temperature range is from -20C to 70C.
From the Wikipedia article on thermistor:
$R=r_{\infty}e^{B\over{T}}$, where $r_{\infty}=R_0e^{-B\over{T_0}}$
Here $R_0$ is a resistance of the thermistor for temperature $T_0$ and $B$ is its beta value. Lets plot it
%pylab
%matplotlib inline
Using matplotlib backend: TkAgg
Populating the interactive namespace from numpy and matplotlib
class Thermistor(object):
    def __init__(self, r0=100000, beta=3950, t0=298.15):
        self.r0 = r0 # Ohm
        self.beta = beta
        self.t0 = t0 # Kelvin

        self.r_inf = self.r0 * numpy.e ** -(self.beta / self.t0)

    def resistance(self, temp):
        """
        Calculate resistance of a thermistor based on temperature in Kelvin
        """
        return self.r_inf * (numpy.e ** (self.beta/temp))
    

T = np.arange(273.15-20, 273.15+70, 0.05)
thermistor = Thermistor()
temperature = np.vectorize(thermistor.resistance)
plt.plot(T, temperature(T))

print 'Maximal resistance: {}'.format(thermistor.resistance(273.15-20))
print 'Minimal resistance: {}'.format(thermistor.resistance(273.15+70))
Maximal resistance: 1053846.90206
Minimal resistance: 17598.3700852
TR1 resistance will be between 17.6KOhm to 105.3KOhm, depending on temperature.

Figuring out R1 value

Voltage at COLDJT depends on resistance of TR1 and on resistance of R1 which is constant. Lets call it $V_{CJT}$ and try to optimize its range.
 class Sense(object):
    def __init__(self,
                 vdd=3.3,
                 thermo_resistance_start=17598,
                 thermo_resistance_stop=1053846):
        self.thermo_range = [thermo_resistance_start,
                             thermo_resistance_stop]
        self.vdd = vdd

    def voltage(self, sense, thermo):
        """
        Calculate voltage on thermistor/sense resistor junction based on
        their resistances
        """
        return float(sense)/(sense+thermo) * self.vdd

    def delta(self, sense):
        """
        Calculate thermistor/sense voltage range depending on
        sense resistance and required thermistor resistance ranges
        """
        return abs(self.voltage(sense, self.thermo_range[0]) - \
                   self.voltage(sense, self.thermo_range[1]))

    def optimum_sense(self, start, stop, step):
        """
        Find sense resistance in specified interval that leads to a
        maximum voltage range in thermistor/sense junction for specified
        thermistor resistance ranges.
        """
        sense_values = np.arange(start, stop, step)
        delta = np.vectorize(self.delta)
        deltas = delta(sense_values)
        plt.plot(sense_values, deltas)
        sense = sense_values[numpy.argmax(deltas)]
        return (sense, [self.voltage(sense, t) for t in self.thermo_range])
    
    
sense = Sense()
(optimum_sense, vcjt_range) = sense.optimum_sense(10000, 300000, 1000)
print 'Optimum R1 value and corresponding voltage range {}, {}'.format(optimum_sense, vcjt_range)
Optimum R1 value and corresponding voltage range 
136000, [2.921913045742783, 0.3771916701825278]
The code above fixes a single value of R1, then it calculates a difference of values of $V_{CJT}$ at minimum and maximum possible temperature (-20C to 70C, corresponding to TR1 between 17.6KOhm and 105.3KOhm, see above). It calculates the difference for a range of R1 values.
The graph above shows dependency of that difference on different values of R1. We have a single R1 value of 136KOhm that corresponds to the widest $V_{CJT}$ range 0.337V to 2.922V. Lets see if this is useful.

Does my schematic do what I want?

STM32F030 has 12-bit ADC. This means that $V_{CJT}$ will be sampled and compared with $V_{dd}$ and software will obtain a value in "ADC Clicks", lets call it $S_{CJT}$, it will have values in range [0..0xfff] where 0 corresoponds to $V_{CJT}=0$ and 0xfff corresponds to $V_{CJT}=3.3V$ or above: $$S_{CJT}=\lceil{V_{CJT}\over{V_{dd}}}0xfff\rceil$$
Given that it is easy to calculate the range of $S_{CJT}$ values given the voltage range:
width = vcjt_range[0] - vcjt_range[1]
print 'Range width: {}'.format(width)

adc_width = int(width / 3.3 * 0xfff)
print 'Range width in ADC clicks: {}'.format(adc_width)

clicks_per_degree = adc_width / (70 - (-20))
print 'ADC clicks per degree Celsius: {}'.format(clicks_per_degree)
Range width: 2.54472137556
Range width in ADC clicks: 3157
ADC clicks per degree Celsius: 35
A-ha! So I have 35 ADC clicks per single degree. Thus, setting R1 to 136KOhm will provide voltage range that is more than enough for temperature tracking over ADC!
Good to go!

Converting to temperature units

The remaining question is how do I convert the ADC clicks into actual temperature value in Kelvin or Celsius?
Lets make some schematic analysis.
We have Ohm law for both TR1 and R1:
$R_{TR1}={{V_{dd}-V_{CJT}}\over{I}}$, where $I$ is a current flowing over TR1 and R1
$I={{V_{CJT}}\over{R_{R1}}}$
Substituting $I$ in the first equation gives us $$R_{TR1}={R_{R1}\big({V_{dd}\over{V_{CJT}}}-1\big)}$$
Again from the Wikipedia article on thermistor: $$T={B\over{\ln{\big({R_{TR1}\over{r_{\infty}}}\big)}}}$$
Substituting the $R_{TR1}$ formula: $$T={B\over{\ln{\big({R_{R1}\over{r_{\infty}}}\big({V_{dd}\over{V_{CJT}}}-1\big)\big)}}}$$
Substituting the $S_{CJT}$ formula we get: $$T={B\over{\ln{\big({R_{R1}\over{r_{\infty}}}\big({0xfff\over{S_{CJT}}}-1\big)\big)}}}$$
Note: The resulting temperature is in Kelvin. Converting to Celsius is trivial.
Now, there is good news and bad news.
The good news is that $V_{dd}$ magically disappeared from the equation. This means that the chip will compensate $V_{dd}$ fluctuations and there is no need to do that in software.
The bad news is that the software will need to do that $\ln()$ stuff.
But wait, my STM32F030 does not have FPU!
I can go Adafruit path and rely on compiler to insert floating point simulation code. But something in me screams "No Way!!!".
Luckily the solution is simpler than expected.

Throwing out floating point

Lets precalculate the function and put on chip its linear approximation.
%matplotlib inline

# Ramer-Douglas-Peucker Algorithm implementation from
# git@github.com:fhirschmann/rdp.git
from rdp import rdp

class Thermistor(object):
    def __init__(self, r0=100000, beta=3950, t0=298.15, r_sense=136000):
        self.r0 = r0 # Ohm
        self.beta = beta
        self.t0 = t0 # Kelvin
        self.r_sense = r_sense # Ohm

        self.r_inf = self.r0 * numpy.e ** -(self.beta / self.t0)

    def temp(self, v_adc):
        """
        Accept ADC value from thermistor connected to ADC_IN0.
        Return temperature in Kelvin
        """
        return self.beta / numpy.log(
            self.r_sense / self.r_inf * (float(0xfff) / v_adc - 1)
        )

    def resistance(self, temp):
        """
        Calculate resistance of a thermistor based on temperature
        """
        return self.r_inf * (numpy.e ** (self.beta/temp))

    def temp_approximation(self, adc_start=100, adc_stop=3900, epsilon=0.3):
        """
        Calculate linear approximation to temperature curve for a range of
        ADC values.
        """
        adc_values = numpy.arange(adc_start, adc_stop)
        temperature = self.temp(adc_values)
        return (adc_values, temperature, rdp(zip(adc_values, temperature), epsilon=epsilon))

# I probably need to put a trimmer to get exact value of 136KOhm, but I didn't have suitable one
# so I have put a pair of 67KOhm in series. Close enough to the optimum.
actual_sense_value = 67000 * 2 
adc_stop = int( sense.voltage(actual_sense_value, sense.thermo_range[0]) * 0xfff / sense.vdd )
adc_start = int( sense.voltage(actual_sense_value, sense.thermo_range[1]) * 0xfff / sense.vdd )

thermo = Thermistor(r_sense=actual_sense_value)
(adc_clicks, temperature, temp_approx) = thermo.temp_approximation(adc_start, adc_stop)
plt.plot(adc_clicks, temperature)
plt.plot([v[0] for v in temp_approx], [v[1] for v in temp_approx])

import pprint
pprint.pprint(temp_approx)
[[461.0, 253.11222730625923],
 [699.0, 261.21337199513243],
 [1012.0, 269.5324152051872],
 [1355.0, 277.28655943093554],
 [2344.0, 298.12754663750894],
 [2626.0, 304.780313660113],
 [2865.0, 311.1343478978771],
 [3105.0, 318.6005327616949],
 [3307.0, 326.2646538317835],
 [3477.0, 334.36045885545735],
 [3618.0, 343.0345723209372]]
This seems to be a single graph, but actually are two graphs that are very close. Lets try to calculate how actually close they are.
for (start_x, start_y), (end_x, end_y) in zip(temp_approx, temp_approx[1:]):
    axis = np.arange(start_x, end_x)
    delta = np.abs(np.interp(axis, [start_x, end_x], [start_y, end_y]) - 
                   thermo.temp(axis))
    plt.plot(delta)     
As expected, the error does not exceed 0.3 degrees. More than enough for my needs!

Lets Implement!

Note: The latest and greatest is in the thermo github repository
Here is my implementation of a simple function that converts ADC click value into Kelvin degrees. I have implemented binary search just to practice simple algorithm implementation, it took me some time to get it right. If I was asked to do this in a programming interview I have probably failed it miserably.
typedef struct _temp_point {
   uint16_t adc_value;
   uint32_t temp_kelvin;
} temp_point_t;

static temp_point_t steinhart_approximation[] = { 
   {461, 253112},
   {699, 261213},
   {1012, 269532},
   {1355, 277286},
   {2344, 298127},
   {2626, 304780},
   {2865, 311134},
   {3105, 318600},
   {3307, 326264},
   {3477, 334360},
   {3618, 34303})
};

/**
 * @brief Convert ADC value into temperature in Kelvin
 * 
 * Look-up an appropriate entry in the precalculated linear approximation table of the
 * Steinhart-Hart function. 
 *
 * @returns Linear approximation of the temperature as 32-bit integer that 
 *          represents the temperature multiplied by 1000. 
 */
uint32_t steinhart(uint16_t adc_value)
{
   int size = sizeof(steinhart_approximation)/sizeof(temp_point_t);
   int index, upper = size-1, lower = 0;

   while(upper > lower) {
      index = lower + (upper - lower) / 2;
      if( adc_value >= steinhart_approximation[index].adc_value ) {
         if (adc_value < steinhart_approximation[index+1].adc_value)
            break;
         lower = index+1;
      } else if( adc_value < steinhart_approximation[index].adc_value ) {
         if (adc_value >= steinhart_approximation[index-1].adc_value) {
            index -= 1;
            break;
         }
         upper = index;
      }
   } 

   if (upper == lower || adc_value == steinhart_approximation[index].adc_value)
      return steinhart_approximation[index].temp_kelvin;

   uint32_t linear_approx =
      (steinhart_approximation[index+1].temp_kelvin -
       steinhart_approximation[index].temp_kelvin) *
      (adc_value - steinhart_approximation[index].adc_value) /
      (steinhart_approximation[index+1].adc_value -
       steinhart_approximation[index].adc_value) +
      steinhart_approximation[index].temp_kelvin;

   return linear_approx;
}