# Detour 定位接口信息确认（源码答复）

用途：答复控制程序（`MultiWheelC` / `StateEstimation`）使用定位结果所必需的接口语义。
依据：本仓库 `DetourCore` 源码（`Location.cs`、`TightCoupler.cs`、`Frame.cs`、`LidarOdometry.cs`、`LidarMap.cs`、`WebAPI.cs`、`G.cs`）。
仓库内**没有**名为 `getCartLocation()` 的函数；控制侧该调用对应 Detour 的两类对外位姿出口。下文按字段对齐后统一说明。

---

## 对外位姿出口（先对齐接口）

控制程序看到的 `x / y / theta / tick / l_step`，来自下面之一（或对它们的封装）。字段名在源码里是 `th`，不是 `theta`。

| 出口 | 入口 | 字段 | `tick` 的真实来源 |
|---|---|---|---|
| HTTP `GET /getPos` | `CartLocation.FormatPosition()` | JSON：`x, y, th, l_step, tick`，异常时多 `error` | `tick = CartLocation.st_time`（观测时刻） |
| 共享对象 `DetourPos{shareObjectTag}` | `TightCoupler.CommitLocation` 每次提交后 `Post` | 二进制：`x, y, th, tick, l_step, error` | `tick = DateTime.Now.Ticks`（提交/推送时刻） |

默认 HTTP 端口：`Configuration.conf.guru.DetourPort`，默认 `4321`。
`shareObjectTag` 必须与控制侧 Clumsy 的 soTag 一致。

两种出口的 `x/y` 都已经过：

```
x_out = 内部x / guru.inputScale + guru.biasX
y_out = 内部y / guru.inputScale + guru.biasY
```

默认 `inputScale = 1`、`biasX = biasY = 0`，因此默认单位就是内部单位（毫米）。
`th` / `l_step` 不做缩放。

---

## 1. `getCartLocation()` 字段定义

### 1.1 各字段含义

| 字段 | 源码名 | 含义 |
|---|---|---|
| `x` | `CartLocation.x` | 车体原点在**当前地图坐标系**中的 X。已从传感器坐标系经安装外参反变换到车体中心（`TightCoupler.CommitLocation` 里 `ReverseTransform(传感器位姿, 组件安装位姿)`）。 |
| `y` | `CartLocation.y` | 同上，Y。 |
| `theta` | `CartLocation.th` | 车体航向。`0°` 朝向地图 `+X`，逆时针为正（内部用 `cos(th)` / `sin(th)` 画朝向）。 |
| `tick` | 见上表 | **不是**统一的一种时间。HTTP 是观测时间 `st_time`；DObject 是提交瞬间的 `DateTime.Now.Ticks`。控制侧必须先确认自己走的是哪条通道。 |
| `l_step` | `Frame.l_step` | **到已标注（labeled）关键帧的图上步数**，用来表达“离可信锚点有多远”，**不是**优化迭代次数，也不是匹配分数。注释原文：`steps to labeled keyframe`。 |

### 1.2 单位、正方向、坐标系

- **内部单位**：`x`、`y` 为毫米。UI 直接按 `mm` 显示。
- **对外单位**：默认仍是毫米；只有改了 `guru.inputScale`（例如设为 `1000` 输出米）才会变。
- **角度单位**：度。
- **坐标系**：当前加载的 SLAM 地图坐标系。原点由建图/标注关键帧决定，不是 GNSS 或车体启动点。
- **车体姿态**：输出的是**车体原点**，不是雷达原点。雷达/相机安装位姿在 `layout.components` 的 `x,y,th` 里。
- **右手平面**：`+X` 为航向 0，`+Y` 为航向 +90°。

### 1.3 `theta` 取值范围

**不是严格的 `[-180°, 180°]`。**

`LessMath.normalizeTh` 只在绝对值 ≥ 360 时按 360° 回绕，`(-360, 360)` 内原样返回。因此对外可能看到 `200`、`-270` 这类值。控制程序若假设 `[-180, 180]` 或 `[0, 360)`，应自己归一化。内部比较角度用 `LessMath.thDiff`，会按最短弧处理。

### 1.4 一次调用的字段是否同一帧

**是。** 一次读取对应同一个 `CartLocation` 对象上的 `x/y/th/l_step/st_time`。
`getPos` 先用当前 `latest` 判断 Timeout/Unstable，再序列化；若这两步之间恰好发生新提交，状态字和位姿可能差一帧，但单次 JSON 里的数值字段仍来自同一个对象。

---

## 2. `tick` 的准确含义（优先）

### 2.1 它是什么时刻

分通道：

**HTTP `/getPos` 的 `tick` = `st_time`**

- `st_time` 在 `Frame` 上的注释是：传感器数据**到达 Detour 时**由 `G.watch` 打的时间，不是激光头硬件曝光时刻，也不是 HTTP 返回时刻。
- 激光采集线程会按扫描周期做轻微平滑，并加上 `time_bias_ms`。
- 里程计把 `frame.st_time` 原样交给 `TightCoupler.CommitLocation`，再写入 `CartLocation.st_time`。
- 因此：`tick` ≈ **该位姿所对应的那帧观测到达时刻**。SLAM 计算发生在这之后，接口返回更晚。

**DObject `DetourPos` 的 `tick` = `DateTime.Now.Ticks`**

- 这是 **TightCoupler 提交并推送的本地时刻**，100 纳秒为单位，从公元 1 年 1 月 1 日起算（.NET `DateTime.Ticks`）。
- 它**不是**传感器采集时刻，也**不是** Unix epoch。
- 受本机本地时钟、时区、校时影响，必要时可能回跳。

### 2.2 单位、频率、单调、回绕

| 项目 | HTTP `tick`（`st_time`） | DObject `tick`（`DateTime.Now.Ticks`） |
|---|---|---|
| 单位 | 毫秒 | 100 ns（1 ms = 10000） |
| 时钟 | 进程启动时锚定一次 UTC，之后用 `Stopwatch` 单调累加：`ElapsedTicks*1000/Frequency + stMillis` | 本机本地 `DateTime.Now` |
| 更新频率 | 随传感器/融合提交，通常接近雷达帧率（常见 10–20 Hz，取决于设备 `timeBudget`） | 每次 `CommitLocation` 成功推一次 |
| 单调 | 进程内单调递增（不受事后改系统时间影响） | 一般递增；改系统时间或 DST 可能回跳 |
| 回绕 | `long` 毫秒，实际不会回绕 | `long` ticks，实际不会回绕 |
| 进程重启 | 重新锚定当前 UTC，**不会从 0 开始**，但与重启前不保证连续 | 继续跟本机时钟，与进程无关 |

`G.watch.TimeStampMillis` **不是** Unix 毫秒（1970），而是从公元 1 年起算的 UTC 毫秒，再加上启动后的单调流逝。

### 2.3 多车能否用 `tick` 对齐

**不能当作已同步的多车时钟。**

- 各车各自启动 `G.watch`，没有 PTP/NTP 协议，也没有跨车时间服务。
- HTTP `tick`：若各车系统 UTC 在启动时大致同步，数值会接近“同一套绝对时间”，但仍有启动锚定误差和各车处理延迟，**不能直接当多车位姿对齐的主时钟**。
- DObject `tick`：本地时间 ticks，跨时区会直接错开，更不适合多车对齐。
- 进程重启、暂停（`G.paused`）、提交失败都会造成时间空洞。

控制侧建议：单车内部用 `tick` 做延迟估计和短时预测；多车对齐应使用外部统一时钟，或只把 Detour `tick` 当相对时间。

---

## 3. 定位延迟与更新方式

### 3.1 输出位姿比真实运动滞后多少

源码**没有**标定“官方延迟 xx ms”。能确定的是延迟结构：

```
真实运动 → 雷达扫描/到达(st_time) → 里程计配准 → TightCoupler 融合 → 写入 CartLocation.latest → 接口读到缓存
```

经验上由源码阈值约束：

- 激光一圈通常几十毫秒（`Lidar2DStat.timeBudget`）。
- TightCoupler 常用窗口 `TCtimeWndSz = 150 ms`，上限 `TCtimeWndLimit = 700 ms`。
- `LidarOdometry` 把 `reg_ms > 200` 记为 `bad perf`。
- `/getPos` 若 `now - st_time > 500 ms` 打 `Timeout`。

因此正常运行时，**从观测到可读位姿大约是 1 帧雷达周期 + 配准/融合，常见几十到两百毫秒；超过 500 ms 会被 HTTP 接口判超时。** 这是实现上的门槛，不是出厂标定值。

### 3.2 `getCartLocation()` 是否返回最近一次缓存

**是。**

- `CartLocation.latest` 是全局缓存。
- 只有 `TightCoupler.CommitLocation` 成功后才会替换（取 `history` 中 `st_time` 最新的一帧）。
- `/getPos` 只读缓存，不触发新的 SLAM 计算。
- 配准失败、`CommitLocation` 返回 `null`（`bad variance` / `bad trace`）、或 `G.paused` 时，缓存停在上一帧。

### 3.3 原地自转时频率/延迟是否明显变化

**轮询频率不变，有效更新和延迟会变差。**

- 接口仍按调用方频率读同一缓存。
- 原地旋转时二维扫描重叠变差，帧间分、相位锁分容易掉，`allowCommit` 更常失败，缓存更容易停住。
- 配准变慢时单帧 `reg_ms` 上升；失败后 `l_step` 被加大（见第 4 节），看起来像“转的时候定位变钝”。
- 源码没有“自转专用降频”。

控制侧：自转短时预测应假设**有效位姿更新可能变稀、变老**，不要假设 `tick` 仍按雷达周期前进。

---

## 4. `l_step` 的含义（优先）

### 4.1 它是什么

**定位质量的图距离，不是优化迭代次数，也不是匹配状态枚举。**

定义：当前位姿/关键帧沿约束图走到**已标注关键帧**还要几步。

- `0`：本身就是标注帧（`labeledXY` / `labeledTh`），或刚被标成锚点。
- `1`：刚和地图/标注帧配准成功（`LidarMap` 成功后会把 `compared.l_step = 1`）。
- 正常递推：新关键帧 `l_step = 旧关键帧.l_step + lstepInc`。
- TightCoupler 提交时：有参考关键帧则 `reference.l_step + 1`；没有则 `latest.l_step + 3`。
- `9999`：未定位、手动设位、或与标注图断开。这是 `Frame` / 初始 `CartLocation` 的默认值。

**越小越好、越可信。**
`AIImplementationNote.md` 里“越小越不确定”与源码相反，不要采用。

内部用途包括：

- TightCoupler 边权：`l_step > 10 → 1`，`> 5 → 2`，`> 2 → 3`，否则 `4`；标注帧权 1000。
- 图优化用 `1/(l_step+0.01)` 当权。
- `l_step` 大时 `LidarMap` 更容易走全局配准（`forceGRegStep`、`GregThresK * l_step`）。
- `l_step > 1000` 且孤立的关键帧可被删掉。

源码 TODO 也写过：希望将来“去掉 `l_step`，改用误差圆”。当前对外接口仍是这个整数。

### 4.2 为什么原地自转会从 2～4 升到几十、上百

自转本身不会改公式，但会让 **`lstepInc` 连续被惩罚**，再在切关键帧时一次性加到 `l_step` 上。

`LidarOdometry` 里常见加项：

| 条件 | `lstepInc` 增量 |
|---|---|
| 帧间隔过大 | `+3`～`+20` |
| 时间落后 > 1 s | 可到 `+1000` 量级并重启局部图 |
| 有效点太少 | `+3` |
| 掩膜后点过少 | `+10` |
| 帧间序贯配准失败 | `+5` |
| 局部里程计分过低 | `+15` |
| 历史分偏低 | `+1`～`+15` |
| 局部图重启 / 相位锁差 | `+2` |
| 上一帧能提交、这一帧不能 | `+100` |
| 离开 hard 区域 | 默认再 `+20` |

原地自转时典型连锁是：旋转导致重叠变差 → 分数掉 → `lstepInc` 累加 → 因“新点变多 / 超时 / restart”切关键帧 → `新 l_step = 旧值 + lstepInc`。
若此时地图匹配也失败，TightCoupler 按 `latest.l_step + 3` 继续推高。
所以 2～4 很快可以变成几十，失败恢复前看到上百是符合实现的。

匹配一旦重新成功，关键帧会被改回 `l_step = 1`，输出也会掉下来。

### 4.3 有没有官方阈值

**没有写给控制程序的官方阈值表。** 下面是源码内部实际在用的分档，可作控制侧初值，现场仍要按地图和雷达标定。

| `l_step` | 源码含义 | 给控制程序的建议 |
|---|---|---|
| `0` | 标注锚点 | 最可信 |
| `1`～`2` | 刚贴上地图 / 离锚点很近 | **正常，接受** |
| `3`～`5` | TightCoupler 已降权 | 可用，开始警惕跳变 |
| `6`～`10` | 权更低；地图更倾向全局配准 | **退化，降低信任 / 收紧预测** |
| `11`～`98` | 最低权；曾从不稳定区回到 `2` 时会打工作区快照 | **明显退化，不宜做精细控制** |
| `≥ 99` | TightCoupler 允许关键帧被推得更狠 | 按不可靠处理 |
| `≥ 1000` | 孤立帧可删 | 基本断开 |
| `9999` | 未定位 / 手动设位 / 断开 | **不可用，应安全停止或等待重定位** |

Detour 自己判断“车是否静止可做全局重定位”时用：`l_step > 2` 且 TightCoupler 速度估计很小。这是内部策略，不是对外状态机。

---

## 5. 坐标跳变与重定位行为（优先）

### 5.1 重定位、回环、失败恢复后，`x/y/theta` 会不会永久跳变

**会。跳变是设计行为，不是毛刺。**

会改当前输出的情况：

1. **地图配准成功**：`LidarMap` 把当前关键帧改到匹配位姿，并 `l_step = 1`，再交给 TightCoupler。下一帧 `CartLocation` 跟着新参考走，表现为一次台阶。
2. **全局重定位**（`/relocalize` 或 UI Relocalize）：定位器进入 `relocalizing`，按全图关键帧搜索（`source = 9`）。第一次更好的匹配会 `TightCoupler.Reset`，位姿被拉到地图上，通常是大幅度永久跳变。
3. **回环 + `GraphOptimizer`**：关键帧 `x/y/th` 被就地改写。当前参考帧若被挪动，后续融合位姿跟着变。优化有动量平滑，但仍可能出现肉眼可见的台阶。
4. **手动 `/setLocation`**：直接改 `CartLocation.latest` 并重置融合窗口。
5. **匹配长期失败后突然恢复**：从里程计漂过的位置一下子贴回地图，跳变幅度等于累计漂移。

没有“只在内部跳、对外插值抹平”的保证。控制程序必须自己做跳变连续化或拒绝。

### 5.2 跳变后还在原来的地图坐标系吗

**同一张已加载地图内：是。**
跳的是车在这张图里的估计，不是换了一套轴。

会换坐标系的只有：换图（`/loadMap`）、`Remapper`（GNSS/外参映射）、或手动把车标到另一个锚点。这些不是普通重定位。

### 5.3 有没有“正在重定位 / 定位丢失 / 地图坐标调整”标志

**定位结果包里没有这些标志。**

内部有、但**不随 `getPos` / `DetourPos` 下发**：

| 内部量 | 作用 | 是否对外 |
|---|---|---|
| `Locator.relocalizing` / `relocalized` | 地图层正在/已经全局搜 | 否 |
| `G.IsSettingPosition` | 正在手动设位 | 否（`/getStat` 的 `globalStat` 里能看到） |
| `G.paused` | 定位暂停 | 否（同上，或调 `/pause` `/resume`） |
| `CartLocation.unstable` | 本帧掩膜过狠、场景不稳定 | **仅 HTTP**：`error = "Unstable"`。DObject 当前推送的 `error` 被写成空串 |
| `l_step` 升到很大 / `9999` | 实际的“丢了” | 是，但这是间接指标 |
| 图优化改关键帧 | “地图坐标调整” | 无单独标志 |

HTTP `/getPos` 仅有的显式错误：

- `"Timeout"`：`st_time` 已超过 500 ms
- `"Unstable"`：`latest.unstable == true`

没有 `"Relocalizing"`、`"Lost"`、`"MapAdjusted"`。
`/getStat` 可拉到各模块 `StatusMember`（雷达间隔、里程计状态、TC 状态等），但不是每帧位姿附属字段，也不适合当硬实时互锁。

控制侧应自己构造状态：

- **疑似丢失**：`error` 为 Timeout/Unstable，或 `l_step ≥ 99`，或 `tick` 长期不涨。
- **疑似重定位/回环跳变**：相邻两帧 `x/y/th` 突变，同时 `l_step` 突然掉回 1～2。
- **地图在拧**：跳变较缓、持续多帧，且 `l_step` 并不爆掉。

---

## 6. 可用的定位质量接口（优先）

### 6.1 除 `l_step` 外还能拿到什么

**位姿包几乎只有 `l_step` + HTTP 的 `error`。**

| 信息 | 有没有 | 说明 |
|---|---|---|
| 匹配得分 | 对控制程序：无 | 在 `LidarOdometry` / `LidarMap` 内部（`score`、`phaselocker_score`），不下发 |
| 协方差 | 对控制程序：无 | `Frame.errXX/errXY/errYY` 已预留，**未填进 `/getPos` 或 `DetourLocation`** |
| 置信度 | 间接 | 就是 `l_step`；源码 TODO 想换成误差圆，尚未做 |
| 定位状态 | 很弱 | HTTP：`Timeout` / `Unstable`；DObject：`error` 目前恒为空 |
| 错误码 | 无枚举 | 只有上述字符串 |
| 系统状态 | 有，但是慢接口 | `GET /getStat` → 布局/里程计/定位器/TC/GO/`G.paused`/`G.IsSettingPosition` |

因此控制程序**不能**指望每帧拿到匹配分或协方差。

### 6.2 Detour 实际用什么条件接受一帧（可当作官方内部规则）

源码里“接受并提交”的条件是分层的，没有单独的对外规范文档：

1. **里程计层**（`LidarOdometry.updateLocation`）
   - 分数过低会把 `strength` 压下去，甚至不把 `reference` 挂上。
   - 掩膜过狠 → `unstable = true`。
   - 点太少、序贯/局部配准失败 → 不切健康关键帧，并加大 `lstepInc`。
2. **融合层**（`TightCoupler.CommitLocation`）
   - 传感器处于 `bad variance` / `bad trace` → 直接丢弃，返回 `null`。
   - 丢弃后该源会被禁一段时间（约 1～2 s）。
3. **地图层**（`LidarMap`）
   - `result.score < ScoreThres` 丢弃。
   - 相对已有位姿的 `xy/th` 偏差超过按 `l_step` 放大的门限则丢弃（防止乱跳）。
   - 落到无效区域丢弃。
4. **HTTP 出口**
   - 缓存超过 500 ms → `Timeout`。
   - `unstable` → `Unstable`。

### 6.3 建议控制程序如何接受/拒绝一帧

源码没有写给 `MultiWheelC` 的官方判据。按上面的内部规则，建议：

**接受（正常闭环）**

- HTTP：无 `error`（DObject：至少 `tick` 在前进）。
- `l_step ≤ 5`。
- `tick` 新鲜（HTTP：`now - tick < 300 ms` 较稳妥；DObject：换算成 ms 后同样看提交间隔）。
- 相对上一接受帧：位移/转角不超过本底盘短周期能达到的上限。

**降级（短时预测、降低增益、禁止精细对位）**

- `6 ≤ l_step ≤ 10`，或偶发 Timeout 后立刻恢复。
- 原地自转期间 `l_step` 爬升但 `tick` 仍在更新。

**拒绝并安全停止 / 等待**

- `error == "Timeout"` 持续，或 `error == "Unstable"`。
- `l_step ≥ 99`（含 `9999`）。
- 单帧出现与运动学不符的永久台阶（尤其伴随 `l_step` 从很大突然回到 1）：先当重定位跳变，做连续化或刹停，不要直接当编码器。

**不要做的事**

- 不要用 `l_step` 当 ICP 迭代次数或“正在计算中”。
- 不要假设 `theta ∈ [-180, 180]`。
- 不要用 `tick` 做多车时间同步主时钟。
- 不要假设 DObject 的 `error` 会带 Timeout/Unstable（当前实现是空的）。

---

## 对 `StateEstimation` 的直接含义

清单里最优先的 2 / 4 / 5 / 6，对应控制侧应这样定：

1. **时间同步**
   - 先确认 `getCartLocation` 走 HTTP 还是 `DetourPos`。两条通道的 `tick` **单位和语义都不同**。
   - 单车：用 `tick` 估延迟、做自转短时预测。
   - 多车：另选同步时钟。

2. **自转短时预测**
   - 自转时有效更新可能变稀，`l_step` 会从个位数爬到几十上百。
   - 这是质量变差，不是接口卡死。预测窗口应随 `tick` 变老、`l_step` 变大而缩短。

3. **跳变连续化**
   - 重定位、回环、失败恢复都会造成**同地图下的永久台阶**。
   - 没有“正在改图”标志；用位姿差分 + `l_step` 回落来识别。

4. **安全停止**
   - 硬条件：`Timeout` / `Unstable` / `l_step ≥ 99` / `tick` 停更。
   - `l_step` 建议按 `≤5` 正常、`6–10` 退化、`>10` 不可用于精细控制。

---

## 源码锚点

| 主题 | 位置 |
|---|---|
| 对外 JSON / Timeout / Unstable | `DetourCore/Location.cs`：`ConstructRet`、`FormatPosition` |
| HTTP 路由 | `DetourCore/WebAPI.cs`：`/getPos`、`/setLocation`、`/relocalize`、`/getStat` |
| DObject 推送与 `tick=DateTime.Now.Ticks` | `DetourCore/Algorithms/TightCoupler.cs`：`DetourLocation`、`CommitLocation` |
| `l_step` 定义 | `DetourCore/Types/Frame.cs` |
| 车体中心变换、提交缓存 | `TightCoupler.CommitLocation` |
| 时钟 | `DetourCore/G.cs`：`DetourWatch.TimeStampMillis` |
| 角度回绕 | `DetourCore/LessMath.cs`：`normalizeTh` |
| 自转时 `l_step` 被加大 | `DetourCore/Algorithms/LidarOdometry.cs`（`lstepInc`） |
| 匹配成功后的位姿跳变 | `DetourCore/LocatorTypes/LidarMap.cs`（loop / relocalize） |
| 回环拧图 | `DetourCore/Algorithms/GraphOptimizer.cs` |
| 全局重定位入口 | `DetourCore/DetourLib.cs`：`Relocalize()` |
| 单位缩放 | `DetourCore/Configuration.cs`：`GuruOptions.inputScale / biasX / biasY` |

---

## 修订记录

- 2026-08-24：按当前 Detour 源码逐条答复原《Detour 信息确认清单》。
