Increasing a DAC's Output Accuracy Using the Two-Point Calibration Method
Share
Two-Point Calibration for DAC Outputs
Fix static offset & gain error so your DAC's set voltage is the voltage it outputs
Digital-to-analog converter's output voltage (or current) curve is linear in theory, but real world non-ideal hardware means there are always error and the DAC's output will not be ideal. There are various error contributors in a DAC circuit and covering them all is beyond the scope of this tutorial, but the goal of the two-point calibration is to help eliminate static offset and gain errors. Offset and gain errors are deviations from the DAC's ideal linear output curve. A simple two-point calibration measures your DAC’s actual line and then uses its inverse to compute the code that produces any requested voltage. The payoff is big: after two-point calibration, your error is dominated by residual linearity and temperature drift instead of static offset/gain.
What two-point calibration corrects
The ideal transfer is a straight line. Actual devices can be shifted (offset) and tilted (gain). The plot below shows:
- The ideal line (continuous).
- The quantization staircase for a 4-bit DAC, with steps aligned to the ideal curve (mid-tread behavior).
- Offset error and gain error examples (exaggerated for clarity).
The goal of Two-Point Calibration approach is to measure the offset and gain error curve so that we can correct output values in firmware to match the DAC's ideal curve.
Step-by-step: fast, accurate two-point calibration
1) Warm-up and stabilize
- Power your DAC design up and let it thermally settle (5–10 minutes typical).
- For power, communication, and output measurement lines connected to the DAC design, keep wiring as short as possible. If possible use shielded cabling for measuring the DAC's output voltage
- Try to use a low noise (linear regulator based) power source for powering your DAC design to reduce circuit noise
2) Choose your two calibration codes
Pick two output setting points well inside the range of the DAC to avoid endpoint artifacts.
- Unipolar (0…FS): use 10% FS and 90% FS codes.
- Bipolar (±FS, offset-binary): pick points symmetrically around mid-code (e.g., mid − 40% FS and mid + 40% FS).
Example (14-bit): FS = 16383, mid = 8192.
Unipolar:C1≈1638
,C2≈14745
.
Bipolar:C1≈8192−6554
,C2≈8192+6554
.
3) Measure carefully at each point
- Program the DAC's output to point C1.
- Wait for settling: at least 10–20× the output settling time of the DAC circuit
-
Average multiple readings from your DMM, voltmeter, or a high accuracy ADC: 64–256 samples is a good start.
- Note: If you are using an ADC circuit for your calibration measurement, ensure it has accuracy specifications that are at least 4x times better than the DAC circuit you are calibrating
- Record (C1, V1) where V1 is the measured average in volts.
- Repeat for (C2, V2).
Tips for accurate averages
- Discard the first few samples after each code change.
- If two repeat measurements differ by more than a few LSBs, increase settle time or sample count.
4) Compute your DAC’s actual line
Model the measured behavior as:
a = (V2 - V1) / (C2 - C1)
b = V1 - a * C1
where:
a
= slope (volts per code) and b
= offset (volts)
5) Use the inverse to command voltages
Given a requested voltage , compute the command code:
C_cmd = round( (V_req - b) / a )
where: V_req
= the voltage you want the DAC to output and C_cmd
= the calibrated code to send to the DAC
Make sure result is clamped to the legal code range (0…FS for unipolar; 0…FS for offset-binary/bipolar).
6) Save and re-use
Store and (or equivalently gain and offset) in EEPROM/flash so you can apply the correction on every startup. Re-run the quick calibration if the environment changes significantly.
Example Arduino code (drop-in)
This sketch shows how to:
- define two calibration codes,
- paste your two measured voltages,
- compute and , and
- map any requested output voltage to a calibrated DAC code.
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include <stdint.h>
#include <math.h>
// ====== User settings ======
#define DAC_BITS 14 // e.g., 12, 14, 16
#define DAC_BIPOLAR 0 // 0=unipolar 0..FS, 1=bipolar ±FS (offset-binary codes)
// Choose two well-inside codes:
#if DAC_BIPOLAR
static const uint32_t FS = (1UL << DAC_BITS) - 1UL;
static const uint32_t MID = (1UL << (DAC_BITS - 1));
static const uint32_t C1 = MID - (uint32_t)(0.40f * (float)FS);
static const uint32_t C2 = MID + (uint32_t)(0.40f * (float)FS);
#else
static const uint32_t FS = (1UL << DAC_BITS) - 1UL;
static const uint32_t C1 = (uint32_t)(0.10f * (float)FS); // ~10% FS
static const uint32_t C2 = (uint32_t)(0.90f * (float)FS); // ~90% FS
#endif
// Paste your measured voltages (averaged) for those codes:
float V1_meas = 1.234f; // measured volts at code C1
float V2_meas = 3.210f; // measured volts at code C2
// ====== Calibration state ======
static float a_slope = 1.0f; // volts per code
static float b_offs = 0.0f; // volts
// Compute a and b from the two measurements
void computeCalibration() {
a_slope = (V2_meas - V1_meas) / (float)((int32_t)C2 - (int32_t)C1);
b_offs = V1_meas - a_slope * (float)C1;
}
// Convert requested voltage to a *calibrated* DAC code
uint32_t dacVoltageToCodeCal(float vreq) {
float c = (vreq - b_offs) / a_slope; // inverse of V = a*C + b
if (c < 0.0f) c = 0.0f;
float cmax = (float)FS;
if (c > cmax) c = cmax;
return (uint32_t)lroundf(c);
}
// Example of averaging N readings from an ADC function `readVoltsOnce()`
// (Replace readVoltsOnce() with your meter/ADC hook.)
float averageVolts(size_t N) {
float sum = 0.0f;
const size_t throwaway = min<size_t>(N/8, 8); // discard a few first samples
for (size_t i=0; i<throwaway; ++i) { /* readVoltsOnce(); */ }
for (size_t i=0; i<N; ++i) {
// sum += readVoltsOnce();
}
return sum / (float)N;
}
void setup() {
Serial.begin(115200);
delay(50);
// 1) Program C1 on your DAC here, wait for settling, measure and set V1_meas.
// 2) Program C2, wait, measure and set V2_meas.
// For this blog example we assume V1_meas/V2_meas have been pasted above.
computeCalibration();
Serial.print("Calibrated slope (V/code): "); Serial.println(a_slope, 9);
Serial.print("Calibrated offset (V): "); Serial.println(b_offs, 6);
// Request a voltage and get the calibrated code:
float vreq = 2.500f;
uint32_t code = dacVoltageToCodeCal(vreq);
Serial.print("Request "); Serial.print(vreq, 3);
Serial.print(" V -> DAC code "); Serial.println(code);
// TODO: write 'code' to your DAC via SPI/I2C here.
}
void loop() {
// Nothing here — calibration constants are ready for use
}
FAQ & pro tips
-
Unipolar vs. bipolar?
The method is identical. For bipolar (offset-binary) devices, just pick symmetric codes around mid-code. The math uses the raw codes, so you don’t need special handling. -
How many samples should I average?
Start with 128 and check the standard deviation; increase until the mean is stable within a fraction of an LSB. -
Will this fix non-linearity?
No—two points correct only offset and gain. If integral non-linearity dominates, use a multi-point LUT. -
Do I need to recalibrate?
If temperature or supply/reference changes matter for your spec, store per unit and re-run a quick 2-point on installation or after large temp shifts.
Bottom line: Two measurements, a little math, and every voltage you command lands where you expect—despite real-world offset and gain error.