1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
"""v1.1: (A) ENOB-after-per-cell-cal Monte Carlo (resistor lot spread + code-dependent R_eq),
(B) op-amp noise integrated over the closed-loop bandwidth at the v1 operating point
(R_f=1k, C_f=294pF) vs the 0.1-1 mV contrast floor.
DC transfer is algebraic (settled state); SPICE noise via ngspice .noise on the TIA.
"""
import os
os.environ.setdefault('NGSPICE_LIBRARY_PATH', '/home/yurenh2/miniconda3/lib/libngspice.so')
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from PySpice.Spice.Netlist import Circuit, SubCircuit
rng = np.random.default_rng(7)
N = 64
R0 = 11e3
RF, CF = 1e3, 294e-12
EN_V = 8.7e-9 # MCP6022 input voltage noise, V/rtHz (flatband)
FS_CODE = 255
def req_of_code(code, rbase):
# datasheet: equivalent output R varies 0.8R..2R with code (excluding all-0s).
# smooth monotone proxy between the endpoints:
frac = max(code, 1) / 255.0
return rbase * (2.0 - 1.2 * frac)
def settled_out(codes, rbases, vref=0.1, rf=RF):
# inverting summing node: Vout = -rf * sum(vref*code_i/256 / Req_i(code_i))
return -rf * sum(vref * (c / 256.0) / req_of_code(c, rb) for c, rb in zip(codes, rbases))
# ---------- A: per-BIT ladder mismatch -> INL not removable by single-scale cal ----------
# each DAC's 8 bit-legs carry independent relative errors eps_k; per-cell cal removes only the
# overall gain; the residual code-shape error IS the INL. sigma_bit set so typical INL ~ datasheet
# relative accuracy (+-1 LSB class).
NMC = 400
SIG_BIT = 0.004
enob = []
for _ in range(NMC):
eps = rng.normal(0, SIG_BIT, (N, 8))
w = (2 ** np.arange(8))[None, :] * (1 + eps) # per-cell bit weights
codes = np.arange(1, 256)
bits = ((codes[:, None] >> np.arange(8)[None, :]) & 1) # 255 x 8
val = bits @ w.T # 255 x N raw values
ideal = codes.astype(float)[:, None]
cal = ideal[-1] / val[-1] # per-cell single-scale cal at code 255
resid = val * cal[None, :] - ideal # LSB units
rms = np.sqrt(np.mean(resid ** 2))
enob.append(8 - np.log2(max(2 * np.sqrt(3) * rms / 1.0, 1e-9)) - 0)
enob = np.array(enob)
inl_p95 = None
print(f'A: per-bit mismatch sigma {SIG_BIT*100:.1f}% -> ENOB after per-cell cal: '
f'mean {enob.mean():.2f} b, p5 {np.percentile(enob,5):.2f} b [screen bar >=7.0]')
# ---------- B: ngspice noise analysis at the operating point ----------
class OpAmp(SubCircuit):
NODES = ('inp', 'inn', 'out')
def __init__(self, name):
super().__init__(name, *self.NODES)
self.B('gain', 'x', self.gnd, v=f'1e5*(v(inp)-v(inn))')
rp = 1e6
cp = 1.0 / (2 * np.pi * 100.0 * rp)
self.R('p', 'x', 'p1', rp)
self.C('p', 'p1', self.gnd, cp)
self.B('buf', 'o', self.gnd, v='v(p1)')
self.R('out', 'o', 'out', 25)
c = Circuit('noise_col')
c.subcircuit(OpAmp('opamp'))
c.SinusoidalVoltageSource('vn', 'np', c.gnd, amplitude=1.0) # noise injection point (input-referred)
for i in range(N):
c.R(f'w{i}', c.gnd, 'sum', req_of_code(128, R0))
c.C('bus', 'sum', c.gnd, 64 * 85e-12)
c.R('f', 'out', 'sum', RF)
c.C('f', 'out', 'sum', CF)
c.X('amp', 'opamp', 'np', 'sum', 'out') # inject at the + input = input-referred source
sim = c.simulator(temperature=27, nominal_temperature=27)
an = sim.ac(start_frequency=10, stop_frequency=100e6, number_of_points=30, variation='dec')
f = np.array(an.frequency)
gain = np.abs(np.array(an['out'])) # noise gain vs frequency
# integrate (EN_V * gain)^2 df
integrand = (EN_V * gain) ** 2
vn_rms = np.sqrt(np.trapz(integrand, f))
nbw_gain = np.sqrt(np.trapz(gain ** 2, f) / max(f[-1] - f[0], 1))
print(f'B: output-referred op-amp noise RMS {vn_rms*1e6:.2f} uV over closed-loop band '
f'(peak noise gain {gain.max():.1f}) | contrast floor 0.1 mV -> '
f'{"OK" if vn_rms < 1e-4 else "MARGINAL"} (ratio {1e-4/vn_rms:.1f}x headroom)')
# ---------- figure ----------
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.2))
axes[0].hist(enob, bins=30, color='#2c6fbb', alpha=0.85)
axes[0].axvline(7.0, color='#b03a2e', ls='--')
axes[0].text(7.0, axes[0].get_ylim()[1]*0.9, ' screen bar 7.0', color='#b03a2e', fontsize=9)
axes[0].set_xlabel('ENOB proxy after per-cell cal (bits)'); axes[0].set_ylabel('MC count')
axes[0].set_title(f'A: 400-trial MC, per-bit mismatch 0.4% — mean {enob.mean():.2f} b')
axes[1].loglog(f, EN_V * gain * 1e9, color='#7b5aa6')
axes[1].set_xlabel('Hz'); axes[1].set_ylabel('output noise density (nV/√Hz)')
axes[1].set_title(f'B: op-amp noise → output; integrated RMS {vn_rms*1e6:.1f} µV (floor 100 µV)')
fig.suptitle('EP column v1.1 — ENOB-after-cal Monte Carlo + noise budget (datasheet-anchored)', fontsize=11)
fig.tight_layout()
fig.savefig('/home/yurenh2/ept/assets/figs/fig_spice_v11.png', dpi=150)
print('DONE_SPICE_V11')
|