Spurious INT0 interrupts triggered?

I've been working with an infrared LED/receiver to measure RPM on a nano V3. The IR LED/Receiver simply supplies input to an LM393 opamp that triggers pin D2 each time a revolution is complete. For testing purposes I'm using a 80mm fan which operates between 2200 - 3100 RPM (depending on input voltage 11-12.8V).

I've used this same setup with TI MSP boards and Pi boards and I've not experienced any spurious interrupts being triggered. However, when using D2 as the interrupt pin, I'm receiving roughly 3 spurious interrupts between each valid interrupt and I'm not sure what the cause is. I don't know if there may be a timer conflict or the like that is causing the issue on the nano.

I'm using micros() once at the beginning of the interrupt handler to obtain a timestamp and the time between interrupts to determined the RPM (e.g. 1e7 / period). Normally the time period between interrupts ~27000 microseconds, which translates into ~2200 RPM. The problem is that between valid time periods, additional interrupts are triggered at periods like 56 microseconds or 2300 microseconds. (over 1,000,000 RPM and 25,600 RPM, respectively). I'm trying to track down how those are triggered and why.

I can smooth them out by filtering, but that just adds to the code required in the interrupt handler which I'd like to eliminate.

The code essentially has the interrupt triggered on pin D2 and then computes the RPM within the handler. The body of the code then has a 100 ms timer that updates the RPM and max RPM values shown on the terminal. The code is the following:

#ifdef __cplusplus
extern "C"
{
#include "numeric-str-cnv.h"
}
#endif


#define USESERIAL          1

#define SERIALBAUD    115200    /**< baud for Linux terminal in minicom */

#define PIN_IRSNSR         2    /**< only D2, D3 support interrupts on nano */

#define PCTCHGMAX         70    /**< max percent change revolution time */

#ifdef USERPMMS
#define RT_100MS         100    /**< 100 ms expressed in us */
#define MILLISECRPM      6e4    /**< microseconds per RPM */
#define REVMINMS          10    /**< min ms in rev (6000 RPM) */
#define REVMAXMS        1000    /**< min ms in rev (   1 RPM) */
#else
#define RT_100MS         1e5    /**< 100 ms expressed in us */
#define MICROSECRPM      6e7    /**< microseconds per RPM */
#define REVMINUS       10000    /**< min usec in rev    (6000 RPM) */
#define REVMAXUS         1e6    /**< min usec in rev    (   1 RPM) */
#endif


static volatile unsigned long us_start = 0,     /**< last rising edge */
                              us_end = 0,       /**< current rising edge */
                              us_rev = 0,       /**< usec difference */
                              us_rev_last = 0;  /**< last valid rev */
static volatile uint16_t  rpm = 0,              /**< RPM measured */
                          rpm_max = 0;          /**< maximum RPM */


/**
 * @brief percentage change between last value (vold) and current (vnew) used
 * to eliminate spurious interrupts triggered.
 *
 * @param vnew current value.
 * @param vold previous value.
 *
 * @return returns percentage change between values new and old.
 */
int pctchg (long int vnew, long int vold)
{
  long int diff = 0;
  
  /* deal with positive values only */
  vnew = vnew < 0 ? -vnew : vnew;
  vold = vold < 0 ? -vold : vold;
  
  /* difference between old and new */
  diff = vnew > vold ? vnew - vold : vold - vnew;
  
  /* return percentage change */
  return (diff * 100) / vnew;
}


/**
 * @brief read IR sernsor rising edge and compute current RPM.
 *
 * @note millis() and micros() not updated in ISR, so read in loop()
 * for all timed events.
 */
void isr_irsnsr (void)
{
  /* get current timestamp */
#ifdef USERPMMS
  us_end = millis();
#else
  us_end = micros();
#endif

  /* calculate time for revolution */
  us_rev = us_end - us_start;

  /* if first or invalid, save start for next */
  if (!us_start || us_rev < REVMINUS || REVMAXUS < us_rev) {
    us_start = us_end;
    return;
  }
  
  /* if last period not set, update value and get next */
  if (!us_rev_last) {
    us_rev_last = us_rev;
    us_start = us_end;
    return;
  }
  
  /* compare percentage change between time periods with max */
  if (pctchg (us_rev, us_rev_last) > PCTCHGMAX) {
    us_rev_last = us_rev;
    us_start = us_end;
    return;
  }
  
  /* calculate angular rate in RPM */
#ifdef USERPMMS
  rpm = (unsigned long)MILLISECRPM / us_rev;
#else
  rpm = (unsigned long)MICROSECRPM / us_rev;
#endif

  /* save max RPM */
  if (rpm > rpm_max) {
    rpm_max = rpm;
  }

  /* save rev time, update start time for next rev */
  us_rev_last = us_rev;
  us_start = us_end;
}


void setup()
{
  /* initialize led pins as an OUTPUT: */
  pinMode (LED_BUILTIN, OUTPUT);

  /* initialize sensor pin as input */
  pinMode (PIN_IRSNSR, INPUT);

  /* attach interrupt on input pint to isr_irsnsr on rising edge
   *
   *  PCMSK2 ^ PCINT18  (enable pin 2 PCINT18 in pin control mask)
   *  EICRA - bits 0,1 - INT0, bits 2,3 - INT1
   *      1 - CHANGE
   *      2 - FALLING
   *      3 - RISING
   */
  attachInterrupt (digitalPinToInterrupt (PIN_IRSNSR), isr_irsnsr, RISING);

#ifdef USESERIAL
  Serial.begin (SERIALBAUD);
  delay (1000);

  Serial.println ("\nIR sensor RPM:\033[?25l\n\n  rpm : \n");
#endif
}


void loop()
{
#ifdef USESERIAL
  char buf[IVMAXC] = "";
#endif
  static unsigned long  rt_start = 0,
                        rt_accum_100ms = 0;
#ifdef USERPMMS
  unsigned long rt_now = millis(),
#else
  unsigned long rt_now = micros(),
#endif
                rt_diff = rt_now - rt_start;
  uint16_t rpm_out = rpm;

  rt_accum_100ms += rt_diff;
  rt_start = rt_now;

  /* 100 ms repeating events */
  if (rt_accum_100ms >= RT_100MS) {
    digitalWrite (LED_BUILTIN, !digitalRead (LED_BUILTIN));

#ifdef USESERIAL
#ifdef USERPMMS
    if (rt_now - us_end > REVMAXMS) {
#else
    if (rt_now - us_end > REVMAXUS) {
#endif
      rpm_out = 0;
    }
    Serial.print ("\033[2A\033[8C");
    Serial.println (uint2strdec_w (buf, rpm_out, 4));
    Serial.print ("  max : ");
    Serial.println (uint2strdec_w (buf, rpm_max, 4));
#endif

    rt_accum_100ms = 0;
  }
}

I've also dumped the values for the time between when the interrupts are being called so I could see the actual data. It is consistent, so there is something triggering the additional interrupts. Each line of data below holds the time periods for interrupts triggered with a 100 ms period (each line represents one iteration of the 100ms timing of the main program loop). The values were buffered in an array and then output at 100 ms intervals. Below you have 3 parts, the start of the motor turning (first two values are skipped), then steady-state, and then the motor spinning down:

9966532 56 17692 56
130176 56 9884 56
90012 56 7720 56 74548 56
6612 56 65660 56 5936 56
59800 56 5436 565551256508856
52268 56 4788 56
49656 56 4572 56 47544 56 4364 56
45764 56 4216 56 44272 56 4068 56
42972 56 3952 56 41860 56 3840 52 40860 56 3756 56
39988 56 3660 56 39208 56 3596 56
38512 52 3520 563788056346056
37308 56 3400 56 36772 56 3352 56 36284 56 3300 52
35836 56 3252 56 35424 56 3208 56
35036 56 3176 56 34680 56 3136 56 34352 56 3108 56
<snip>
27536 56 2360 56 27536 52 2360 56 27532 52 2364 52 2752856235656
27520 56 2360 56 27516 56 2360 56 27508 56 2356 56
27504 56 2356 56 27496 56 2352 56 2749256235256
27480 56 2352 56 27472 56 2352 56 27464 56 2352 56 27456 56 2348 56
27456 56 2344 56 27452562344562744856234456
27440 52 2348 56 27432 56 2348 56 27428 56 2348 56
27424 56 2344 56 274205623445627416562344562741656234056
27412 56 2348 56 27404 56 2344 56 27400 56 2348 56
27404562340562740856233656274045623405627396
2400 56 27392 56 2348 56 27392 56 2344 56 27392 56234056
27396 56 2336 56 27396 56 2344 56 27384 56 2344 56
27388 56 2344 56 27380 56 2340 56 27384 562340522738056234056
27376 56 2340 56 27368 56 2344 56 27368 56 2336 56
27368 56 2340 56 27364 562340562736056234056
27356 56 2340 56 27356 56 2336 56 27356 56 2336 56 27356 56 2336 56
27356 5623405627356562336562735656234056
27356 56 2336 56 27360 56 2336 56 27360 56 2336 56
27356 56234056273525623365627356562336562734456233656
27344 56 2332 56 27340 56 2336 56 27336 52233656
27332 52 2340 56 27328 56 2332 56 27328 56 2336 56
27324 56 2328 56 27328 56 2336 56 27324 562332562732856233256
27328 56 2336 56 27328 56 2332 56 27328 56 2332 56
<snip>
45668 56 4352 56 4746856453256
49392 56 4740 56 51480 56 4948 60
53756 56 5184 56 56256 56
5448 56 58996 56 5728 56
62040566036566544056
6392 56 69260 56 6804 52
73584 56 7252 56
78548 56 7784 56
84308 56 8400 56
9112056915256
99296 10104 52
109312 52 11184 56
121912 56
12620 52
138552 56 14584 56
162008 56 17504 56
198848 562248056
269344 56 34076 56
560544 56 56 56 56 56 56 56 56
134440 56 56 88 56 56 56 56 5656565656

The first interrupt triggered in each 100 ms timing period is correct 99% of the time, e.g. 27332 with an odd one every once in a while, e.g. 2340. However, the next three interrupts make no sense, e.g. 52 2340 56. That sequence then repeats two more time throughout the 100 ms period, e.g.

27332 52 2340 56 27328 56 2332 56 27328 56 2336 56

The 273xx microsecond values are correct, the rest are not.

I'm pretty well stumped on this one, but I suspect it has something to do with the interplay between the timers at use, but I'm not familiar enough with the nano to put my finger on it?

The IR LED/Receiver board is a simple design. It is essentially the following (just adjust the 3.3V for 5V for the arduino):

In each case, the sensor has an activity LED that shows when the RPM is being measured and when the interrupt should be triggered. In this case, the activity LED corresponding the valid measurements are shown. There is no activity shown when the spurious events occur, so I'm confident it isn't noise coming from the IR sensor board.

I'm open to any help and suggestions. It is probably something I missed in the Arduino language reference or the 328p datasheet. So I won't be surprised if it is something basic. Let me know if I need to provide additional information. I tried include as much as I could to make this make sense.

Compliment for your post!

Your isr seems to do far more than necessary...
It could just set a flag....(rising pin detected)
All calculations could be done in loop().
If you use variables set by an isr for calculations in your loop (with more than one byte of size), you need to temporarily stop your isr...
After that, you should put it on again...
https://docs.arduino.cc/language-reference/en/functions/interrupts/noInterrupts/

You need to realize that the isr has priority 1. So within one loop, your three or more variables changed by the irs may not result from the same irs event...
Hence the tip to use a flag only...

This may also help you:
https://docs.arduino.cc/language-reference/en/functions/math/abs/

Thanks. I'll rewrite with the processing in loop() and disable/enable interrupts for the critical section. Originally, the ISR started out with just the call to micros() and the rpm calculation with the single conditional for rpm_max. When the filtering started, it then grew much longer than I would like.

I'll report back after re-adding the code to dump the time periods and see how it goes. Thanks!

No noise that you can see. But the Arduino interrupt pin will react to noise pulses far too short to be visible to the eye.

Do you have access to an oscilloscope?

I would suggest that there are spurious pulses being generated by the comparator when the two inputs are at similar voltages.

Try connecting a high value resistor of say 1MĪ© between the output of the comparator and its non-inverting input.
This small amount of positive feedback will provide some hysteresis and help to prevent spurious outputs from the comparator.

1 Like

No it is not,
The schematic shown in the link you provided is different. They also recommend using 3.3V because 5V would increase the LED output power and maybe cause spurious responses.

The datasheet for the sensor provides internal voltage regulation for either 3.3 or 5v inputs. Running at 3.3V on the arduino makes no difference with the spurious outputs, but just adds a 3.3-5v logic level shifter from the sensor output back to D2.

There are a number of these little boards out there. Some using the LM358 sensor, and some with the LM393, this particular one uses the LM393 and works fine at 5v or 3.3v. (no telling where whoever wrote the Amazon description got their information)

But that was a good test to put it through, thanks!

It very difficult to help you debug when you show a schematic of one design, provide a link to another design and a datasheet to yet a third design.

The datasheet for the sensor provides internal voltage regulation for either 3.3 or 5v inputs.

I see no mention of that.

I apologize, the schematic was intended as a rough equivalence to what is on the sensor board. The presence of internal voltage regulation came from analysis of the chips on the blue board by those smarter than I on electronics.stackexchange.com. The Hanson Tech link to their product sheet (can't really call it a datasheet) is the closest I've found and matches the board I have. The Amazon link to the product provides no reference to documentation and if you google that part, you turn up nothing (or at least I don't).

It makes it a bit frustrating on this end as well, but testing at both 5V and at 3.3V was a good exercise. The only difference is running at 5V, the timing is a bit more precise (meaning measured RPM +/- 5-7 at steady state, while at 3.3V you measure RPM +/- 10-12 or so). That I'm not too worried about.

I'm happy to take close-ups of the front and back of the board if you think that would help. I didn't do that originally because I didn't think it was germane to the issue, but I'm happy to post them.

This is a very simple program to just count the number interrupts that have ocured in one second.

If you do indeed have a hardware issue then it should show up as an very erratic pulse count.

volatile int pulseCount;
int pulseCount1;

const byte sensor_pin = 2; // RPM input signal


//This is the function that the interupt calls
void pulseISR ()
{
  pulseCount++;
}


void setup()
{
  pinMode(sensor_pin, INPUT);
  Serial.begin(115200);
  attachInterrupt(digitalPinToInterrupt(sensor_pin), pulseISR, RISING);
}

void loop ()
{
  pulseCount = 0; //Set count to 0
  delay (1000); //Wait 1 second
  pulseCount1 = pulseCount;
  
  Serial.println(pulseCount1);
}

Thank you, that does break things down to a manageable level. Things looks pretty stable in the output, though I'm unclear just how stable perfect should be. The fan RPMs being measured do fluctuate a bit, so I would expect some difference in count in a second, but that's where I can use a bit of help understanding the output:

28
76
118
132
133
128
137
139
133
131
133
142
139
135
142
135
145
138
137
144
140
141
136
147
135
147
148
141
140
145
141
142
147
119
61
39
27

That must include spurious interrupt triggers. For example 1 / 140 * 1e6 yields ~7143 microseconds between each interrupt, or 6e7 / 7143 yields ~8400 RPM.

In the same light, I rewrote the code to dump the timestamps per-100ms period after moving processing from the ISR to loop() and guarding the interrupt processing between noInterrupt() and interrupt(). That made significant improvement (ever other is a valid measurement, instead of 1-in-4), e.g. same 3 sections of output, startup, steady state, powerdown:

10048 96112
7324 75168 6116
64420 5400
57824 4940 53176 4580
49764 4332 47048 4120
44892 3952 43080 3800
41580 3684 40264 3576
39148 3484 38148 3392 37264 3328
36484 3264 35768 3208
35136 3140 34560 3096 34020 3052
33540 3016 33100 2960 32688 2932
<snip>
24716 2172 24712 2176 24724 2168
24712 2180 24716 2176247162168247162176
24716 2168 24716 2172 24716 2172 24716 2168
24716 2172 24716 2172 24716 2164
24720 2168 24716 2168 24712 2172247122172
24712 2168 24716 2168 24708 2172 24712 2164
24712 2172 24708 2168 24704 2172 24708 2168
24704 2168 24708 2168 24704 2172
24704 2168 24704 2172 24704 2168 24692 2180
24704 2168 24700 2168 24704 2164 24700 2168
24688 2176 24684 2176 24696 2164 24680
2176246882168246842168246842168
24672 2176 24680 2164 24676 2168 24668 2172
24664 2172 24672 2164 24664 2164 24668 2164
24656 2164246642160246562156
24656 2156 24656 2164 24648 2160 24644 2164
24640 2160 24628 2160 24644 2160 24632 2160
24632 2164 24624 2164246242160246242164
24624 2156 24624 2160 24620 2156
24616 2164 24612 2160 24612 2160 24612 2148
<snip>
38740 3548 40068 3668 41488 3804
43028 3924 44664 4076
46396 4248 48300 4412
50348 4596
52568 4800 54992 5020
57648 5256 60592 5516
63852 5808
67500 6152
71616 6512 76308
6944 81716 7448
88032 8040
95556 8740
104708
9624
116112 10752
130952 12236
151216 14364
181380 17700
233244 24060
362012
45456

So is the every "other" interrupt trigger a problem with my rearranged code or is it a hardware issue? (no, I have no oscilloscope, but wish I did...). The 24K+ values are correct (2500 RPM), the every other 2K values (30,000 RPM) appear to be spurious, but we eliminated 2 out of 3 spurious values just by moving the code out of the ISR and disabling interrupts while processing. (I guess disabling interrupts within the ISR and leaving the code there would have had the same result, will test later)

The code processing the RPM calculations (with the same filter) is quite stable. The rewrite is:

#ifdef __cplusplus
extern "C"
{
#include "numeric-str-cnv.h"
}
#endif


#define USESERIAL          1

#define SERIALBAUD    115200    /**< baud for Linux terminal in minicom */

#define PIN_IRSNSR         2    /**< only D2, D3 support interrupts on nano */

#define PCTCHGMAX         70    /**< max percent change revolution time */

#ifdef USERPMMS
#define RT_100MS         100    /**< 100 ms */
#define MILLISECRPM      6e4    /**< milliseconds per RPM */
#define REVMINMS          10    /**< min ms in rev (6000 RPM) */
#define REVMAXMS        1000    /**< min ms in rev (   1 RPM) */
#else
#define RT_100MS         1e5    /**< 100 ms expressed in us */
#define MICROSECRPM      6e7    /**< microseconds per RPM */
#define REVMINUS       10000    /**< min usec in rev (6000 RPM) */
#define REVMAXUS         1e6    /**< min usec in rev (   1 RPM) */
#endif


static volatile unsigned long us_start = 0,     /**< last rising edge */
                              us_end = 0,       /**< current rising edge */
                              us_rev = 0,       /**< usec difference */
                              us_rev_last = 0;  /**< last valid rev */
static volatile uint16_t  rpm = 0,              /**< RPM measured */
                          rpm_max = 0;          /**< maximum RPM */
static volatile uint8_t int0_rdy = 0,           /**< int0 ready flag */
                        rpm_rdy = 0;            /**< rpm calc ready */


/**
 * @brief percentage change between last value (vold) and current (vnew) used
 * to eliminate spurious interrupts triggered.
 *
 * @param vnew current value.
 * @param vold previous value.
 *
 * @return returns percentage change between values new and old.
 */
int pctchg (long int vnew, long int vold)
{
  long int diff = 0;

  /* deal with positive values only */
  vnew = vnew < 0 ? -vnew : vnew;
  vold = vold < 0 ? -vold : vold;

  /* difference between old and new */
  diff = vnew > vold ? vnew - vold : vold - vnew;

  /* return percentage change */
  return (diff * 100) / vnew;
}


/**
 * @brief read IR sernsor rising edge and compute current RPM.
 *
 * @note millis() and micros() not updated in ISR, so read in loop()
 * for all timed events.
 */
void isr_irsnsr (void)
{
  /* get current timestamp */
#ifdef USERPMMS
  us_end = millis();
#else
  us_end = micros();
#endif

  /* set interrupt ready flag */
  int0_rdy = 1;
}


/**
 * @brief compute current RPM from IR sernsor rising edge timestamp.
 *
 */
void compute_rpm (void)
{
  /* calculate time for revolution */
  us_rev = us_end - us_start;

  /* if first or invalid, save start for next */
  if (!us_start || us_rev < REVMINUS || REVMAXUS < us_rev) {
    us_start = us_end;
    return;
  }

  /* if last period not set, update value and get next */
  if (!us_rev_last) {
    us_rev_last = us_rev;
    us_start = us_end;
    return;
  }

  /* compare percentage change between time periods with max */
  if (pctchg (us_rev, us_rev_last) > PCTCHGMAX) {
    us_rev_last = us_rev;
    us_start = us_end;
    return;
  }

  /* calculate angular rate in RPM */
#ifdef USERPMMS
  rpm = (unsigned long)MILLISECRPM / us_rev;
#else
  rpm = (unsigned long)MICROSECRPM / us_rev;
#endif

  /* save max RPM */
  if (rpm > rpm_max) {
    rpm_max = rpm;
  }
  
  /* set rpm ready flag */
  rpm_rdy = 1;

  /* save rev time, update start time for next rev */
  us_rev_last = us_rev;
  us_start = us_end;
}


void setup()
{
  /* initialize led pins as an OUTPUT: */
  pinMode (LED_BUILTIN, OUTPUT);

  /* initialize sensor pin as input */
  pinMode (PIN_IRSNSR, INPUT);

  /* attach interrupt on input pint to isr_irsnsr on rising edge
   *
   *  PCMSK2 ^ PCINT18  (enable pin 2 PCINT18 in pin control mask)
   *  EICRA - bits 0,1 - INT0, bits 2,3 - INT1
   *      1 - CHANGE
   *      2 - FALLING
   *      3 - RISING
   *  PCINT18 - what the hall is value 2.
   */
  attachInterrupt (digitalPinToInterrupt (PIN_IRSNSR), isr_irsnsr, RISING);

#ifdef USESERIAL
  Serial.begin (SERIALBAUD);
  delay (1000);

  Serial.println ("\nIR sensor RPM:\033[?25l\n\n  rpm : \n");
#endif
}


void loop()
{
#ifdef USESERIAL
  char buf[IVMAXC] = "";
#endif
  static unsigned long  rt_start = 0,
                        rt_accum_100ms = 0;
#ifdef USERPMMS
  unsigned long rt_now = millis(),
#else
  unsigned long rt_now = micros(),
#endif
                rt_diff = rt_now - rt_start;
  uint16_t rpm_out = rpm;
  
  /* handle int0 ready */
  if (int0_rdy == 1) {
    noInterrupts();             /* disable interrupts */
    compute_rpm();              /* compute RPM from timestamp */
    int0_rdy = 0;               /* reset int0 flag */
    interrupts();               /* enable interrupts */
  }
  
  /* compute real-time period for 100ms events */
  rt_accum_100ms += rt_diff;
  rt_start = rt_now;

  /* 100 ms repeating events */
  if (rt_accum_100ms >= RT_100MS) {
    digitalWrite (LED_BUILTIN, !digitalRead (LED_BUILTIN));
    /* if time period greater than max, set rpm_out zero */
#ifdef USESERIAL
#ifdef USERPMMS
    if (rt_now - us_end > REVMAXMS) {
#else
    if (rt_now - us_end > REVMAXUS) {
#endif
      rpm_out = 0;      /* set RPM 0 for period greater than max */
      rpm_rdy = 1;      /* set rpm ready flag */
    }
    /* if rpm ready to print */
    if (rpm_rdy == 1) {
      Serial.print ("\033[2A\033[8C");
      Serial.println (uint2strdec_w (buf, rpm_out, 4));
      Serial.print ("  max : ");
      Serial.println (uint2strdec_w (buf, rpm_max, 4));
      rpm_rdy = 0;
    }
#endif
    /* reset accumulated time period zero */
    rt_accum_100ms = 0;
  }
}

So far, a 75% improvement just by moving the ISR calculation outside the ISR is real progress. If anyone sees anything within the updated code that could explain the "every other" issue, I'm happy to test.

Your code seems overly complex to me. If sensor outputs one pulse per revolution, RPM would = 60000000 (60 million) divided by microseconds per pulse. If pulse interval were, say, 35000:
RPM = 60000000 / interval = 1714.3.

Well,

That's exactly what the code does. The complexity comes from variables needed to determine not only the current time-period for the revolution, but also retaining the prior period to use in filtering out the spurious interrupts. The actual code for the RPM calculation can be done in a couple of lines, and the conditional to store the max RPM. (which is why I started with the code in the ISR)

After running into odd values and dumping the raw data, that's when the code began to grow to come up with an efficient way to cover all possible caveats with the interrupt timing. I tried several schemes, and ultimately decided on percent-change which handles increasing and decreasing as well as the spurious values during steady-state. I'm sure there are better ways, but this will optimize well and works.

I've tried all tests powering the sensor board at 5V and 3.3V and the spurious interrupts remain (slightly less at 3.3V than 5V, but overall performance of the board is much better at 5V). I've got several other sensor boards that I'll swap in and test (when I can remember where I hid them....)

Thanks for the check of the code.

What type of sensor and what does it sense on the fan?

To determine whether the problem is with the code or with the sensor, you could program a second Arduino to provide a steady stream of pulses, and use those instead of the sensor output.

Things looks pretty stable in the output, though I'm unclear just how stable perfect should be.

If you were to use a signal from another Arduino as suggested above, then the results should be very stable.

For example, jim-p's code from post #10, with a100kHz 1% duty cycle signal from a function generator, the count only varies by 1.

I could test your code if I could get it to compile.
Where would I find "numeric-str-cnv.h"?

My photography may not be the best, but this will show the fan and sensor setup. Essentially you have a 1/4 - 5/16 inch piece of white paper taped to a fan blade which triggers the sensor for each rising edge. The sensor itself is the one shown in the links (Hanson tech and Amazon):

And the test setup:

(don't laugh, it tracks 80x38 6800 RPM fans just fine)

Maybe if the blades were painted FLAT black to reduce reflection and sensor were closer (3 - 4mm) and a piece of shrink tubing or black tape around the sensor & emitter to reduce incident light pickup?

John, thank you for your two nano idea - I have two. The numeric-str-cnv.[ch] files are below, they are just my numeric conversion files I use to specify a fixed width of output. They basically do what the overloads to print() do as far a numeric conversions. But they add a few creature comforts I'm familiar with:

numeric-cnv.h

#ifndef __numeric_str_cnv__
#define __numeric_str_cnv__  1

#include <stdint.h>
#include <stdbool.h>

#define FPMAXC 32                           /* floating-point max chars */
#define IVMAXC FPMAXC + 8                   /* integer value max chars */

/* convenience defines for int to string conversions providing simple
 * str, val parameter shorthand for basic unpadded conversions for each
 * of the four common base types (bin, oct, dec, hex).
 */
#define int2strbin(str,val) int2str(str,val,2,0,0,' ')
#define int2stroct(str,val) int2str(str,val,8,0,0,' ')
#define int2strdec(str,val) int2str(str,val,10,1,0,' ')
#define uint2strdec(str,val) int2str(str,val,10,0,0,' ')
#define int2strhex(str,val) int2str(str,val,16,0,0,'0')

/* convenience defines allowing width specification */
#define int2strbin_w(str,val,width) int2str(str,val,2,0,width,' ')
#define int2stroct_w(str,val,width) int2str(str,val,8,0,width,' ')
#define int2strdec_w(str,val,width) int2str(str,val,10,1,width,' ')
#define uint2strdec_w(str,val,width) int2str(str,val,10,0,width,' ')
#define int2strhex_w(str,val,width) int2str(str,val,16,0,width,'0')

/* convenience define for float conversion without padding */
#define float2str_nopad(str,f,prec) float2str(str,f,0,prec)

/* convert signed/unsigned int to string with base padded to width with padc */
char *int2str (char *str, uint32_t val, uint8_t base, uint8_t signedval,
               uint8_t width, uint8_t padc);

/* float to string */
char *float2str (char *str, float f, uint8_t width, uint8_t prec);

/* string to uint16_t */
bool str2uint16 (char *s, uint16_t *u);

/* string to uint32_t */
bool str2uint32 (char *s, uint32_t *u);

/* string to uint64_t */
bool str2uint64 (char *s, uint64_t *u);


#endif

and the source file:

#include "numeric-str-cnv.h"

#define FLTERR 1.0e-6f

/**
 * @brief convert signed/unsigned int to string with base, padded to width
 * with padc.
 *
 * int2str converts the integer val to string padded to width using
 * the padc character. Set width to 0 for no padding. Set pad to ' '
 * (space) for normal right justification of string to width or use
 * '0' to zero pad the value. (or any other character you choose)
 *
 * @param str destination string for numeric conversion.
 * @param val integer value to convert, signed or unsigned.
 * @param base numeric base to use for conversion (valid 2-16), base outside
 * valid range will be set to 10.
 * @param signedval treat val as signed for base 10 conversions only.
 * @param width pad conversion to width characters. width of 0 - no padding.
 * @param padc pad character to use, e.g. ' ' for space padding, '0' for zero
 * padding.
 *
 * @note unsigned conversions (bin, oct, hex) ignore signedval and no '-'
 * sign is prepended to the conversion. any value provided with the sign-bit
 * set and signedval nonzero will result in unsigned converstion to the
 * twos-compliment positive value. base 10 conversions for negative values
 * with signedval set will be properly converted with '-' prepended.
 */
char *int2str (char *str, uint32_t val, uint8_t base, uint8_t signedval,
               uint8_t width, uint8_t padc)
{
  uint8_t nbits = sizeof val * __CHAR_BIT__;
  char  tmp[IVMAXC],                        /* temp buffer for conversion  */
        *p = tmp + IVMAXC - 1;              /* pointer to last char in tmp */
  const char *digits = "0123456789abcdef";  /* digits for base conversion */
  int i,                                    /* counter var */
      sign = val >> (nbits - 1);            /* sign-bit */

  /* if base is out of range binary - hex, set base 10 by default */
  if (base < 2 || 16 < base) {
    base = 10;
  }

  /* if signedval and negative val provided, make positive for conversion */
  if (signedval != 0 && sign != 0) {
    val = -val;
  }

  /* limit width to IVMAXC to protect array bounds */
  if (width >= IVMAXC) {
    width = IVMAXC - ((signedval && sign) ? 2 : 1);
  }
  *p = 0;         /* nul-terminate tmp */

  /* if value is zero, set zero in buffer */
  if (val == 0) {
    *--p = '0';
    if (width) {
      width -= 1;
    }
  }

  /* convert val to string with requested base for conversion */
  while (p > tmp && val) {
      *--p = digits[val % base];
      val /= base;
      if (width) {
        width -= 1;
      }
  }

  /* if signedval given and sign-bit set, add '-' for base 10 conversion */
  if (p > tmp && base == 10 && signedval && sign) {
    *--p = '-';
    if (width) {
      width -= 1;
    }
  }

  /* pad to width with pad-char */
  while (width-- && p > tmp) {
    *--p = padc;
  }

  /* copy to destination str with nul-terminating character */
  for (i = 0;; i++, p++) {
    str[i] = *p;
    if (*p == 0) {
      break;
    }
  }

  return str;     /* return destination string */
}

/**
 * @brief convert float 'f' into string of a total of 'width'
 * characters with 'prec' digits in fractional part, if width is
 * zero, no padding is applied.
 *
 * convert float f to string with fractional part
 * limited to 'prec' digits padded to 'width' spaces.
 * str must have adequate storage to hold the converted
 * value. If converted value does not fit in non-zero width
 * the string is filled with "INV" for INVALID. if width is
 * zero, then no padding is applied and only the minimum
 * number of characters needed for real-part, radix point, and
 * fractional part are used along with space for '-' if the
 * value is negative.
 *
 * @param str destination string.
 * @param f float value to convert to string.
 * @param width total number of characters for conversion in str.
 * @param prec precision, number of significant digits in fractional part.
 */
char *float2str (char *str, float f, uint8_t width, uint8_t prec)
{
  char  tmp[FPMAXC] = "",
       *p = tmp + FPMAXC - 1;
  uint8_t fpcnt = 0,                          /* fractional digit count */
          i = 0,                              /* loop counter */
          radix_set = 0,                      /* flag - radix point set */
          sign = f < 0 ? 1 : 0,               /* set sign if negative */
          width_given = width;                /* flag - width specified */
  uint32_t  fpm,                              /* floating-point multiplied */
            mult = 1;                         /* multiplier for precision */
  float round = .5f;                          /* set round factor */

  /* perform conversion with positive value */
  if (sign == 1) {
    f = -f;
  }

  /* limit width to IVMAXC to protect array bounds */
  if (width >= FPMAXC) {
    width = FPMAXC - (sign ? 2 : 1);
  }
  *p = 0;     /* nul-terminate str */

  /* handle zero case (f within FLTERR), sign ignored.
   *
   * if WRTSTR0 is defined, then the for (;;) loop version is used
   * to write direct to str and avoid the copy from tmp to str.
   *
   * (need to compare dumped assembly, but for a few byte copy it
   *  is likely irrelevant, but does save two separate loops)
   */
  if (-FLTERR < f && f < FLTERR) {
#ifndef WRTSTR0
    while (prec--) {
      *--p = '0';                       /* pad fp to prec with '0' */
      if (width) {
        width -= 1;
      }
    }

    *--p = '.';                         /* set radix point separator */
    if (width) {
      width -= 1;
    }

    *--p = '0';                         /* set leading 0 */
    if (width) {
      width -= 1;
    }

    while (width--) {                   /* pad to width with spaces */
      *--p = ' ';
    }

    for (i = 0; p[i];) {                /* copy tmp to str */
      str[i] = p[i];
      i++;
    }
#else
    uint8_t radix_idx = width - prec - 1,
            real_part = radix_idx - 1;

    if (width == 0 || prec == 1) {
      str[0] = '0';
      str[1] = '.';
      str[2] = '0';
      str[3] = 0;

      return str;
    }

    for (i = 0; i < width; i++) {
      if (i < real_part) {
        str[i] = ' ';
      }
      else if (i == radix_idx) {
        str[i] = '.';
      }
      else {
        str[i] = '0';
      }
    }
    str[width] = 0;
#endif
    return str;
  }

  /* compute multiplier to needed remove fractional part */
  for (i = 0; i < prec; i++) {
    mult *= 10;
  }

  if ((uint32_t)(f * mult) == 0) {      /* if f * mult still 0 */
    round = 0.5 / (mult * 10);          /* adjust round factor */
  }

  /* multiply float by mult to remove fractional part for prec digits */
  fpm = (uint32_t)(f * mult + round);

  while (fpm) {                         /* convert integer value */
    *--p = fpm % 10 + '0';
    if (width) {
      width -= 1;
    }
    fpm /= 10;
    fpcnt += 1;
    if (fpcnt == prec) {                /* fpcnt == prec, add '.' */
      *--p = '.';
      radix_set = 1;
      if (width) {
        width -= 1;
      }
    }
  }

  /* if precision remains, zero pad remainder of
   * fractional part and set radix point and leading zero
   */
  while (fpcnt < prec) {
    *--p = '0';
    if (width) {
      width -= 1;
    }
    fpcnt += 1;
  }
  /* set radix point separator */
  if (radix_set == 0) {
    *--p = '.';
    if (width) {
      width -= 1;
    }
    *--p = '0';
    if (width) {
      width -= 1;
    }
  }

  /* width_given and no room for '-' or all digits, conversion
   * is invalid, fill string with "INV" and return.
   */
  if ((width_given && (sign && !width)) || fpm) {
    str[0] = 'I';
    str[1] = 'N';
    str[2] = 'V';
    str[3] = 0;

    return str;
  }

  /* add '-' for negative values */
  if (sign) {
    *--p = '-';
    if (width) {
      width -= 1;
    }
  }

  /* pad to width with spaces */
  while (width--) {
    *--p = ' ';
  }

  /* copy tmp to str */
  for (i = 0; p[i];) {
    str[i] = p[i];
    i++;
  }

  return str;     /* return nul-terminated string */
}


/**
 * @brief Convert string to uint16_t.
 * @param s string to convert.
 * @param u pointer to uint16_t to hold result.
 */
bool str2uint16 (char *s, uint16_t *u)
{
  /* validate s not NULL and non-empty */
  if (!s || !*s) {
    return false;
  }

  /* loop over digits in s converting to unsigned value */
  for (*u = 0; *s && '0' <= *s && *s <= '9'; s++) {
    *u = *u * 10 + *s - '0';
  }

  return true;
}


/**
 * @brief Convert string to uint32_t
 * @param s string to convert.
 * @param u pointer to uint32_t to hold result.
 */
bool str2uint32 (char *s, uint32_t *u)
{
  /* validate s not NULL and non-empty */
  if (!s || !*s) {
    return false;
  }

  /* loop over digits in s converting to unsigned value */
  for (*u = 0; *s && '0' <= *s && *s <= '9'; s++) {
    *u = *u * 10 + *s - '0';
  }

  return true;
}


/**
 * @brief Convert string to uint64_t
 * @param s string to convert.
 * @param u pointer to uint64_t to hold result.
 */
bool str2uint64 (char *s, uint64_t *u)
{
  /* validate s not NULL and non-empty */
  if (!s || !*s) {
    return false;
  }

  /* loop over digits in s converting to unsigned value */
  for (*u = 0; *s && '0' <= *s && *s <= '9'; s++) {
    *u = *u * 10 + *s - '0';
  }

  return true;
}

I think your "only varies by 1" is the answer here. I'll have to try that setup and that should rule out software or hardware (which I'm beginning to suspect may be the culprit - though I've not experienced any spurious triggers with the TI boards or the Pi boards -- hosted or freestanding)

All good suggestions. I've tried the sensors with and without black tubes isolating each LED and it makes no difference. On all fans I've run, I've used the stock blade with only the white paper for difference. The key is to adjust the trim-pot on the sensor so it only detects the paper. I was surprised at just how good the adjusting was to ensure it would only trigger on the paper.

Don't get me wrong, nothing is perfect, and this is a loosely controlled experiment - but so long as you keep the ambient lighting fixed (e.g. same lights on, no side lamps, etc..), it does a good job. If you change the lighting, you can just readjust the comparator voltage most times.

I don't rule out there being some scatter with the default finish on the fan, but this is the same fan and sensor I've used when writing the code and testing on the TI and Pi boards as well. The paper has been on the fan for the past couple of years. That at least gives me a good baseline.

It could be something that has changed with this sensor board over the past year, LED decay, micro scratches in the lens from moving around in its storage box, etc.. I'll try the two arduino signal generation test with a pwm driving output to trigger the interrupt and see if it's just noise from the sensor board that's the issue - for whatever reason.

Are you working under LED or fluorescent lights that flicker at 100 / 120 Hz?