How to make ‘High-Resolution’ Sensor Readings with DIGITAL I/O pins

AN685 figure 8: The RC rise-time response of the circuit allows microcontroller timers to be used to determine the relative resistance of the NTC element.

For more than a year we’ve been oversampling the Arduino’s humble ADC to get >16bit ambient temperature readings from a 20¢ thermistor. That pin-toggling method is simple and delivers solid results, but it requires the main CPU to stay awake long enough to capture multiple readings for the decimation step. (~200 miliseconds @ 250 kHz ADC clock) While that only burns 100 mAs per day with a typical 15 minute sample interval, a read through Thermistors in Single Supply Temperature Sensing Circuits hinted that I could ditch the ADC and read those sensors with a pin-interrupt method that would let me sleep the cpu during the process. An additional benefit is that the sensors would draw no power unless they were actively being read.

Given how easy it is drop a trend-line on your data, I’ve never understood why engineers dislike non-linear sensors so much. If you leave out the linearizing resistor you end up with this simple relationship.

The resolution of time based methods depends on the speed of the clock, and Timer1 can be set with a prescalar = 1; ticking in step with the 8 mHz oscillator on our Pro Mini based loggers. The input capture unit can save Timer1’s counter value as soon as the voltage on pin D8 passes a high/low threshold. This under appreciated feature of the 328p is faster than typical interrupt handling, and people often use it to measure the time between rising edges of two inputs to determine the pulse width/frequency of incoming signals.

You are not limited to one sensor here – you can line them up like ducks on as many driver pins as you have available.  As long as they share that common connection you just read each one sequentially with the other pins in input mode. Since they are all being compared to the same reference, you’ll see better cohort consistency than you would by using multiple series resistors.

Using 328p timers is described in detail at Nick Gammons Timers & Counters page, and I realized that I could tweak the method from ‘Timing an interval using the input capture unit’ (Reply #12) so that it recorded only the initial rise of an RC circuit.  This is essentially the same idea as his capacitance measuring method except that I’m using D8’s external pin change threshold rather than a level set through the comparator. That should be somewhere around 0.6*Vcc, but the actual level doesn’t matter so long as it’s consistent between the two consecutive reference & sensor readings. It’s also worth noting here that the method  doesn’t require an ICU peripheral – any pin that supports a rising/ falling interrupt can be used. (see addendum for details) It’s just that the ICU makes the method more precise which is important with small capacitor values. (Note that AVRs have a Schmitt triggers on  the digital GPIO pins. This is not necessarily true for other digital chips. For pins without a Schmitt trigger, this method may not give consistent results)

Using Nicks code as my guide, here is how I setup the Timer1 with ICU:

float referencePullupResistance=10351.6;   // a 1% metfilm measured with a DVM
volatile boolean triggered;
volatile unsigned long overflowCount;

ISR (TIMER1_OVF_vect) {  // triggers when T1 overflows: every 65536 system clock ticks
overflowCount++;
}

 

ISR (TIMER1_CAPT_vect) {     // transfers Timer1 when D8 reaches the threshold
sleep_disable();
unsigned int timer1CounterValue = ICR1;       // Input Capture register (datasheet p117)
unsigned long overflowCopy = overflowCount;

if ((TIFR1 & bit (TOV1)) && timer1CounterValue < 256){    // 256 is an arbitrary low value
overflowCopy++;    // if “just missed” an overflow
}

if (triggered){ return; }  // multiple trigger error catch

finishTime = (overflowCopy << 16) + timer1CounterValue;
triggered = true;
TIMSK1 = 0;  // all 4 interrupts controlled by this register are disabled by setting zero
}

 

void prepareForInterrupts() {
noInterrupts ();
triggered = false;                   // reset for the  do{ … }while(!triggered);  loop
overflowCount = 0;               // reset overflow counter
TCCR1A = 0; TCCR1B = 0;     // reset the two (16-bit) Timer 1 registers
TCNT1 = 0;                             // we are not preloading the timer for match/compare
bitSet(TCCR1B,CS10);           // set prescaler to 1x system clock (F_CPU)
bitSet(TCCR1B,ICES1);          // Input Capture Edge Select ICES1: =1 for rising edge
// or use bitClear(TCCR1B,ICES1); to record falling edge

// Clearing Timer/Counter Interrupt Flag Register  bits  by writing 1
bitSet(TIFR1,ICF1);         // Input Capture Flag 1
bitSet(TIFR1,TOV1);       // Timer/Counter Overflow Flag

bitSet(TIMSK1,TOIE1);   // interrupt on Timer 1 overflow
bitSet(TIMSK1,ICIE1);    // Enable input capture unit
interrupts ();
}

With the interrupt vectors ready, take the first reading with pins D8 & D9 in INPUT mode and D7 HIGH. This charges the capacitor through the reference resistor:

//========== read 10k reference resistor on D7 ===========

power_timer0_disable();    // otherwise Timer0 generates interrupts every 1us

pinMode(7, INPUT);digitalWrite(7,LOW);     // our reference
pinMode(9, INPUT);digitalWrite(9,LOW);     // the thermistor
pinMode(8,OUTPUT);digitalWrite(8,LOW);  // ground & drain the cap through 300Ω
LowPower.powerDown(SLEEP_30MS, ADC_OFF, BOD_ON);  //overkill: 5T is only 0.15ms

pinMode(8,INPUT);digitalWrite(8, LOW);    // Now pin D8 is listening

set_sleep_mode (SLEEP_MODE_IDLE);  // leaves Timer1 running
prepareForInterrupts ();
noInterrupts ();
sleep_enable();
DDRD |= (1 << DDD7);          // Pin D7 to OUTPUT
PORTD |= (1 << PORTD7);    // Pin D7 HIGH -> charging the cap through 10k ref

do{
interrupts ();
sleep_cpu ();                //sleep until D8 reaches the threshold voltage
noInterrupts ();
}while(!triggered);      //trapped here till TIMER1_CAPT_vect changes value of triggered

sleep_disable();           // redundant here but belt&suspenders right?
interrupts ();
unsigned long elapsedTimeReff=finishTime;   // this is the reference reading

Now repeat the process a second time with D7 & D8 in INPUT mode, and D9 HIGH to charge the capacitor through the NTC thermistor:

//==========read the NTC thermistor on D9 ===========

pinMode(7, INPUT);digitalWrite(7,LOW);     // our reference
pinMode(9, INPUT);digitalWrite(9,LOW);     // the thermistor
pinMode(8,OUTPUT);digitalWrite(8,LOW);  // ground & drain the cap through 300Ω
LowPower.powerDown(SLEEP_30MS, ADC_OFF, BOD_ON);

pinMode(8,INPUT);digitalWrite(8, LOW);    // Now pin D8 is listening

set_sleep_mode (SLEEP_MODE_IDLE);
prepareForInterrupts ();
noInterrupts ();
sleep_enable();
DDRB |= (1 << DDB1);          // Pin D9 to OUTPUT
PORTB |= (1 << PORTB1);    // set D9 HIGH -> charging through 10k NTC thermistor

do{
interrupts ();
sleep_cpu ();
noInterrupts ();
}while(!triggered);

sleep_disable();
interrupts ();
unsigned long elapsedTimeSensor=finishTime;   //this is your sensor reading

Now you can determine the resistance of the NTC thermistor via the ratio:

unsigned long resistanceof10kNTC=
(elapsedTimeSensor * (unsigned long) referencePullupResistance) / elapsedTimeReff;

pinMode(9, INPUT);digitalWrite(9,LOW);pinMode(7, INPUT);digitalWrite(7,LOW);
pinMode(8,OUTPUT);digitalWrite(8,LOW);  //discharge the capacitor when you are done

The integrating capacitor does a fantastic job of smoothing the readings and getting the resistance directly eliminates 1/2 of the calculations you’d normally do. To figure out your thermistor constants, you need to know the resistance at three different temperatures. These should be evenly spaced and at least 10 degrees apart with the idea that your calibration covers the range you expect to use the sensor for. I usually put loggers in the refrigerator & freezer to get points with enough separation from normal room temp. – with the thermistor taped to the surface of a si7051.  Then plug those values into the thermistor calculator provided by Stanford Research Systems.

Just for comparison I ran a few head-to-head trials against my older dithering/oversampling method:

°Celcius vs time [1 minute interval]  si7051 reference [±0.01°C resolution] vs. Pin-toggle Oversampled 5k NTC vs. ICUtiming 10k NTC.   I’ve artificially separated these curves for visual comparison, and the 5K was not in direct contact with the si7051 reference, while the 10k NTC was taped to the surface of chip – so some of the 5ks offset is an artifact. The Timer1 ratios delver better resolution than 16-bit (equivalent) oversampling in 1/10 the time.

There are a couple of things to keep in mind with this method:

si7051 reference temp (C) vs 10k NTC temp with with a ceramic 106 capacitor. If your Ulong calculations overflow at low temperatures like the graph above, switch to doing the division before the multiplication or use larger ‘long-long’ variables. Also keep in mind that the Arduino will default to 16bit calculations unless you set/cast everything to unsigned long.  Or you could make you life easy and save the raw elapsedTimeReff & elapsedTimeSensor values to your data file and do the calculations later in Excel.

1) Because my Timer1 numbers were small with a 104 cap I did the multiplication before the division. But keep in mind that this method can easily generate values that over-run your variables during calculationUlong MAX is 4,294,967,295  so the elapsedTimeSensor reading must be below 429,496 or the multiplication overflows with a 10,000 ohm reference. Dividing that by our 8mHz clock gives about 50 milliseconds. The pin interrupt threshold is reached after about one rise-time constant so you can use an RC rise time calculator to figure out your capacitors upper size limit. But keep in mind that’s one RC at the maximum resistance you expect from your NTC – that’s the resistance at the coldest temperature you expect to measure as opposed to its nominal rating.  (But it’s kind of a chicken&egg thing with an unknown thermistor right?  See if you can find a manufacturers table of values on the web, or simply try a 0.1uF and see if it works).  Once you have some constants in hand Ametherm’s Steinhart & Hart page lets you check the actual temperature at which your particular therm will reach a given resistance.  Variable over-runs are easy to spot because the problems appear & then disappear whenever some temperature threshold is crossed. I tried to get around this on a few large-capacitor test runs by casting everything to float variables, but that lead to other calculation errors.

(Note: Arduino supports 64-bit “long long” int64_t & uint64_t integers for large number calculations but be careful –  they will gobble up lots of program memory space. typically adding 1-3k to a given compile size. Also Arduino’s printing function can not handle 64 bit numbers, so you have to slice them into smaller pieces before using any .print functions

2) This method works with any kind of resistive sensor, but if you have one that goes below ~200 ohms (like a photoresistor in full sunlight) then the capacitor charging could draw more power than you can safely supply from the D9 pin.  In those cases add a ~300Ω resistor in series with your sensor to limit the current, and subtract that value from the output of the final calculation. At higher currents you’ll also have voltage drop accross the mosfets controlling the I/O pins (~40Ω on a 3.3v Pro Mini), so make sure the calibration includes the ends of your range.

There are a host of things that might affect the readings because every component has a temperature, aging, and other coefficients, but for the accuracy level I’m after many of those factors are handled by the ratio-metric nature of the method. It’s almost independent of value of the capacitor, so you can get away with a cheap ceramic even though it’s value changes dramatically with temperature and age. (I’d still use at least an X7R or, a plastic film cap for measuring colder temps) Last year I found that the main system clock was variable to the tune of about 0.05% over a 40°C range, but that shouldn’t matter if the reference and the sensor readings are taken close to each other. Ditto for variations in your supply. None of it matters unless it affects the system ‘between’ the two measurements.

The significant digits from your final calculation depend on the RC rise time, so switching to 100k thermistors increases the precision. Processors with higher clock speeds should also boost your resolution.  You can shut down more peripherals with the PRR to use less power during the readings as long as you leave Timer1 running.  I’ve also found that cycling the capacitor through an initial charge/discharge cycle (via the 300Ω on D8) after long sleep intervals improved the consistency of the readings. That capacitor shakedown might be an artifact of the ceramics I was using but reading your reference twice at the start of each run might be a good way of eliminating errors that arise from the pre-existing conditions, though you’d also be heating up that resistor so it’s a bit of a trade-off.

Will this method replace our pin-toggled oversampling?  Perhaps not for something as simple as a thermistor since that method has already proven itself in the real world, and I don’t really have anything better to do with A6 & A7. And oversampling still has the advantage of being simultaneously available on all the analog inputs, while the ICU is a more limited resource.  Given the high resolution that’s potentially available with the Timer1/ICU combination, I might save this method for sensors with less dynamic range.  I already have some ideas there and, of course, lots more testing to do before I figure out if there are other problems related to this new method.  I still haven’t determined what the long-term precision is with our Pro Mini clones, and the WDT experiment taught me to be cautious about counting those chickens.


Addendum:   Using the processors built in pull-up resistors

After a few successful runs, I realized that I could use the internal pull-up resistor on D8 as my reference; bringing it down to only three components. Measuring the resistance of the internal pull-up is simply a matter of enabling it and then measuring the current that flows between that pin and ground. I ran several tests, and the Pro Mini’s the internal pullups were all close to 36,000 ohms, so my reference would become 36k + the 300Ω resistor needed on D8 to safely discharge of the capacitor between cycles. I just have to set the port manipulation differently before the central do-while loop:

PORTB |= (1 << PORTB0);     //  enable pullup on D8 to start charging the capacitor

A comparison of the two approaches:

°Celcius vs time: (Post-calibration, 0.1uF X7R ceramic cap)  si7051 reference [±0.01°C] vs. thermistor ICU ratio w 10k reference resistor charging the capacitor vs. the same thermistor reading calibrated using the rise time through pin D8’s internal pull-up resistor. The reported ‘resistance’ value of the NTC themistor was more than 1k different between the two methods, with the 10k met-film reference providing values closer to the rated spec. However the Steinhart-Hart equation constants from the calibration were also quite different, so the net result was indistinguishable between the two references in the room-temperatures range.

The internal pull-up probably isn’t a real resistor. More likely its a very thin semiconductor channel that simply acts like one, and I found the base-line temperature variance to be about 200 ohms over my 40°C calibration range. And because you are charging the capacitor through the reference and through the thermistor, the heat that generates necessarily changes those values during the process.  However when you run a calibration, those factors get absorbed into the S&H coefficients provided you let the system equilibrate during the run.

As might be expected, all chip operation time affects the resistance of the internal pull-up, so the execution pattern of the code used for your calibration must exactly match your deployment code or the calibration constants will give you an offset error proportional to the variance of the internal pull-up caused by the processors run-time. Discharging the capacitor through D8, also generates some internal heating so those (~30-60ms) sleep intervals also have to be consistent.  In data logging applications you can read that reference immediately after a long cool-down period of processor sleep and use the PRR to reduce self-heating while the sample is taken.  Code consistency is always important with time based calibrations no matter how stable your reference is.

Another issue was lag because that pull-up is embedded with the rest of the chip in a lump of epoxy. This was a pretty small, with a maximum effect less than ±0.02°C/minute and I didn’t see that until temperatures fell below -5 Celsius.  Still, for situations where temperature is changing quickly I’d stick with the external reference, and physically attach it to the thermistor so they stay in sync.


Addendum:   What if your processor doesn’t have an Input Capture Unit?

With a 10k / 0.1uF combination, I was seeing Timer1 counts of about 5600 which is pretty close to one 63.2% R * C time constant for the pair.  That combination limits you to 4 significant figures and takes about 2x 0.7msec per reading on average.  Bumping the integrating capacitor up to 1uF (ceramic 105) multiplies your time by a factor of 10 – for another significant figure and an average of ~15msec per paired set readings.  Alternatively, a 1uF or greater capacitor allows you to record the rise times with micros()  (which counts 8x slower than timer1) and still get acceptable results. (even with the extra interrupts that leaving Timer0 running causes…) So the rise-time method could be implemented on processors that lack an input capture unit – provided that they have Schmitt triggers on the digital inputs like the AVR which registers a cmos high transition at ~0.6 * Vcc.

void d3isr() {
triggered = true;
}

pinMode(7, INPUT);digitalWrite(7,LOW);     // reference resistor
pinMode(9, INPUT);digitalWrite(9,LOW);     // the thermistor
pinMode(8,OUTPUT);digitalWrite(8,LOW);  // ground & drain the cap through 300Ω
LowPower.powerDown(SLEEP_30MS, ADC_OFF, BOD_ON);  // 5T is now 1.5ms w 10k

pinMode(8,INPUT);digitalWrite(8, LOW);    // Now pin D8 is listening

triggered = false;
set_sleep_mode (SLEEP_MODE_IDLE);  // leave micros timer0 running for micros()
unsigned long startTime = micros();
noInterrupts ();
attachInterrupt(1, d3isr, RISING); // using pin D3 here instead of D8
DDRD |= (1 << DDD7);          // Pin D7 to OUTPUT
PORTD |= (1 << PORTD7);    // Pin D7 HIGH -> charging the cap through 10k ref
sleep_enable();

do{
interrupts ();
sleep_cpu ();
noInterrupts ();
}while(!triggered);

sleep_disable();
detachInterrupt(1);
interrupts ();
unsigned long D7riseTime = micros()-startTime;

Then repeat the pattern shown earlier for the thermistor reading & calculation. I’d probably bump it up to a ceramic 106 for the micros method just for some extra wiggle room. The method doesn’t really care what value of capacitor you use, but you have to leave more time for the discharge step as the size of your capacitor increases.

7 thoughts on “How to make ‘High-Resolution’ Sensor Readings with DIGITAL I/O pins

  1. Brian Davis

    I like this method, and it makes me want to make sure I always break out D7-D8-D9 for techniques like this. I’ve been using something similar to measure the frequency of a 555-based sensor, and it seems to work well (I haven’t shut down Timer0 for that as yet; there are other things going on to worry about first).

    One thing I have noticed however is setting up the code so it’s possible for the system to also time-out. That way if a wire breaks (so the pin is never pulled high) the system doesn’t lock in to a sleep state forever and ever. I’ve been doing this by keeping the processor awake and having it check to see if there’s a valid return from the timer system, -or- if a set time has elapsed. That way I can kick out of the loop even if the pin the timer is watching never changes state.

    With the system sleeping, you might be able to do something similar in the overflow ISR (if the counter has rolled over some large number of times, set ‘triggered’ to true but have some way of noting that the measurement isn’t valid).

    Reply
    1. edmallon Post author

      Yep, I handle that inside the ISR exactly the way you described:
      if the overflow counter>some large value, set ‘triggered’ = true

      It’s the simplest way to keep the system from locking up.

      Reply
    1. edmallon Post author

      The method is already effectively better than our standard 16-bit oversampling with the Arduino ADC but I’ve been at this game long enough to become cautious about making resolution claims because it seems like every time I figure out how to increase resolution, some “other factor” in the system becomes increasingly significant until it’s imposing some new limitations on quality of the output. But on a fundamental level – it’s a time based method, so at least the resolution is only limited by the components you use. Make the capacitor 10x bigger and you make the time 10x longer. Each order of magnitude (however you created it) adds about 3 extra bits of resolution, though at some point you start to pay too much in terms of run-time power burned for those extra bits to be worth it. And I usually see about 100-200 clock cycles of noise/variability per second from those cheap ceramic resonators on the Pro Mini, so that’s contributing ~ 0.02% error. Arduino’s clocked from a quartz crystal should do better.

      Accuracy is always a matter of calibration, and so depends on your technique & the quality of your reference. Drift is also a matter of component quality: fortunately this method is not really affected by the capacitor since they never age very well. Generally I do the individual sensor calibrations with a si7051 reference, with a nominal accuracy of ± 0.1C in our sensing range. Strictly speaking your calibration reference should be about 10X better than your target accuracy, but in practice that’s too steep a price for most DIY builders.

      In my experience the toughest part of the game for home builders is keeping your reference sensor at the same temperature as the sensor you are trying to calibrate. Thermal inertial makes this hard even if you have them in physical contact with each other. But if you are VERY careful to only use data from times when the system has had time to equilibrate – you can get the hysteresis low enough to bring your DIY sensor pretty close to the reference sensors accuracy -> my thermal calibration runs usually take several days to accomplish, just to get a few good plateaus of usable data. That’s a trade I’m willing to make for loggers that could be in the field for years, but that level of effort would never be ‘economically viable’. For me it costs nothing because I have to run the units for a couple of weeks just to get all the components/sensors through their ‘infant mortality’ period anyway.

      I then add a second set of corrections to “normalize” a given batch of sensors if they are being deployed together as a set. This is easily generated by running the loggers together inside of a big “thermal mass” box for a few days if they are dry, or in large circulating water bath tests if they are underwater units. I salvaged a very large old chiller bath from a university lab: its controller was broken but a jury-rigged switch lets me run the chiller till some ice forms on the surface of the bath, and then I use the natural warming curve of the water as the cal data. The nice thing about that is even without using distilled water, I still get a nice big 0 degC plateau in the data set as that surface layer of ice melts.

      Reply
  2. Pingback: HIGH PRECISION ANALOG IO WITH DIGITAL PINS -Use Arduino for Projects

  3. Pingback: High Precision Analog IO With Digital Pins

  4. edmallon Post author

    Hackaday gave us a nod for this one – generating the usual round of interesting feedback. Many pointed out factors that could affect the readings but only one of them really understood the strength of this ‘mostly’ ratiometric method:

    Greg A wrote:

    “It seems to me as though if he has all the components (including the microcontroller) at the same temperature, then it doesn’t need accuracy, only repeat-ability. the fact that the time base, logic gates, capacitor, and reference resistor all change with temperature doesn’t matter so long as it repeats the same behavior it showed when it was calibrated.”

    Many of the tempco’s in the system get rolled into the S&H constants during the calibration. And this works pretty well…but only up to a point which I haven’t bothered to characterize that well. I’m sure you could do better with a higher order poly.

    This technique does depend on the consistency of the Schmidt trigger on D8. That trigger point will certainly vary over the length of a long deployment, or because of temperature, but it’s only the variation between the Vhigh trigger points on two successive readings that matters. I’ve not gone digging for that info yet, and I have a sneaky suspicion that it won’t be listed in the datasheet because it’s probably not something they cared about when they designed the chip. And possibly because jitter errors caused by coupling / crosstalk inside the chip also depend on what else you have going on inside the processor at the time.

    Earlier I dismissed the capacitor variation over time because it drops out of the math, but on reflection it could affect the readings in a more round about way – even with your system regulator keeping Vcc stable: if the capacitor gets smaller over time due to aging effects you would then be passing less current through the thermistor – which would cause less self-heating than it experienced during calibration. If that’s a real concern for your deployment then I’d go with Plastic film capacitors, which have very good thermal coefficients: Polyphenylene sulfide (PPS ±1.5%) or Polypropylene (CBB or PP ±2.5%) and aging rates listed as ‘negligible’ in the datasheets (% change/decade hour).

    The next most important factor is the drift of the reference resistor, which again is a matter of how picky you are on the spec. If you are serious about accuracy use 0.01% tolerance resistors, but don’t be surprised if they cost more than your thermistor.

    And Timing Jitter will always be a fundamental limitation with this method. For a given Promini ceramic oscillator at 8mhz, I typically see about 75 counts raw timing jitter per second at a given temp, overlay-ed by 75-100 counts of positively correlated variation per degree Celsius. So you’ve got something like 0.03% error over a typical 40C range if you don’t correct for it. Hysteresis caused by your reference and your sensor not being in sync is usually a larger source of error.

    Reply

Leave a Reply to edmallon Cancel reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s