Thực hành hiệu chuẩn cảm biến áp suất với áp kế chuẩn, ADS1115, ESP32: khớp đa thức, chỉnh điểm 0
Hiệu chuẩn cảm biến áp suất 0,5–4,5 V bằng áp kế chuẩn: ADS1115 đo tỉ lệ qua cầu chia, ESP32 ghi điểm tăng giảm áp, khớp đa thức, chỉnh điểm 0 tại chỗ và ước lượng độ không đảm bảo.
Phần thực hành của nhóm bài áp suất: hiệu chuẩn một cảm biến áp suất công nghiệp giá rẻ (0–12 bar, đầu ra 0,5–4,5 V, nguồn 5 V) bằng cách so với một áp kế chuẩn trên cùng đường áp. Chương trình ESP32 đọc cảm biến qua ADS1115 theo kiểu tỉ lệ, ghi các điểm khi tăng và giảm áp theo số đọc của áp kế chuẩn, khớp đa thức, tính sai số theo phần trăm khoảng đo, lưu hệ số, và có thêm lệnh chỉnh điểm 0 tại chỗ giống cách làm với transmitter công nghiệp. Cuối bài là cách đọc phần dư và ngân sách độ không đảm bảo của chính bộ đo vừa hiệu chuẩn.
1. Chuẩn bị
- ESP32 DevKit V1 30 chân.
- Mạch chuyển đổi ADC ADS1115 16 bit.
- Cảm biến áp suất loại tỉ lệ 0–12 bar (1,2 MPa), đầu ra 0,5–4,5 V, nguồn 5 V, ren lắp phù hợp đường áp.
- Áp kế chuẩn hiện số (ví dụ 0–16 bar, độ không đảm bảo 0,01 bar), bơm tay khí hoặc dầu, tê ba ngả, van xả, ống và khớp nối chịu áp.
- Bốn điện trở: hai 10 kΩ, hai 20 kΩ (nên cùng lô, sai số 1 % hoặc tốt hơn); dây cắm test board.
Áp kế chuẩn là chuẩn của phép hiệu chuẩn; nó cần tốt hơn cảm biến nhiều lần. Ở đây áp kế chuẩn 0,01 bar so với cảm biến có sai số cỡ vài phần trăm khi chưa hiệu chuẩn.
2. Đường áp và mạch điện

| Từ | Nối tới |
|---|---|
| Cảm biến: dây nguồn (thường đỏ) | ESP32 VIN (5 V từ cổng USB) |
| Cảm biến: dây GND (thường đen) | GND chung |
| Cảm biến: dây tín hiệu (thường vàng) | Qua 10 kΩ tới AIN0, AIN0 qua 20 kΩ xuống GND |
| 5 V (VIN) | Qua 10 kΩ tới AIN1, AIN1 qua 20 kΩ xuống GND |
| ADS1115 VDD, GND, SDA, SCL, ADDR | 3V3, GND, GPIO21, GPIO22, GND |
- Vì sao có cầu chia: ADS1115 cấp 3,3 V không được nhận điện áp vào lớn hơn nguồn của nó. Cầu chia 10k/20k đưa 4,5 V về 3,0 V.
- Vì sao đo cả nguồn 5 V: cảm biến loại tỉ lệ cho đầu ra tỉ lệ với điện áp nguồn. Nguồn USB dao động vài phần trăm; lấy tỉ số đầu ra/nguồn thì dao động đó triệt tiêu. Hai cầu chia giống nhau, phần lệch nhỏ giữa chúng được phép hiệu chuẩn hấp thụ.
- Đường áp: tâm cảm biến và áp kế chuẩn đặt ngang nhau; kiểm tra rò rỉ bằng cách bơm lên, khóa bơm và xem áp kế chuẩn có tụt không.
3. Chương trình
Thư viện Wire và Preferences có sẵn trong gói esp32. Lệnh gõ trong Serial Monitor (115200 baud, kiểu xuống dòng Newline):
| Lệnh | Việc làm |
|---|---|
| d6.002 | Ghi một điểm, kèm số đọc của áp kế chuẩn lúc đó (bar) |
| l, x | Liệt kê, xóa các điểm |
| k | Khớp bậc 1, bậc 2, in phần dư, sai số lớn nhất theo % khoảng đo, chọn bậc và lưu |
| z | Chỉnh điểm 0: khi cảm biến thông khí quyển (0 bar), dịch hệ số a₀ để số chỉ bằng 0, giữ nguyên độ dốc |
| m | Bật hoặc tắt chế độ đo liên tục |
/*
PYWORLD.VN - Thực hành hiệu chuẩn cảm biến áp suất 0,5-4,5 V với áp kế chuẩn: ADS1115 đo tỉ lệ, ESP32 khớp và chỉnh điểm 0
Cảm biến cấp 5 V; đầu ra qua cầu chia 10k/20k vào AIN0, nguồn 5 V qua cầu chia 10k/20k vào AIN1. ADS1115 cấp 3V3, SDA 21, SCL 22
Lệnh Serial: d<bar> ghi điểm theo áp kế chuẩn, l liệt kê, x xóa, k khớp và lưu, z chỉnh điểm 0 ở 0 bar, m bật/tắt đo
*/
#include <Wire.h>
#include <Preferences.h>
#define CHAN_SDA 21
#define CHAN_SCL 22
#define SO_DIEM_TOI_DA 12
#define SO_MAU_DIEM 32 // Số lần đọc tỉ số cho mỗi điểm hiệu chuẩn
const uint8_t DIA_CHI_ADS = 0x48;
const uint8_t THANH_GHI_KET_QUA = 0x00;
const uint8_t THANH_GHI_CAU_HINH = 0x01;
// Đo một lần, dải ±4,096 V, 128 mẫu/s, tắt bộ so sánh: AIN0 (đầu ra cảm biến) và AIN1 (nguồn 5 V), cả hai qua cầu chia
const uint16_t CAU_HINH_AIN0 = 0xC383;
const uint16_t CAU_HINH_AIN1 = 0xD383;
Preferences boNho;
float apSuatDiem[SO_DIEM_TOI_DA]; // Áp suất đọc trên áp kế chuẩn, bar
double tiSoDiem[SO_DIEM_TOI_DA]; // Tỉ số đầu ra / nguồn trung bình
double lechChuanDiem[SO_DIEM_TOI_DA];
int soDiem = 0;
double heSo[3] = {-1.5, 15.0, 0.0}; // bar = a0 + a1*r + a2*r*r; giá trị đầu gần đúng cho cảm biến 0-12 bar
int bacDaThuc = 1;
bool dangDo = false;
int16_t docAds(uint16_t cauHinh) {
Wire.beginTransmission(DIA_CHI_ADS);
Wire.write(THANH_GHI_CAU_HINH);
Wire.write(cauHinh >> 8);
Wire.write(cauHinh & 0xFF);
Wire.endTransmission();
delay(9); // Một lần chuyển đổi ở 128 mẫu/s mất khoảng 7,8 ms
Wire.beginTransmission(DIA_CHI_ADS);
Wire.write(THANH_GHI_KET_QUA);
Wire.endTransmission();
if (Wire.requestFrom(DIA_CHI_ADS, (uint8_t)2) != 2) {
return 0;
}
uint8_t cao = Wire.read();
uint8_t thap = Wire.read();
return (int16_t)((cao << 8) | thap);
}
// Cảm biến loại tỉ lệ: đầu ra tỉ lệ với nguồn 5 V, nên lấy tỉ số để nguồn dao động không làm sai áp suất
bool docTiSo(double *tiSo) {
int16_t dauRa = docAds(CAU_HINH_AIN0);
int16_t nguon = docAds(CAU_HINH_AIN1);
if (nguon < 1000) {
return false; // Không có nguồn 5 V ở AIN1 hoặc mất kết nối I2C
}
*tiSo = (double)dauRa / nguon;
return true;
}
bool docNhieuMau(int n, double *trungBinh, double *lechChuan) {
double tong = 0, tongBinhPhuong = 0;
for (int i = 0; i < n; i++) {
double r;
if (!docTiSo(&r)) {
return false;
}
tong += r;
tongBinhPhuong += r * r;
}
*trungBinh = tong / n;
double phuongSai = (tongBinhPhuong - tong * tong / n) / (n - 1);
*lechChuan = phuongSai > 0 ? sqrt(phuongSai) : 0;
return true;
}
// Khử Gauss có chọn phần tử trụ cho hệ n ẩn (n ≤ 3)
bool giaiHe(double a[3][4], int n, double nghiem[3]) {
for (int cot = 0; cot < n; cot++) {
int hangTru = cot;
for (int h = cot + 1; h < n; h++) {
if (fabs(a[h][cot]) > fabs(a[hangTru][cot])) {
hangTru = h;
}
}
if (fabs(a[hangTru][cot]) < 1e-12) {
return false;
}
for (int k = 0; k <= n; k++) {
double tam = a[cot][k];
a[cot][k] = a[hangTru][k];
a[hangTru][k] = tam;
}
for (int h = 0; h < n; h++) {
if (h != cot) {
double f = a[h][cot] / a[cot][cot];
for (int k = cot; k <= n; k++) {
a[h][k] -= f * a[cot][k];
}
}
}
}
for (int i = 0; i < n; i++) {
nghiem[i] = a[i][n] / a[i][i];
}
return true;
}
// Bình phương tối thiểu: áp suất theo tỉ số, đa thức bậc 1 hoặc 2
bool khopDaThuc(int bac, double c[3]) {
int n = bac + 1;
double a[3][4] = {{0}};
for (int i = 0; i < soDiem; i++) {
double r = tiSoDiem[i];
double luyThua[5] = {1, r, r * r, r * r * r, r * r * r * r};
for (int h = 0; h < n; h++) {
for (int k = 0; k < n; k++) {
a[h][k] += luyThua[h + k];
}
a[h][n] += luyThua[h] * apSuatDiem[i];
}
}
c[2] = 0;
return giaiHe(a, n, c);
}
double tinhApSuat(double r, const double c[3]) {
return c[0] + c[1] * r + c[2] * r * r;
}
void luuHeSo() {
boNho.begin("cambienapsuat", false);
boNho.putBytes("heso", heSo, sizeof(heSo));
boNho.putInt("bac", bacDaThuc);
boNho.end();
}
void napHeSo() {
boNho.begin("cambienapsuat", true);
if (boNho.getBytesLength("heso") == sizeof(heSo)) {
boNho.getBytes("heso", heSo, sizeof(heSo));
bacDaThuc = boNho.getInt("bac", 1);
Serial.println("Da nap he so hieu chuan da luu");
} else {
Serial.println("Chua co he so luu: can hieu chuan voi ap ke chuan");
}
boNho.end();
}
void lenhGhiDiem(float bar) {
if (soDiem >= SO_DIEM_TOI_DA) {
Serial.println("Da du so diem toi da");
return;
}
double tb, s;
if (!docNhieuMau(SO_MAU_DIEM, &tb, &s)) {
Serial.println("Khong doc duoc ADS1115 hoac thieu nguon o AIN1");
return;
}
apSuatDiem[soDiem] = bar;
tiSoDiem[soDiem] = tb;
lechChuanDiem[soDiem] = s;
soDiem++;
Serial.printf("Diem %d: %.3f bar -> ti so %.6f (s = %.6f)\n", soDiem, bar, tb, s);
}
void lenhLietKe() {
for (int i = 0; i < soDiem; i++) {
Serial.printf("Diem %d: %.3f bar -> ti so %.6f (s = %.6f)\n", i + 1, apSuatDiem[i], tiSoDiem[i], lechChuanDiem[i]);
}
Serial.printf("Tong %d diem\n", soDiem);
}
void lenhKhop() {
if (soDiem < 3) {
Serial.println("Can it nhat 3 diem, nen phu ca dai do");
return;
}
double c1[3], c2[3];
if (!khopDaThuc(1, c1)) {
Serial.println("Khong khop duoc: kiem tra cac diem");
return;
}
bool coBac2 = soDiem >= 4 && khopDaThuc(2, c2);
double tong1 = 0, tong2 = 0, nhoNhat = apSuatDiem[0], lonNhat = apSuatDiem[0];
Serial.println("Diem | Chuan (bar) | Ti so | Du bac 1 (bar) | Du bac 2 (bar)");
for (int i = 0; i < soDiem; i++) {
double du1 = apSuatDiem[i] - tinhApSuat(tiSoDiem[i], c1);
double du2 = coBac2 ? apSuatDiem[i] - tinhApSuat(tiSoDiem[i], c2) : 0;
tong1 += du1 * du1;
tong2 += du2 * du2;
if (apSuatDiem[i] < nhoNhat) {
nhoNhat = apSuatDiem[i];
}
if (apSuatDiem[i] > lonNhat) {
lonNhat = apSuatDiem[i];
}
Serial.printf("%4d | %11.3f | %8.6f | %14.4f | %14.4f\n", i + 1, apSuatDiem[i], tiSoDiem[i], du1, du2);
}
double s1 = sqrt(tong1 / (soDiem - 2));
double s2 = coBac2 ? sqrt(tong2 / (soDiem - 3)) : 0;
Serial.printf("Do lech chuan phan du: bac 1 = %.4f bar, bac 2 = %.4f bar\n", s1, s2);
// Bậc 2 chỉ dùng khi giảm phần dư rõ rệt, tránh khớp theo nhiễu và độ trễ
bacDaThuc = (coBac2 && s2 < 0.5 * s1) ? 2 : 1;
for (int k = 0; k < 3; k++) {
heSo[k] = bacDaThuc == 2 ? c2[k] : c1[k];
}
double duLonNhat = 0;
for (int i = 0; i < soDiem; i++) {
double du = fabs(apSuatDiem[i] - tinhApSuat(tiSoDiem[i], heSo));
if (du > duLonNhat) {
duLonNhat = du;
}
}
Serial.printf("Sai so lon nhat sau khop: %.3f %% khoang do (%.2f bar)\n", duLonNhat / (lonNhat - nhoNhat) * 100, lonNhat - nhoNhat);
luuHeSo();
Serial.printf("Chon bac %d: a0 = %.4f bar, a1 = %.4f bar, a2 = %.4f bar (da luu)\n", bacDaThuc, heSo[0], heSo[1], heSo[2]);
}
// Chỉnh điểm 0 tại chỗ: chỉ dịch a0 để số chỉ bằng 0 khi cảm biến thông khí quyển, giữ nguyên độ dốc
void lenhChinhDiemKhong() {
double tb, s;
if (!docNhieuMau(SO_MAU_DIEM, &tb, &s)) {
Serial.println("Khong doc duoc ADS1115 hoac thieu nguon o AIN1");
return;
}
double truoc = tinhApSuat(tb, heSo);
heSo[0] -= truoc;
luuHeSo();
Serial.printf("Chinh diem 0: truoc %.4f bar, da dich a0 thanh %.4f bar (da luu)\n", truoc, heSo[0]);
}
void inHuongDan() {
Serial.println("Lenh: d<bar> ghi diem | l liet ke | x xoa diem | k khop va luu | z chinh diem 0 | m bat/tat do");
}
void xuLyLenh() {
if (!Serial.available()) {
return;
}
String lenh = Serial.readStringUntil('\n');
lenh.trim();
if (lenh.length() == 0) {
return;
}
char ma = lenh.charAt(0);
String thamSo = lenh.substring(1);
thamSo.trim();
if (ma == 'd') {
lenhGhiDiem(thamSo.toFloat());
} else if (ma == 'l') {
lenhLietKe();
} else if (ma == 'x') {
soDiem = 0;
Serial.println("Da xoa cac diem");
} else if (ma == 'k') {
lenhKhop();
} else if (ma == 'z') {
lenhChinhDiemKhong();
} else if (ma == 'm') {
dangDo = !dangDo;
Serial.println(dangDo ? "Bat che do do" : "Tat che do do");
} else {
inHuongDan();
}
}
void setup() {
Serial.begin(115200);
Wire.begin(CHAN_SDA, CHAN_SCL, 400000);
delay(200);
Serial.println();
Wire.beginTransmission(DIA_CHI_ADS);
if (Wire.endTransmission() != 0) {
Serial.println("Khong thay ADS1115 o dia chi 0x48: kiem tra SDA, SCL va chan ADDR");
}
napHeSo();
inHuongDan();
}
void loop() {
xuLyLenh();
if (!dangDo) {
return;
}
double tb, s;
if (!docNhieuMau(8, &tb, &s)) {
Serial.println("Khong doc duoc ADS1115 hoac thieu nguon o AIN1");
delay(1000);
return;
}
Serial.printf("Ti so %.6f | Ap suat %.3f bar\n", tb, tinhApSuat(tb, heSo));
delay(200);
}
Lệnh d nhận đúng giá trị đọc trên áp kế chuẩn, không cần đặt áp suất tròn số: bơm tới gần 6 bar, chờ ổn định, đọc chuẩn được 6,002 bar thì gõ d6.002. Lệnh z mô phỏng việc chỉnh điểm 0 (zero trim) của transmitter: chỉ sửa độ lệch, không đụng tới độ dốc đã hiệu chuẩn.
4. Trình tự hiệu chuẩn

- Chuẩn bị: lắp đường áp, kiểm tra rò, bật nguồn 15 phút cho cảm biến và mạch ổn định.
- Gia áp sơ bộ: bơm tới 10 bar rồi xả về 0, hai lần.
- Tăng áp: ở 0 bar (van xả mở) gõ
d0; bơm tới khoảng 2, 4, 6, 8, 10 bar, mỗi lần chờ áp kế chuẩn ổn định (khí nóng lên khi nén, áp tụt nhẹ khi nguội), gõdkèm số đọc chuẩn. - Giảm áp: mở van xả từ từ về khoảng 8, 6, 4, 2, 0 bar, ghi tiếp. Không xả quá điểm rồi bơm lại, vì như vậy điểm đó thành điểm tăng áp.
- Khớp và lưu: gõ
k. - Chỉnh điểm 0 và kiểm tra: mỗi khi lắp lại hoặc định kỳ, xả về 0 bar và gõ
z; đặt một điểm giữa dải (5 bar) để kiểm tra.
5. Kết quả mong đợi


- Đặc tuyến cảm biến hơi cong: phần dư bậc 1 tới 0,024 bar. Khớp bậc 2 giảm độ lệch chuẩn phần dư từ 0,014 xuống 0,006 bar, chương trình chọn bậc 2.
- Sau bậc 2, nhánh tăng áp lệch dương, nhánh giảm lệch âm khoảng 0,006 bar: đó là độ trễ của màng cảm biến.
- Vài ngày sau, ở 0 bar cảm biến chỉ 0,0062 bar (trôi điểm 0); lệnh
zsửa lại. Đặt 5,000 bar theo chuẩn, bộ đo chỉ 4,993 bar, nằm trong độ không đảm bảo ước lượng ở mục sau.
6. Độ không đảm bảo sau hiệu chuẩn

Độ không đảm bảo mở rộng khoảng 0,04 bar, tức 0,4 % khoảng đo 10 bar. Gần ba phần tư đến từ ảnh hưởng nhiệt độ: cảm biến giá rẻ thường bù nhiệt kém, nên hiệu chuẩn ở nhiệt độ gần với lúc dùng và che chắn khỏi nắng. Nếu muốn biết con số thật thay cho ước lượng, đặt cảm biến ở hai nhiệt độ khác nhau và so điểm 0, điểm giữa dải với áp kế chuẩn.
7. Xử lý lỗi
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Khong thay ADS1115 o dia chi 0x48 | Sai SDA, SCL, ADDR chưa nối GND | Kiểm tra dây GPIO21, GPIO22, ADDR |
| Khong doc duoc ADS1115 hoac thieu nguon o AIN1 | Chưa nối cầu chia 5 V vào AIN1 | Nối đúng cầu chia nguồn |
| Áp kế chuẩn tụt dần khi giữ áp | Rò rỉ ở khớp nối, van; khí nguội | Siết lại, dùng băng tan đúng cách, chờ nhiệt ổn định |
| Tỉ số ở 0 bar xa 0,1 | Cảm biến loại khác (0–5 V, dải khác) hoặc cầu chia sai | Kiểm tra thông số cảm biến và điện trở |
| Phần dư lớn, ngẫu nhiên | Áp chưa ổn định khi ghi, nhiễu nguồn | Chờ lâu hơn, dây ngắn, nguồn ổn định |
8. Xem thêm
- Cảm biến áp suất: cấu tạo, nguyên lý áp trở, điện dung, áp điện và cách chế tạo.
- Hướng dẫn đo điện áp 16 bit bằng module ADS1115 với ESP32.
- Các bài khác trong loạt: hiệu chuẩn transmitter 4–20 mA, các nguồn sai số khi đo áp suất, các loại áp suất và an toàn.
Xem thêm