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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
209 lines
9.9 KiB
Python
209 lines
9.9 KiB
Python
"""真实感定位模型:多尺度 GPS 误差 + 发送节奏抖动 + 运动学速度。
|
||
|
||
为什么不用 random():真实手机的 GPS 误差是**缓慢游走**的(受卫星几何、多径、电离层影响),
|
||
相邻两次定位通常只差几十厘米到一两米,而且有惯性。如果每个点独立随机跳,轨迹会变成毛刺,
|
||
速度和航向也会剧烈抖动——这类特征在验收测试里会掩盖真实问题(也有经验的风控一眼能看出)。
|
||
|
||
这里用**值噪声(value noise)+ 平滑插值 + 多尺度叠加**建模:
|
||
- **多尺度误差**:真实 GPS 误差是幂律分布(Allan 方差),不是单一尺度。
|
||
这里用 3 个八度叠加:慢漂(相关长度 ~120 米,多径/电离层)+ 中尺度(~30 米,卫星几何)
|
||
+ 快抖(~6 米,接收机噪声)。单尺度噪声的统计特征一眼可辨。
|
||
- **发送节奏抖动**:真实 GPS 回调不是精确 1.000 秒,而是 0.85~1.15 秒抖动,
|
||
偶尔丢一次采样。节奏完全均匀是最容易被识别的特征之一。
|
||
- **速度由位移/时间推算**:这与 iOS 从坐标序列推算 speed 的方式一致,
|
||
因此"上报速度"与"实际位移"天然自洽,不会出现自相矛盾的数据。
|
||
- 纯函数式、无状态、可复现:同样的距离轴/时间轴永远得到同样的噪声值,便于回归测试。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
# 相关长度:多少米走完一个完整的"漂移周期"
|
||
DEFAULT_CORRELATION_M = 30.0
|
||
# 默认漂移半径(米):手机 GPS 在城市环境下的典型误差量级
|
||
DEFAULT_DRIFT_M = 4.0
|
||
DEFAULT_DRIFT_PERIOD_S = 18.0
|
||
# 默认速度浮动幅度:±8%
|
||
DEFAULT_SPEED_JITTER = 0.08
|
||
|
||
MAX_DRIFT_M = 50.0
|
||
MAX_SPEED_JITTER = 0.6
|
||
|
||
# 多尺度误差的三个八度:(相对权重, 相关长度倍数)
|
||
# 慢漂:中尺度:快抖 ≈ 0.5 : 0.33 : 0.17,接近 GPS 误差的幂律谱。
|
||
# 快抖的相关长度不能太短:取 0.5×30m = 15m。若取 6~10m,1Hz 下每秒误差变化可达
|
||
# 6~8 米,位移推算出的"瞬时速度"会明显超出步行/跑步范围(真实接收机内部对速度做过平滑)。
|
||
ERROR_OCTAVES = ((0.5, 4.0), (0.34, 1.0), (0.16, 0.5))
|
||
# 叠加后的幅值归一化系数:实测三个尺度加权叠加的 RMS 约为单尺度的 0.55 倍,
|
||
# 除以该系数即可让 drift_m 参数与"典型偏移半径"一致(数值由测试标定)。
|
||
OCTAVE_NORMALIZER = 1.0 / 0.55
|
||
# 单个采样的偏移上限(相对 radius 的倍数):真实 GPS 误差有长尾,但不会无限大
|
||
MAX_OFFSET_FACTOR = 1.8
|
||
|
||
# 发送间隔抖动:真实 1Hz GPS 的实际间隔分布
|
||
DEFAULT_TIMING_JITTER = 0.15 # ±15%
|
||
DROPOUT_PROBABILITY = 0.02 # 约 2% 的采样会丢一次(变成 2 秒间隔)
|
||
MIN_INTERVAL_FACTOR = 0.3 # 抖动下限保护(避免退化成高频)
|
||
|
||
|
||
def _hash01(seed: int, index: int) -> float:
|
||
"""把 (seed, index) 映射成 [0,1) 的确定性伪随机数(无需 numpy)。"""
|
||
x = (index * 374761393 + seed * 668265263) & 0xFFFFFFFF
|
||
x = (x ^ (x >> 13)) * 1274126177 & 0xFFFFFFFF
|
||
x = x ^ (x >> 16)
|
||
return (x & 0xFFFFFF) / float(0x1000000)
|
||
|
||
|
||
def _smooth(t: float) -> float:
|
||
"""smoothstep:让分段插值在节点处一阶连续,避免折角。"""
|
||
return t * t * (3.0 - 2.0 * t)
|
||
|
||
|
||
def value_noise(seed: int, x: float) -> float:
|
||
"""一维值噪声,返回 [-1, 1],在 x 的整数节点间平滑插值。"""
|
||
i0 = math.floor(x)
|
||
frac = x - i0
|
||
a = _hash01(seed, i0) * 2.0 - 1.0
|
||
b = _hash01(seed, i0 + 1) * 2.0 - 1.0
|
||
return a + (b - a) * _smooth(frac)
|
||
|
||
|
||
def fbm(seed: int, x: float, octaves: int = 2) -> float:
|
||
"""两个八度的分形噪声,归一化到 [-1, 1]。"""
|
||
total = 0.0
|
||
amplitude = 1.0
|
||
frequency = 1.0
|
||
norm = 0.0
|
||
for octave in range(max(1, octaves)):
|
||
total += value_noise(seed + octave * 7919, x * frequency) * amplitude
|
||
norm += amplitude
|
||
amplitude *= 0.5
|
||
frequency *= 2.3
|
||
return total / norm if norm else 0.0
|
||
|
||
|
||
class DriftGenerator:
|
||
"""按"沿轨迹行进的距离"生成平滑漂移偏移量与速度浮动系数。
|
||
|
||
- 位置漂移:沿行进方向与垂直方向各一份噪声,合成横向摆动(GPS 误差的常见形态);
|
||
- 速度浮动:乘性系数,围绕 1.0 平滑波动,让瞬时速度在设定值上下浮动。
|
||
"""
|
||
|
||
def __init__(self, seed: int = 20261014) -> None:
|
||
self.seed = int(seed)
|
||
|
||
# ---------------- 位置漂移 ----------------
|
||
def offset_m(self, distance_m: float, drift_m: float,
|
||
correlation_m: float = DEFAULT_CORRELATION_M,
|
||
octaves: bool = True) -> tuple[float, float]:
|
||
"""返回 (沿行进方向偏移, 垂直方向偏移),单位米。
|
||
|
||
octaves=True 时叠加 3 个尺度的误差(慢漂 + 中尺度 + 快抖),
|
||
更接近真实 GPS 误差的幂律谱;False 时退化为单一尺度(旧行为)。
|
||
"""
|
||
if drift_m <= 0:
|
||
return 0.0, 0.0
|
||
scale = max(5.0, correlation_m)
|
||
if not octaves:
|
||
x = distance_m / scale
|
||
along = fbm(self.seed, x) * drift_m
|
||
cross = fbm(self.seed + 104729, x + 0.5) * drift_m
|
||
return along, cross
|
||
along = 0.0
|
||
cross = 0.0
|
||
for index, (weight, factor) in enumerate(ERROR_OCTAVES):
|
||
x = distance_m / (scale * factor)
|
||
# 每个尺度用不同种子与相位偏移,避免各尺度同相叠加成规律波形
|
||
along += value_noise(self.seed + index * 1013, x) * weight
|
||
cross += value_noise(self.seed + 104729 + index * 7919, x + 0.37 * (index + 1)) * weight
|
||
# 多尺度叠加后幅值会明显小于单尺度(各尺度并不总是同相),
|
||
# 这里归一化,保证 drift_m 参数就是"典型偏移半径",与用户设定一致。
|
||
along *= OCTAVE_NORMALIZER
|
||
cross *= OCTAVE_NORMALIZER
|
||
# 合成偏移做上限裁剪:真实 GPS 误差有长尾,但不会无限大,
|
||
# 否则偶发的大偏移会被下游 App 当成"跳点"整段丢弃。
|
||
magnitude = math.hypot(along, cross)
|
||
if magnitude > MAX_OFFSET_FACTOR:
|
||
shrink = MAX_OFFSET_FACTOR / magnitude
|
||
along *= shrink
|
||
cross *= shrink
|
||
return along * drift_m, cross * drift_m
|
||
|
||
def offset_latlng(self, lat: float, lng: float, distance_m: float,
|
||
heading_deg: float, drift_m: float,
|
||
correlation_m: float = DEFAULT_CORRELATION_M) -> tuple[float, float]:
|
||
"""把米制偏移换算成经纬度偏移(按当前纬度修正经度比例)。"""
|
||
along, cross = self.offset_m(distance_m, drift_m, correlation_m)
|
||
if along == 0.0 and cross == 0.0:
|
||
return 0.0, 0.0
|
||
rad = math.radians(heading_deg)
|
||
# 行进方向单位向量(x=东, y=北)
|
||
fx, fy = math.sin(rad), math.cos(rad)
|
||
# 垂直方向(右手侧)
|
||
px, py = fy, -fx
|
||
east_m = along * fx + cross * px
|
||
north_m = along * fy + cross * py
|
||
dlat = north_m / 111320.0
|
||
cos_lat = math.cos(math.radians(max(-85.0, min(85.0, lat))))
|
||
dlng = east_m / (111320.0 * max(0.05, cos_lat))
|
||
return dlat, dlng
|
||
|
||
# ---------------- 速度浮动 ----------------
|
||
def speed_factor(self, elapsed_s: float, jitter: float,
|
||
period_s: float = DEFAULT_DRIFT_PERIOD_S) -> float:
|
||
"""返回速度乘性系数(1.0 表示不浮动)。
|
||
|
||
对应真实跑步中步频/步幅的自然波动与路况影响:
|
||
用 2 个八度的低频噪声(不是正弦,因此没有可识别的周期)。
|
||
"""
|
||
if jitter <= 0:
|
||
return 1.0
|
||
x = elapsed_s / max(2.0, period_s)
|
||
value = fbm(self.seed + 15485863, x + 3.25)
|
||
factor = 1.0 + value * jitter
|
||
# 防止极端情况下出现 0 或负速度
|
||
return max(0.2, min(2.0, factor))
|
||
|
||
# ---------------- 发送节奏(真实 GPS 不是精确等间隔) ----------------
|
||
def timing_factor(self, sample_index: int, jitter: float = DEFAULT_TIMING_JITTER,
|
||
dropouts: bool = True) -> float:
|
||
"""返回本次发送间隔的乘性系数。
|
||
|
||
真实 1Hz GPS 的实际间隔在 0.85~1.15 秒之间抖动,偶发丢采样时变成约 2 秒。
|
||
间隔完全均匀(精确 1.000s)在数据上非常"人工",部分验收用例甚至会据此告警。
|
||
"""
|
||
if jitter <= 0:
|
||
return 1.0
|
||
# 用噪声而不是 random:连续几次采样的间隔会小幅相关,像真实接收机
|
||
value = value_noise(self.seed + 777771, sample_index * 0.35)
|
||
factor = 1.0 + value * jitter
|
||
# 丢采样是独立随机事件(不能用平滑噪声表达),用哈希伪随机
|
||
if dropouts and _hash01(self.seed + 31337, sample_index) < DROPOUT_PROBABILITY:
|
||
factor += 1.0 # 丢一次采样 → 间隔约翻倍(真实 GPS 偶发情况)
|
||
return max(MIN_INTERVAL_FACTOR, min(2.5, factor))
|
||
|
||
# ---------------- 粗差(真实 GPS 偶发的坏点) ----------------
|
||
def gross_error_m(self, sample_index: int, probability: float = 0.0,
|
||
magnitude_m: float = 30.0) -> tuple[float, float]:
|
||
"""按概率生成一次"粗差"偏移(多径/城市峡谷导致的坏点)。
|
||
|
||
默认关闭(probability=0)。开启后可用来测你自己 App 的
|
||
`horizontalAccuracy` 过滤与跳点剔除逻辑是否生效。
|
||
"""
|
||
if probability <= 0:
|
||
return 0.0, 0.0
|
||
roll = _hash01(self.seed + 5150, sample_index)
|
||
if roll >= probability:
|
||
return 0.0, 0.0
|
||
# 坏点方向随机、幅度在 0.5~1.5 倍之间
|
||
angle = _hash01(self.seed + 6161, sample_index) * 2.0 * math.pi
|
||
scale = magnitude_m * (0.5 + _hash01(self.seed + 7171, sample_index))
|
||
return math.cos(angle) * scale, math.sin(angle) * scale
|
||
|
||
|
||
_generator = DriftGenerator()
|
||
|
||
|
||
def default_generator() -> DriftGenerator:
|
||
return _generator
|