195 lines
5.9 KiB
Python
195 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import time
|
|
import math
|
|
import cmath
|
|
import argparse
|
|
import errno
|
|
|
|
DEV = "/dev/usbtmc0"
|
|
|
|
|
|
def _write(dev: str, cmd: str) -> None:
|
|
if not cmd.endswith("\n"):
|
|
cmd += "\n"
|
|
fd = os.open(dev, os.O_WRONLY)
|
|
try:
|
|
os.write(fd, cmd.encode("ascii"))
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _read_loop_into(dev: str, buf: bytearray, timeout: float, chunk_size: int = 4096) -> bool:
|
|
"""Keep reading (blocking) into buf until timeout; tolerate ETIMEDOUT."""
|
|
deadline = time.monotonic() + timeout
|
|
fd = os.open(dev, os.O_RDONLY)
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
chunk = os.read(fd, chunk_size)
|
|
if chunk:
|
|
buf += chunk
|
|
return True
|
|
except OSError as e:
|
|
if e.errno == errno.ETIMEDOUT:
|
|
time.sleep(0.01)
|
|
continue
|
|
raise
|
|
time.sleep(0.01)
|
|
return False
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def query_line(cmd: str, timeout: float = 2.0, *, dev: str = DEV) -> str:
|
|
_write(dev, cmd)
|
|
time.sleep(0.03)
|
|
buf = bytearray()
|
|
ok = _read_loop_into(dev, buf, timeout=timeout)
|
|
if not ok or not buf:
|
|
raise TimeoutError(f"query_line timeout: {cmd}")
|
|
line = bytes(buf).split(b"\n", 1)[0]
|
|
return line.decode("ascii", errors="replace").strip()
|
|
|
|
|
|
def query_block(cmd: str, timeout: float = 5.0, *, dev: str = DEV) -> bytes:
|
|
"""
|
|
Read IEEE488.2 block: #<N><len><payload>
|
|
Returns payload bytes.
|
|
"""
|
|
_write(dev, cmd)
|
|
time.sleep(0.03)
|
|
|
|
buf = bytearray()
|
|
deadline = time.monotonic() + timeout
|
|
|
|
while time.monotonic() < deadline:
|
|
_read_loop_into(dev, buf, timeout=0.5) # accumulate in chunks
|
|
|
|
# parse block if possible
|
|
if len(buf) >= 2 and buf[0:1] == b"#" and chr(buf[1]).isdigit():
|
|
ndigits = int(chr(buf[1]))
|
|
header_len = 2 + ndigits
|
|
if len(buf) >= header_len:
|
|
len_str = buf[2:header_len].decode("ascii", errors="replace")
|
|
if len_str.isdigit():
|
|
payload_len = int(len_str)
|
|
total_len = header_len + payload_len
|
|
if len(buf) >= total_len:
|
|
return bytes(buf[header_len:total_len])
|
|
|
|
# some responses might be plain text
|
|
if b"\n" in buf and not buf.startswith(b"#"):
|
|
return bytes(buf)
|
|
|
|
raise TimeoutError(f"query_block timeout: {cmd} (got {len(buf)} bytes)")
|
|
|
|
def wrap_pi(x: float) -> float:
|
|
return (x + math.pi) % (2.0 * math.pi) - math.pi
|
|
|
|
def time_axis(n: int, tscale: float, toffs: float) -> list[float]:
|
|
# screen spans 12 div horizontally (approx). Works well for NORM screen data.
|
|
t0 = toffs - 6.0 * tscale
|
|
dt = (12.0 * tscale) / (n - 1) if n > 1 else 0.0
|
|
return [t0 + i * dt for i in range(n)]
|
|
|
|
def phasor_at(samples: list[float], t: list[float], f0: float) -> complex:
|
|
mean = sum(samples) / len(samples)
|
|
w = 2.0 * math.pi * f0
|
|
s = 0+0j
|
|
for x, ti in zip(samples, t):
|
|
s += (x - mean) * cmath.exp(-1j * w * ti)
|
|
return s
|
|
|
|
|
|
def measure_phase(
|
|
*,
|
|
dev: str = DEV,
|
|
timeout: float = 3.0,
|
|
points_mode: str = "NORM",
|
|
fetch_idn: bool = False,
|
|
) -> dict:
|
|
"""Measure CH1/CH2 phase difference using a single waveform capture.
|
|
|
|
Returns a dict suitable for JSON serialization.
|
|
"""
|
|
if points_mode not in {"NORM", "RAW", "MAX"}:
|
|
raise ValueError("points_mode must be one of: NORM, RAW, MAX")
|
|
|
|
idn = query_line("*IDN?", timeout=timeout, dev=dev) if fetch_idn else None
|
|
|
|
# Stop & set points mode
|
|
_write(dev, ":STOP")
|
|
_write(dev, f":WAV:POIN:MODE {points_mode}")
|
|
time.sleep(0.05)
|
|
|
|
# timebase
|
|
tscale = float(query_line(":TIM:SCAL?", timeout=timeout, dev=dev))
|
|
toffs = float(query_line(":TIM:OFFS?", timeout=timeout, dev=dev))
|
|
|
|
# frequency
|
|
f0 = float(query_line(":MEAS:FREQ? CHAN1", timeout=timeout, dev=dev))
|
|
|
|
# waveform payloads (bytes 0..255)
|
|
b1 = query_block(":WAV:DATA? CHAN1", timeout=timeout, dev=dev)
|
|
b2 = query_block(":WAV:DATA? CHAN2", timeout=timeout, dev=dev)
|
|
n = min(len(b1), len(b2))
|
|
b1, b2 = b1[:n], b2[:n]
|
|
|
|
x1 = [float(v) for v in b1]
|
|
x2 = [float(v) for v in b2]
|
|
t = time_axis(n, tscale, toffs)
|
|
|
|
amp1_pp_adc = (max(x1) - min(x1)) if x1 else float("nan")
|
|
amp2_pp_adc = (max(x2) - min(x2)) if x2 else float("nan")
|
|
|
|
p1 = phasor_at(x1, t, f0)
|
|
p2 = phasor_at(x2, t, f0)
|
|
phi1 = cmath.phase(p1)
|
|
phi2 = cmath.phase(p2)
|
|
dphi = wrap_pi(phi2 - phi1)
|
|
|
|
deg = dphi * 180.0 / math.pi
|
|
dt = dphi / (2.0 * math.pi * f0) if f0 != 0.0 else float("nan")
|
|
|
|
_write(dev, ":RUN")
|
|
|
|
return {
|
|
"ts": time.time(),
|
|
"idn": idn,
|
|
"points_mode": points_mode,
|
|
"n": n,
|
|
"tscale": tscale,
|
|
"toffs": toffs,
|
|
"f0_hz": f0,
|
|
"amp1_pp_adc": amp1_pp_adc,
|
|
"amp2_pp_adc": amp2_pp_adc,
|
|
"phi1_rad": phi1,
|
|
"phi2_rad": phi2,
|
|
"dphi_rad": dphi,
|
|
"dphi_deg": deg,
|
|
"dt_s": dt,
|
|
"wave1": x1,
|
|
"wave2": x2,
|
|
}
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dev", default=DEV)
|
|
ap.add_argument("--mode", default="NORM", choices=["NORM", "RAW", "MAX"], help="WAV:POIN:MODE")
|
|
ap.add_argument("--timeout", type=float, default=3.0)
|
|
args = ap.parse_args()
|
|
|
|
print("[+] *IDN? ->", query_line("*IDN?", timeout=args.timeout, dev=args.dev))
|
|
|
|
r = measure_phase(dev=args.dev, timeout=args.timeout, points_mode=args.mode, fetch_idn=False)
|
|
print(f"[+] Using f0 = {r['f0_hz']:.3f} Hz")
|
|
print(f"[+] Got points: {r['n']} (mode={r['points_mode']})")
|
|
print(f"[+] Phase:")
|
|
print(f" phi1={r['phi1_rad']:+.6f} rad, phi2={r['phi2_rad']:+.6f} rad")
|
|
print(f" dphi={r['dphi_rad']:+.6f} rad = {r['dphi_deg']:+.2f} deg")
|
|
print(f" equivalent delay dt={r['dt_s']:+.9e} s")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|