A resistor, an inductor and a capacitor form a series RLC circuit. At one frequency in the ideal series model, the reactive voltages from the inductor and capacitor have equal magnitude and opposite phase, so they cancel in the series sum. The current peaks there. Add a resistor and the same circuit responds over a wider band, with a lower peak. You can see both changes with two oscilloscope channels and one low-side sense resistor.

The observations sheet is blank. The worked numbers and graph come from a calculated example. The Python checks exercise the analyzer on a host computer. Physical measurements are still needed.

Use these parts and two grounded channels

L1 is a Bourns 77F102J-RC axial inductor. The two leads are interchangeable. The manufacturer lists 1,000 microhenries (1 mH) ±5% under its test conditions, 20 Ω maximum direct-current resistance (DCR), and 1.2 MHz minimum self-resonant frequency. Self-resonance is where the winding's own capacitance becomes important. Our 35 kilohertz (35 kHz) upper limit is less than 3% of that minimum. The listed minimum quality factor of 50 is at 796 kHz. Use your own sweep to characterize the losses in this fixture near 16 kHz. The 100 mA direct-current figure corresponds to a 35 °C rise in the manufacturer's test.

C1 is a KEMET R82DC3100DQ50K nonpolar metallized polyester film capacitor. Its exact sheet gives 100 nanofarads (100 nF) ±10%, 5 mm lead spacing, 63 V direct current (DC) and 40 V alternating current (AC) ratings. The film capacitor can handle the low-voltage sine centered at zero volts. For RS and RADD use ordinary 22 Ω and 33 Ω metal-film resistors, respectively, each ±1% and rated at least 0.25 W. Measure each resistor off the board if your meter can resolve these values. Subtract the resistance of shorted meter leads when measuring the inductor's winding.

Use Tektronix AFG1022 generator channel 1 and RIGOL DS1054Z scope channels 1 and 2, with two compensated RIGOL PVP3150 probes at 10×. Set both scope menus to the matching attenuation and DC coupling. The scope's direct analog input is 1 MΩ in parallel with 15 pF nominal. The compensated probe adds its own loading. The 22 Ω sense path is much lower in impedance, which helps make the probe's effect small. Avoid a 50 Ω scope termination, which would materially change the sense path.

The generator center drives a link or 33 ohm resistor, a 1 millihenry coil and 100 nanofarad capacitor in series. A 22 ohm resistor returns SENSE to ground. Both scope clips and the generator shield share ground. Channel 1 measures VIN and channel 2 measures SENSE.
Connect both probe ground clips and the generator shield to the same ground bus. Editable fixture in Scalable Vector Graphics (SVG).
  1. With the generator output off, connect the center of the channel 1 bayonet Neill–Concelman (BNC) coaxial connector to VIN. Connect VIN through a short wire link at RADD, L1 and C1 to SENSE in that order. Connect RS from SENSE to ground (GND). Connect the generator connector shield to GND.
  2. Attach both probe clips to GND. Put the channel 1 tip at VIN and the channel 2 tip at SENSE. Both clips are earth referenced on this oscilloscope, and the generator's output shield is chassis grounded. A clip on VIN, SENSE or the junction between the inductor and capacitor would short that point through the instruments. Keep the board connected only to this low-voltage fixture.
  3. Use the generator's sine setting, zero offset and High Z display setting. High Z means high impedance. This setting changes the amplitude shown on the generator. The output still has a typical 50 Ω source impedance. Start with a displayed 1.00 V peak to peak (Vpp), check the two channels, then use 2.00 Vpp displayed for both sweeps if needed for resolution. Keep the measured VIN at or below 2.20 Vpp and near zero DC offset. Turn off the output before moving probes or replacing the link.

For a steady sine and the simple series model, 2.20 Vpp at VIN is 1.10 V peak. Keeping the 22 Ω ±1% sense resistor in place gives a deliberately conservative current bound of 1.10 V ÷ 21.78 Ω ≈ 51 mA peak, even when the inductor and capacitor reactances cancel. The same bound gives at most about 28 mW average dissipation in RS. These bounds use the measured drive and intact sense path. Stop if a connection bypasses RS, either trace clips, or a part warms appreciably.

Measure a ratio on a common frequency grid

Set the generator to 5 kHz. On the scope, trigger from channel 1 and display several complete, steady cycles. Set each vertical scale so its trace fills several divisions without clipping. As a channel check, use the same VIN point for both tips at a midband frequency and verify their readings agree to a few percent before putting channel 2 back on SENSE. Use averaging on a stable trigger if the lowest sense reading is noisy. Read peak-to-peak voltage on each channel. Use the frequency measured at VIN to check the generator's setting, and record the same set frequency for both runs when the scope agrees.

Fill a copy of the blank observations sheet in comma-separated values (CSV) format from 5 to 35 kHz. It has 34 frequency points, with tighter spacing around the expected peak and crossings. Use baseline for the wire link. At each frequency wait for the traces to settle and record VIN and SENSE in volts peak to peak from the same acquisition. Turn the output off, replace only the link with the 33 Ω resistor, then repeat the exact frequency grid under damped. Keep the generator amplitude setting, probe connections and RS unchanged.

Compare the ratio H = VSENSE,pp ÷ VIN,pp at each frequency. Using peak-to-peak voltage on both channels cancels the scaling between peak and root-mean-square voltage for clean sinusoids. The current through RS is its voltage divided by RS. Channel 1 measures the actual loaded voltage at the connector side of the fixture. Dividing by it removes amplitude changes due to the source's series impedance from the comparison. Dividing by the generator's setting instead would include that impedance in the response. The calculation treats the probe on SENSE as a high-impedance observer. See the capacitor discharge experiment for a circuit where the meter load is much more consequential.

Predict where the peak and the two crossings land

Reactance is the frequency-dependent opposition from an inductor or capacitor, in ohms. Angular frequency ω = 2πf is measured in radians per second. In this circuit the inductor gives XL = ωL. The capacitor contributes XC = −1/(ωC). The series reactances cancel at f0 = 1/(2π√(LC)). With 1 mH and 100 nF, the prediction is 15.92 kHz. Treating the printed component tolerances as independent gives a rough 14.81–17.21 kHz range. The winding is characterized under different conditions, and fixture parasitics can affect the result.

Let Rtotal be the resistance inside the measured fixture. It includes RS, the added resistor if present, the winding and other series losses. When those losses are approximately constant over the measured band, use the following ratio.

H(f) = RS / √(Rtotal2 + (ωL − 1/(ωC))2)

The peak ratio is RS/Rtotal. The half-power points are where H falls to the peak divided by √2. At fixed VIN this is half the peak power in RS, since the power is proportional to the square of the sense voltage. Their difference is the bandwidth B = Rtotal/(2πL). The geometric mean of the crossing frequencies, √(flowfhigh), equals f0 in this ideal series model. You can use that equality as a cross-check. The two crossings generally lie at unequal distances from the peak in hertz. The quality factor Q = f0/B describes how narrow the band is relative to the peak frequency.

Two calculated response curves peak near 15.9 kilohertz. The 34 ohm series model has a higher, narrower peak, while the 67 ohm model has a lower, wider peak. Dotted horizontal segments mark the respective half-power bands.
Calculated curves for a 12 Ω illustrative winding, with 22 Ω sense and either a link or 33 Ω added. Editable response SVG.
Calculated values, with 12 Ω used only for this example
ConfigurationSeries RPeak ratioIdeal bandwidthFinite-grid example
Link34 Ω0.6475.41 kHz5.42 kHz
33 Ω added67 Ω0.32810.66 kHz10.67 kHz

Try two estimates of effective series resistance. Divide RS by the sampled peak ratio, then calculate 2πLB from the observed width. The first estimate tends high if your grid misses the true peak. The second depends on your inductance and the crossing interpolation. The coil's DC resistance is a separate observation. Copper, core and capacitor losses can change with frequency. If the two estimates disagree, tighten the grid and check the channels before using the numbers to infer a loss mechanism.

Run the analyzer

Save the program as rlc_sweep.py. Open a terminal in its directory and run python3 rlc_sweep.py observations.csv with your filled sheet. To compare the width against a DC-resistance model, append --winding-dc-ohms followed by your isolated coil's resistance in ohms. The optional --l-mh, --c-nf, --sense-ohms and --damping-ohms let you enter the component values used in the comparison.

The program requires two runs on the same grid. It reports the greatest sampled ratio, then linearly interpolates one crossing on each side at that run's own half-power level. Its neighboring sample frequencies show the local grid spacing. If a peak is tied, occurs at an edge, or the sweep fails to give one crossing on each side, the bandwidth is null. Extend the sweep, add matching frequency rows near the peak or crossings in both runs, or improve the acquisition and repeat. A ratio above unity calls for rechecking attenuation, channel labels and the shared ground.

To check the program before the bench work, run python3 rlc_sweep.py synthetic-sweep.csv --winding-dc-ohms 12. The file uses the model above with an intentionally changing input amplitude. Both runs yield a sampled peak at 15.9 kHz, while their widths differ. The complete experiment archive includes that input, the expected report, the blank worksheet and the behavior tests.

#!/usr/bin/env python3
"""Summarize paired, steady-sine series RLC measurements."""
import argparse
import csv
import json
import math
from pathlib import Path

FIELDS = ("run", "frequency_hz", "input_vpp", "sense_vpp")
RUNS = ("baseline", "damped")


def read_runs(stream):
    """Read Vpp pairs on the same frequency grid for both configurations."""
    reader = csv.DictReader(stream)
    if tuple(reader.fieldnames or ()) != FIELDS:
        raise ValueError("CSV header must be " + ",".join(FIELDS))
    runs = {run: {} for run in RUNS}
    for row in reader:
        run = row["run"]
        if run not in runs:
            raise ValueError(f"line {reader.line_num}: use baseline or damped")
        if None in row or any(value is None for value in row.values()):
            raise ValueError(f"line {reader.line_num}: expected four fields")
        try:
            f, vin, sense = (float(row[field]) for field in FIELDS[1:])
        except ValueError:
            raise ValueError(f"line {reader.line_num}: enter three numeric values") from None
        if not all(map(math.isfinite, (f, vin, sense))):
            raise ValueError(f"line {reader.line_num}: use finite values")
        if not 100 <= f <= 100_000 or not 0.05 <= vin <= 2.20 or not 0 < sense <= 1.01 * vin:
            raise ValueError(f"line {reader.line_num}: check frequency, Vpp and channel scaling")
        if f in runs[run]:
            raise ValueError(f"line {reader.line_num}: duplicate {run} frequency {f:g}")
        runs[run][f] = sense / vin
    if runs["baseline"].keys() != runs["damped"].keys():
        raise ValueError("both runs must use the same frequencies")
    if len(runs["baseline"]) < 7:
        raise ValueError("enter at least seven frequencies in each run")
    return {run: sorted(ratios.items()) for run, ratios in runs.items()}


def crossings(points, target):
    """Return exact hits and linear estimates between straddling samples."""
    hits = {f for f, ratio in points if ratio == target}
    for (f0, a), (f1, b) in zip(points, points[1:]):
        if min(a, b) < target < max(a, b):
            hits.add(f0 + (f1 - f0) * (target - a) / (b - a))
    return sorted(hits)


def summarize(points, inductance_h, sense_ohms):
    ratios = [ratio for _, ratio in points]
    peak = max(ratios)
    indices = [i for i, ratio in enumerate(ratios) if math.isclose(ratio, peak, rel_tol=1e-6)]
    threshold = peak / math.sqrt(2)
    result = {
        "sample_count": len(points), "peak_ratio": peak, "peak_hz": None,
        "peak_neighbor_span_hz": None, "half_power_ratio": threshold,
        "half_power_hz": None, "bandwidth_hz": None, "sampled_q": None,
        "resistance_from_peak_ohms": None, "resistance_from_bandwidth_ohms": None,
        "flags": [],
    }
    flags = result["flags"]
    if peak > 1:
        flags.append("ratio_above_one_check_channels")
    if len(indices) != 1:
        flags.append("peak_tied")
        return result
    index = indices[0]
    peak_hz = points[index][0]
    result["peak_hz"] = peak_hz
    if index in (0, len(points) - 1):
        flags.append("peak_at_sweep_edge")
        return result
    result["peak_neighbor_span_hz"] = [points[index - 1][0], points[index + 1][0]]
    if peak <= 1:
        result["resistance_from_peak_ohms"] = sense_ohms / peak

    left = crossings(points[:index + 1], threshold)
    right = crossings(points[index:], threshold)
    if len(left) > 1 or len(right) > 1:
        flags.append("multiple_half_power_crossings")
    if not left or not right:
        flags.append("half_power_crossing_missing")
    if len(left) == len(right) == 1:
        low, high = left[0], right[0]
        bandwidth = high - low
        result.update(half_power_hz=[low, high], bandwidth_hz=bandwidth,
                      sampled_q=peak_hz / bandwidth,
                      resistance_from_bandwidth_ohms=2 * math.pi * inductance_h * bandwidth)
    return result


def analyze(runs, l_mh, c_nf, sense_ohms, damping_ohms, winding_dc_ohms=None):
    values = (l_mh, c_nf, sense_ohms, damping_ohms)
    bounds = ((0.1, 10), (10, 1000), (1, 1000), (1, 1000))
    if not all(math.isfinite(x) and lo <= x <= hi for x, (lo, hi) in zip(values, bounds)):
        raise ValueError("component inputs are outside the supported positive ranges")
    if winding_dc_ohms is not None and not (0 <= winding_dc_ohms <= 200):
        raise ValueError("component winding DC resistance must be 0 to 200 ohms")
    inductance_h = l_mh / 1000
    capacitance_f = c_nf / 1e9
    f0 = 1 / (2 * math.pi * math.sqrt(inductance_h * capacitance_f))
    reports = {}
    for run in RUNS:
        report = summarize(runs[run], inductance_h, sense_ohms)
        if winding_dc_ohms is not None:
            extra = damping_ohms if run == "damped" else 0
            dc_model_r = sense_ohms + winding_dc_ohms + extra
            report["dc_model_peak_ratio"] = sense_ohms / dc_model_r
            report["dc_model_bandwidth_hz"] = dc_model_r / (2 * math.pi * inductance_h)
        reports[run] = report
    return {"nominal_f0_hz": f0, "runs": reports}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("csv_path", type=Path)
    parser.add_argument("--l-mh", type=float, default=1.0)
    parser.add_argument("--c-nf", type=float, default=100.0)
    parser.add_argument("--sense-ohms", type=float, default=22.0)
    parser.add_argument("--damping-ohms", type=float, default=33.0)
    parser.add_argument("--winding-dc-ohms", type=float)
    args = parser.parse_args()
    try:
        with args.csv_path.open(encoding="utf-8-sig", newline="") as stream:
            runs = read_runs(stream)
        report = analyze(runs, args.l_mh, args.c_nf, args.sense_ohms,
                         args.damping_ohms, args.winding_dc_ohms)
    except (OSError, UnicodeError, csv.Error, ValueError) as error:
        parser.exit(2, f"error: {error}\n")
    print(json.dumps(report, indent=2, allow_nan=False))


if __name__ == "__main__":
    main()

Challenge

On the same frequency grid, the 33 Ω run has a lower peak. Someone uses the baseline peak divided by √2 as the threshold for both runs and reports that the second run has no bandwidth. What happened?

Show the answer

Each half-power threshold belongs to its own peak. In the calculated example the baseline threshold is about 0.458, while the damped peak is only about 0.328. The second curve never reaches 0.458. Use its own threshold of about 0.232. The larger series resistance then gives a larger bandwidth even though the nominal L and C and the calculated resonance stay the same.

Manufacturer references