#!/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 switch_transition_end = switch_time + 1.0e-9 raw_transition_start = switch_time + 10.0e-9 raw_transition_end = switch_time + 11.0e-9 measurement_start = switch_time + 20.0e-9 measurement_end = measurement_start + 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} {raw_transition_start} {OFFSET_V} {raw_transition_end} {OFFSET_V + LEARNING_SIGNAL_V} {simulation_end} {OFFSET_V + LEARNING_SIGNAL_V}) Vsample sample 0 PWL(0 5 {switch_time} 5 {switch_transition_end} 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()] reference = 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", }, "reference_configuration": reference, "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), "reference_configuration": reference, "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()