既製基板を利用して12bitDAC追加して0~1.0Vを発生する電圧発生治具を製作
追加したDACは12bit MCP4726、外部Ref接続するタイプです
Vrefを1.235Vとし、0V/1.0Vの二点でスケール較正したところ、中間電圧で数mV以上のずれが発生したので補正することにしました
12bitのリアルコードは0~4095ですが、上記0~1.0Vを0~10,000に正規化した入力で補正した覚書です


DACの非直線性誤差INL/DNL
MCP4726は抵抗ストリング構成のDACで、各抵抗のマッチング度合いで入力コード2n間隔で誤差が出る可能性がある構成です。
データシートのINL/DNLの代表特性を見ると、DNLはコード64ごとに±の2値(グラフから±0.1LSBぐらい)をとるらしい。
ほぼ2値なので、積分した累積誤差はおそらく三角波に近い形でその周期は128ごと、すなわちINLの一番細かい成分と一致している
ということは、INLのグラフはDNLも含まれていると推測できる


まずは実機の特性をとってみよう
0~10,000を100刻みで手動でとってみる
コントローラはESP32-S3、arduino環境で開発。0~10,000の入力で0~1.0Vを出力する関数dac_set()は既に作成してあり、一品ものなので、10000で1.0Vになるように2点較正して、コードに埋め込んである
下の集計は、基板上のSWを押すごとにDACを100ずつアップさせるファームに変更し101回DMMで測定したもの。ふうーッ!DMMの最下位桁見極めるのに神経使う

誤差グラフはDataSheetのINLグラフと包絡の形こそ異なるが、それなりの波形になっている
入力10000はDACのリアルコードでは約3316。in-errグラフから横軸換算すると一番細かい波形成分の周期はほぼ128である。これはDNL累積成分と同等で、入力100刻みでは少し粗いが結構とれるものである
もう少し細かくデータ採取したいが手動ではキツイので自動化してみることにした
DMMで自動測定してみる。昔は結構手間がかかった
愛機 agilent 34401AのRS232CポートにUSB-UART変換ケーブルでPCに接続。ESP32-S3もOTGポートで仮想シリアルが使えるので、PCから各々制御してデータをとってみることにしました
34401Aのリモート制御は十数年ぶり。Lab○○みたいな計測アプリなんぞ持ってないので、VBかVC#でアプリ作成だった。VB6の時代は非同期意識せず良かったが、今はどちらも非同期シリアル扱うのにデリケートするので手間がかかる。DMMの通信コマンドとにらめっこで、簡単な接続でも1日がかりだった
今どきはpythonだ!claudeに助けてもらおう
というわけで、claudeにDMM接続確認プログラムをお願いしてわずか数十秒。anaconda環境でpySerialを組み込んで、spyderで実行させアッという間に接続確認完了!
つぎに、ESP32-S3のシリアルコマンドを決める。”D”に入力数値を続け\nで終わるアスキー文字コマンドを実装
ESPにコマンドを送信し、しかるべきのちにDMMで一回分変換サイクルを実行しPCにcsvとして蓄積するアプリをclaudeに頼む
な、なんと、これも数分で出してくれた!
1,2回修正のあと動作完了

その結果

100刻みよりかなり精密な波形になった。よく見ると3角波よりもさらに細かい波も観察できるが、2の倍数ではなく量子化誤差かもしれない。そうなると12bitではこれ以上取り切れないので良しとします
このデータを作成したPCのpythonプログラム
AIにまるごと出してもらったアプリをそのまま載せるのも気が引けるが、それっ!。コメントは特に要求してないが、このステップ数でできてしまうのはやはりpythonならではですね
# dac_sweep.py -- DAC校正自動計測
# DAC基板: COM7 (CDC), コマンド "Dnnnnn\n" (0-10000), 正常時は同一文字列をエコーバック
# DMM : 34401A, COM9 (RS-232, 9600 7E2, DTR/DSR)
# 出力 : sweep_YYYYMMDD_HHMMSS.csv (input, mV) を1点ごとに逐次書き込み
import serial
import time
import csv
from datetime import datetime
STEP = 10 # 刻み
SETTLE_SEC = 0.3 # DAC設定後の待ち時間
NPLC = 10 # DMM積分時間 (10PLC ≈ 200ms, ノイズ除去重視)
# ---- ポートオープン ----
dac = serial.Serial('COM7', 115200, timeout=0.5) # CDCなのでボーレートは形式上
dmm = serial.Serial('COM9', 9600,
bytesize=serial.SEVENBITS,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_TWO,
timeout=5, dsrdtr=True)
def dmm_cmd(s):
dmm.write((s + '\n').encode('ascii'))
def dmm_query(s):
dmm_cmd(s)
return dmm.readline().decode('ascii', errors='replace').strip()
last_m = None
def dac_set(val, tries=6, wait=1.0):
global last_m
tx = f'D{val}'
for attempt in range(1, tries + 1):
dac.write((tx + '\n').encode('ascii'))
t0 = time.time()
while time.time() - t0 < wait:
line = dac.readline().decode('ascii', errors='replace').strip()
if not line:
continue
toks = line.split()
if toks[0] == tx:
dt = time.time() - t0
m = next((int(t[1:]) for t in toks if t.startswith('M')), None)
if dt > 1.0 or attempt > 1:
gap = (m - last_m) if (m and last_m) else None
print(f' * {tx}: 受信{dt:.1f}s (試行{attempt}) M={m} M間隔={gap}ms')
if m: last_m = m
return
print(f' * 読み飛ばし: {line!r} (待機中: {tx})')
print(f' * {tx}: 応答なし、再送 ({attempt}/{tries})')
raise RuntimeError(f'エコー待ちタイムアウト: {tx}')
fname = datetime.now().strftime('sweep_%Y%m%d_%H%M%S.csv')
try:
# ---- DMM初期設定(1回だけ) ----
dmm_cmd('SYST:REM')
time.sleep(0.3)
dmm_cmd('*CLS')
dmm_cmd('CONF:VOLT:DC 1') # 1Vレンジ固定(オートレンジ切替遅延を排除)
dmm_cmd(f'VOLT:DC:NPLC {NPLC}')
dmm_cmd('TRIG:SOUR IMM')
err = dmm_query('SYST:ERR?')
print('DMM設定完了, ERR:', err)
t0 = time.time()
with open(fname, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['input', 'mV'])
points = range(0, 10001, STEP)
for i, val in enumerate(points):
dac_set(val)
time.sleep(SETTLE_SEC)
resp = dmm_query('READ?')
mv = float(resp) * 1000.0
w.writerow([val, f'{mv:.4f}'])
f.flush() # 中断しても途中まで残す
if val % 500 == 0:
elapsed = time.time() - t0
total = elapsed / (i + 1) * len(points)
print(f'{val:5d} : {mv:9.4f} mV '
f'[{elapsed/60:4.1f}/{total/60:4.1f} min]')
print(f'\n完了: {fname} ({len(points)}点, {(time.time()-t0)/60:.1f}分)')
except KeyboardInterrupt:
print(f'\n中断: 途中までのデータは {fname} に保存済み')
finally:
try:
dac_set(0) # DAC出力を0に戻す
except Exception:
pass
dmm_cmd('SYST:LOC') # DMMをローカルに戻す(フリー測定再開)
dac.close()
dmm.close()
得られたデータで、ESP32側のDAC出力に補正を実装する
↑でえられた1,001個のデータから、逆算して補正する関数をda_set()に実装しました。これも、ほとんどAIです(ほんと、自分で考えることをしなくなって退化している自分が情けないorz)
#define DAC_CAL_N 1001
#define DAC_CAL_STEP 10
// 実測出力 [mV] : 入力 0,10,20,...,10000 に対応 (約4KB)
static const float dacMeas[DAC_CAL_N] = {
0.8987f, 0.8988f, 1.4495f, 2.4815f, 3.5046f,
4.9004f, 5.8884f, 6.9116f, 8.3171f, 9.3706f,
中略 ・・・・
987.8415f, 988.4052f, 989.1784f, 989.7413f, 990.3195f,
991.0516f, 991.5987f, 992.1509f, 992.8999f, 993.4584f,
994.0397f, 995.4857f, 996.7149f, 997.9571f, 999.6010f,
1000.8718f
};
void da_set(int16_t val) {
if (val < 0) val = 0;
if (val > 10000) val = 10000;
float target = val * 0.1f; // 目標出力 [mV]
float x; // 補正後の等価入力値 (0-10000, float)
if (target <= dacMeas[0]) {
x = 0.0f;
} else if (target >= dacMeas[DAC_CAL_N - 1]) {
x = 10000.0f;
} else {
int lo = 0, hi = DAC_CAL_N - 1;
while (hi - lo > 1) {
int mid = (lo + hi) >> 1;
if (dacMeas[mid] <= target) lo = mid;
else hi = mid;
}
float y0 = dacMeas[lo];
float y1 = dacMeas[lo + 1];
x = (float)DAC_CAL_STEP * ((float)lo + (target - y0) / (y1 - y0));
}
float fval = x * 0.3344f;
I2Cdevice.i2c_mcp4726_write(I2C_MAIN_NUM, (int16_t)(fval - 0.5f));
}
補正の結果
補正を施したda_set()をファームに組み込み、ふたたび自動測定をして得られたcsvファイルで、補正後の結果を出した

この結果、補正後誤差(ブルーのグラフ)p-pは1mV以下に収まっている。1.235V 12bit変換の量子化誤差は約0.3uV(4095を12350に換算しているので演算誤差も加算される)なのでそれを考慮しても十分な結果といえる
満足する補正結果
補正結果としては、かなり満足する値です
今回は一品物の治具なので、補正値はファーム埋め込みですが、採取したcsvファイルは15KB程度なのでデータ取得後、続けてESPへ送信してEEP等に格納すれば、完全自動化もたやすいと思います
この自動測定1001ポイントにかかる時間は十数分で、DMMのデフォルトの変換時間は0.3sでしたが、これは6+1/2桁測定時の値、測定は10uVのオーダーでよいのでDMMのサンプリングを早くすることでもっと短縮できるはずです
量産のDACにも十分応用できるやり方と思います
なにより、十数年ぶりに愛機34401Aの自動測定ができてハッピーでした。それも数分で(゜.゜)
今度は、標準電圧発生器とDMMとでADCの自動較正もいつかやってみよう