diff --git a/README.md b/README.md index 2475c81..ae51c5b 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,8 @@ python exp_s01_analyze_spectrum_simplest.py from adctoolbox import analyze_spectrum # Analyze signal spectrum -result = analyze_spectrum(signal, fs=800e6, show_plot=True) -print(f"ENOB: {result['enob']:.2f} bits, SNDR: {result['sndr_db']:.2f} dB") +result = analyze_spectrum(signal, fs=800e6, create_plot=True) +print(f"ENOB: {result['enob']:.2f} bits, SNDR: {result['sndr_dbc']:.2f} dB") ``` See [Usage Examples](#usage-examples) section below for detailed code examples. @@ -167,8 +167,8 @@ Helper functions for unit conversions and metric calculations. from adctoolbox import analyze_spectrum # Single-tone analysis -result = analyze_spectrum(signal, fs=800e6, harmonic=5, show_plot=True) -print(f"ENOB: {result['enob']:.2f} bits, SNDR: {result['sndr_db']:.2f} dB") +result = analyze_spectrum(signal, fs=800e6, max_harmonic=5, create_plot=True) +print(f"ENOB: {result['enob']:.2f} bits, SNDR: {result['sndr_dbc']:.2f} dB") ``` @@ -184,17 +184,17 @@ from adctoolbox import ( ) # Error PDF -result = analyze_error_pdf(signal, resolution=12, show_plot=True) +result = analyze_error_pdf(signal, resolution=12, create_plot=True) print(f"Std: {result['sigma']:.2f} LSB, KL div: {result['kl_divergence']:.4f}") # Error autocorrelation -result = analyze_error_autocorr(signal, max_lag=100, show_plot=True) +result = analyze_error_autocorr(signal, max_lag=100, create_plot=True) # Error spectrum -result = analyze_error_spectrum(signal, fs=800e6, show_plot=True) +result = analyze_error_spectrum(signal, fs=800e6, create_plot=True) # Error envelope spectrum (AM detection) -result = analyze_error_envelope_spectrum(signal, fs=800e6, show_plot=True) +result = analyze_error_envelope_spectrum(signal, fs=800e6, create_plot=True) ``` @@ -208,8 +208,8 @@ from adctoolbox import fit_sine_4param, analyze_decomposition_time result = fit_sine_4param(signal, frequency_estimate=0.1) print(f"Freq: {result['frequency']:.6f}, Amp: {result['amplitude']:.4f}") -# Harmonic decomposition -result = analyze_decomposition_time(signal, fs=800e6, harmonic=5, show_plot=True) +# Harmonic decomposition (does not take fs) +result = analyze_decomposition_time(signal, harmonic=5, create_plot=True) ``` @@ -219,7 +219,7 @@ result = analyze_decomposition_time(signal, fs=800e6, harmonic=5, show_plot=True ```python from adctoolbox import analyze_inl_from_sine -result = analyze_inl_from_sine(signal, resolution=12, show_plot=True) +result = analyze_inl_from_sine(signal, num_bits=12, create_plot=True) print(f"INL: [{result['inl'].min():.2f}, {result['inl'].max():.2f}] LSB") print(f"DNL: [{result['dnl'].min():.2f}, {result['dnl'].max():.2f}] LSB") ``` @@ -229,11 +229,15 @@ print(f"DNL: [{result['dnl'].min():.2f}, {result['dnl'].max():.2f}] LSB") Digital Calibration ```python -from adctoolbox import calibrate_weight_sine +from adctoolbox import calibrate_weight_sine, analyze_spectrum -# Weight calibration -result = calibrate_weight_sine(digital_codes, order=5) -print(f"SNR: {result['snr_db']:.2f} dB, THD: {result['thd_db']:.2f} dB") +# bits: (N_samples, N_bits) of {0, 1}; freq is normalized (Fin / Fs) +cal = calibrate_weight_sine(bits, freq=Fin / Fs, harmonic_order=5) +calibrated_signal = cal["calibrated_signal"] # also: cal["weight"], cal["offset"] + +# Verify the calibration by analyzing the corrected waveform +metrics = analyze_spectrum(calibrated_signal, fs=Fs, create_plot=False) +print(f"SNR: {metrics['snr_dbc']:.2f} dBc, THD: {metrics['thd_dbc']:.2f} dBc") ``` @@ -254,10 +258,10 @@ A, DC, noise_rms = 0.49, 0.5, 100e-6 signal = A * np.sin(2*np.pi*Fin*t) + DC + np.random.randn(N) * noise_rms # Analyze -result = analyze_spectrum(signal, fs=Fs, harmonic=5, show_plot=True) +result = analyze_spectrum(signal, fs=Fs, max_harmonic=5, create_plot=True) snr_theory = amplitudes_to_snr(sig_amplitude=A, noise_amplitude=noise_rms) -print(f"Measured SNR: {result['snr_db']:.2f} dB") +print(f"Measured SNR: {result['snr_dbc']:.2f} dBc") print(f"Theoretical SNR: {snr_theory:.2f} dB") ``` diff --git a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/SKILL.md b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/SKILL.md index c485007..582422c 100644 --- a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/SKILL.md +++ b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/SKILL.md @@ -36,31 +36,47 @@ Do NOT use for: ## 2. Critical conventions (read first — these are the common bug sources) +### Names + +- **`bits`** is the per-sample binary decision matrix shape `(N_samples, N_bits)`, + values in `{0, 1}` — used by every digital-calibration / bit-level helper. +- **`aout`** is the analog output (1D `float` array) — used by spectrum and + error-analysis helpers. +- ADC integer codes are *not* `bits`; convert separately if your data is + packed as integers. + ### Frequency units -- `fs`, `Fin`, and plotting frequencies are in **Hz**. -- `fit_sine_4param(...)['frequency']` returns **normalized `Fin/Fs`**, not Hz. -- `calibrate_weight_sine`, `calibrate_weight_sine_lite`, and most - `dout` helpers expect **normalized `freq = Fin/Fs`**. +- `fs`, `Fin`, plotting frequencies: **Hz**. +- `fit_sine_4param(...)["frequency"]`: **normalized** `Fin/Fs` (range 0–0.5), + **not** Hz. +- `calibrate_weight_sine`, `calibrate_weight_sine_lite`, `analyze_enob_sweep`, + `generate_dout_dashboard`: `freq` parameter is **normalized** `Fin/Fs`. +- `generate_aout_dashboard`: `freq` is in **Hz** (it normalizes internally). +- `analyze_spectrum` does NOT take `Fin` — it auto-detects the fundamental + from the FFT. -### Return shapes are not uniform +### Return shapes -Most analysis functions return `dict`. Exceptions: +Most analysis functions return `dict`. Notable exceptions and dict-key gotchas: | Function | Return | |---|---| -| `find_coherent_frequency` | `tuple (fin_hz, bin_idx)` | -| `analyze_bit_activity` | `ndarray` | -| `analyze_overflow` | `tuple` | +| `analyze_spectrum`, `analyze_spectrum_polar` | `dict` — keys: `enob`, `sndr_dbc`, `sfdr_dbc`, `snr_dbc`, `thd_dbc`, `sig_pwr_dbfs`, `noise_floor_dbfs`, `nsd_dbfs_hz`, `harmonics_dbc` | +| `compute_spectrum` | `dict` — top-level keys `metrics` (same as above) and `plot_data` (`freq`, `power_spectrum_db_plot`, `complex_spectrum`, `fundamental_bin`, …) | +| `fit_sine_4param` | `dict` — `frequency` (normalized), `amplitude`, `phase`, `dc_offset`, `rmse`, `fitted_signal`, `residuals` | +| `find_coherent_frequency` | `tuple (fin_actual_hz, best_bin)` | +| `calibrate_weight_sine` | `dict` — `weight`, `offset`, `calibrated_signal`, `ideal`, `error`, `refined_frequency` | +| `calibrate_weight_sine_lite` | `ndarray` (weights only) | +| `analyze_bit_activity` | `ndarray` (% of 1's per bit, length = N_bits) | +| `analyze_overflow` | `tuple` of 4 ndarrays `(range_min, range_max, ovf_pct_zero, ovf_pct_one)` | | `analyze_enob_sweep` | `tuple (enob_sweep, n_bits_vec)` | -| `fit_static_nonlin` | `tuple` | -| `calibrate_weight_sine_lite` | `ndarray` | +| `analyze_weight_radix` | `dict` — `radix`, `wgtsca`, `effres` | +| `fit_static_nonlin` | `tuple (k2, k3, fitted_sine, fitted_transfer)` | | `convert_cap_to_weight` | `tuple (weights, c_total)` | -| `analyze_weight_radix` | `dict` (was `ndarray` in old versions) | -| `compute_spectrum` | both metrics and plot data | -When docs conflict, trust the current `__init__.py` exports and -packaged examples over older README text. +When docs conflict, trust the current `__init__.py` exports + the +`tests/integration/test_user_guide_skill_examples.py` smoke tests. ## 3. Basic workflow — spectrum @@ -69,46 +85,65 @@ from adctoolbox import ( analyze_spectrum, analyze_spectrum_polar, find_coherent_frequency, fit_sine_4param, ) -from adctoolbox.fundamentals import validate_aout_data, validate_dout_data +from adctoolbox.fundamentals import validate_aout_data -validate_dout_data(dout) -fin_hz, k = find_coherent_frequency(fs=fs, n=len(dout), fin_target=fin_target_hz) -metrics = analyze_spectrum(dout, fs=fs, Fin=fin_hz, n_bits=N) -print(metrics["SNDR"], metrics["SFDR"], metrics["ENOB"]) +validate_aout_data(aout) +metrics = analyze_spectrum(aout, fs=fs, create_plot=False) +print(metrics["sndr_dbc"], metrics["sfdr_dbc"], metrics["enob"]) +``` + +To set up a coherent capture *upstream* (where you control the stimulus +frequency), snap `Fin` to an FFT bin first: + +```python +fin_hz, k_bin = find_coherent_frequency(fs, fin_target_hz, n_fft=len(aout)) +# now drive the test with fin_hz ``` Pick the variant by output: -- `analyze_spectrum` — standard magnitude spectrum + SNDR/SFDR/ENOB/THD -- `analyze_spectrum_polar` — complex/phase-aware spectrum (I/Q or mixer contexts) -- `compute_spectrum` — both metrics and plot-ready data (use when you - want to customize plotting) -- `find_coherent_frequency` — pre-step to align `Fin` to an FFT bin +- `analyze_spectrum` — magnitude spectrum + SNDR/SFDR/ENOB/THD metrics dict +- `analyze_spectrum_polar` — phase-aware (I/Q or mixer contexts); same keys +- `compute_spectrum` (from `adctoolbox.spectrum`) — both metrics and plot-ready + data (access via `result["plot_data"]["freq"]` etc.) +- `find_coherent_frequency` — pre-step at *signal generation* time, not analysis - `fit_sine_4param` — pre-step for nonlinearity work; remember its - `'frequency'` key is normalized + `"frequency"` key is normalized `Fin/Fs` ## 4. Basic workflow — digital calibration ```python from adctoolbox import calibrate_weight_sine from adctoolbox.calibration import calibrate_weight_sine_lite +from adctoolbox.fundamentals import validate_dout_data + +validate_dout_data(bits) # bits: (N_samples, N_bits) in {0, 1} -freq_norm = fin_hz / fs # normalized — not Hz -weights_full = calibrate_weight_sine(dout, freq=freq_norm, n_bits=N) -weights_fast = calibrate_weight_sine_lite(dout, freq=freq_norm, n_bits=N) +freq_norm = fin_hz / fs # normalized — not Hz +result = calibrate_weight_sine(bits, freq=freq_norm) +weights = result["weight"] +calibrated = result["calibrated_signal"] + +weights_fast = calibrate_weight_sine_lite(bits, freq_norm) # ndarray, no dict ``` -Pick `_lite` when you need a fast estimate; use the full variant when -you need convergence quality or diagnostic fields. +`calibrate_weight_sine` returns a dict with `weight`, `offset`, +`calibrated_signal`, `ideal`, `error`, `refined_frequency`. The `_lite` variant +returns just the weights ndarray and is positional (no `freq=` kw). ## 5. Import rules (compressed) | Kind | Use | |---|---| | Anything re-exported by `adctoolbox.__init__` | `from adctoolbox import X` | -| Submodule-only public tool (e.g. `siggen`, `toolset`, `aout`, `calibration`, `fundamentals`) | `from adctoolbox. import X` | +| Submodule-only public tool (`siggen`, `toolset`, `aout`, `calibration`, `fundamentals`, `spectrum`) | `from adctoolbox. import X` | If a flat import fails, check the submodule's `__init__.py` before -concluding the tool is gone. +concluding the tool is gone. Common submodule-only names: +`ADC_Signal_Generator` (siggen), `compute_spectrum` (spectrum), +`calibrate_weight_sine_lite` (calibration), `validate_aout_data` / +`validate_dout_data` / `convert_cap_to_weight` (fundamentals), +`analyze_phase_plane` / `analyze_error_phase_plane` (aout), +`generate_aout_dashboard` / `generate_dout_dashboard` (toolset). ## 6. Going further @@ -121,3 +156,7 @@ concluding the tool is gone. + plot template, adapt `02_spectrum/exp_s03_analyze_spectrum_savefig.py` (see `references/example-map.md` for the path). The packaged CLI `adctoolbox-get-examples [dest]` dumps the full example tree. + +Every code block in this file (and in `references/advanced-debug.md`) is +exercised by `python/tests/integration/test_user_guide_skill_examples.py` +— if a future edit breaks one, that test fails. diff --git a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/advanced-debug.md b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/advanced-debug.md index 867f8ac..ac01141 100644 --- a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/advanced-debug.md +++ b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/advanced-debug.md @@ -1,56 +1,90 @@ # ADCToolbox — Advanced Debug Reference Load this file when the basic spectrum/calibration tier in `SKILL.md` -is not enough. Each section below is keyed by the user's likely -question, not by file layout. +is not enough. Each section is keyed by the user's likely question, +not by file layout. Import conventions follow `SKILL.md` §5. Frequency conventions follow -`SKILL.md` §2. +`SKILL.md` §2 — pay particular attention: `generate_aout_dashboard` +takes `freq` in **Hz**, while `generate_dout_dashboard` takes `freq` +**normalized** to `Fin/Fs`. ## "I want one image showing all aout/dout diagnostics" -Goal: generate a multi-plot dashboard (time-domain + spectrum + INL/DNL + extras). - ```python from adctoolbox.toolset import generate_aout_dashboard, generate_dout_dashboard -generate_aout_dashboard(aout, fs=fs, savepath="aout_dash.png") -generate_dout_dashboard(dout, n_bits=N, fs=fs, savepath="dout_dash.png") +# aout dashboard: freq in Hz +fig, axes = generate_aout_dashboard( + aout, fs=fs, freq=fin_hz, output_path="aout_dash.png" +) + +# dout dashboard: freq normalized (Fin/Fs) +fig, axes = generate_dout_dashboard( + bits, freq=fin_hz / fs, output_path="dout_dash.png" +) ``` -Use `_aout_` when you have reconstructed analog output (floats); use -`_dout_` when you only have raw digital codes. +Use `generate_aout_dashboard` when you have reconstructed analog output +(floats); use `generate_dout_dashboard` when you only have the per-bit +decision matrix `bits`. Both return `(fig, axes_array)` and write a PNG +when `output_path=` is given. ## "I need to see nonlinearity structure, not just a single INL/DNL number" ```python from adctoolbox.aout import analyze_phase_plane, analyze_error_phase_plane -analyze_phase_plane(aout, fs=fs, Fin=Fin) # full signal phase trajectory -analyze_error_phase_plane(aout, fs=fs, Fin=Fin) # error-only phase plane +pp = analyze_phase_plane(aout, fs=fs) # full signal trajectory +epp = analyze_error_phase_plane(aout, fs=fs) # error-only (sine subtracted first) ``` -Use `analyze_error_phase_plane` after `fit_sine_4param` to isolate -nonlinearity from the fundamental. +Neither helper takes `Fin` — `analyze_phase_plane` infers the lag from +the FFT (`lag='auto'` default), and `analyze_error_phase_plane` calls +`fit_sine_4param` internally. Returns are dicts: `pp` has +`{lag, outliers}`, `epp` has `{residual, fitted_params, trend_coeffs, +hysteresis_gap}`. Use `epp` to isolate harmonic structure from the +fundamental. -## "I want per-bit behavior — activity, overflow, or ENOB vs bit depth" +## "I want per-bit behavior — activity, overflow, ENOB vs bit depth" ```python -from adctoolbox import analyze_bit_activity, analyze_overflow, analyze_enob_sweep, analyze_weight_radix +from adctoolbox import ( + analyze_bit_activity, analyze_overflow, + analyze_enob_sweep, analyze_weight_radix, + calibrate_weight_sine, +) + +activity = analyze_bit_activity(bits) # ndarray, length N_bits +weights = calibrate_weight_sine(bits, freq=fin_hz/fs)["weight"] +range_min, range_max, ovf_pct_zero, ovf_pct_one = analyze_overflow(bits, weights) +enob_sweep, n_bits_vec = analyze_enob_sweep(bits, freq=fin_hz/fs) +radix_info = analyze_weight_radix(weights) # dict: radix, wgtsca, effres ``` -- `analyze_bit_activity(dout, n_bits=N)` → `ndarray` -- `analyze_overflow(dout, n_bits=N)` → `tuple` -- `analyze_enob_sweep(dout, n_bits=N)` → `tuple (enob_sweep, n_bits_vec)` -- `analyze_weight_radix(weights)` → `dict` (was a bare array in old versions — now a dict) +Notes on shapes: +- `analyze_bit_activity` returns an ndarray of length `N_bits`, not a dict. +- `analyze_overflow` is a 4-tuple of ndarrays — needs the calibrated + `weights` as its second argument (it's measuring digital-domain over-range, + not raw saturation). +- `analyze_enob_sweep` is a 2-tuple `(enob_sweep, n_bits_vec)` and runs + `calibrate_weight_sine` once internally. +- `analyze_weight_radix(weights)` returns a `dict` (was an ndarray in + pre-`v0.6` versions). -## "I have static INL/DNL data and want a nonlinearity fit" +## "I have a distorted sine and want to extract HD2/HD3 coefficients" ```python from adctoolbox import fit_static_nonlin -coef, residual = fit_static_nonlin(inl_or_dnl, order=3) # returns tuple + +k2, k3, fitted_sine, fitted_transfer = fit_static_nonlin(sig_distorted, order=3) +# fitted_transfer is a tuple (x_smooth, y_smooth) of the fitted nonlinear +# transfer characteristic. k2/k3 are normalized to k1 (unity-gain). ``` +Note: input is a *distorted signal* (post-nonlinearity), not INL/DNL data. +`order` must be `>=2`; `k3` is `NaN` when `order < 3`. + ## "I want to decompose total error into component contributions" ```python @@ -72,21 +106,30 @@ Pick by the error view you need: - by phase / by value → `analyze_error_by_phase`, `analyze_error_by_value` - harmonic decomposition → `decompose_harmonic_error` -- spectral view of the error → `analyze_error_spectrum`, `analyze_error_envelope_spectrum` +- spectral view of the error → `analyze_error_spectrum`, + `analyze_error_envelope_spectrum` - statistical → `analyze_error_pdf`, `analyze_error_autocorr` -- polar / time decomposition views → `analyze_decomposition_polar`, `analyze_decomposition_time` +- polar / time decomposition views → `analyze_decomposition_polar`, + `analyze_decomposition_time` - INL from sine test → `analyze_inl_from_sine` -All of the above expect `fs` and `Fin` in Hz and assume a sine test -that has already passed `validate_aout_data` / `validate_dout_data`. +All of these expect `aout` (or fall back to `signal`) plus `fs` in Hz. +They each fit a sine internally if no `frequency` kwarg is supplied. ## "I need cap array → weight conversion for CDAC modeling" ```python from adctoolbox.fundamentals import convert_cap_to_weight -weights, c_total = convert_cap_to_weight(cap_array) # returns tuple + +weights, c_total = convert_cap_to_weight(caps_bit, caps_bridge, caps_parasitic) ``` +Three input arrays (LSB-to-MSB ordering, all the same length): +`caps_bit` — main capacitor sizes, `caps_bridge` — bridge caps between +sub-DAC sections (use 0 where there is no bridge), `caps_parasitic` — +parasitic caps per node. Returns a `(weights, c_total)` tuple — never +a dict. + ## When to fall back to `SKILL.md` If the task is plain spectrum analysis (SNDR / SFDR / ENOB), basic diff --git a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/api-quickref.md b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/api-quickref.md index f8a2f35..c685cc5 100644 --- a/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/api-quickref.md +++ b/python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/references/api-quickref.md @@ -89,7 +89,9 @@ from adctoolbox import ( ```python from adctoolbox.toolset import generate_aout_dashboard, generate_dout_dashboard from adctoolbox.fundamentals import convert_cap_to_weight -from adctoolbox.aout import analyze_phase_plane, analyze_error_phase_plane +from adctoolbox.aout import ( + analyze_phase_plane, analyze_error_phase_plane, decompose_harmonic_error, +) ``` ### Default Entry Points @@ -119,16 +121,48 @@ adctoolbox-install-skill --dev ## Key Conventions -- `fit_sine_4param(... )["frequency"]` is normalized `Fin/Fs`, not Hz. -- Calibration helpers such as `calibrate_weight_sine(..., freq=...)` and - `calibrate_weight_sine_lite(..., freq=...)` expect normalized `Fin/Fs`. -- `find_coherent_frequency(...)` returns a tuple: - `(fin_actual_hz, best_bin)`. -- `analyze_overflow(...)` returns a tuple. -- `analyze_enob_sweep(...)` returns a tuple: - `(enob_sweep, n_bits_vec)`. -- `calibrate_weight_sine_lite(...)` returns weights only. -- `analyze_weight_radix(...)` returns a dict. -- `compute_spectrum(...)` returns both `metrics` and `plot_data`. +### Frequency + +- `fit_sine_4param(...)["frequency"]` is normalized `Fin/Fs` (range 0–0.5), + not Hz. +- `calibrate_weight_sine(bits, freq=...)` and `calibrate_weight_sine_lite(bits, + freq)` expect normalized `freq = Fin/Fs`. The `_lite` variant takes `freq` + positionally (not as a keyword) and is required (no auto-search). +- `analyze_enob_sweep(bits, freq=...)` and `generate_dout_dashboard(bits, + freq=...)` also expect normalized `freq`. +- `generate_aout_dashboard(aout, fs=..., freq=...)` takes `freq` in Hz (it + normalizes internally). +- `find_coherent_frequency(fs, fin_target, n_fft)` returns a tuple + `(fin_actual_hz, best_bin)`. Argument order matters: `fs` first, then + `fin_target`, then `n_fft`. +- `analyze_spectrum` does NOT take `Fin` — the fundamental is auto-detected. + +### Return shapes + +- Spectrum metrics dicts (`analyze_spectrum`, `analyze_spectrum_polar`, + `compute_spectrum["metrics"]`) use lowercase `_dbc` keys: `enob`, + `sndr_dbc`, `sfdr_dbc`, `snr_dbc`, `thd_dbc`, `sig_pwr_dbfs`, + `noise_floor_dbfs`, `nsd_dbfs_hz`, `harmonics_dbc` — **not** + uppercase `SNDR` / `SFDR` / `ENOB`. +- `compute_spectrum(...)` returns a dict with top-level keys `metrics` and + `plot_data` (the latter has `freq`, `power_spectrum_db_plot`, + `complex_spectrum`, `fundamental_bin`, …). +- `calibrate_weight_sine(...)` returns a dict: `weight`, `offset`, + `calibrated_signal`, `ideal`, `error`, `refined_frequency`. + `calibrate_weight_sine_lite(...)` returns just the weights ndarray. +- `analyze_bit_activity(bits)` returns an ndarray (% of 1's per bit). +- `analyze_overflow(bits, weight)` returns a 4-tuple of ndarrays + `(range_min, range_max, ovf_pct_zero, ovf_pct_one)`. The second argument + is the calibrated weights vector — not optional. +- `analyze_enob_sweep(bits, freq=...)` returns `(enob_sweep, n_bits_vec)`. +- `analyze_weight_radix(weights)` returns a dict (`radix`, `wgtsca`, `effres`). +- `fit_static_nonlin(sig_distorted, order)` returns + `(k2, k3, fitted_sine, fitted_transfer)`. Input is a distorted signal, + not INL/DNL data; `order >= 2`. +- `convert_cap_to_weight(caps_bit, caps_bridge, caps_parasitic)` takes + three same-length arrays (LSB→MSB; pass zeros where there is no + bridge / parasitic) and returns `(weights, c_total)`. +- `analyze_phase_plane(aout, fs=...)` and `analyze_error_phase_plane(aout, + fs=...)` do NOT take `Fin` — they self-fit the fundamental. If unsure which file to copy from, open `example-map.md`. diff --git a/python/tests/integration/test_user_guide_skill_examples.py b/python/tests/integration/test_user_guide_skill_examples.py new file mode 100644 index 0000000..6015afa --- /dev/null +++ b/python/tests/integration/test_user_guide_skill_examples.py @@ -0,0 +1,290 @@ +"""Smoke tests that actually run every code example shown in the bundled +`adctoolbox-user-guide` skill. + +Background — Issue #11 + the user-guide-skill-revamp PR (#13) shipped doc +examples that didn't match the real API (made-up params like `Fin=`, +`n_bits=`; wrong return keys like `metrics["SNDR"]` instead of +`metrics["sndr_dbc"]`). The eval used during the revamp only scored text +similarity from subagents — nothing actually imported and called the +code. + +These tests are the real fence: if a future SKILL.md / advanced-debug.md +example diverges from the API again, this file fails. + +Each test mirrors a code block from one of: +- `python/src/adctoolbox/_bundled_skills/skills/adctoolbox-user-guide/SKILL.md` +- `.../adctoolbox-user-guide/references/advanced-debug.md` +- `.../adctoolbox-user-guide/references/api-quickref.md` +""" + +import numpy as np +import pytest + + +# ---------------------------------------------------------------------- +# Shared fixture — a small synthetic dout/aout pair used by most tests +# ---------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def synth_capture(): + """8-bit, 4096-sample coherent sine — small enough to be fast, + large enough that all analysis APIs accept it.""" + from adctoolbox.siggen import ADC_Signal_Generator + from adctoolbox import find_coherent_frequency + + n_samples = 4096 + fs = 100e6 + n_bits = 8 + fin_target = 5e6 + fin_actual, _ = find_coherent_frequency(fs, fin_target, n_samples) + + gen = ADC_Signal_Generator(N=n_samples, Fs=fs, Fin=fin_actual, A=0.45, DC=0.5) + aout = gen.get_clean_signal() + aout = gen.apply_thermal_noise(noise_rms=1e-4, input_signal=aout) + dout_codes_signal = gen.apply_quantization_noise( + n_bits=n_bits, quant_range=(0.0, 1.0), input_signal=aout + ) + + code_levels = np.round((dout_codes_signal - 0.0) / (1.0 / (2 ** n_bits))).astype(int) + code_levels = np.clip(code_levels, 0, 2 ** n_bits - 1) + bits = np.array([ + [(c >> b) & 1 for b in range(n_bits - 1, -1, -1)] + for c in code_levels + ]) + + return { + "aout": aout.astype(float), + "bits": bits, + "fs": fs, + "fin_actual": fin_actual, + "n_bits": n_bits, + "n_samples": n_samples, + "freq_norm": fin_actual / fs, + } + + +# ---------------------------------------------------------------------- +# SKILL.md §3 — basic spectrum workflow +# ---------------------------------------------------------------------- + +def test_skill_md_section_3_spectrum_workflow(synth_capture): + from adctoolbox import ( + analyze_spectrum, + analyze_spectrum_polar, + find_coherent_frequency, + fit_sine_4param, + ) + from adctoolbox.fundamentals import validate_aout_data, validate_dout_data + from adctoolbox.spectrum import compute_spectrum + + aout = synth_capture["aout"] + bits = synth_capture["bits"] + fs = synth_capture["fs"] + n_samples = synth_capture["n_samples"] + + validate_aout_data(aout) + validate_dout_data(bits) + + fin_hz, k = find_coherent_frequency(fs, 5e6, n_samples) + assert isinstance(k, (int, np.integer)) + assert fin_hz > 0 + + metrics = analyze_spectrum(aout, fs=fs, create_plot=False) + assert {"sndr_dbc", "sfdr_dbc", "enob"} <= set(metrics.keys()) + assert metrics["enob"] > 0 + + polar = analyze_spectrum_polar(aout, fs=fs, create_plot=False) + assert "sndr_dbc" in polar + + full = compute_spectrum(aout, fs=fs) + assert {"metrics", "plot_data"} <= set(full.keys()) + assert "freq" in full["plot_data"] + + fit = fit_sine_4param(aout) + assert 0.0 <= fit["frequency"] <= 0.5 + assert {"amplitude", "phase", "dc_offset", "rmse"} <= set(fit.keys()) + + +# ---------------------------------------------------------------------- +# SKILL.md §4 — basic digital calibration +# ---------------------------------------------------------------------- + +def test_skill_md_section_4_calibration(synth_capture): + from adctoolbox import calibrate_weight_sine + from adctoolbox.calibration import calibrate_weight_sine_lite + + bits = synth_capture["bits"] + freq_norm = synth_capture["freq_norm"] + + full = calibrate_weight_sine(bits, freq=freq_norm) + assert "weight" in full + assert isinstance(full["weight"], np.ndarray) + + fast = calibrate_weight_sine_lite(bits, freq_norm) + assert isinstance(fast, np.ndarray) + assert fast.ndim == 1 + assert len(fast) == bits.shape[1] + + +# ---------------------------------------------------------------------- +# advanced-debug.md — dashboards +# ---------------------------------------------------------------------- + +def test_advanced_dashboards(synth_capture, tmp_path): + import matplotlib + matplotlib.use("Agg") + from adctoolbox.toolset import generate_aout_dashboard, generate_dout_dashboard + + aout = synth_capture["aout"] + bits = synth_capture["bits"] + fs = synth_capture["fs"] + fin_actual = synth_capture["fin_actual"] + freq_norm = synth_capture["freq_norm"] + + fig_a, axes_a = generate_aout_dashboard( + aout, fs=fs, freq=fin_actual, output_path=str(tmp_path / "aout.png") + ) + fig_d, axes_d = generate_dout_dashboard( + bits, freq=freq_norm, output_path=str(tmp_path / "dout.png") + ) + assert (tmp_path / "aout.png").exists() + assert (tmp_path / "dout.png").exists() + + +# ---------------------------------------------------------------------- +# advanced-debug.md — phase-plane +# ---------------------------------------------------------------------- + +def test_advanced_phase_plane(synth_capture): + import matplotlib + matplotlib.use("Agg") + from adctoolbox.aout import analyze_phase_plane, analyze_error_phase_plane + + aout = synth_capture["aout"] + fs = synth_capture["fs"] + + pp = analyze_phase_plane(aout, fs=fs, create_plot=False) + assert "lag" in pp and "outliers" in pp + + epp = analyze_error_phase_plane(aout, fs=fs, create_plot=False) + assert {"residual", "fitted_params"} <= set(epp.keys()) + + +# ---------------------------------------------------------------------- +# advanced-debug.md — bit-level / overflow / enob sweep / radix +# ---------------------------------------------------------------------- + +def test_advanced_bit_level(synth_capture): + import matplotlib + matplotlib.use("Agg") + from adctoolbox import ( + analyze_bit_activity, + analyze_overflow, + analyze_enob_sweep, + analyze_weight_radix, + calibrate_weight_sine, + ) + + bits = synth_capture["bits"] + freq_norm = synth_capture["freq_norm"] + + activity = analyze_bit_activity(bits, create_plot=False) + assert isinstance(activity, np.ndarray) + assert activity.shape == (bits.shape[1],) + + weight_dict = calibrate_weight_sine(bits, freq=freq_norm) + weights = weight_dict["weight"] + + rmin, rmax, ovf0, ovf1 = analyze_overflow(bits, weights, create_plot=False) + assert all(isinstance(arr, np.ndarray) for arr in (rmin, rmax, ovf0, ovf1)) + + enob_sweep, n_bits_vec = analyze_enob_sweep( + bits, freq=freq_norm, create_plot=False + ) + assert enob_sweep.shape == n_bits_vec.shape + + radix_info = analyze_weight_radix(weights, create_plot=False) + assert {"radix", "wgtsca", "effres"} <= set(radix_info.keys()) + + +# ---------------------------------------------------------------------- +# advanced-debug.md — static nonlinearity +# ---------------------------------------------------------------------- + +def test_advanced_static_nonlinearity(synth_capture): + from adctoolbox import fit_static_nonlin + + aout = synth_capture["aout"] + + k2, k3, fitted_sine, fitted_transfer = fit_static_nonlin(aout, order=3) + assert isinstance(fitted_sine, np.ndarray) + assert isinstance(fitted_transfer, tuple) + assert len(fitted_transfer) == 2 + + +# ---------------------------------------------------------------------- +# advanced-debug.md — cap-to-weight +# ---------------------------------------------------------------------- + +def test_advanced_cap_to_weight(): + from adctoolbox.fundamentals import convert_cap_to_weight + + n_bits = 6 + caps_bit = np.array([2 ** i for i in range(n_bits)], dtype=float) + caps_bridge = np.zeros(n_bits, dtype=float) + caps_parasitic = np.zeros(n_bits, dtype=float) + + weights, c_total = convert_cap_to_weight(caps_bit, caps_bridge, caps_parasitic) + assert isinstance(weights, np.ndarray) + assert c_total > 0 + + +# ---------------------------------------------------------------------- +# api-quickref.md — flat-export presence (one big import sanity check) +# ---------------------------------------------------------------------- + +def test_api_quickref_flat_imports_resolve(): + from adctoolbox import ( + analyze_spectrum, analyze_spectrum_polar, + find_coherent_frequency, fit_sine_4param, + calibrate_weight_sine, + analyze_error_by_value, analyze_error_by_phase, + analyze_error_pdf, analyze_error_spectrum, + analyze_error_autocorr, analyze_error_envelope_spectrum, + analyze_inl_from_sine, + analyze_decomposition_time, analyze_decomposition_polar, + fit_static_nonlin, + analyze_bit_activity, analyze_overflow, + analyze_weight_radix, analyze_enob_sweep, + plot_residual_scatter, + calculate_walden_fom, calculate_schreier_fom, + calculate_thermal_noise_limit, calculate_jitter_limit, + db_to_mag, mag_to_db, db_to_power, power_to_db, + snr_to_enob, enob_to_snr, snr_to_nsd, nsd_to_snr, + bin_to_freq, freq_to_bin, fold_frequency_to_nyquist, + ntf_analyzer, + ) + # Each should be a callable / class + locals_dict = locals() + for name, obj in locals_dict.items(): + assert callable(obj), f"{name} is not callable" + + +def test_api_quickref_submodule_imports_resolve(): + from adctoolbox.siggen import ADC_Signal_Generator + from adctoolbox.calibration import calibrate_weight_sine_lite + from adctoolbox.fundamentals import ( + validate_aout_data, validate_dout_data, convert_cap_to_weight + ) + from adctoolbox.aout import analyze_phase_plane, analyze_error_phase_plane + from adctoolbox.spectrum import compute_spectrum + from adctoolbox.toolset import ( + generate_aout_dashboard, generate_dout_dashboard + ) + for obj in ( + ADC_Signal_Generator, calibrate_weight_sine_lite, + validate_aout_data, validate_dout_data, convert_cap_to_weight, + analyze_phase_plane, analyze_error_phase_plane, + compute_spectrum, generate_aout_dashboard, generate_dout_dashboard, + ): + assert callable(obj)