diff options
Diffstat (limited to 'experiments')
| -rw-r--r-- | experiments/spice_autozero_primitive_p8.py | 175 |
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() |
