Put a passive piezo sounder on a sine-wave source and compare it with an ordinary capacitor. Measure the voltage on each side of a known resistor, then use the two waveforms to recover how much current the load draws and how far that current leads or lags its voltage. The comparison separates an electrical frequency response from the sound-pressure curve in a catalogue.
A piezoelectric element flexes when voltage is applied. The TDK PS1720P02 sounder, labeled BZ1, contains a vibrating element and needs an external drive signal. The device has two pins at a 10 mm pitch. TDK specifies a sound-pressure test at 2 kHz with a rectangular drive of 3 V from zero to peak. The catalogue separately gives a maximum input of 30 V from zero to peak without direct-current bias. This experiment uses a much smaller zero-offset sine. The catalogue’s acoustic test frequency is a starting point for the sweep. The electrical peak of your particular unit may occur elsewhere or be too small to resolve.
Use a TDK C1608C0G1H103J080AA capacitor as the control, labeled C1. It is a 10 nF ±5%, 50 V part with a C0G ceramic dielectric, a stable material between its electrodes. The 0603 surface-mount package needs a short two-terminal adapter. Use a measured 1 kΩ, 1%, quarter-watt series resistor, labeled R1. Wire just one load at a time. The same resistor, probe settings and measurement nodes serve both runs.
| Node | Connections |
|---|---|
| VIN | Generator CH1 output centre, upstream end of the 1 kΩ resistor and oscilloscope CH1 probe tip. |
| VD | Downstream end of the resistor, one load terminal and oscilloscope CH2 probe tip. |
| 0 V | Generator output shell, other load terminal and both oscilloscope probe reference clips. |

Use channel 1 (CH1) of the Tektronix AFG1022 or an equivalent grounded bench generator. Set sine, 0 V offset and about 1.0 V peak-to-peak, abbreviated Vpp. Set the generator load display to High Z, meaning a high-impedance load. Confirm the real amplitude at VIN, because the generator’s load setting changes how it displays the requested voltage. Start with the output disabled. Use two channels of an oscilloscope with suitable 1 MΩ inputs and two compensated Tektronix P2220 probes at 10×. The Siglent SDS1104X-E specifies 1 MΩ in parallel with 17 pF ±3 pF. A unit at the low end may fall outside the P2220’s 15–25 pF compensation range. Use the scope reference square wave to verify flat compensation on each actual channel before using those probes. If you cannot get a flat top, choose a compatible probe pair and revisit its input-load model. Set both scope channels to DC coupling and the actual 10× attenuation. DC here means direct current. A 50 Ω terminator would change the fixture.
Keep the equipment’s protective earth connections intact. All cable shells and both reference clips belong at 0 V, including when you use scope maths. A reference clip on VIN or VD would short that node to earth. Keep the probes and load leads short and clear of the sounder opening. With 1.0 Vpp at VIN and a short replacing the load, the nominal resistor current is 0.5 mA peak. At the 2.0 Vpp setup limit the short would give 1 mA peak and 0.5 mW average in 1 kΩ for a zero-offset sine. This is a low-voltage generator fixture. Stop if a waveform exceeds 2.0 Vpp, has an unexpected offset, clips or stops looking sinusoidal. Disable the output before changing the load or moving clips.
Admittance, measured in siemens (S), tells you how much alternating current a voltage produces. A phasor is a complex number that stores a sine wave’s size and phase. Use VIN as the zero-phase reference. The phasor for VD has the measured Vpp as its magnitude and phase −2π × frequency × lag_seconds. Consistent peak-to-peak magnitudes work because the factor of two cancels in a ratio.
Subtract the two phasors to find the voltage across the series resistor. Divide by the measured resistance to get the branch current. The load-plus-probe admittance is (VIN / VD − 1) / R. The generator’s source resistance is upstream of the measured VIN, so it does not go into R. Simple subtraction of the two Vpp readouts loses their phase difference. The two scope channels let you avoid placing a grounded clip across the resistor.
The real part of admittance, called conductance, represents the in-phase current. The imaginary part, called susceptance, represents the component that is a quarter cycle out of phase. An ideal capacitor’s susceptance is positive and equals 2π × frequency × capacitance with this phase convention. A nominal 10 nF control therefore gives about 62.8 µS at 1 kHz and 125.7 µS at 2 kHz. At 1 kHz and 1.0 Vpp, the series-resistor phasor drop is about 62.7 mVpp. Its magnitude is large enough to see why a 1 kΩ resistor is useful here.
The P2220 specification at 10× is 10 MΩ at direct current, with 13–17 pF system input capacitance. The analyzer separately reports the result before and after subtracting a nominal 10 MΩ and 15 pF parallel load from VD. At 2 kHz that nominal correction is 0.1 µS of conductance and 0.188 µS of susceptance. CH1 loads the source too, but VIN already includes its effect. The correction does not calibrate the scope channels, the wiring or the actual probe. The model can report an apparent capacitance from susceptance, which describes the reactive reading at one frequency. Near a mechanical mode of the sounder it can vary strongly or turn negative.

A repeatable peak in conductance can point to an electrical effect of mechanical motion in the sounder. The code labels only the largest sampled conductance and flags a maximum at a sweep edge. A peak between sampled frequencies, a mounting change, amplitude mismatch or noise can all change the result. Read both curves before interpreting a single point. The capacitor control should give approximately constant apparent capacitance and conductance close to zero within the measurement uncertainty. A small negative conductance can be a measurement residual. The analyzer preserves it so you can spot the problem. There are no acoustic pressure measurements in this setup.
Run python3 piezo_admittance.py synthetic-sweep.csv with Python 3.9 or newer. The supplied data were generated for an ideal control and a hypothetical vibrating branch in parallel with a capacitor. For real data, fill the two runs in a comma-separated values (CSV) file with the header below. Use volts, hertz and microseconds. Replace 1000 with the measured resistor value using --series-ohm. Both loads need the same frequency grid with at least three points.
The program accepts paired 500 Hz to 10 kHz rows, even though 1–4 kHz is the suggested first sweep. It rejects wrong headers, missing or duplicate points, nonfinite values, a crossing lag outside a quarter cycle, VD below 30 mVpp and a calculated resistor-vector drop below 30 mVpp. These lower bounds guard against very weak signals. They do not establish measurement uncertainty. Increase resolution or repeat a point if its result changes with the channel settings. If you cannot resolve a point for both loads, leave both corresponding rows out of the analysis file and document the omission.
Exact CSV header fixture,frequency_hz,source_vpp,device_vpp,device_lag_us. Use control and piezo for the fixture labels. The field branch_current_ma_pp includes the VD probe current. The output keys ending in _us in the conductance and susceptance fields denote microsiemens. In the lag field _us denotes microseconds.
"""Recover terminal admittance from a low-voltage two-channel sine sweep."""
import argparse
import cmath
import csv
import json
import math
from pathlib import Path
HEADER = ["fixture", "frequency_hz", "source_vpp", "device_vpp", "device_lag_us"]
FIXTURES = ("control", "piezo")
MIN_DROP_VPP = .03
def bounded(value, low, high, label):
try:
number = float(value)
except (TypeError, ValueError):
number = math.nan
if not math.isfinite(number) or not low <= number <= high:
raise ValueError(f"{label} must be between {low} and {high}")
return number
def decode(fields, series, probe_mohm, probe_pf):
fixture, f, source, device, lag = fields
if fixture not in FIXTURES:
raise ValueError("fixture must be control or piezo")
hz = bounded(f, 500, 10_000, "frequency Hz")
vin = bounded(source, .1, 2, "source Vpp")
vd = bounded(device, .03, 2, "device Vpp")
max_lag_us = 250_000 / hz
lag_us = bounded(lag, -max_lag_us, max_lag_us, "signed lag within a quarter cycle")
omega = math.tau * hz
load_voltage = cmath.rect(vd, -omega * lag_us * 1e-6)
resistor_vpp = abs(vin - load_voltage)
if resistor_vpp < MIN_DROP_VPP - 1e-12:
raise ValueError("resistor vector difference is less than 30 mVpp")
loaded = (vin / load_voltage - 1) / series
probe = complex(1 / (probe_mohm * 1e6), omega * probe_pf * 1e-12)
corrected = loaded - probe
return {
"fixture": fixture, "frequency_hz": hz, "device_lag_us": lag_us,
"resistor_vpp": resistor_vpp, "branch_current_ma_pp": 1000 * resistor_vpp / series,
"loaded_conductance_us": 1e6 * loaded.real,
"loaded_susceptance_us": 1e6 * loaded.imag,
"conductance_us": 1e6 * corrected.real, "susceptance_us": 1e6 * corrected.imag,
"apparent_capacitance_nf": 1e9 * corrected.imag / omega,
}
def read_sweep(path, series, probe_mohm, probe_pf):
readings = {name: {} for name in FIXTURES}
csv.field_size_limit(100)
with path.open(encoding="utf-8-sig", newline="") as stream:
reader = csv.reader(stream, strict=True)
if next(reader, None) != HEADER:
raise ValueError("CSV header must be " + ",".join(HEADER))
for number, fields in enumerate(reader, start=2):
try:
if len(fields) != len(HEADER) or number > 401:
raise ValueError("expected five fields and at most 400 rows")
decoded = decode(fields, series, probe_mohm, probe_pf)
values = readings[decoded["fixture"]]
frequency = decoded["frequency_hz"]
if frequency in values:
raise ValueError("duplicate frequency within this fixture")
values[frequency] = decoded
except ValueError as error:
raise ValueError(f"row {number}: {error}") from error
frequencies = sorted(readings["control"])
if len(frequencies) < 3 or readings["control"].keys() != readings["piezo"].keys():
raise ValueError("both fixtures need the same frequencies and at least three points")
control = [readings["control"][hz] for hz in frequencies]
piezo = [readings["piezo"][hz] for hz in frequencies]
highest = max(piezo, key=lambda row: row["conductance_us"])
value = highest["conductance_us"]
tied = [row["frequency_hz"] for row in piezo if row["conductance_us"] == value]
return {
"series_ohm": series, "probe_mohm": probe_mohm, "probe_pf": probe_pf,
"rows": control + piezo,
"piezo_conductance_peak": {
"frequency_hz": highest["frequency_hz"], "conductance_us": value,
"at_sweep_edge": any(hz in (frequencies[0], frequencies[-1]) for hz in tied),
"equal_peak_frequencies_hz": tied,
},
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("csv", type=Path, help="paired control and piezo readings")
parser.add_argument("--series-ohm", type=float, default=1000)
parser.add_argument("--probe-mohm", type=float, default=10)
parser.add_argument("--probe-pf", type=float, default=15)
args = parser.parse_args()
try:
series = bounded(args.series_ohm, 900, 1100, "series resistance Ω")
probe_mohm = bounded(args.probe_mohm, 9, 11, "device probe MΩ")
probe_pf = bounded(args.probe_pf, 0, 25, "device probe pF")
result = read_sweep(args.csv, series, probe_mohm, probe_pf)
except (OSError, ValueError, csv.Error) as error:
parser.exit(2, f"error: {error}\n")
print(json.dumps(result, indent=2, allow_nan=False))
if __name__ == "__main__":
main()
The two scope traces have nearly equal Vpp at 1 kHz with the 10 nF control. A friend subtracts the two displayed magnitudes and decides almost no current flows. Where did the current go in that calculation?
The two voltages are angled apart in phase. The simple scalar subtraction sees only their magnitudes. Subtract the voltage phasors with the measured lag included, then divide their difference by 1 kΩ. With the nominal values and 1.0 Vpp, the vector drop is about 62.7 mVpp, or 62.7 µA peak-to-peak. Ignoring the phase discards most of that voltage difference.
Get the reader experiment ZIP, complete Python source, synthetic sweep or run notes. The test suite exercises the actual analyzer. The traces shown here are calculated and the worksheet contains no bench readings. For a different use of a passive sounder, see the ATtiny85 piezo timer experiment, which checks the drive timing and stopping behavior.