Ceramic Capacitor DC Bias: Measure Two Small Steps

Keep one ceramic capacitor in the fixture and move its steady voltage from about 0.5 V to 4.5 V. At each voltage, record the same small step and use its settling time to estimate capacitance. Repeat both captures with a film capacitor as a control.

Evidence scope. The example waveforms are synthetic and the supplied analyzer was tested on a computer. The fixture is proposed for an isolated low-voltage bench. No instrument trace, hardware verification or tolerance guarantee is claimed.

Why keep the step small?

A multilayer ceramic capacitor (MLCC) stores charge between stacked electrodes separated by ceramic. Direct-current (DC) bias means the steady voltage across it. With a Class 2 ceramic such as X5R, the extra charge gained for a small extra voltage can depend on the steady voltage already present. X5R describes a temperature characteristic under specified test conditions. The marking alone gives no capacitance at 4.5 V.

For a small voltage excursion, the effective incremental capacitance is approximately the change in charge divided by the change in voltage near that operating point. Charging from zero all the way to 4.5 V would sample a broad voltage range. Here each rising step covers about 0.20 V. The fitted number is a local, finite-step estimate for this circuit, time scale, temperature and part history. It can differ from a vendor’s sinusoidal small-signal graph.

Build one fixture and change only the capacitor

The generator center conductor drives a 1 kilohm resistor from DRIVE to NODE. The selected capacitor and the scope input equivalent are connected from NODE to a common 0 V. Scope CH1 senses DRIVE and CH2 senses NODE, with both shields and the generator shield at 0 V.
Original low-voltage fixture schematic. Fit just one capacitor at C1. Editable SVG

Connect the generator center to DRIVE. R1 goes from DRIVE to NODE. Connect the ceramic or film capacitor from NODE to 0 V. Connect scope CH1 center to DRIVE and CH2 center to NODE. Every cable shield goes to 0 V. A grounded scope shield at NODE would bypass C1. Disable the generator, discharge C1 through a resistor to the common return and verify near zero volts before swapping either part.

Set the generator for a high-impedance load display and a 1 Hz square wave at 50% duty, 0.20 V peak to peak. Start with a +0.50 V offset. Then repeat at +4.50 V. The nominal DRIVE levels are 0.40/0.60 V and 4.40/4.60 V. Check the actual waveforms before relying on the display. Keep the measured voltage positive and below 5 V throughout normal operation. The generator still has a series output impedance near 50 Ω. CH1 measures the loaded voltage downstream of it. A short at NODE would put under 5 mA through the 1 kΩ branch at these voltages.

Fit the measured drive and account for the scope

CH2 contributes an input resistance Rp to 0 V and a parallel capacitance Cp that includes its cable and fixture. The DHO800 specification gives 1 MΩ ±1% and 15 pF ±3 pF for the bare input. A short direct cable adds capacitance. Use a measured total when available. The supplied 150 pF default is an explicit fixture estimate. Even a 2 nF correction equals only 0.002 µF, which is 0.2% of the bottom of this analyzer’s 1–15 µF range.

Current through R1 is the difference between DRIVE and NODE divided by R1. Part of that current flows through the scope resistance. For the rest, the fitted model is (C1 + Cp) · dVNODE/dt = (VDRIVE − VNODE)/R1 − VNODE/Rp. The steady gain is g = Rp / (R1 + Rp). The fitted time constant is τ = (R1 ∥ Rp) · (C1 + Cp). Solve for C1 = τ / (R1 ∥ Rp) − Cp. The symbol ∥ means the parallel equivalent. With 1 kΩ and 1 MΩ that is 999.001 Ω. The model already uses the recorded DRIVE, so the generator’s 50 Ω does not enter this parallel resistance.

The program subtracts the measured pre-step means, treats DRIVE as linear between adjacent samples and tests a range of single-pole time constants. A time constant is the time to cover about 63.2% of the remaining distance for an ideal instantaneous step. With a slower measured edge, the program follows the actual input instead of using a threshold time alone. The root mean square residual combines the voltage differences between model and capture. A large residual calls for another look at input settling, noise, changing capacitance over the step or extra time constants.

A calculated chart shows one 200 millivolt drive edge and three response curves. Synthetic ceramic at 4.5 volts settles faster than at 0.5 volts. The two synthetic film captures have the same response and overlap.
Constructed inputs and outputs from the shipped synthetic fixture. The example capacitances are chosen model inputs. Editable SVG

Capture four rising steps

  1. Set both scope channels to DC coupling, 1× and about 100 mV per division. Use the vertical offset to center the traces near each bias. The DHO800 sheet gives ±8 V offset in this range. Inspect both traces for clipping, ground errors and sufficient resolution. Trigger on the rising DRIVE edge and use a common timebase for both channels.
  2. Keep roughly 6 ms of settled pre-edge data and at least 75 ms after the edge. The first 5% of samples and the final 10% must fall within their respective steady plateaus. If you extend the post-edge capture, extend the pre-edge capture as needed to retain a stable first 5%. Keep the sample interval at 0.10 ms or less for the nominal fixture and retain enough samples across the response. Save the two channels from the same acquisition. Crop to one rising edge and both plateaus, then transfer the time-aligned values in seconds and volts to the reader CSV. Do not fill missing channel values or replace DRIVE with a guessed square wave.
  3. Capture mlcc_low near 0.5 V and mlcc_high near 4.5 V. After safely changing C1, capture film_low and film_high the same way. Repeat both materials with the same wiring, step size and temperature. A second low-bias run after high bias can expose drift or history. Compare complete sets rather than mixing runs from different setups.
  4. Compare the ceramic high/low ratio with the film high/low ratio. If the film changes by more than the analyzer’s 5% screen, first investigate setup, noise, offset, unexpected loading or repeatability. That screen is a bench decision threshold. Passing it gives no error bound on the ceramic result.

The MCP1700 dropout experiment gives a circuit context where input and output capacitors have a practical job. This fixture estimates a standalone part’s local response. It cannot establish regulator stability, large-signal energy storage, capacitance at other frequencies or the performance of a production lot.

Run the complete analyzer

Use Python 3.9 or later. Each CSV row has run,t_s,drive_v,node_v. All four names are required and time must increase strictly within each run. The blank worksheet has one starter row per run. Replace those blank entries with actual paired samples. Use observed only for your own capture.

python3 bias_step.py --kind synthetic synthetic-captures.csv

python3 bias_step.py --kind observed --resistance-ohm 998.7 --probe-ohm 1000000 --parasitic-pf 150 my-captures.csv

The JSON records the measured step, loaded bias center, fitted time constant, largest sample gap and residuals for each run. The CLI rejects incomplete, unmatched or unstable captures, residuals outside the single-pole screen, too little settling time or sample gaps above one tenth of the fitted time constant. The rising step range is 0.08–0.30 V. It accepts the stated fixture range of 1–15 µF. With a valid but shifting control it reports a caution and retains the numerical estimates.

#!/usr/bin/env python3
"""Fit four small-step captures using the recorded input, not an ideal edge."""
import argparse
import csv
import json
from math import exp, expm1, isfinite, log, sqrt
from pathlib import Path
from statistics import fmean

RUNS = ('mlcc_low', 'mlcc_high', 'film_low', 'film_high')
HEADER = ['run', 't_s', 'drive_v', 'node_v']


def read_runs(path):
    runs = {name: [] for name in RUNS}
    with path.open(encoding='utf-8-sig', newline='') as stream:
        rows = csv.reader(stream, strict=True)
        if next(rows, None) != HEADER:
            raise ValueError('expected header ' + ','.join(HEADER))
        for row in rows:
            if len(row) != 4 or row[0] not in runs:
                raise ValueError(f'line {rows.line_num}: expected a named run and three values')
            samples = runs[row[0]]
            try:
                t, drive, node = map(float, row[1:])
            except ValueError as exc:
                raise ValueError(f'line {rows.line_num}: expected three finite numbers') from exc
            if not all(isfinite(x) for x in (t, drive, node)):
                raise ValueError(f'line {rows.line_num}: expected three finite numbers')
            if abs(t) > 2 or not -.05 <= min(drive, node) <= max(drive, node) <= 5.25:
                raise ValueError(f'line {rows.line_num}: outside fixture time or voltage limits')
            if samples and t <= samples[-1][0]:
                raise ValueError(f'{row[0]}: time must be strictly increasing')
            if len(samples) >= 20000:
                raise ValueError(f'{row[0]}: at most 20000 points per run')
            samples.append((t, drive, node))
    if any(not samples for samples in runs.values()):
        raise ValueError('all four named runs are required')
    return runs


def advance(state, a, b, dt, tau):
    """Exact step between two forcing voltages with linear interpolation."""
    ratio = dt / tau
    decay_complement = -expm1(-ratio)
    if ratio < 0.001:
        ramp_weight = ratio * (0.5 - ratio / 6 + ratio * ratio / 24)
    else:
        ramp_weight = 1 - decay_complement / ratio
    return state + (a - state) * decay_complement + (b - a) * ramp_weight


def fit_tau(intervals):
    def loss(tau):
        state = 0.0
        errors = []
        for dt, a, b, observed in intervals:
            state = advance(state, a, b, dt, tau)
            errors.append(abs(state - observed))
        return sqrt(fmean(x * x for x in errors)), max(errors)

    # Fixture bounds encompass the intended 1–15 µF with a 1 kΩ resistor.
    lo, hi = log(0.0002), log(0.040)
    for _ in range(52):
        x = lo + (hi - lo) / 3
        y = hi - (hi - lo) / 3
        if loss(exp(x))[0] < loss(exp(y))[0]:
            hi = y
        else:
            lo = x
    tau = exp((lo + hi) / 2)
    return tau, loss(tau)


def characterize(name, samples, gain, effective, parasitic):
    if len(samples) < 40:
        raise ValueError(f'{name}: too few samples')
    t, drive, node = zip(*samples)
    head, tail = max(5, len(t) // 20), max(5, len(t) // 10)
    v0, v1 = fmean(drive[:head]), fmean(drive[-tail:])
    x0, x1 = fmean(node[:head]), fmean(node[-tail:])
    step = v1 - v0
    # Endpoints tolerate only floating-point subtraction, not meter uncertainty.
    if not 0.08 - 1e-9 <= step <= 0.30 + 1e-9:
        raise ValueError(f'{name}: drive step amplitude must be 0.08–0.30 V, rising')
    center = (x0 + x1) / 2
    want = 0.5 if name.endswith('low') else 4.5
    if abs(center - want) > 0.25:
        raise ValueError(f'{name}: loaded capacitor bias window must be near {want:.1f} V')
    if max(abs(v-v0) for v in drive[:head]) > .03*step or max(abs(v-v1) for v in drive[-tail:]) > .03*step:
        raise ValueError(f'{name}: drive plateaus are not stable')
    if max(abs(v-x0) for v in node[:head]) > .03*step:
        raise ValueError(f'{name}: capacitor baseline is not settled')
    fractions = [(v-v0)/step for v in drive]
    if min(fractions) < -.05 or max(fractions) > 1.05:
        raise ValueError(f'{name}: single rising step exceeded amplitude envelope')
    prior_max = fractions[0]
    for value in fractions:
        if value < prior_max - .06:
            raise ValueError(f'{name}: expected a single rising step without a second edge')
        prior_max = max(prior_max, value)
    i10, i90 = (next(i for i, f in enumerate(fractions) if f >= level) for level in (.1, .9))
    if t[i10]-t[0] < .002:
        raise ValueError(f'{name}: retain at least 2 ms of baseline before the edge')
    def crossing(index, level):
        prev = index - 1
        f = (level-fractions[prev])/(fractions[index]-fractions[prev])
        return t[prev] + f*(t[index]-t[prev])
    rise = crossing(i90,.9) - crossing(i10,.1)
    gaps = [b-a for a,b in zip(t,t[1:])]
    intervals = [(dt, gain*(drive[i]-v0), gain*(drive[i+1]-v0), node[i+1]-x0)
                 for i,dt in enumerate(gaps)]
    tau, (rms, peak) = fit_tau(intervals)
    if max(gaps) > tau/10:
        raise ValueError(f'{name}: sample gaps exceed one tenth of fitted tau')
    if t[-1]-t[i90] < 5*tau or abs((x1-x0)-gain*step) > .035*step:
        raise ValueError(f'{name}: insufficient settling or unexpected final gain')
    if rms > .02*step or peak > .06*step:
        raise ValueError(f'{name}: residual too large for a single-pole approximation')
    value = (tau/effective-parasitic)*1e6
    # The numerical fit can land microscopically outside an exact endpoint.
    if not 1 - 1e-6 <= value <= 15 + 1e-6:
        raise ValueError(f'{name}: capacitance outside this fixture’s 1–15 µF analysis range')
    value = min(15, max(1, value))
    return {'samples':len(t),'loaded_mid_bias_v':center,'drive_step_v':step,'node_step_v':x1-x0,
            'drive_rise_10_to_90_ms':1000*rise,'largest_sample_gap_ms':1000*max(gaps),
            'time_constant_ms':1000*tau,'effective_capacitance_uF':value,
            'rms_residual_mV':1000*rms,'peak_residual_mV':1000*peak}


def analyze(runs, resistance, probe, parasitic_pf, kind):
    if not 500 <= resistance <= 1500:
        raise ValueError('series resistance must be 500–1500 ohm')
    if not 800000 <= probe <= 1200000:
        raise ValueError('probe input resistance must be 0.8–1.2 megohm')
    if not 0 <= parasitic_pf <= 2000:
        raise ValueError('parasitic capacitance must be 0–2000 pF')
    gain = probe/(resistance+probe)
    effective = resistance*gain
    out = {name:characterize(name, samples, gain, effective, parasitic_pf*1e-12)
           for name,samples in runs.items()}
    for bias in ('low','high'):
        a,b = (out[f'{part}_{bias}'] for part in ('mlcc','film'))
        if abs(a['loaded_mid_bias_v']-b['loaded_mid_bias_v']) > .10:
            raise ValueError(f'{bias}: ceramic and film bias centres must match within 0.10 V')
    steps = [x['drive_step_v'] for x in out.values()]
    if max(steps)-min(steps) > .10*max(steps):
        raise ValueError('all four runs require a matched step within 10% of the largest')
    def ratio(part):
        return out[f'{part}_high']['effective_capacitance_uF']/out[f'{part}_low']['effective_capacitance_uF']
    film = ratio('film')
    control = abs(film-1) <= .05
    return {'kind':kind,'parallel_resistance_ohm':effective,'drive_to_node_dc_gain':gain,
            'assumed_total_node_parasitic_pF':parasitic_pf,'runs':out,
            'comparison':{'mlcc_high_over_low':ratio('mlcc'),'film_high_over_low':film,
                          'film_control_within_5_percent':control,
                          'interpretation':'local_estimates_only' if control else 'check_film_control_first'}}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--kind', choices=('synthetic','observed'), required=True)
    parser.add_argument('--resistance-ohm', type=float, default=1000)
    parser.add_argument('--probe-ohm', type=float, default=1e6)
    parser.add_argument('--parasitic-pf', type=float, default=150)
    parser.add_argument('csv', type=Path)
    args = parser.parse_args()
    try:
        result = analyze(read_runs(args.csv), args.resistance_ohm, args.probe_ohm,
                         args.parasitic_pf, args.kind)
    except (OSError, ValueError, UnicodeError, csv.Error) as exc:
        parser.error(str(exc))
    print(json.dumps(result, indent=2, allow_nan=False))


if __name__ == '__main__':
    main()

Blank capture worksheet · Synthetic four-run capture · Full reader experiment and tests ZIP

Challenge

Assume the loaded time constants are 9.09 ms near 0.5 V and 4.30 ms near 4.5 V, the film control remains stable, and R1 and the scope load stay the same. What ratio should you report?

Show the answer

Using R1 ∥ Rp = 999.001 Ω and a 150 pF correction gives about 9.10 µF and 4.30 µF. The high/low ratio is about 0.47, or 47% retained for these example steps. Both times use the same resistance, so ignoring the tiny parasitic correction gives almost the same ratio. Report the bias levels and the step size with it.

Primary references