Capture a red light signal near 173 Hz with a BPW34 photodiode. Then keep fewer samples from the same recording. At roughly 600 samples per second, the main frequency should stay near 173 Hz. At roughly 250 samples per second, the retained points should fit a slower wave near 77 Hz. The signal and its analog circuit stay the same while the sampling rate changes.
The bench procedure uses one function generator, a two-channel capture and a repeat with the light path blocked. You can run the complete analysis first on the supplied synthetic recordings. In the DRV5032 door-sensor experiment, sampling can miss a short opening entirely. Here it gives a steady, repeated signal a different apparent frequency.

Use a Vishay TLHR5400 red light-emitting diode (LED), a Vishay BPW34 silicon photodiode, 330 Ω, 1 kΩ and 10 kΩ resistors (each ±1%, 0.25 W is sufficient), and a 100 nF ceramic capacitor rated for at least 10 V. Use a SIGLENT SDG1032X dual-output generator and a RIGOL DHO804 oscilloscope with two compensated PVP2350 probes switched to 10×. Check that the scope menus also show 10×.
Turn both generator outputs off before wiring. Use one common 0 V point for both generator output shields, the scope reference clips and the fixture return. Keep the instruments grounded as their manuals specify. Work only with this isolated low-voltage fixture. Put probe tips on the named nodes and leave both reference clips at 0 V, including when checking a voltage across a component.
| Node | Connection |
|---|---|
| DRIVE | Generator CH1 center to 330 Ω, then to LED anode. LED cathode to 0 V. Scope CH1 tip at DRIVE. |
| BIAS | Generator CH2 center through 1 kΩ to BIAS. Fit 100 nF from BIAS to 0 V. Connect the marked BPW34 cathode to BIAS. |
| SENSE | BPW34 anode to SENSE and 10 kΩ from SENSE to 0 V. Scope CH2 tip at SENSE. |
Use the BPW34 package drawing to identify its cathode mark. The LED drawing shows the cathode on the flat side of the flange. Check both with the power off if the leads have been trimmed. Face the LED toward the photodiode’s sensitive window. Start about 10 mm apart inside an opaque, nonconductive tube. Leave a narrow slot for a matte opaque card that can block the light without moving either device. Close the slot against room light during the open recording as well.
The 10× probe’s specified 10 MΩ at DC makes the 10 kΩ load about 9.99 kΩ, a 0.1% change at nominal values. The BPW34 data sheet gives 25 pF typical and 40 pF maximum at 3 V reverse bias, in darkness at 1 MHz. The probe adds 10 pF ±5 pF and the wiring adds an unknown amount. For scale, a lumped model with 120 pF and 11.2 kΩ gives a time constant near 1.34 µs, versus about 5.78 ms per cycle at 173 Hz. The 120 pF is an illustrative allowance. Keep the wiring short and treat the actual capture as the check for analog attenuation and distortion.
Let fs be the sample rate from the exported timestamps and M the integer stride. Keeping row 0 and every Mth row after it gives a new sample rate F = fs / M. The Nyquist frequency for those samples is F / 2. Fold the original drive frequency f0 into that interval by subtracting the nearest integer multiple of F and taking the absolute value.

At 8,000 samples/s, keeping every 13th row gives 615.38 samples/s and leaves the 173 Hz tone below the new Nyquist frequency. Keeping every 32nd row gives 250 samples/s. In that case f0 exceeds 125 Hz and the expected alias is 250 − 173 = 77 Hz. At each retained instant the two ideal cosine values can agree exactly, as the figure shows. An alias can look like a clean signal.
The code deliberately drops rows without an anti-alias filter. In a real downsampling chain, a digital low-pass filter before discarding samples suppresses content above the new band. An analog filter plays the corresponding role before an analog-to-digital converter. Filtering after the fold cannot tell the 77 Hz alias apart from a real 77 Hz component. To retain the original 173 Hz signal, keep an adequately higher rate and allow room for the filter transition.
For each export, remove the instrument metadata in a copy, convert time to seconds and voltages to volts, and keep every simultaneous row in its original order. Use the header time_s,drive_v,sense_v. Confirm the conversion with the oscilloscope display. Save the normalized files as open.csv and blocked.csv, then run python3 optical_alias.py open.csv blocked.csv.
The code checks matching timebases and voltage bounds, then uses a Hann window to reduce leakage in each frequency estimate. It finds the strongest eligible peaks independently before comparing them with predictions from the measured DRIVE and sample rate. It requires the running drive to match in the two acquisitions. At each sample rate the analyzer checks the blocked trace both at the observed detector peak and at the frequency predicted from the blocked capture’s own DRIVE. The larger blocked-path amplitude must be at most 20% of the optical peak. Failure means the capture cannot support this comparison. It does not identify the physical cause.
Try python3 optical_alias.py synthetic-open.csv synthetic-blocked.csv with the provided synthetic data. The actual host output rounds to 172.97 Hz on DRIVE and 172.97 Hz on native SENSE. It gives 173.01 Hz at 615.38 samples/s and 77.03 Hz at 250 samples/s. These files model one main tone, a small harmonic, an ambient term and weak feedthrough. They are not measurements from the fixture.
"""Measure and predict raw-stride aliases in two optical capture CSVs."""
import argparse
import cmath
import csv
import json
import math
from statistics import fmean
import sys
HEADER = ["time_s", "drive_v", "sense_v"]
MAX_ROWS = 250_000
def read_capture(path):
times, drive, sense = [], [], []
with open(path, newline="", encoding="utf-8-sig") as source:
rows = csv.reader(source, strict=True)
if next(rows, []) != HEADER:
raise ValueError("expected header time_s,drive_v,sense_v")
for number, row in enumerate(rows, 2):
if len(row) != 3:
raise ValueError(f"row {number} must contain three values")
t, d, s = map(float, row)
if not all(map(math.isfinite, (t, d, s))):
raise ValueError(f"row {number} must contain finite numbers")
times.append(t)
drive.append(d)
sense.append(s)
if len(times) > MAX_ROWS:
raise ValueError("record exceeds 250000 samples")
if len(times) < 2:
raise ValueError("record duration cannot be determined")
duration = times[-1] - times[0]
if not 0.85 <= duration <= 2.5:
raise ValueError("record duration must be 0.85 to 2.5 seconds")
dt = duration / (len(times) - 1)
if not 4000 <= 1 / dt <= 200_000:
raise ValueError("sample rate must be 4 to 200 kHz")
for i in range(1, len(times)):
step = times[i] - times[i - 1]
if abs(step - dt) > 0.05 * dt or abs(times[i] - times[0] - i * dt) > 0.15 * dt:
raise ValueError("timestamps do not follow one uniform sample grid")
if min(drive) < 0 or max(drive) > 4.0:
raise ValueError("drive voltage falls outside the 0 to 4.0 V fixture bound")
if min(sense) < -0.015 or max(sense) > 0.8:
raise ValueError("sense voltage falls outside the −0.015 to +0.8 V fixture bound")
return 1 / dt, drive, sense
def fft(values):
"""Iterative radix-2 discrete Fourier transform, padded length a power of two."""
n = len(values)
j = 0
for i in range(1, n):
bit = n // 2
while j & bit:
j ^= bit
bit //= 2
j ^= bit
if i < j:
values[i], values[j] = values[j], values[i]
size = 2
while size <= n:
half = size // 2
for k in range(half):
w = cmath.exp(-2j * math.pi * k / size)
for lo in range(k, n, size):
hi = lo + half
x, y = values[lo], w * values[hi]
values[lo], values[hi] = x + y, x - y
size *= 2
def windowed(values):
center = fmean(values)
weights = [0.5 - 0.5 * math.cos(math.tau * i / (len(values) - 1))
for i in range(len(values))]
return [(v - center) * w for v, w in zip(values, weights)], sum(weights)
def amplitude(values, rate, hz):
window, weight = windowed(values)
tone = sum(v * cmath.exp(-1j * math.tau * hz * i / rate)
for i, v in enumerate(window))
return 2 * abs(tone) / weight
def peak(values, rate):
window, weight = windowed(values)
size = 1 << (len(window) - 1).bit_length()
spectrum = [complex(v) for v in window] + [0j] * (size - len(window))
fft(spectrum)
delta = rate / size
lo = math.ceil(20 / delta)
hi = math.floor(min(350, rate / 2 - 8) / delta)
levels = [abs(spectrum[i]) for i in range(lo - 1, hi + 2)]
j = max(range(1, len(levels) - 1), key=levels.__getitem__)
left, middle, right = levels[j - 1:j + 2]
if middle <= 0 or j in (1, len(levels) - 2):
raise ValueError("no interior spectral peak in the analysis band")
curve = left - 2 * middle + right
shift = 0.5 * (left - right) / curve if curve else 0.0
hz = (lo - 1 + j + shift) * delta
rival = max((level for i, level in enumerate(levels[1:-1], 1)
if abs(i - j) * delta > 4 * rate / len(values)), default=0.0)
return hz, amplitude(values, rate, hz), rival / middle
def fold(hz, rate):
return abs((hz + rate / 2) % rate - rate / 2)
def verify_detector(open_values, blocked_values, rate, blocked_rate, target, blocked_target, label):
if max(open_values) - min(open_values) < 0.020:
raise ValueError(f"{label} detector amplitude is too small")
hz, volts, rival = peak(open_values, rate)
tolerance = max(2.0, 2 * rate / len(open_values))
if abs(hz - target) > tolerance:
raise ValueError(f"{label} detector peak does not match predicted frequency")
if volts < 0.010 or rival >= 0.75:
raise ValueError(f"{label} detector amplitude or peak dominance is insufficient")
dark_volts = max(amplitude(blocked_values, blocked_rate, frequency)
for frequency in (hz, blocked_target))
if dark_volts > 0.20 * volts:
raise ValueError(f"{label} blocked-path tone exceeds 20% of optical peak")
return {"sense_peak_hz": hz, "sense_peak_v": volts,
"blocked_peak_at_target_v": dark_volts, "tolerance_hz": tolerance}
def analyze(open_path, blocked_path):
rate, drive, light = read_capture(open_path)
dark_rate, dark_drive, dark = read_capture(blocked_path)
if len(light) != len(dark) or abs(dark_rate / rate - 1) > 0.002:
raise ValueError("use the same record length and timebase for both captures")
drive_hz, drive_v, drive_rival = peak(drive, rate)
blocked_hz, blocked_v, blocked_rival = peak(dark_drive, dark_rate)
if not 160 <= drive_hz <= 186 or abs(blocked_hz - drive_hz) > 1.0:
raise ValueError("drive frequencies must agree and be near 173 Hz")
if drive_v < 0.10 or not 0.8 <= blocked_v / drive_v <= 1.2 or max(drive_rival, blocked_rival) >= 0.75:
raise ValueError("both drive captures need a matching, dominant sine")
native = verify_detector(light, dark, rate, dark_rate, drive_hz, blocked_hz, "native")
result = {"source_rows": len(light), "source_hz": rate,
"drive_peak_hz": drive_hz, "blocked_drive_peak_hz": blocked_hz,
"native": native}
for name, requested in (("high_rate", 600), ("low_rate", 250)):
stride = round(rate / requested)
kept_rate = rate / stride
predicted = fold(drive_hz, kept_rate)
blocked_kept_rate = dark_rate / stride
blocked_prediction = fold(blocked_hz, blocked_kept_rate)
sub = verify_detector(light[::stride], dark[::stride], kept_rate,
blocked_kept_rate, predicted, blocked_prediction, name)
result[name] = {"stride": stride, "retained_rows": len(light[::stride]),
"sample_hz": kept_rate, "predicted_hz": predicted,
"observed_hz": sub["sense_peak_hz"],
"sense_peak_v": sub["sense_peak_v"],
"blocked_peak_at_target_v": sub["blocked_peak_at_target_v"],
"tolerance_hz": sub["tolerance_hz"]}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("open_csv", help="scope capture with optical path open")
parser.add_argument("blocked_csv", help="same drive with light path blocked")
args = parser.parse_args()
try:
result = analyze(args.open_csv, args.blocked_csv)
except (OSError, ValueError, csv.Error) as exc:
print(f"Capture rejected: {exc}", file=sys.stderr)
return 2
print(json.dumps(result, indent=2, allow_nan=False))
return 0
if __name__ == "__main__":
sys.exit(main())
Download the complete reader files (ZIP). The archive contains the Python program, delivered CLI checks, two synthetic CSV recordings, a blank worksheet and both original diagrams. The run guide includes the input format and scope.
The same measured drive is 173 Hz, but an integer stride now leaves exactly 280 samples/s. Where should the strongest detector peak appear in the raw retained data?
Nyquist is 140 Hz. The 173 Hz tone folds to 280 − 173 = 107 Hz. The generator and the original detector waveform remain at 173 Hz.