|
<< Click to Display Table of Contents >> A/C Mains Zero-Crossing Detector · 2025.05.09 |
![]() ![]()
|
When doing Triac phase control with a microcontroller, the turn-on or turn-off points are timed from the zero-crossing point of the A/C mains signal. Or if you're switching large loads on or off it's best to switch them at the zero-crossing point. For these applications you'll need a reliable way to find the zero crossing point of both rising and falling edges of the A/C signal. This post describes an easy way to do it using minimal hardware and a small amount of microcontroller code (C++). I also found a solution to the SAMD problem when calling micros() from an interrupt handler.
An optocoupler is used to safely isolate the mains voltage from the microcontroller. The best optocouplers have a Schmitt trigger feature, switching at reliable voltage levels to give a sharp-edged output signal. The circuit here uses the VOH1016 high-speed optocoupler, but any other device with a Schmitt trigger output can be used, like the HL11L1M. (Links to data sheets at end of page.)

Matt's Safety Tip: When using mains voltages, never (intentionally or unintentionally) use your finger as a voltmeter!
See Mains Voltages and Isolation Transformers and the Disclaimer.
Make sure you keep the detector circuit in a box so nobody can touch it! And keep it away from your pets! Cats often play with the wires :-o
This is about the simplest zero-crossing detector circuit you can make. It is fully isolated from the mains, range 120..240VAC, and it works for 5V and 3.3V microcontrollers without modifications. It's a good idea to add a fuse and a mains filter to the finished design.

The current limiting resistors R1 and R2 are 100K 0.5W for 220-240VAV, or 33K for 120VAC.
A full-wave mains voltage bridge rectifier (or 4 x mains voltage diodes) is used.
The open collector optocoupler output needs a 10K pull-up resistor.
The digital output pulse Vout is connected to a microcontroller input that generates an interrupt. Not all inputs can generate interrupts, this depends on your microcontroller. On the Arduino Uno, you can only use input 2 or 3.
The prototype, with a few extra high value resistors for mains voltage measurement with an oscilloscope...

DANGER! Make sure you keep the detector circuit in a box so nobody can touch it! We don't want you to end up in a box!
Below is the waveform from the LTspice circuit simulation. The green trace is the mains AC waveform (divided by 200). The red trace is the full-wave rectified voltage (divided by 100). The blue trace is the optocoupler output Vout, which occurs a little over 1.5mS before the actual zero crossing.

In the real world screenshot below, the blue trace is the output of the optocoupler, Vout. The orange trace is the mains voltage via a high resistance voltage divider (see below).

An interrupt handler measures the time between the rising and falling edges of Vout. The Vout pulse is quite wide, about 1.6mS for 220V. The zero crossing point is at the 'pulse_width / 2' point, minus a factor to compensate for the Schmitt trigger's hysteresis (described below). On the rising edge interrupt, a timer is started that will generate a timer interrupt at the exact zero-crossing point. The timer value (microseconds) is calculated using the pulse width from the previous rising/falling edge interrupts. The hardware timer interrupt library used is uTimerLib or SAMD_TimerInterrupt for SAMD devices (uTimerLib did not work for these, it says "SAMD51 support is still experimental"), but you can easily modify the code to use a different timer library. Beware that hardware timers often cause problems because they may be used by other functions like PWM or communications, and it's often not clear what the Libraries are doing. On SAMD devices there's also a problem using micros() in an interrupt handler, see below.
void begin(int pin, void (*zeroCrossingCallback)());
Initializes the detector. Your own zeroCrossingCallback() function is called at the zero-crossing point. When using the SAMD_TimerInterrupt library, you MUST disable the timer at the start of the zeroCrossCallback() function by calling ITimer.disableTimer(), see the example sketch below.
void zeroCrossingInterrupt();
This is the internal interrupt handler for the rising and falling edge of the VOH1016 output Vout. It starts an internal hardware timer which calls your own zeroCrossingCallback() method at the exact zero-crossing point.
int getFrequency();
Returns the mains frequency in Hz, usually 50 or 60. If zero is returned it means that power has been lost.
int getVoltage();
Returns the mains voltage, between about 110 and 260VAC (if 100K resistors are used). If zero is returned it means that power has been lost. To be accurate for your own setup, this can be calibrated to determine the correct equation to calculate the voltage from the pulse width. This can be accurate to about ±3% or better, but it is affected by temperature so it needs a few minutes to settle when first powered up. See the 'Voltage calibration procedure' below.
void getData(unsigned long* period, unsigned long* pulseWidth);
Returns the period and the pulse widths in microseconds. The period is the time between rising edges of the last two Vout pulses. The pulse width is the time between the rising and falling edges of the last Vout pulse. The period is from the A/C mains frequency multiplied by two because of the full-wave rectification. The pulse width will vary with the voltage and ambient temperature. They are both set to zero when reset() is called from getVoltage() or getFrequency() if power loss is detected. The accuracy depends on the accuracy of the microcontroller's clock.
void reset();
Resets the measurements when power loss is detected.
Problem (Arduino bug) with SAMD controllers when calling micros() from an interrupt handler
I used an old Arduino Uno (8-bit ATmega328 at 16MHz), and a fast Adafruit Metro M4 Express (32-bit Cortex M4 at 120MHz) for testing. The Arduino Uno worked perfectly. The uTimerLib library (version 1.7.5) did not work with the M4, so I used SAMD_TimerInterrupt - but then I had a different problem...
Calling micros() from the zero crossing interrupt handler sometimes returned a value that was 1 millisecond too short. After a lot of messing about, I found out this was because the System Timer interrupt (SysTick_DefaultHandler) had a lower priority than the interrupt from the input (!?), so the SysTick handler was not called and the millisecond interrupt could be missed! The only way I found to fix this was to change the SysTick_IRQn interrupt to priority 0 using NVIC_SetPriority(SysTick_IRQn, 0); in begin().
I found only one post about the interrupt priority bug: https://github.com/arduino/ArduinoCore-samd/issues/463
The source code for the offending SAMD micros() function is here: https://github.com/arduino/ArduinoCore-samd/blob/master/cores/arduino/delay.c
Detecting power loss
The interrupt cannot be used to detect power loss because the interrupt will not occur if there's no power. But the getFrequency() and getVoltage() methods detect power loss by checking the time of the last pulse and return 0 when power fails. Call one (or both) of these to determine if power has failed, usually from loop(). The speed of power loss detection depends on how often these are called. The code detects 4 missing edges (two A/C cycles), but you can adjust this if it's too sensitive, or make it so sensitive that it detects a missing half-cycle.
Voltage and temperature compensation
The pulse width of the Vout signal varies with the mains voltage and the ambient temperature. Using the pulse width to determine the exact crossing point provides very good voltage and temperature compensation.
Accuracy and jitter
Even on a slow antique Arduino Uno, crossing point detection is typically within +-30µS (30 millionth's of a second) of the actual crossing point, which is fine for most applications. When doing Triac phase-angle control (leading or trailing edge), the turn-on or turn-off points are timed from the zero-crossing point. These do not normally extend all the way to the end of the AC half-cycle period, so a small inaccuracy has no noticeable effect. 'Interrupt latency' also has an effect - other processes which disable interrupts for too long will introduce a bit of 'jitter'. Because the zero crossing point is based on the pulse width, it remains accurate across voltages 110..250VAC and quite large temperature changes.
Hysteresis
The Schmitt trigger feature has 'hysteresis' which means that the turn-off voltage is slightly lower than the turn-on voltage. This prevents oscillation when the voltage is varying very close to the turn-on point. The data sheet will have a 'Hysteresis ratio' which is the relationship between the turn-on and the turn-off currents. For the VOH1016, the typical hysteresis ratio is 0.9, but it can be between 0.5 and 0.95, so you may need to fine-tune this to be accurate.
Calculating the effect of hysteresis on the pulse width is almost impossible (for me ;-), but an approximation works well. You can multiply the pulse width by the hysteresis ratio and divide by two to get the number of microseconds from the rising edge to the zero-crossing point. Always do this using integer arithmetic, floating point can be very slow. To multiply the pulse width by 0.9, first multiply by 90, then divide by 100, then divide by two to get the delay to the zero-crossing. After experimentation, my hysteresis value turned out to be 0.75.
In the image below, the green trace is the mains AC waveform (divided by 200). The red trace is the full-wave rectified voltage (divided by 100). The blue trace is the optocoupler output Vout. The black line is the zero crossing point, and the yellow line shows the hysteresis.
The time t is the time in microseconds between the rising edge interrupt and the zero-crossing point. This is calculated from the period of the preceding pulse using this approximate formula (which is probably completely wrong, but it seems to work ok):
t = (period_in_microseconds * hysteresis_ratio) / 2
For a hysteresis ratio of 0.75,
t = ((period_in_microseconds * 75) / 100) / 2 = (period_in_microseconds * 75) / 200

For the best accuracy, you will need to modify this code for your optocoupler's hysteresis ratio and the equation for the voltage calculation, see details below. You will need an oscilloscope and a Variac for this. (And I put the SASM_TimerInterrupt library source in a subdirectory.)
⬛ ZeroCrossing.h [Click to expand]
|
This sketch toggles the zeroCrossingPin output at the zero crossing point in the zeroXingCallback() function, and detects power failure by polling using getFrequency() or getVoltage(). If using the SASM_TimerInterrupt library, don't forget to call ZeroCrossing::ITimer.disableTimer() at the start of your zero crossing callback function.
⬛ ZeroCrossing.ino [Click to expand]
|
!!! WARNING MAINS VOLTAGES AND OSCILLOSCOPES DO NOT MIX !!!
People often use an isolating transformer for safety, but that's not recommended because it disables your low-current circuit breaker! I use a Hager ACA910C Residual Current Circuit Breaker RCCB, which turns off with a 10mA imbalance (I haven't tried it with my finger yet, but it trips with a 470K resistor to GND). This is a good article about the risks (read it before it's too late ;-) ...
https://sound-au.com/articles/iso-xfmr.htm
To check that your zero-crossing point is spot-on, you'll need to see the AC waveform and the zero crossing point on your 'scope. For safety, to reduce the current the scope (and your finger) sees, I used three high value resistors, say 3 Meg ohms each, connected as below. The max. current should be low enough (around 50 microamps?) not to be too dangerous to you or your scope.
!!! DO NOT CONNECT THE PROBE'S GROUND - ALWAYS REMOVE THE PROBE'S GROUND WIRE !!!

Set the AC scope probe to 10x so its impedance is about 10 Meg ohms (at 1x, the impedance is usually only 1 Meg). This will show a sine wave with a peak-to-peak voltage a bit less than half the mains voltage (depending on your probe's impedance).
Connect one probe of your scope to the mains voltage via the 3 Meg resistor circuit described above DO NOT CONNECT THE GND, and the other probe to the microcontroller output which is toggled on each zero crossing. Below is the waveform you should see. If the zero crossing point is out of position, adjust the hysteresis value until it's spot-on.

The zero crossing pulse is a few microseconds before the actual zero crossing.

The Vout pulse width is proportional to the voltage and the ambient temperature. We can use this to get an approximation of the A/C voltage, but it will drift a bit with temperature changes. To calibrate it you will need an A/C voltmeter and a "Variac" so you can set a sequence of A/C voltages in (say) 10 volt steps and record the pulse width for each step. Let the detector run with the power on for a few minutes before you start so it "warms up" and the pulse width settles. The pulse width to voltage ratio is not linear.
What's a Variac? It's a variable transformer that allows you to vary the output voltage from 0 to about 330VAC (vary-a/c). It is NOT an isolation transformer! So your emergency circuit breaker will still work. A Variac is essential for testing circuits with different supply voltages, for example 110/120/220/240VAC. My one is about the cheapest you can buy...

1) Write a small sketch to display the pulse width in a loop by calling getData(). Maybe use the RollingAverager to smooth the values.
2) Using the Variac, record the pulse width for steps of (say) 10 volts between 110 and 280 volts (depending on your R1/R2 values and your mains voltage). Use the A/C voltmeter to measure the Variac's output voltage if the Variac's built-in voltmeter is less accurate than your Fluke voltmeter.
3) Enter these values into an Excel table, with the pulse widths in microseconds in column A, and the voltages in column B.
4) Select the data values in the two columns, do 'Insert > Charts > Scatter > Scatter with smooth lines and markers'.
This displays the scatter chart.

5) Right-click on the curve and select 'Add Trendline...' from the context menu.
This displays the 'Format Trendline' property window.
6) Click the 'Power' radio button. This should show a dotted trend line that closely matches the data points.
7) Click the 'Display Equation on chart' checkbox, and you'll see the curve's equation, e.g. "y = 377356x-0.947".
Copy the equation and paste it into the comment in the source code.

8) Modify the code for the equation at the end of the getVoltage() function in ZeroCrossing.h
9) Modify the code from step 1 to show the voltage. Repeat the voltage steps with the Variac, checking that the results of getVoltage() are reasonably close to the readings from the voltmeter. The voltages will never be exact because of the effect of temperature fluctuations on the electronics.
Instead of polling to detect a power failure, you can do this in hardware with a capacitor that discharges when power fails and generates an interrupt to indicate power failure. This can be fine-tuned to generate the signal after just a couple missing cycles by selecting R2 and C2. In this case you can use a cheaper more common optocoupler like the PC817A. Note that one or two A/C cycles are sometimes missed when the grid operator switches to a different power source.

Triac Phase Control
A Triac can be used to switch the mains signal off and on at a particular point in the A/C cycle to vary a motor's speed or a lamp's brightness. Both the +ve and the -ve A/C cycles are switched at the same (variable) point.
Automatic circuits do this according to the voltage, so they don't care about the zero-crossing point. But microprocessor controlled circuits need to know the zero-crossing point so they know when to turn the Triac on or off.
Automatic circuits normally use a Diac (a voltage-controlled switch) to turn the Triac off an on at the right point. For controlled circuits, you can use an optocoupler with integrated Triac driver, such as the Onsemi MOC30xx range (data sheet links below). Some of these also have an integrated zero-crossing detector for switching loads at the zero-crossing point (so you won't need my circuit :-)

Data sheets for suitable Schmitt trigger optocouplers
https://www.vishay.com/docs/84896/voh1016ab.pdf
https://www.onsemi.com/download/data-sheet/pdf/h11l3m-d.pdf
Data sheets for Triac driver optocouplers ('random phase' means you must do the zero-crossing detection yourself)
https://www.onsemi.com/pdf/datasheet/moc3023m-d.pdf
https://www.onsemi.com/pdf/datasheet/moc3023m-d.pdf
https://optoelectronics.liteon.com/upload/download/DS70-2001-026/MOC306X%20series%20201606.pdf
The Accuracy and stability of the 50Hz mains frequency - a nice study by P.T.Deboer
https://ptdeboer.personalweb.utwente.nl/misc/mains.html
This brilliant article 'Zero Crossing Detectors and Comparators' has some great circuits and lots of details. Go to the 'Mains Voltage ZCDs' section. This has an excellent detector that uses an LM393 comparator (see Figure 11 - LM393 comparator detector) which does not need any software or a timer interrupt (except to detect power loss), but it's got a lot more components. I will build this circuit next, and maybe use it instead of my first rather messy effort.
https://sound-au.com/appnotes/an005.htm
There's also a great article on safety when using mains voltages, which everyone should read (before it's too late ;-)
https://sound-au.com/articles/iso-xfmr.htm
I found only one post about the SAMD micros() bug
https://github.com/arduino/ArduinoCore-samd/issues/463
Source code for the SAMD micros() function
https://github.com/arduino/ArduinoCore-samd/blob/master/cores/arduino/delay.c