This commit is contained in:
2026-01-28 14:40:48 +08:00
parent 73c8acc8f4
commit 2df97a60aa
10 changed files with 743 additions and 0 deletions
Binary file not shown.
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
import argparse
import csv
import os
import time
from datetime import datetime
from gpiozero import OutputDevice
from rigol_phase import DEV as RIGOL_DEV, measure_phase
# 这里的“step”按全步计(不考虑细分)。细分后会自动把全步换算为脉冲数。
BASE_STEPS_PER_MM = 200 # 200 full-steps = 1mm
MM_MIN = 0
MM_MAX = 300
class GPIOStepper:
def __init__(
self,
*,
dir_pin: int,
step_pin: int,
microsteps: int = 8,
) -> None:
if microsteps <= 0:
raise ValueError("microsteps must be a positive integer")
self.microsteps = int(microsteps)
# gpiozero 默认使用 BCM 编号
self._dir = OutputDevice(dir_pin)
self._step = OutputDevice(step_pin)
self._step.off()
def close(self) -> None:
try:
self._step.off()
except Exception:
pass
try:
self._dir.off()
except Exception:
pass
def ping(self) -> str:
return "GPIO"
def set_dir(self, inc: bool) -> None:
value = 1 if inc else 0
self._dir.value = value
def move_steps(self, full_steps: int, freq_hz: int) -> None:
if full_steps < 0:
raise ValueError("steps must be >= 0")
if freq_hz <= 0:
raise ValueError("freq_hz must be > 0")
pulses = int(full_steps) * self.microsteps
if pulses <= 0:
return
half_period = 0.5 / float(freq_hz)
for _ in range(pulses):
self._step.on()
time.sleep(half_period)
self._step.off()
time.sleep(half_period)
def _default_csv_path() -> str:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return os.path.join(os.getcwd(), f"scan_{ts}.csv")
def _ensure_csv_header(path: str) -> None:
exists = os.path.exists(path) and os.path.getsize(path) > 0
if exists:
return
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(
[
"ts",
"mm",
"f0_hz",
"phi1_rad",
"phi2_rad",
"dphi_rad",
"dphi_deg",
"amp1_pp_adc",
"amp2_pp_adc",
"rigol_idn",
"points_mode",
"n",
]
)
def _append_row(path: str, *, mm: float, r: dict) -> None:
with open(path, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(
[
r.get("ts"),
mm,
r.get("f0_hz"),
r.get("phi1_rad"),
r.get("phi2_rad"),
r.get("dphi_rad"),
r.get("dphi_deg"),
r.get("amp1_pp_adc"),
r.get("amp2_pp_adc"),
r.get("idn"),
r.get("points_mode"),
r.get("n"),
]
)
f.flush()
def main() -> None:
ap = argparse.ArgumentParser(description="电机每1mm移动一次,并读取Rigol示波器数据写入CSV")
ap.add_argument("--mm", type=float, required=True, help="启动时装置当前位置(mm),范围0-300")
ap.add_argument("--end-mm", type=float, default=300.0, help="扫描终点(mm),默认300")
ap.add_argument(
"--step",
type=int,
default=40,
help="每步移动(全步 steps),默认40(200 step = 1mm;会按细分自动换算为脉冲)",
)
ap.add_argument("--microsteps", type=int, default=8, help="驱动器细分倍数,默认8")
ap.add_argument("--dir-pin", type=int, default=22, help="DIR 引脚(BCM编号),默认22")
ap.add_argument("--step-pin", type=int, default=27, help="STEP 引脚(BCM编号),默认27")
ap.add_argument("--move-freq", type=int, default=1200, help="MOVE命令使用的频率Hz,默认500")
ap.add_argument("--settle-s", type=float, default=0.05, help="每次移动后等待再测量(秒),默认0.05")
ap.add_argument("--rigol-dev", default=RIGOL_DEV, help="Rigol设备路径(默认/dev/usbtmc0)")
ap.add_argument("--rigol-timeout", type=float, default=3.0)
ap.add_argument("--rigol-mode", default="NORM", choices=["NORM", "RAW", "MAX"], help="WAV:POIN:MODE")
ap.add_argument(
"--live",
action="store_true",
help="开启 Matplotlib 实时图形监视器(相位差/峰峰值/频率 + 进度条)",
)
ap.add_argument("--csv", default="", help="输出CSV路径,默认当前目录scan_时间戳.csv")
args = ap.parse_args()
if args.step <= 0:
raise SystemExit("--step 必须是正整数")
if args.microsteps <= 0:
raise SystemExit("--microsteps 必须是正整数")
if not (MM_MIN <= args.mm <= MM_MAX):
raise SystemExit("--mm 超出范围 0-300")
if not (MM_MIN <= args.end_mm <= MM_MAX):
raise SystemExit("--end-mm 超出范围 0-300")
# 用“步数”作为内部位置单位,避免浮点累积误差。
# 这里的 steps 是“全步”;实际输出到 STEP 引脚时会乘以 microsteps。
start_steps = int(round(args.mm * BASE_STEPS_PER_MM))
end_steps = int(round(args.end_mm * BASE_STEPS_PER_MM))
step_steps = int(args.step)
csv_path = args.csv or _default_csv_path()
_ensure_csv_header(csv_path)
motor = GPIOStepper(
dir_pin=args.dir_pin,
step_pin=args.step_pin,
microsteps=args.microsteps,
)
live = None
if args.live:
try:
from live_monitor import LiveMonitor
start_mm = start_steps / BASE_STEPS_PER_MM
end_mm = end_steps / BASE_STEPS_PER_MM
live = LiveMonitor(start_mm=start_mm, end_mm=end_mm, csv_path=csv_path)
except Exception as e:
print(f"[!] Live monitor disabled: {e}")
live = None
def live_add(mm: float, meas: dict) -> None:
nonlocal live
if live is None:
return
try:
live.add_point(mm=mm, meas=meas)
except Exception as e:
print(f"[!] Live monitor error (disabled): {e}")
try:
live.close()
except Exception:
pass
live = None
try:
r = motor.ping()
print(
f"[+] Motor GPIO: DIR={args.dir_pin}, STEP={args.step_pin}, microsteps={args.microsteps} (PING -> {r})"
)
# 先测一次(包含一次性IDN)
start_mm = start_steps / BASE_STEPS_PER_MM
print(f"[+] Measure @ {start_mm:.3f}mm")
meas = measure_phase(dev=args.rigol_dev, timeout=args.rigol_timeout, points_mode=args.rigol_mode, fetch_idn=True)
_append_row(csv_path, mm=start_mm, r=meas)
live_add(start_mm, meas)
print(f"[+] CSV: {csv_path}")
cur_steps = start_steps
if cur_steps == end_steps:
print("[+] 已在终点,无需移动")
return
inc = end_steps > cur_steps
direction_str = "+" if inc else "-"
step_mm = step_steps / BASE_STEPS_PER_MM
end_mm = end_steps / BASE_STEPS_PER_MM
print(f"[+] Scan: {start_mm:.3f} -> {end_mm:.3f} (step {direction_str}{step_steps} steps = {step_mm:.3f}mm)")
while cur_steps != end_steps:
motor.set_dir(inc=inc)
remaining = (end_steps - cur_steps) if inc else (cur_steps - end_steps)
move = step_steps if step_steps <= remaining else remaining
motor.move_steps(move, args.move_freq)
time.sleep(args.settle_s)
cur_steps = cur_steps + (move if inc else -move)
cur_mm = cur_steps / BASE_STEPS_PER_MM
print(f"[+] Measure @ {cur_mm:.3f}mm")
meas = measure_phase(dev=args.rigol_dev, timeout=args.rigol_timeout, points_mode=args.rigol_mode, fetch_idn=False)
_append_row(csv_path, mm=cur_mm, r=meas)
live_add(cur_mm, meas)
except KeyboardInterrupt:
print("\n[!] Interrupted")
finally:
motor.close()
if live is not None:
try:
live.close()
except Exception:
pass
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import glob
import math
import os
from dataclasses import dataclass
import numpy as np
def _should_use_agg(save_path: str | None) -> bool:
if save_path:
return True
# Headless environment
return not (os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
def _parse_float(x: object) -> float:
try:
if x is None:
return float("nan")
s = str(x).strip()
if not s:
return float("nan")
return float(s)
except Exception:
return float("nan")
@dataclass
class ScanSeries:
label: str
mm: np.ndarray
f0_hz: np.ndarray
dphi_rad: np.ndarray
dphi_deg: np.ndarray
amp1_pp_adc: np.ndarray
amp2_pp_adc: np.ndarray
def load_scan_csv(path: str, *, label: str | None = None) -> ScanSeries:
mm: list[float] = []
f0_hz: list[float] = []
dphi_rad: list[float] = []
dphi_deg: list[float] = []
amp1: list[float] = []
amp2: list[float] = []
with open(path, "r", encoding="utf-8", newline="") as f:
r = csv.DictReader(f)
for row in r:
mm.append(_parse_float(row.get("mm")))
f0_hz.append(_parse_float(row.get("f0_hz")))
dphi_rad.append(_parse_float(row.get("dphi_rad")))
dphi_deg.append(_parse_float(row.get("dphi_deg")))
amp1.append(_parse_float(row.get("amp1_pp_adc")))
amp2.append(_parse_float(row.get("amp2_pp_adc")))
arr_mm = np.asarray(mm, dtype=float)
order = np.argsort(arr_mm)
series_label = label or os.path.basename(path)
return ScanSeries(
label=series_label,
mm=arr_mm[order],
f0_hz=np.asarray(f0_hz, dtype=float)[order],
dphi_rad=np.asarray(dphi_rad, dtype=float)[order],
dphi_deg=np.asarray(dphi_deg, dtype=float)[order],
amp1_pp_adc=np.asarray(amp1, dtype=float)[order],
amp2_pp_adc=np.asarray(amp2, dtype=float)[order],
)
def nan_moving_average(y: np.ndarray, window: int) -> np.ndarray:
if window <= 1:
return y
y = np.asarray(y, dtype=float)
n = y.size
out = np.full(n, np.nan, dtype=float)
half = window // 2
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
out[i] = np.nanmean(y[lo:hi])
return out
def _mask_range(x: np.ndarray, *, x_min: float | None, x_max: float | None) -> np.ndarray:
m = np.isfinite(x)
if x_min is not None:
m &= x >= x_min
if x_max is not None:
m &= x <= x_max
return m
def _get_pingfang_font_properties(ttf_path: str):
"""Return (FontProperties, name) for PingFang SC from a TTF file."""
from matplotlib import font_manager
if not os.path.exists(ttf_path):
raise FileNotFoundError(
f"未找到字体文件:{ttf_path}(请确认 PingFangSC-Regular.ttf 已放在项目目录,或已安装到 ~/.local/share/fonts)"
)
# Ensure Matplotlib can see this font even if fontconfig cache is stale.
try:
font_manager.fontManager.addfont(ttf_path)
except Exception:
pass
fp = font_manager.FontProperties(fname=ttf_path)
return fp, fp.get_name()
def main() -> None:
ap = argparse.ArgumentParser(description="可视化 motor_rigol_scan_csv.py 输出的扫描 CSV")
ap.add_argument(
"csv",
nargs="+",
help="CSV 路径(支持通配符),例如 scan_*.csv",
)
ap.add_argument("--unwrap", action="store_true", help="对相位差 dphi 做 unwrap(基于 dphi_rad)")
ap.add_argument("--smooth", type=int, default=1, help="平滑窗口(点数),默认1=不平滑")
ap.add_argument("--mm-min", type=float, default=None, help="仅显示 mm >= mm-min")
ap.add_argument("--mm-max", type=float, default=None, help="仅显示 mm <= mm-max")
ap.add_argument("--save", default="", help="保存为图片(PNG/PDF等),例如 out.png;为空则弹窗显示")
ap.add_argument("--title", default="Motor Scan", help="图标题")
args = ap.parse_args()
paths: list[str] = []
for p in args.csv:
expanded = sorted(glob.glob(p))
if expanded:
paths.extend(expanded)
else:
paths.append(p)
if not paths:
raise SystemExit("未找到任何 CSV")
save_path = args.save.strip() or None
if _should_use_agg(save_path):
import matplotlib
matplotlib.use("Agg")
import matplotlib
pingfang_ttf = os.path.join(os.path.dirname(__file__), "PingFangSC-Regular.ttf")
cjk_fp, cjk_name = _get_pingfang_font_properties(pingfang_ttf)
matplotlib.rcParams["font.family"] = cjk_name
matplotlib.rcParams["axes.unicode_minus"] = False
print(f"[+] Font: {cjk_name} ({pingfang_ttf})")
import matplotlib.pyplot as plt # type: ignore
series_list: list[ScanSeries] = []
for path in paths:
if not os.path.exists(path):
raise SystemExit(f"CSV 不存在:{path}")
series_list.append(load_scan_csv(path))
fig = plt.figure(figsize=(12, 5.5))
gs = fig.add_gridspec(1, 2)
ax_phase = fig.add_subplot(gs[0, 0])
ax_amp = fig.add_subplot(gs[0, 1])
ax_phase.set_title("相位差", fontproperties=cjk_fp)
ax_phase.set_xlabel("位置 (mm)", fontproperties=cjk_fp)
ax_phase.set_ylabel("相位差 (度)", fontproperties=cjk_fp)
ax_phase.grid(True, alpha=0.3)
ax_amp.set_title("峰峰值", fontproperties=cjk_fp)
ax_amp.set_xlabel("位置 (mm)", fontproperties=cjk_fp)
ax_amp.set_ylabel("峰峰值 (ADC)", fontproperties=cjk_fp)
ax_amp.grid(True, alpha=0.3)
for s in series_list:
mm_mask = _mask_range(s.mm, x_min=args.mm_min, x_max=args.mm_max)
mm = s.mm[mm_mask]
amp1 = s.amp1_pp_adc[mm_mask]
amp2 = s.amp2_pp_adc[mm_mask]
# phase (deg)
if args.unwrap:
dphi_r = s.dphi_rad[mm_mask]
dphi_r_unwrapped = np.unwrap(dphi_r)
dphi_deg = dphi_r_unwrapped * 180.0 / math.pi
else:
dphi_deg = s.dphi_deg[mm_mask]
# scatter raw
ax_phase.plot(mm, dphi_deg, ".", ms=3, alpha=0.6, label=s.label)
ax_amp.plot(mm, amp1, ".", ms=3, alpha=0.35, label=f"{s.label} CH1")
ax_amp.plot(mm, amp2, ".", ms=3, alpha=0.35, label=f"{s.label} CH2")
# smoothed overlay
if args.smooth and args.smooth > 1:
ax_phase.plot(mm, nan_moving_average(dphi_deg, args.smooth), "-", lw=1.2, alpha=0.9)
ax_amp.plot(mm, nan_moving_average(amp1, args.smooth), "-", lw=1.2, alpha=0.9)
ax_amp.plot(mm, nan_moving_average(amp2, args.smooth), "-", lw=1.2, alpha=0.9)
# legends (avoid huge)
ax_phase.legend(loc="best", fontsize=8)
ax_amp.legend(loc="best", fontsize=7, ncols=2)
subtitle = " | ".join([s.label for s in series_list])
# 中文标题使用 CJK 字体;subtitle 一般是文件名/ASCII,用默认字体。
fig.suptitle(f"{args.title}", fontproperties=cjk_fp)
fig.text(0.5, 0.965, subtitle, ha="center", va="top", fontsize=9)
fig.tight_layout(rect=(0, 0, 1, 0.95))
if save_path:
fig.savefig(save_path, dpi=160)
print(f"[+] Saved: {save_path}")
else:
plt.show()
if __name__ == "__main__":
main()