MATLAB 角度解缠遇到丢样:先分段,再说明圈数为什么不可恢复

先把断点留下来

角度传感器把 190° 报成 −170°,通常只是角度表示跨过了边界。但如果两条数据之间已经丢了几百毫秒,补上一个 360° 就可能把真实运动解释错。

这里处理一条带时间戳的角度序列:先在缺失值和过长时间间隔处切段,再仅对段内数据调用 unwrap。每段以自己的首个包裹角为参考,不把不同段偷偷接成一条"累计转角"曲线。这样能避免代码编造跨断点的圈数,但仍不能证明段内没有采样混叠。

**实测环境:**MATLAB 25.2.0.2998904(R2025b),Windows 64 位(PCWIN64),2026-10-24。依赖分析仅列出基础 MATLAB,不需要 Mapping、DSP 或其他工具箱。下文结果和图片来自本次成功的原生运行;首次图形失败与重试情况一并记录在第 7 节。

1 一个能手算的跨边界例子

输入角度为 170°、−170°、−150°。如果已知相邻真实角增量为 20°,连续表示就是 170°、190°、210°。

这里的"已知"很关键。单凭三条观测,同样可能对应 170°、550°、930°:每次增加 380°,取模后仍得到相同的观测。任何只接收这些包裹角的算法,都无法判断物体每次增加的是 20° 还是 380°。

我们把连续角记为 θ,把传感器输出记为 w。二者满足

w i = θ i ( m o d 2 π ) . w_i=\theta_i\pmod{2\pi}. wi=θi(mod2π).

因此所有 θ_i + 2πk_i 都给出相同的模角观测。需要额外的物理约束,才能排除错误分支。此前的采样混叠实验用 100 Hz 采样 20、80、120 Hz 的零相位余弦,说明不同真实过程可以留下相同样本。本文讨论角度的整数圈分支,以及 NaN 和时间缺口两侧的局部参考,不重复那组余弦实验。

2 unwrap 能做的事和不能证明的事

unwrap 接收弧度,通过补偿 2π 的整数倍消除包裹表示引入的跳变。矩阵默认按列处理,也可以明确指定维度。本文先把输入转成列向量,再使用 unwrap(segment,[],1),避免行列方向混淆。小于 π 的 tol 会按默认 π 处理,不能把 unwrap(w,0.1) 当作"角速度绝不超过某值"的验证器。MathWorks R2025b unwrap 文档

本文不根据恰好 π 的离散输入推断物理方向,也不承诺实现对浮点 π 附近的某种选边行为。即使软件固定选择一边,物理上 +π 与 −π 仍给出相同的模角差。

假设存在来自机构规格、独立编码器或其他可靠依据的角速度绝对值上界 Ω,且真实连续运动满足 |dθ/dt| ≤ Ω。在两个样本之间有

∣ θ i + 1 − θ i ∣ ≤ Ω Δ t . |\theta_{i+1}-\theta_i|\leq\Omega\Delta t. ∣θi+1−θi∣≤ΩΔt.

如果 ΩΔt < π ,真实净角增量位于 (-π,π),同一模角差在这个开区间内至多有一个代表值。这才建立相邻样本之间的分支唯一性。等于 π 不够;物理条件比算法的阈值参数更重要。

这个 Ω 必须来自外部,不能因为观测包裹角差很小,就反过来宣称 ΩΔt < π。380° 会伪装成 20°。存在测角误差时,上述无噪声论证也不能原样套用;若每次角误差绝对值均不超过 ε,ΩΔt + 2ε < π 是保证含误差相邻增量仍留在开区间内的一种充分余量条件,误差上界同样需要依据。

即使段内分支唯一,第一点的绝对整数圈仍未知。下面函数输出的是相对于该段首个包裹角的连续表示,不是绝对累计圈数。分段后也不输出跨段角速度。

3 明确输入和断点规则

函数 segmented_unwrap 的契约如下。

项目 约定
时间 t 同一时钟、同一单位的实数 full double 向量,非负、有限且严格递增;不自动排序、不合并重复时间
角度 wrapped 与 t 等长的实数 full double 向量,单位 rad,有限值必须在 [-pi,pi];NaN 表示缺失,Inf 不接受
时间上限 maxGap 正的有限实数 full double 标量,单位与 t 一致;本例为秒
NaN 规则 NaN 本身没有段编号,前后不相连,即使相邻有效数据很近
间隔规则 dt <= maxGap 允许留在同一段;只有 dt > maxGap 才切段
输出 全部转成列向量;缺失角输出 NaN、段编号 0、起点标志 false
空输入与单点 接受二维空数组并归一化输出为 0×1;单个有限角原样保留并形成一段;全 NaN 没有有效段

maxGap 首先是数据连续性的业务规则,不会自动变成角速度保证。若有可信外部 Ω,可把 Ω*maxGap < π 作为无噪声采样设计的充分条件,并留出测量和时钟误差余量。若没有这样的条件,本函数仍可画出局部连续表示,但不能声称恢复了真实运动。

比较针对已存储的 double 值,没有偷偷加 eps 放宽阈值。十进制时间戳在边界附近可能有舍入差异,应在采集和时间规范化阶段明确精度。测试用 0.25 这类二进制可精确表示的数检查等号规则。

4 完整分段实现

保存为 source/segmented_unwrap.m。先找一段完全有限、没有超限间隔的数据,再把整段交给基础 unwrap;不依赖 unwrap 对 NaN 的隐含处理。

matlab 复制代码
function [localAngle, segmentId, isStart] = segmented_unwrap(t, wrapped, maxGap)
%SEGMENTED_UNWRAP Unwrap finite contiguous segments, never bridge a gap.
% t: finite, nonnegative, strictly increasing double times in seconds.
% wrapped: double angles in [-pi,pi] radians; NaN breaks a segment.
% maxGap: positive finite double seconds; dt <= maxGap stays connected.
% All outputs are columns. Missing angles have NaN, 0, false outputs.
% Inputs accept row/column vectors or any two-dimensional empty arrays.
% This function does NOT establish physical angular-speed limits or turns.

if ~validVector(t) || ~validVector(wrapped) || numel(t) ~= numel(wrapped)
    error('segmented_unwrap:Shape', ...
        't and wrapped must be equal-length real full double vectors.');
end
if ~(isa(maxGap,'double') && isreal(maxGap) && isscalar(maxGap) ...
        && ~issparse(maxGap) && isfinite(maxGap) && maxGap > 0)
    error('segmented_unwrap:MaxGap','maxGap must be positive and finite.');
end
t = t(:);
wrapped = wrapped(:);
if any(~isfinite(t)) || any(t < 0) || any(diff(t) <= 0)
    error('segmented_unwrap:Time', ...
        'Times must be finite, nonnegative, and strictly increasing.');
end
if any(isinf(wrapped)) || any(abs(wrapped(~isnan(wrapped))) > pi)
    error('segmented_unwrap:Angle','Angles must be in [-pi,pi] or NaN.');
end

n = numel(t);
localAngle = nan(n,1);
segmentId = zeros(n,1);
isStart = false(n,1);
i = 1;
sid = 0;
while i <= n
    if isnan(wrapped(i))
        i = i + 1;
        continue
    end
    j = i;
    while j < n && ~isnan(wrapped(j+1)) && t(j+1)-t(j) <= maxGap
        j = j + 1;
    end
    sid = sid + 1;
    localAngle(i:j) = unwrap(wrapped(i:j),[],1);
    segmentId(i:j) = sid;
    isStart(i) = true;
    i = j + 1;
end
end

function ok = validVector(x)
ok = isa(x,'double') && isreal(x) && ~issparse(x) && ismatrix(x) ...
    && (isvector(x) || isempty(x));
end

5 断言与独立参照

保存为 source/test_segmented_unwrap.m。本次原生运行已完成这些作者断言;独立验收及导出回读结果见第 7 节。

长序列和示例首先分别断言独立列出的期望段号与起点,避免只跟着被测函数的段号选择参照范围。参照计算不重复调用 unwrap,而是对段内相邻差使用 atan2(sin(delta),cos(delta)) 后累加。atan2 的角度范围为 [-pi,pi];参考数据刻意避开半圈边界。MathWorks R2025b atan2 文档

测试还保留一条已知构造轨迹,并把每段首点对齐到自己的包裹角,再比较整个段。这个参照能检查代码路径,不能证明真实传感器符合角速度假设。20°/380° 的反例专门防止把"数值平滑"误认成"物理恢复"。

matlab 复制代码
function test_segmented_unwrap
% Authored assertions for the segmented-unwrapping input contract.
d = pi/180;
[q,id,start] = segmented_unwrap([0 1 2],[170 -170 -150]*d,1);
assertNear(q,[170;190;210]*d,1e-12);
assert(isequal(id,[1;1;1]) && isequal(start,[true;false;false]));

% NaN breaks the chain even when the surrounding time gap is small.
[q,id,start] = segmented_unwrap(0:4,[NaN 170 NaN -170 NaN]*d,2);
assert(isequaln(q,[NaN;170;NaN;-170;NaN]*d));
assert(isequal(id,[0;1;0;2;0]));
assert(isequal(start,[false;true;false;true;false]));

% Exact binary fractions make the equality rule unambiguous here.
[q,id] = segmented_unwrap([0 .25 .75],[170 -170 -150]*d,.25);
assertNear(q,[170;190;-150]*d,1e-12);
assert(isequal(id,[1;1;2]));
[q,id,start] = segmented_unwrap([],[],.25);
assert(isequal(size(q),[0 1]) && isequal(size(id),[0 1]));
assert(isequal(size(start),[0 1]));
[q,id,start] = segmented_unwrap(0,-pi,.25);
assert(q == -pi && id == 1 && start);
[q,id,start] = segmented_unwrap([0 1],[NaN NaN],1);
assert(all(isnan(q)) && all(id == 0) && ~any(start));
[q,id] = segmented_unwrap(0:2,[pi pi pi],1);
assertNear(q,pi*ones(3,1),1e-12);
assert(isequal(id,ones(3,1)));

% Deterministic non-boundary data: independent incremental reference.
t = (0:40)'/8;
truth = (170 + 30*t + 5*sin(t))*d;
w = atan2(sin(truth),cos(truth));
w([8 19 20]) = NaN;
[q,id,start] = segmented_unwrap(t,w,.125);
expectedId = zeros(41,1);
expectedId(1:7) = 1;
expectedId(9:18) = 2;
expectedId(21:41) = 3;
expectedStart = false(41,1);
expectedStart([1 9 21]) = true;
assert(isequal(id,expectedId) && isequal(start,expectedStart));
for s = 1:max(id)
    idx = find(id == s);
    delta = diff(w(idx));
    ref = w(idx(1)) + [0;cumsum(atan2(sin(delta),cos(delta)))];
    assertNear(q(idx),ref,1e-12);
    knownLocalTruth = truth(idx)-truth(idx(1))+w(idx(1));
    assertNear(q(idx),knownLocalTruth,1e-12);
    assert(q(idx(1)) == w(idx(1)) && start(idx(1)));
end
assert(all(isnan(q(isnan(w)))) && all(id(isnan(w)) == 0));

% Identical observations cannot distinguish 20 and 380 degree increments.
a = [170 190 210]*d;
b = [170 550 930]*d;
wa = atan2(sin(a),cos(a));
wb = atan2(sin(b),cos(b));
assertNear(wa,wb,1e-12);
assertNear(segmented_unwrap(0:2,wa,1),a(:),1e-12);
assert(max(abs(segmented_unwrap(0:2,wb,1)-b(:))) > 2*pi);

mustError(@() segmented_unwrap([0 0],[0 0],1),'segmented_unwrap:Time');
mustError(@() segmented_unwrap([1 0],[0 0],1),'segmented_unwrap:Time');
mustError(@() segmented_unwrap([-1 0],[0 0],1),'segmented_unwrap:Time');
mustError(@() segmented_unwrap([0 Inf],[0 0],1),'segmented_unwrap:Time');
mustError(@() segmented_unwrap([0 NaN],[0 0],1),'segmented_unwrap:Time');
mustError(@() segmented_unwrap([0 1],[0 Inf],1),'segmented_unwrap:Angle');
mustError(@() segmented_unwrap(0,pi+1e-6,1),'segmented_unwrap:Angle');
mustError(@() segmented_unwrap([0 1],0,1),'segmented_unwrap:Shape');
mustError(@() segmented_unwrap(single(0),0,1),'segmented_unwrap:Shape');
mustError(@() segmented_unwrap(0,1i,1),'segmented_unwrap:Shape');
mustError(@() segmented_unwrap(zeros(2),zeros(2),1),'segmented_unwrap:Shape');
mustError(@() segmented_unwrap(0,0,0),'segmented_unwrap:MaxGap');
mustError(@() segmented_unwrap(0,0,Inf),'segmented_unwrap:MaxGap');
fprintf('test_segmented_unwrap: all authored assertions completed.\n');
end

function assertNear(a,b,tol)
assert(isequal(size(a),size(b)));
assert(all(abs(a(:)-b(:)) <= tol));
end

function mustError(f,id)
try
    f();
catch ex
    assert(strcmp(ex.identifier,id),'Unexpected error: %s',ex.identifier);
    return
end
error('test_segmented_unwrap:MissingError','Expected error %s.',id);
end

6 原生图和数据由同一个示例生成

保存为 source/demo_segmented_unwrap.m。数据是本文构造的确定性轨迹,不是设备采集:初值 170°,角速度 160°/s,中间删去时间区间,并将一条角观测置为 NaN。代码中设定的 Ω=200°/s 对这条构造轨迹有效;它不是从包裹角估出来的设备规格。

图的第一行只画观测点,不跨缺失区连线;第二行逐段绘制局部解缠角;第三行标记段编号。各段在不同包裹参考上起步,纵向错位应保留,而不是人为平移后伪装成连续轨迹。图例位于绘图区外。本次导出的标题、坐标标签和图例可读,断点处没有跨段连线。

下图由本次 MATLAB 原生运行导出,尺寸为 1867×1368。示例同时生成 FIG、CSV 和 MAT,并记录实际 MATLAB 版本。CSV 中 truth 是构造真值,不是传感器额外输出。

图中 1.5 s 处缺失的是角度观测;下方段编号 0 只标记缺失,不表示角度为 0。0.5--1.25 s 和 1.875--2.5 s 是超过 maxGap 的时间缺口,1.375--1.625 s 之间则由 NaN 切断。

总标题使用 title(layout,...),PNG 使用 exportgraphics 导出,接口见 MathWorks R2025b title 和 R2025b exportgraphics。

matlab 复制代码
function artifacts = demo_segmented_unwrap(outDir)
% Native MATLAB demo and CSV/MAT/PNG/FIG exports.
if nargin < 1
    outDir = fullfile(pwd,'unwrap_native_output');
end
if ~exist(outDir,'dir')
    mkdir(outDir);
end
test_segmented_unwrap;
d = pi/180;
t = [0 .125 .25 .375 .5 1.25 1.375 1.5 1.625 1.75 1.875 ...
    2.5 2.625 2.75]';
truth = (170 + 160*t)*d;
wrapped = atan2(sin(truth),cos(truth));
wrapped(8) = NaN;
maxGap = .25;
[localAngle,segmentId,isStart] = segmented_unwrap(t,wrapped,maxGap);
expectedId = [1 1 1 1 1 2 2 0 3 3 3 4 4 4]';
expectedStart = false(numel(t),1);
expectedStart([1 6 9 12]) = true;
assert(isequal(segmentId,expectedId) && isequal(isStart,expectedStart));

% Known only because this is a constructed trajectory, not sensor evidence.
omegaMax = 200*d;
assert(omegaMax*maxGap < pi);
for s = 1:max(segmentId)
    idx = find(segmentId == s);
    expected = truth(idx)-truth(idx(1))+wrapped(idx(1));
    assert(all(abs(localAngle(idx)-expected) < 1e-12));
end

data = table(t,truth,wrapped,localAngle,segmentId,isStart);
fig = figure('Color','w','Position',[80 80 1100 800]);
layout = tiledlayout(fig,3,1,'TileSpacing','compact','Padding','loose');
ax1 = nexttile(layout);
plot(ax1,t,wrapped/d,'ko','MarkerFaceColor',[.2 .2 .2]);
grid(ax1,'on'); ylabel(ax1,'Wrapped (deg)');
title(ax1,'Samples only: NaN and time gaps remain explicit');
ylim(ax1,[-200 200]);
ax2 = nexttile(layout); hold(ax2,'on');
for s = 1:max(segmentId)
    idx = find(segmentId == s);
    plot(ax2,t(idx),localAngle(idx)/d,'-o','LineWidth',1.5, ...
        'DisplayName',sprintf('Segment %d',s));
end
grid(ax2,'on'); ylabel(ax2,'Local angle (deg)');
title(ax2,'Each segment starts at its own wrapped reference');
legend(ax2,'show','Location','eastoutside');
ax3 = nexttile(layout);
stem(ax3,t,segmentId,'filled','LineWidth',1);
grid(ax3,'on'); xlabel(ax3,'Same-clock time (s)');
ylabel(ax3,'Segment ID');
title(ax3,'ID 0 denotes a missing angle; no bridge is constructed');
linkaxes([ax1 ax2 ax3],'x');
xlim(ax1,[-.1 2.85]);
title(layout,'Gap-aware angular unwrapping: constructed data');
drawnow;

meta = struct('matlabVersion',version,'release',version('-release'), ...
    'generatedAt',datestr(now,30),'maxGapSeconds',maxGap, ...
    'externalBoundRadPerSecForSyntheticTruth',omegaMax);
artifacts = struct('png',fullfile(outDir,'segmented_unwrap.png'), ...
    'csv',fullfile(outDir,'segmented_unwrap.csv'), ...
    'mat',fullfile(outDir,'segmented_unwrap.mat'), ...
    'fig',fullfile(outDir,'segmented_unwrap.fig'));
exportgraphics(fig,artifacts.png,'Resolution',180);
savefig(fig,artifacts.fig);
writetable(data,artifacts.csv);
save(artifacts.mat,'data','meta');
disp(data);
disp(meta);
end

7 复现命令与本次验收结果

将上面三个文件按给出的名称保存在同一项目的 source 目录,把 MATLAB 当前文件夹切换到该项目根目录,再运行:

matlab 复制代码
addpath(fullfile(pwd,'source'));
artifacts = demo_segmented_unwrap(fullfile(pwd,'unwrap_native_output'));

demo_segmented_unwrap 内部会先调用 test_segmented_unwrap,因此上面的命令只运行一次示例,就会执行作者断言并将导出文件写入 unwrap_native_output。需要保存实际版本、命令日志和生成文件,不能仅凭最后一条 fprintf 声称验证完整。

本次独立验收另使用 review/test_segmented_unwrap_independent.m。该文件未嵌入正文;正文中的三份文件已足以复现示例和作者断言。只有另外取得独立验收文件时,才可继续运行下方命令,不要仅复制正文三文件后直接调用:

matlab 复制代码
addpath(fullfile(pwd,'review'));
independentResult = test_segmented_unwrap_independent;

实际运行结果

成功批次在 2026-10-24 01:01:54 至 01:02:30 UTC 执行,进程退出码为 0,未超时。作者断言、独立验收、四种文件导出及原生回读均完成。

检查项 本次成功批次结果
作者断言 由 demo 调用一次,执行完成
独立 oracle 对照 30 个案例通过,分段参照由输入时间与缺失位置独立生成
半圈接口检查 2 个案例通过,不据此推断半圈的物理方向
非法输入检查 40 个案例通过,逐项比较错误标识符
跨调用性质检查 5 项通过,复用上述 oracle 数据,不计为 5 个互不重叠的新案例
示例数据 14 行、4 个有效段;仅第 8 行即 1.5 s 的角度缺失
段首位置 第 1、6、9、12 行
原生 FIG 回读 3 个数据坐标轴,局部角坐标轴含 4 条独立段线,未跨缺口连接

实际段号为 [1 1 1 1 1 2 2 0 3 3 3 4 4 4]。局部角度与手算的 [170 190 210 230 250 10 30 NaN 70 90 110 -150 -130 -110] 度在 1e-12 rad 容差内一致,每段首点仍锚定自己的包裹角。

MATLAB 内重新读取 CSV 和 MAT 后,段号、起点标志、NaN 位置精确一致;时间和角度列按 1e-12 s、1e-12 rad 的绝对容差比较通过。CSV/MAT 间时间列最大差为 0,角度列的最大差为 4.4408920985006262e-14 rad,出现在 truth 列。这是文件回读之间的数值差,不是算法相对真实运动的误差,也不能据此声称 CSV 与 MAT 中的浮点数逐位相同。

原生输出及 PNG 像素已经过独立运行复核。正文三份源码与成功批次的执行快照逐字节一致。Code Analyzer 对核心函数、作者测试和独立测试无诊断;demo 中 datestr、now 各有一条维护建议,审计包装器另有一条 isscalar 性能建议。这些提示与完整运行记录保留在证据包中。

本次并非首次运行即成功。首次尝试在创建图形时记录访问冲突(0xc0000005),启动器同时记录超时;该轮仅完成作者断言,未完成导出和独立验收。保持源码不变重新运行后,得到上面的成功批次。两轮合计作者断言完成两次,独立验收完成一次;现有证据不足以确定首次失败的根因。

结尾

分段解缠的价值,是把"可以做局部连续表示"的区间和"没有证据连接"的区间分开。丢样后少了一圈还是多了几圈,需要设备约束、独立累计计数或其他测量来回答。

你的角度数据在最长丢样间隔内,是否有可靠的角速度上界,使 ΩΔt + 2ε < π 仍然成立?如果不能,后续控制或统计是否已经明确拒绝跨段计算角速度和累计转角?

相关推荐
qq_401700411 小时前
Qt Modbus 源码剖析:从 QModbusClient 到工业级通信框架
开发语言·qt·系统架构
我命由我123451 小时前
6-2.Python 异常处理 - 异常捕获(捕获指定异常、常见异常类型、异常信息)
java·运维·开发语言·python·http·https·java-ee
ZoeLandia1 小时前
React08:自定义 Hook
开发语言·前端·react.js·typescript
染指11101 小时前
157.Agent-LangGraph-工具(实现ReAct Agent模式)
开发语言·人工智能·python·langchain·agents
程序猿乐锅2 小时前
【从0-1学习Python | 第九篇】继承
开发语言·后端·python·学习·fastapi
wdfk_prog2 小时前
lwip教程 38:从 `lwipopts.h` 到 Port Contract——lwIP 裁剪、构建选择、OS Port 与 Network Port
java·开发语言
开开心心就好2 小时前
证件照排版打印工具,免费可自定义尺寸
服务器·开发语言·算法·随机森林·pdf·音视频·散列表
我是高手高手高高手2 小时前
宝塔 PHP多版本,切换PHP默认版本
开发语言·php
CodeYp20192 小时前
第16讲:set 和 map 的使用
开发语言·c++·set·map