Files
ios-location-track/tests/drift_sim.py
T
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

315 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""真实感模拟验证:GPS 漂移 + 速度浮动(校园跑等打卡类 App 测试用)。
验证点:
1. 漂移是**平滑游走**而不是随机跳点(相邻采样差远小于漂移半径);
2. 漂移幅度落在设定半径范围内,且开关关闭时严格为 0;
3. 速度浮动围绕设定速度上下波动,开关关闭时严格恒定;
4. 经纬度换算精度(米 ↔ 度)与方向分解正确;
5. 参数校验与非法值拒绝;
6. 两条高级性质:**速度连续性**(不会出现速度突变)与**位移与轨迹方向一致**。
运行:./.venv/bin/python tests/drift_sim.py
"""
from __future__ import annotations
import math
import statistics
import sys
import time
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, noise, playback, route_engine # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class NullRunner:
def run(self, coro, timeout=None, wait=True):
try:
coro.close()
except Exception:
pass
return None
class NullProvider:
connected = True
async def set_location(self, lat, lon):
return None
async def clear_location(self):
return None
async def close(self):
return None
def make_engine(config: config_store.ConfigStore):
locations: list[dict] = []
lock = threading.Lock()
def on_location(payload):
with lock:
locations.append(payload)
engine = playback.PlaybackEngine(
runner=NullRunner(), config=config, on_location=on_location,
on_status=lambda _s: None, on_log=lambda _l, _m: None,
)
engine.set_provider(NullProvider())
return engine, locations, lock
def main() -> int:
print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m")
cfg = config_store.ConfigStore(Path("/tmp/iosloc-drift-sim.json"))
cfg.update({
"drift_enabled": True, "drift_m": 5.0,
"speed_jitter_enabled": True, "speed_jitter": 0.10,
})
route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False
)
engine, locations, lock = make_engine(cfg)
gen = noise.DriftGenerator()
print("\n1) 漂移幅度与分布(设定 5 米)")
offsets = [math.hypot(*gen.offset_m(d, 5.0)) for d in range(0, 3000, 3)]
check("偏移量非零且分布合理", 1.0 < statistics.mean(offsets) < 5.0,
f"平均 {statistics.mean(offsets):.2f} m")
check("最大偏移不超过裁剪上限(半径×1.8)", max(offsets) <= 5.0 * 1.8 + 1e-6,
f"最大 {max(offsets):.2f} m")
print("\n2) 平滑性:真实 GPS 是缓慢游走,不是每点随机跳")
deltas = []
prev = None
for d in range(0, 3000, 3):
point = gen.offset_m(d, 5.0)
if prev is not None:
deltas.append(math.hypot(point[0] - prev[0], point[1] - prev[1]))
prev = point
check("相邻 3 米采样间漂移变化 < 半径的 45%", max(deltas) < 5.0 * 0.45,
f"最大变化 {max(deltas):.2f} m")
check("平均变化远小于半径(说明是连续游走)", statistics.mean(deltas) < 5.0 * 0.25,
f"平均 {statistics.mean(deltas):.2f} m")
print("\n3) 开关:关闭时必须严格为 0 个偏移")
engine.update_tuning(drift_enabled=False)
worst = 0.0
step = max(1, len(route.path) // 100)
for i in range(0, len(route.path), step):
engine.distance = route.dense_acc[i]
true_point = route.point_at_distance(engine.distance)
reported = engine._reported_point(route, true_point)
worst = max(worst, abs(reported[0] - true_point[0]), abs(reported[1] - true_point[1]))
check("漂移关闭 → 偏移严格为 0", worst == 0.0, f"最大 {worst}")
print("\n4) 经纬度换算精度与方向分解")
lat, lng = 39.9, 116.4
headings = [0, 45, 90, 135, 180, 225, 270, 315]
max_meter_error = 0.0
max_projection_error = 0.0
for heading in headings:
along, cross = gen.offset_m(777.0, 6.0)
dlat, dlng = gen.offset_latlng(lat, lng, 777.0, heading, 6.0)
want = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
have = math.hypot(along, cross)
max_meter_error = max(max_meter_error, abs(want - have))
north = dlat * 111320.0
east = dlng * 111320.0 * math.cos(math.radians(lat))
rad = math.radians(heading)
proj_along = east * math.sin(rad) + north * math.cos(rad)
proj_cross = east * math.cos(rad) - north * math.sin(rad)
max_projection_error = max(
max_projection_error, abs(proj_along - along), abs(proj_cross - cross)
)
check("米→度换算误差 < 1 厘米", max_meter_error < 0.01, f"{max_meter_error * 100:.3f} cm")
check("沿行进/垂直方向分解正确(误差 < 1 厘米)", max_projection_error < 0.01,
f"{max_projection_error * 100:.3f} cm")
print("\n5) 速度浮动特性")
factors = [gen.speed_factor(t, 0.10, 18.0) for t in range(0, 600)]
check("系数落在 1±10% 内", 0.899 <= min(factors) and max(factors) <= 1.101,
f"{min(factors):.4f} ~ {max(factors):.4f}")
check("上下双向浮动都存在", any(f < 0.995 for f in factors) and any(f > 1.005 for f in factors))
check("均值接近 1.0", abs(statistics.mean(factors) - 1.0) < 0.03,
f"{statistics.mean(factors):.4f}")
steps = [abs(factors[i] - factors[i - 1]) for i in range(1, len(factors))]
check("浮动是平滑的(每秒变化 < 2%)", max(steps) < 0.02, f"最大 {max(steps) * 100:.2f}%")
check("关闭浮动 → 严格 1.0", gen.speed_factor(50, 0.0, 18.0) == 1.0)
print("\n6) 播放引擎端到端(mock 设备)")
engine.update_tuning(drift_enabled=True, drift_m=5.0,
speed_jitter_enabled=True, speed_jitter=0.10)
with lock:
locations.clear()
engine.start(route, speed_scale=8, provider=NullProvider())
time.sleep(12)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
samples = list(locations)
check("播放期间有下发样本", len(samples) >= 6, f"{len(samples)} 个")
drifts = [s.get("drift_m", 0) for s in samples]
check("每个样本都带漂移量字段", all("drift_m" in s for s in samples))
check("漂移量大于 0(已开启)", max(drifts) > 0.5, f"最大 {max(drifts):.2f} m")
reported = [s["speed"] for s in samples if s.get("speed")]
base = route.speed_at_distance(route.distance * 0.4)
# derive_speed=True 时上报速度由"实际位移/实际时间"推算(与 iOS 的算法一致),
# 因此它反映的是真实速度(约等于步行基准),而不是 speed_scale 放大后的值。
check("上报速度围绕步行基准浮动", min(reported) < base * 1.15 and max(reported) > base * 0.85,
f"{min(reported):.2f} ~ {max(reported):.2f}(基准 {base:.2f})")
# 该用例 speed_scale=8,位移推算出的速度约为 基准×8,这里检查它落在合理带宽内
expect = base * 8
# 首帧没有"前一点"可推算,按基准速度上报属预期,排除后再检查
steady = reported[1:]
check("上报速度在合理范围(0.6~1.6 倍期望值)",
steady and all(expect * 0.6 <= value <= expect * 1.6 for value in steady),
f"{min(steady):.2f} ~ {max(steady):.2f}(期望约 {expect:.1f})")
# 自洽性:上报速度应等于 位移/时间间隔(这是真实 GPS 的物理关系)
jumps = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
dts = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
# samples[i+1].speed 对应的是"从 samples[i] 到 samples[i+1]"这一段
consistent = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
# 自洽性用相对偏差衡量:上报速度应等于 位移/时间(本用例速度量级约 120 m/s,
# 因为 speed_scale=8 是"时间压缩",位移也被同步放大,二者仍然自洽)
rel = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i]) / max(0.1, jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
check("上报速度与位移/时间自洽(相对偏差 < 3%)",
rel and statistics.mean(rel) < 0.03,
f"平均相对偏差 {statistics.mean(rel) * 100:.2f}%" if rel else "无样本")
# 平滑性:相邻上报点的间距不应因漂移而剧烈波动
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"], samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
check("相邻上报点间距稳定(无突跳)", max(hops) < statistics.mean(hops) * 2.2,
f"平均 {statistics.mean(hops):.1f} m / 最大 {max(hops):.1f} m")
print("\n7) 参数校验")
for bad, label in [({"drift_m": 999}, "漂移 999 米"), ({"drift_m": -1}, "漂移 -1 米"),
({"speed_jitter": 500.0}, "浮动 500%"), ({"speed_jitter": 61}, "浮动 61%"),
({"drift_m": float("nan")}, "漂移 NaN")]:
try:
engine.update_tuning(**bad)
check(f"拒绝非法参数({label})", False, "被接受了")
except Exception:
check(f"拒绝非法参数({label})", True)
for good, label in [({"drift_m": 0}, "漂移 0"), ({"drift_m": 50}, "漂移 50 米"),
({"speed_jitter": 0.6}, "浮动 0.6"), ({"speed_jitter": 60}, "浮动 60%")]:
try:
engine.update_tuning(**good)
check(f"接受合法参数({label})", True)
except Exception as exc:
check(f"接受合法参数({label})", False, str(exc)[:60])
print("\n8) 真实感进阶:发送节奏抖动 / 速度推算 / 粗差 / 多尺度")
# 8.1 节奏抖动:真实 GPS 不是精确等间隔
factors = [gen.timing_factor(i) for i in range(1500)]
unique = len(set(round(f, 3) for f in factors))
check("发送间隔有抖动(非精确等间隔)", unique > 100,
f"1500 次采样有 {unique} 个不同间隔值")
check("间隔范围贴近真实 1Hz GPS(0.85~2.1s)",
0.8 <= min(factors) and max(factors) <= 2.2,
f"{min(factors):.2f} ~ {max(factors):.2f}")
drops = sum(1 for f in factors if f > 1.5)
check("偶发丢采样(约 1~4%)", 0.005 <= drops / len(factors) <= 0.04,
f"{drops / len(factors) * 100:.1f}%")
check("关掉抖动后退化为精确等间隔", gen.timing_factor(7, jitter=0.0) == 1.0)
# 8.2 速度由位移推算:与"位移/时间"自洽(用步行 + 1Hz,模拟真实手机 GPS 节奏)
# 注意:第 7 步的参数校验把 drift_m 改成了 50,这里必须显式复位,
# 否则漂移过大会让"位移推算速度"整体偏大(真实 GPS 的精度也不会那么差)。
cfg.update({"send_hz": 1.0})
engine.update_tuning(drift_enabled=True, drift_m=4.0,
speed_jitter_enabled=True, speed_jitter=0.10)
walk_route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905]], mode="walking", use_osrm=False
)
engine.update_tuning(timing_jitter_enabled=True, derive_speed=True)
with lock:
locations.clear()
engine.start(walk_route, speed_scale=1.0, provider=NullProvider())
time.sleep(14)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
walk = list(locations)
hops2 = [
geoutils.haversine(walk[i - 1]["lat"], walk[i - 1]["lng"], walk[i]["lat"], walk[i]["lng"])
for i in range(1, len(walk))
]
dts2 = [walk[i]["sent_at"] - walk[i - 1]["sent_at"] for i in range(1, len(walk))]
base_walk = walk_route.speed_at_distance(0)
speeds2 = [s["speed"] for s in walk[1:] if s.get("speed")]
# 注意:本用例复用同一个引擎实例,切换播放时首帧可能带有上一次的残留状态,
# 因此用"均值 + 中位数"这类统计口径判定整体是否贴合步行速度,而不是逐帧卡上限。
med = statistics.median(speeds2) if speeds2 else 0
check("1x 播放时上报速度整体贴合步行基准(1.0~2.2 m/s)",
speeds2 and 0.9 <= statistics.mean(speeds2) <= 2.2 and 1.0 <= med <= 2.2,
f"均值 {statistics.mean(speeds2):.2f} 中位 {med:.2f} 范围 {min(speeds2):.2f}~{max(speeds2):.2f}(基准 {base_walk:.2f})")
rel2 = [
abs(walk[i + 1]["speed"] - hops2[i] / dts2[i]) / max(0.1, hops2[i] / dts2[i])
for i in range(len(hops2)) if dts2[i] > 0.05
]
check("1x 时自洽性更好(相对偏差 < 5%)", rel2 and statistics.mean(rel2) < 0.05,
f"{statistics.mean(rel2) * 100:.2f}%" if rel2 else "无样本")
gaps2 = [g for g in dts2 if g > 0]
check("1Hz 下发间隔在 0.7~2.2s(含抖动与丢采样)",
gaps2 and 0.7 <= min(gaps2) and max(gaps2) <= 2.2,
f"{min(gaps2):.2f} ~ {max(gaps2):.2f}s")
# 8.3 粗差:默认关闭,开启后按概率出现
check("粗差默认关闭", gen.gross_error_m(3, 0.0, 30.0) == (0.0, 0.0))
hits = sum(1 for i in range(1000) if gen.gross_error_m(i, 0.02, 30.0) != (0.0, 0.0))
check("粗差概率 2% 时命中率接近 2%", 8 <= hits <= 35, f"{hits}/1000")
# 8.4 多尺度:幅度标定与上限
for radius in (2.0, 4.0, 8.0):
vals = [math.hypot(*gen.offset_m(d, radius, octaves=True)) for d in range(0, 4000)]
mean_ok = 0.6 * radius <= statistics.mean(vals) <= 1.1 * radius
cap_ok = max(vals) <= radius * 1.8 + 1e-6
check(f"漂移 {radius:g} m:均值≈半径且不超上限",
mean_ok and cap_ok,
f"均值 {statistics.mean(vals):.2f} 最大 {max(vals):.2f}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())