Source code for adctoolbox.models.sar

"""SAR ADC forward model — binary or sub-radix-2, with optional non-idealities.

Function-based, no internal state. Suitable for stimulus generation,
calibration-algorithm development, ENoB Monte Carlo, and RTL backend
verification.

Convention
----------
``vin``     : input voltage in ``quant_range``. Default ``quant_range`` is
              ``(0, 1)``, so the model remains normalized by default.
``weights`` : normalized by ``sum(bit_weights) + 1 LSB``. For a non-redundant
              4-bit ADC this is ``[8, 4, 2, 1] / (8+4+2+1+1)``.
``codes``   : array of {0, 1}, MSB at column 0
``aout``    : reconstructed voltage in ``quant_range``.

For a fully-differential SAR, pass the differential signal ``VIP - VIN`` with
the corresponding differential range, for example ``quant_range=(-VDD, VDD)``.

References
----------
Ported from Arcadia-1/SpecMind ``dataset_generation/simulation_engines/sar_adc.py``
and ``matlab_reference/sar_model_simplest.m`` (icdesign lab, 2025-03-25).
"""
from __future__ import annotations

from typing import Optional

import numpy as np


def _parse_quant_range(quant_range: tuple[float, float]) -> tuple[float, float]:
    if len(quant_range) != 2:
        raise ValueError(f"quant_range must contain exactly 2 values, got {len(quant_range)}")
    v_min, v_max = float(quant_range[0]), float(quant_range[1])
    if not np.isfinite(v_min) or not np.isfinite(v_max):
        raise ValueError("quant_range values must be finite")
    if v_max <= v_min:
        raise ValueError(f"quant_range must be increasing, got {quant_range}")
    return v_min, v_max


[docs] def sar_ideal_weights(num_bits: int, redundant_bit: Optional[int] = None) -> np.ndarray: """Generate ideal binary CDAC weights, optionally with one duplicated bit. The normalization base is the sum of all bit weights plus one LSB: ``denom = sum(raw_bit_weights) + raw_lsb``. In other words, a 4-bit ideal ADC uses ``[8, 4, 2, 1] / (8+4+2+1+1) = /16``, not endpoint-normalized ``/ 15`` weights. With one duplicated redundant bit, ``[8, 4, 4, 2, 1]`` normalizes by ``20``. Parameters ---------- num_bits : int Architectural resolution (number of distinct decision steps). redundant_bit : int, optional If given, the cap at index ``redundant_bit`` is duplicated, yielding an output array of length ``num_bits + 1``. Use to model sub-radix-2 SAR with one bit of redundancy. Architectural resolution stays at ``num_bits`` — the redundancy adds error-correction margin, not bits. Returns ------- weights : ndarray of shape (B,) Cap weights, MSB at index 0. The denominator is ``sum(raw_bit_weights) + raw_lsb``. If ``redundant_bit`` is used, the duplicated cap participates in both the sum and the resulting overrange / correction margin. Examples -------- >>> import numpy as np >>> w = sar_ideal_weights(4) >>> np.allclose(w, [8/16, 4/16, 2/16, 1/16]) True >>> w = sar_ideal_weights(4, redundant_bit=1) >>> len(w) == 5 and np.allclose(w, [8/20, 4/20, 4/20, 2/20, 1/20]) True """ w = [2 ** (num_bits - 1 - i) for i in range(num_bits)] if redundant_bit is not None: w.insert(redundant_bit + 1, w[redundant_bit]) w = np.asarray(w, dtype=float) return w / (w.sum() + w[-1])
[docs] def sar_apply_cap_mismatch( weights: np.ndarray, sigma: float, rng: Optional[np.random.Generator] = None, cap_units: Optional[np.ndarray] = None, ) -> np.ndarray: """Realize unit-cap/Pelgrom-style CDAC mismatch. ``sigma`` is the RMS relative mismatch of a single unit capacitor. A bit made from ``n`` unit capacitors gets relative RMS mismatch ``sigma / sqrt(n)``. This captures the usual ``sigma(C) / C ∝ 1/sqrt(C)`` trend: MSB capacitors are larger and therefore have smaller relative mismatch than LSB capacitors. By default, unit counts are inferred from the normalized weights by dividing by the smallest positive weight. For ideal binary weights this gives ``[8, 4, 2, 1]`` for a 4-bit ADC and ``[8, 4, 4, 2, 1]`` for a redundant array. Pass ``cap_units`` explicitly for custom capacitor arrays. Parameters ---------- weights : ndarray of shape (B,) Nominal analog CDAC weights, typically from :func:`sar_ideal_weights`. sigma : float RMS relative mismatch of one unit capacitor. For example, ``0.01`` is 1% RMS for a 1-Cu element; an 8-Cu MSB then has ``0.01/sqrt(8)`` relative RMS. rng : np.random.Generator, optional Numpy random generator. Use a deterministic seed to lock one chip. cap_units : ndarray of shape (B,), optional Unit capacitor counts or relative capacitor sizes for each weight. Values must be positive. If omitted, they are inferred from ``weights / min(weights)``. Returns ------- perturbed_weights : ndarray of shape (B,) Cap weights with unit-cap-scaled gaussian mismatch applied. The result is NOT re-normalized. """ weights = np.asarray(weights, dtype=float) if weights.ndim != 1: raise ValueError(f"weights must be 1D, got shape {weights.shape}") if len(weights) == 0: raise ValueError("weights must not be empty") if not np.all(np.isfinite(weights)): raise ValueError("weights must be finite") if np.any(weights <= 0): raise ValueError("weights must be positive to infer capacitor sizes") if not np.isfinite(sigma) or sigma < 0: raise ValueError(f"sigma must be finite and non-negative, got {sigma}") if cap_units is None: cap_units = weights / np.min(weights) else: cap_units = np.asarray(cap_units, dtype=float) if cap_units.shape != weights.shape: raise ValueError( f"cap_units shape must match weights shape {weights.shape}, got {cap_units.shape}" ) if not np.all(np.isfinite(cap_units)): raise ValueError("cap_units must be finite") if np.any(cap_units <= 0): raise ValueError("cap_units must be positive") if rng is None: rng = np.random.default_rng() relative_sigma = sigma / np.sqrt(cap_units) return weights * (1.0 + relative_sigma * rng.standard_normal(len(weights)))
[docs] def sar_apply_mismatch( weights: np.ndarray, sigma: float, rng: Optional[np.random.Generator] = None, ) -> np.ndarray: """Legacy per-weight gaussian mismatch helper. This preserves the pre-0.8.2 behavior where each supplied weight receives the same relative RMS perturbation. For Pelgrom/unit-cap-scaled mismatch, use :func:`sar_apply_cap_mismatch`. """ if rng is None: rng = np.random.default_rng() return np.asarray(weights, dtype=float) * (1.0 + sigma * rng.standard_normal(len(weights)))
[docs] def sar_convert( vin: np.ndarray, weights: np.ndarray, quant_range: tuple[float, float] = (0.0, 1.0), sampling_noise_rms: float = 0.0, comparator_noise_rms: float = 0.0, rng: Optional[np.random.Generator] = None, ) -> np.ndarray: """Batch SAR conversion. Per-sample algorithm:: vin_sampled = vin + sampling_noise v_dac = 0 for j in range(B): v_test = v_dac + weights[j] bit[j] = 1 if (vin_sampled + comparator_noise > v_test) else 0 if bit[j]: v_dac = v_test Vectorized over samples so the whole batch runs in one Python loop of length ``B`` (typically 4-20 iterations), not ``N`` (typically 10⁴-10⁶). Multi-channel or multi-run inputs are supported by preserving the input shape and appending one bit axis to the returned codes. Parameters ---------- vin : array-like of shape (...,) Input voltage trace or batch of traces. Interpreted relative to ``quant_range``. weights : array-like of shape (B,) Actual analog CDAC weights (MSB first). Resolution and redundancy are inferred from this vector. Use :func:`sar_ideal_weights` for an ideal binary or redundant array, :func:`sar_apply_cap_mismatch` for unit-cap/Pelgrom-style capacitor mismatch. quant_range : tuple(float, float), default (0.0, 1.0) ADC input and output range ``(v_min, v_max)``. This mirrors ``ADC_Signal_Generator.apply_quantization_noise(..., quant_range=...)``. sampling_noise_rms : float, default 0.0 Sampled input noise RMS, in the same unit as ``vin``. One random value is added per sample before the SAR bit trials. comparator_noise_rms : float, default 0.0 Comparator input-referred thermal noise RMS, in the same unit as ``vin``. A fresh random value is used for each bit decision. rng : np.random.Generator, optional RNG for sampling and comparator noise. Returns ------- codes : ndarray of shape vin.shape + (B,), dtype int8 Raw bit decisions, MSB at column 0. Pass to :func:`sar_reconstruct` for the analog estimate, or to a calibration routine (e.g. :func:`adctoolbox.calibration.calibrate_weight_sine`) for weight estimation. Notes ----- The model does NOT include DAC settling errors, metastability delay, charge-injection artifacts, or PVT drift. For those, supplement with a Spectre / Verilog-A simulation. """ vin = np.atleast_1d(np.asarray(vin, dtype=float)) weights = np.asarray(weights, dtype=float) v_min, v_max = _parse_quant_range(quant_range) full_scale = v_max - v_min if rng is None: rng = np.random.default_rng() vin_sampled = vin.copy() if sampling_noise_rms > 0: vin_sampled = vin_sampled + sampling_noise_rms * rng.standard_normal(vin_sampled.shape) vin_norm = (vin_sampled - v_min) / full_scale comparator_noise_norm = comparator_noise_rms / full_scale B = len(weights) codes = np.zeros(vin_norm.shape + (B,), dtype=np.int8) v_dac = np.zeros_like(vin_norm) for j in range(B): v_test = v_dac + weights[j] noise = ( comparator_noise_norm * rng.standard_normal(vin_norm.shape) if comparator_noise_rms > 0 else 0.0 ) bit = (vin_norm + noise >= v_test).astype(np.int8) codes[..., j] = bit v_dac = np.where(bit, v_test, v_dac) return codes
[docs] def sar_reconstruct( codes: np.ndarray, weights: np.ndarray, quant_range: tuple[float, float] = (0.0, 1.0), ) -> np.ndarray: """Linear weighted-sum reconstruction: ``codes @ digital_weights``. ``weights`` are the digital reconstruction weights, which usually match the analog CDAC weights in an ideal model. With mismatch, keep the two roles explicit: encode with actual analog weights, then reconstruct with the nominal, calibrated, or actual digital weights you want to evaluate. Subtract the sample mean before spectrum analysis to remove the DC offset introduced by the unipolar encoding. Parameters ---------- codes : ndarray of shape (..., B) Raw bit decisions from :func:`sar_convert`. weights : ndarray of shape (B,) Digital reconstruction weights. Use the nominal weights for an "uncalibrated" output; use cal-estimated or actual weights to assess calibration quality. quant_range : tuple(float, float), default (0.0, 1.0) Output voltage range ``(v_min, v_max)``. The default preserves the normalized reconstruction convention. Returns ------- aout : ndarray of shape codes.shape[:-1] Reconstructed analog estimate, range ``[v_min, v_min + (v_max - v_min) * sum(weights)]``. """ v_min, v_max = _parse_quant_range(quant_range) return (codes.astype(float) @ np.asarray(weights, dtype=float)) * (v_max - v_min) + v_min