summaryrefslogtreecommitdiff
path: root/experiments/spice_autozero_primitive_p8.py
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-08-29 16:33:31 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-08-29 16:33:31 -0500
commita183793251dc38164986ea7e928d1f5ed47a5527 (patch)
treef0f04515fde899acbb551e025baa46abdb3f9280 /experiments/spice_autozero_primitive_p8.py
parent2cb88351c890058a964c4ffab6ebce9b79d03653 (diff)
exp: add SPICE autozero primitive check
Diffstat (limited to 'experiments/spice_autozero_primitive_p8.py')
-rw-r--r--experiments/spice_autozero_primitive_p8.py175
1 files changed, 175 insertions, 0 deletions
diff --git a/experiments/spice_autozero_primitive_p8.py b/experiments/spice_autozero_primitive_p8.py
new file mode 100644
index 0000000..9955543
--- /dev/null
+++ b/experiments/spice_autozero_primitive_p8.py
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+"""SPICE sanity check for the auto-zero sample/hold/subtract primitive.
+
+This is a generic switch-capacitor primitive, not a reconstruction of the
+complete CLLN edge. It tests acquisition and droop around the published
+5 mV AD633 output offset and 100 us learning window.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+
+
+OFFSET_V = 5.0e-3
+LEARNING_SIGNAL_V = 1.0e-3
+LEARNING_WINDOW_SECONDS = 100.0e-6
+
+
+def netlist(
+ *,
+ acquisition_seconds: float,
+ switch_resistance_ohm: float,
+ hold_capacitance_f: float,
+ leakage_resistance_ohm: float,
+) -> str:
+ switch_time = acquisition_seconds
+ transition_time = switch_time + 1.0e-9
+ measurement_start = transition_time + 5.0e-9
+ measurement_end = transition_time + LEARNING_WINDOW_SECONDS
+ simulation_end = measurement_end + 1.0e-6
+ return f"""Hardware auto-zero primitive sanity check
+Vraw raw 0 PWL(0 {OFFSET_V} {switch_time} {OFFSET_V} {transition_time} {OFFSET_V + LEARNING_SIGNAL_V} {simulation_end} {OFFSET_V + LEARNING_SIGNAL_V})
+Vsample sample 0 PWL(0 5 {switch_time} 5 {transition_time} 0 {simulation_end} 0)
+Ssample raw hold sample 0 sample_switch
+.model sample_switch SW(Ron={switch_resistance_ohm} Roff=1e12 Vt=2.5 Vh=0.1)
+Chold hold 0 {hold_capacitance_f} IC=0
+Rleak hold 0 {leakage_resistance_ohm}
+Ebuffer held 0 hold 0 1
+Esubtract residual 0 VALUE={{V(raw)-V(held)}}
+.tran 1n {simulation_end} UIC
+.meas tran held_at_open FIND V(held) AT={measurement_start}
+.meas tran residual_at_start FIND V(residual) AT={measurement_start}
+.meas tran residual_at_end FIND V(residual) AT={measurement_end}
+.end
+"""
+
+
+def extract_measurement(output: str, name: str) -> float:
+ match = re.search(
+ rf"^{re.escape(name)}\s*=\s*([-+0-9.eE]+)",
+ output,
+ flags=re.MULTILINE,
+ )
+ if match is None:
+ raise RuntimeError(f"ngspice did not report {name}\n{output[-2000:]}")
+ return float(match.group(1))
+
+
+def simulate(configuration: dict) -> dict:
+ circuit = netlist(**configuration)
+ with tempfile.TemporaryDirectory(prefix="sdil-autozero-spice-") as folder:
+ path = Path(folder) / "autozero.cir"
+ path.write_text(circuit)
+ completed = subprocess.run(
+ ["ngspice", "-b", str(path)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ combined_output = completed.stdout + completed.stderr
+ held = extract_measurement(combined_output, "held_at_open")
+ residual_start = extract_measurement(
+ combined_output, "residual_at_start")
+ residual_end = extract_measurement(combined_output, "residual_at_end")
+ return {
+ **configuration,
+ "held_at_switch_open_v": held,
+ "residual_at_learning_start_v": residual_start,
+ "residual_at_learning_end_v": residual_end,
+ "start_relative_error": abs(
+ residual_start - LEARNING_SIGNAL_V) / LEARNING_SIGNAL_V,
+ "end_relative_error": abs(
+ residual_end - LEARNING_SIGNAL_V) / LEARNING_SIGNAL_V,
+ }
+
+
+def configurations() -> list[dict]:
+ records = []
+ for acquisition_us in (0.25, 0.5, 1.0, 2.0, 4.0):
+ for switch_resistance in (50.0, 200.0, 1000.0):
+ for capacitance_nf in (0.1, 1.0, 10.0):
+ records.append({
+ "acquisition_seconds": acquisition_us * 1e-6,
+ "switch_resistance_ohm": switch_resistance,
+ "hold_capacitance_f": capacitance_nf * 1e-9,
+ "leakage_resistance_ohm": 1e9,
+ })
+ for leakage_resistance in (1e7, 1e8, 1e9, 1e10):
+ records.append({
+ "acquisition_seconds": 1e-6,
+ "switch_resistance_ohm": 200.0,
+ "hold_capacitance_f": 1e-9,
+ "leakage_resistance_ohm": leakage_resistance,
+ })
+ unique = []
+ seen = set()
+ for record in records:
+ key = tuple(record.items())
+ if key not in seen:
+ unique.append(record)
+ seen.add(key)
+ return unique
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--output", type=Path,
+ default=Path(
+ "results/physical_bias/p8_spice_autozero_primitive.json"))
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ records = [simulate(configuration) for configuration in configurations()]
+ nominal = next(record for record in records if (
+ record["acquisition_seconds"] == 1e-6
+ and record["switch_resistance_ohm"] == 200.0
+ and record["hold_capacitance_f"] == 1e-9
+ and record["leakage_resistance_ohm"] == 1e9
+ ))
+ output = {
+ "analysis": "generic_spice_autozero_primitive_p8",
+ "publication_evidence": False,
+ "scope": (
+ "Generic switch-capacitor acquisition and droop sanity check; "
+ "this is not the complete CLLN edge circuit."),
+ "ngspice_model": {
+ "raw_offset_v": OFFSET_V,
+ "learning_signal_v": LEARNING_SIGNAL_V,
+ "learning_window_seconds": LEARNING_WINDOW_SECONDS,
+ "buffer_and_subtractor": "ideal controlled voltage sources",
+ "switch": "ngspice voltage-controlled switch",
+ },
+ "nominal_configuration": nominal,
+ "configuration_count": len(records),
+ "fraction_below_1_percent_error_at_start": sum(
+ record["start_relative_error"] < 0.01 for record in records
+ ) / len(records),
+ "fraction_below_1_percent_error_at_end": sum(
+ record["end_relative_error"] < 0.01 for record in records
+ ) / len(records),
+ "records": records,
+ }
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(output, indent=2) + "\n")
+ print(json.dumps({
+ "configuration_count": len(records),
+ "nominal_configuration": nominal,
+ "fraction_below_1_percent_error_at_start": (
+ output["fraction_below_1_percent_error_at_start"]),
+ "fraction_below_1_percent_error_at_end": (
+ output["fraction_below_1_percent_error_at_end"]),
+ }, indent=2))
+ print(f"wrote {args.output}")
+
+
+if __name__ == "__main__":
+ main()