summaryrefslogtreecommitdiff
path: root/hw_sim/cell_v1.py
blob: ae2cf3bbe90a1eb7994aff0e6e45c700058d71f6 (plain)
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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
"""v1: datasheet-anchored column sim.
AD7528 channel = datasheet Thevenin equivalent: V_thev = VREF*N/256, R_eq(code) in [0.8R, 2R],
R = 11k typ; C_OUT 50-120 pF code-dependent -> 64 outputs share one summing bus:
C_bus = 64 x C_OUT ~ 3.2-7.7 nF  (the v0 60 pF assumption was ~100x optimistic).
TIA = MCP6022-class linear model (GBW 10 MHz, A0 1e5, Vos up to 500 uV).
E1: settle vs feedback R_f and compensation C_f -> find the (R_f, C_f) that meets the
    <=3 us commit gate, or report the gate FAILS at the naive design point.
E2: two-phase offset rejection at the chosen operating point with Vos = 500 uV (datasheet max).
Sources: AD7528 datasheet (R typ 11k, Req 0.8R-2R, COUT 50-120 pF, settling 350/400 ns max);
MCP6022 (GBW 10 MHz, Vos max +-500 uV).
"""
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

N = 64
R_LAD = 11e3
R_EQ = 1.3 * R_LAD        # mid-code equivalent (0.8R..2R) -> 14.3k
C_OUT = 85e-12            # mid-code per-chip output capacitance
C_BUS = N * C_OUT         # 5.44 nF on the summing bus
A0 = 1e5
GBW = 10e6
F_P = GBW / A0            # 100 Hz dominant pole
VREF_STEP = 0.1           # per-branch Thevenin step (VREF*code/256 scale)

class OpAmp(SubCircuit):
    NODES = ('inp', 'inn', 'out')
    def __init__(self, name, vos=0.0):
        super().__init__(name, *self.NODES)
        self.V('os', 'inp', 'inpo', vos)
        self.B('gain', 'x', self.gnd, v=f'{A0}*(v(inpo)-v(inn))')
        rp = 1e6
        cp = 1.0 / (2 * np.pi * F_P * 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)

def build(rf, cf, vos=0.0, nudge=False):
    c = Circuit('ep_col_v1')
    c.subcircuit(OpAmp('opamp', vos=vos))
    for i in range(N):
        c.PulseVoltageSource(f'in{i}', f'n{i}', c.gnd, initial_value=0, pulsed_value=VREF_STEP,
                             delay_time=1e-6, rise_time=50e-9, fall_time=50e-9,
                             pulse_width=1, period=2)
        c.R(f'w{i}', f'n{i}', 'sum', R_EQ)
    c.C('bus', 'sum', c.gnd, C_BUS)
    c.R('f', 'out', 'sum', rf)
    if cf > 0:
        c.C('f', 'out', 'sum', cf)
    c.X('amp', 'opamp', c.gnd, 'sum', 'out')
    if nudge:
        c.PulseVoltageSource('nud', 'nn', c.gnd, initial_value=0, pulsed_value=1.0,
                             delay_time=1e-6, rise_time=50e-9, fall_time=50e-9,
                             pulse_width=1, period=2)
        c.R('nudge', 'nn', 'sum', 500e3)   # 2 uA nudge
    return c

def run(c, end=40e-6, step=4e-9):
    sim = c.simulator(temperature=27, nominal_temperature=27)
    an = sim.transient(step_time=step, end_time=end)
    return np.array(an.time), np.array(an['out'])

def settle_time(t, v, tol=1e-3):
    vf = v[-1]
    band = tol * abs(vf) if abs(vf) > 1e-9 else tol
    outside = np.where(np.abs(v - vf) > band)[0]
    if len(outside) == 0: return 0.0
    idx = outside[-1] + 1
    return (t[idx] - 1e-6) if idx < len(t) else np.inf

# ---------- E1: (R_f, C_f) design sweep ----------
print('E1: settle vs (R_f, C_f)   [commit <=3us | kill >7us]  C_bus = %.2f nF' % (C_BUS * 1e9))
results = []
for rf in (10e3, 3e3, 1e3):
    # optimal TIA comp: C_f ~ sqrt(C_bus / (2*pi*GBW*R_f))
    cf_opt = np.sqrt(C_BUS / (2 * np.pi * GBW * rf))
    for cf in (0.0, cf_opt, 2 * cf_opt):
        t, v = run(build(rf, cf))
        ts = settle_time(t, v)
        ideal = -N * VREF_STEP * rf / R_EQ
        err = abs(v[-1] - ideal) / abs(ideal)
        results.append((rf, cf, ts, v[-1], err))
        print(f'  R_f={rf/1e3:4.0f}k  C_f={cf*1e12:7.1f}pF  t_settle={ts*1e6:7.2f}us  '
              f'Vout={v[-1]*1e3:8.2f}mV  static_err={err*100:.3f}%')

ok = [r for r in results if r[2] <= 3e-6 and r[4] < 0.01]
best = min(ok, key=lambda r: r[2]) if ok else min(results, key=lambda r: r[2])
verdict = 'COMMIT GATE MET' if ok else 'GATE FAILED at all tested points'
print(f'E1 verdict: {verdict} -> best (R_f={best[0]/1e3:.0f}k, C_f={best[1]*1e12:.0f}pF, t={best[2]*1e6:.2f}us)')

# ---------- E2: offset rejection at the chosen point ----------
rf_b, cf_b = best[0], best[1]
_, v_free = run(build(rf_b, cf_b, vos=500e-6))
t2, v_nud = run(build(rf_b, cf_b, vos=500e-6, nudge=True))
ideal = -N * VREF_STEP * rf_b / R_EQ
ideal_diff = -2e-6 * rf_b
single_err = abs(v_free[-1] - ideal)
diff_err = abs((v_nud[-1] - v_free[-1]) - ideal_diff)
print(f'E2: Vos=500uV | single-read err {single_err*1e3:.3f} mV | two-phase diff err '
      f'{diff_err*1e6:.2f} uV | rejection x{single_err/max(diff_err,1e-12):.0f} | '
      f'contrast floor 0.1-1 mV -> {"OK" if diff_err < 1e-4 else "MARGINAL/FAIL"}')

# ---------- figure ----------
fig, axes = plt.subplots(1, 2, figsize=(12, 4.4))
for rf in (10e3, 3e3, 1e3):
    cf_opt = np.sqrt(C_BUS / (2 * np.pi * GBW * rf))
    t, v = run(build(rf, cf_opt))
    axes[0].plot(t * 1e6, v * 1e3, label=f'R_f={rf/1e3:.0f}k, C_f={cf_opt*1e12:.0f}pF '
                 f'({settle_time(t,v)*1e6:.2f}µs)')
axes[0].axvspan(1, 4, color='#2e7d32', alpha=0.07)
axes[0].axvline(8, color='#b03a2e', ls=':', lw=1)
axes[0].set_xlabel('t (µs)'); axes[0].set_ylabel('V_out (mV)')
axes[0].set_title(f'E1: 64-chip column, C_bus={C_BUS*1e9:.1f}nF (datasheet C_OUT) — comp-C TIA')
axes[0].legend(fontsize=8)
axes[1].bar(['single read\n(500 µV Vos, ×noise-gain)', 'two-phase\ndifferential'],
            [single_err * 1e3, diff_err * 1e3], color=['#b03a2e', '#2e7d32'])
axes[1].set_yscale('log'); axes[1].set_ylabel('|error| (mV)')
axes[1].axhline(0.1, color='#888', ls='--', lw=1)
axes[1].text(0.5, 0.105, 'contrast signal floor 0.1 mV', fontsize=8, color='#666')
axes[1].set_title(f'E2 @ (R_f={rf_b/1e3:.0f}k): rejection ×{single_err/max(diff_err,1e-12):.0f}')
fig.suptitle('EP column v1 — AD7528 Thevenin + bus C + MCP6022-class TIA (datasheet-anchored)', fontsize=11)
fig.tight_layout()
fig.savefig('/home/yurenh2/ept/assets/figs/fig_spice_v1.png', dpi=150)
print('DONE_SPICE_V1')