Merge branch 'gpt' of git.simba-robotics.com:HelixCopex/mcm-26-b into gpt
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# 修复计划:7 项 P2 问题 + 3 项规划调整
|
||||||
|
|
||||||
|
已核实:7 个问题的定位与审核报告一致;另确认关联缺陷 `planner/__init__.py:392` 用 `>` 判断遮挡预算(实际放行 5 个被遮挡探点,注释口径为 4)。用户已确认:环探保留启发式+重写依据;误差场采用逐点白噪声哈希。
|
||||||
|
|
||||||
|
## 修改一:模拟器固定误差场(问题1)
|
||||||
|
文件 `src/mcm26b/sim/simulator.py`:
|
||||||
|
- 新增 `_bearing_error(channel, pos)`:`sha256(f"{error_seed}|{channel}|{round(x,6)}|{round(y,6)}")` 前 8 字节映射到 `[-error_half_deg, +error_half_deg)`。同一(源频道, 位置)读数恒定,不同点独立。
|
||||||
|
- `_observe`(L193)改为调用它;删除顺序随机流 `self._rng`(构造参数 `error_seed` 保留)。
|
||||||
|
- 模块 docstring 更新:同位置误差固定;分布仍是测试假设。
|
||||||
|
- 新增测试:同位置重复测量(不同 request_id)svd_deg 相同;同种子两次构造结果一致。
|
||||||
|
|
||||||
|
## 修改二:Q4 对跖探点公式(问题2)
|
||||||
|
文件 `src/mcm26b/planner/__init__.py`(L440-447):
|
||||||
|
- L447:`dx / norm * scale` → `dx * scale`(dy 同理),使偏移模长 = `min(norm, safe)`。圆心为原点、原探点 (200,0)、safe≥200 时返回 (-200,0)。
|
||||||
|
- L444 退化分支:`min(safe, 1.0)` → `safe`(方向 +x),不再钳到 1m。
|
||||||
|
- 新增单测:对跖点坐标(safe≥norm 与 safe<norm 两种)。
|
||||||
|
|
||||||
|
## 修改三:环探依据重写 + 预算一致(问题3,保留启发式)
|
||||||
|
- `planner/__init__.py` 模块 docstring、`DirectionalExtension` docstring、`_next_probe` docstring(L428-431):删除"≥180° 弧"声称,改为诚实表述:源位于环上且朝向沿外切线时可见弧可退化为单点,固定 45° 步进无最坏命中保证;梯子只提高提前命中率,完备性完全由后备清除兜底。
|
||||||
|
- `docs/协议规则表与状态机.md` §7(L166-172)同步重写。
|
||||||
|
- L392 `>` → `>=`,`config.py:85` 注释与文档同步为"4 个被遮挡探点(选点/对跖/环探×2)"。
|
||||||
|
- 新增 `scripts/benchmark_q4.py`:批量种子扫描(`--problem 4`,可配源数/定向数/种子范围/输出路径),逐局记录 measures、clears、后备清除格数与 attempts、virtual_time、completed,落盘 `output/q4/benchmark.json` 并打印汇总表——用于验证修复后的后备清除成本(审核"验证后备成本"要求),同时为论文问题4结果表提供可复现数字。
|
||||||
|
|
||||||
|
## 修改四:通信失败可靠收尾(问题4)
|
||||||
|
文件 `planner/__init__.py` `run()`(L195-205):
|
||||||
|
- `client.enter()` 纳入 try;except 顺序:`DeadlineExceeded` → `(TransportError, ProtocolError, InconsistentBelief)` → `Exception` 兜底;stop_reason 分别记 `real_budget_exhausted` / `transport_failure` / `protocol_error` / `inconsistent_belief` / `unexpected_error`,summary 增 `abort_detail`(异常类型+消息,截断)。
|
||||||
|
- `/exit` 改为尽力而为:`client.exit()` 失败不再中断收尾;`_summary` 接受 `info=None`(`exit_reason="exit_unreachable"`,virtual_time 取 `client.state.virtual_time_s`)。
|
||||||
|
- summary 增 `channel_status` 快照(每频道状态 + 候选 MEC 半径,约 20 条)——即"保存未完成状态";`--log` 的 JSONL 本就有全过程留痕。
|
||||||
|
- cli.py 无需改(`completed=false` 已映射退出码 1)。
|
||||||
|
- 新增测试:注入连续三次传输失败(若 `ScriptedFaultTransport` 不支持持久失败则扩展其故障类型),断言 `run()` 正常返回 summary 不抛异常、有 /exit 尝试留痕、退出码路径为 1。
|
||||||
|
|
||||||
|
## 修改五:Q1 近平行退化情形(问题5)
|
||||||
|
文件 `src/mcm26b/geometry/localization.py`(L216-226):
|
||||||
|
- 衰退方向判定改为对浮点平面系数的精确有理数运算(`Fraction`):候选方向仍枚举各平面法线的垂直方向 `(b,-a)/(-b,a)`;先用浮点快速预筛(max > 1e-9 直接排除,浮点误差远小于该阈值),近零候选再用 Fraction 精确检验 `∀(d,e,f): d·dir_x + e·dir_y ≤ 0`。平面系数本身是 float(精确有理数),故结论对量化输入精确,消除 2e-14 阈值与顶点过滤 eps=1e-9 的口径错配导致的漏判。
|
||||||
|
- 顶点枚举与 `PARALLEL_EPS` 预筛保持现状。
|
||||||
|
- 回归测试:审核复现输入 `[(0,0,1°), (0,0.01,358.99999999999°)]` → `status=unbounded`、`diameter_infinite=true`、不给清除保证;`scripts/benchmark_q1.py` 两档结果不回归。
|
||||||
|
|
||||||
|
## 修改六:默认舍入裕量统一(问题6)
|
||||||
|
- `config.py:75`:`bearing_rounding_margin_deg` 默认 `0.0` → `0.005`,注释改为"覆盖服务器两位小数舍入偏差(≤0.005°);库默认与 CLI 默认一致"。
|
||||||
|
- `q1` 子命令与 `geometry/__main__.py` 的 `--rounding-margin-deg` 默认保持 0(离线敏感性分析语义不变,审核未要求改)。
|
||||||
|
- 新增断言 `SearchConfig().bearing_rounding_margin_deg == 0.005`,并用默认配置复跑"边缘读数保留真源"场景;检查显式传参的现有测试不受影响;README 如有默认值描述一并更新。
|
||||||
|
|
||||||
|
## 修改七:后验评分补 near 分支(问题7)
|
||||||
|
文件 `planner/__init__.py` `_worst_posterior_radius`(L93-110):
|
||||||
|
- 增加 near 分支:`probe.clip_disk(q, field.near_radius_m, search.polygon_sides)`,多边形非空则纳入 max;direction 端点分支保留,docstring 说明 safe 盘内 no_signal 不可能故不枚举。
|
||||||
|
- 调用点(粗评/加密复评两处)传入 `near_radius_m` 与 `polygon_sides`。
|
||||||
|
- 新增测试:候选集 `[(-4,0),(4,0)]`(两点退化区域)、q=原点 → 最坏后验 ≈ 4m(修复前报 2m);`test_loop_q3/q4` 场景回归(选点可能变化,但断言均为清除状态类,预期无碍)。
|
||||||
|
|
||||||
|
## 规划调整落地
|
||||||
|
- **A(第二问定位)**:`docs/algo-2.md` §13(L231 附近)明确交付为"安全选点的启发式基线":解析安全盘证书已实现并证明;§10 区间上界未实现,采样最大值仅作粗筛,不得称严格最坏上界。
|
||||||
|
- **B(第三问)**:无代码改动;顺序约束(先修改一/四,再官方演练)在 `docs/B题建模分析与实施计划.md` 演练清单处注明。
|
||||||
|
- **C(第四问预算口径)**:`_summary` 增 `total_requests`(measures+clears);`DirectionalExtension._summary` 增 `fallback_clear_attempts`(Σ attempts);`docs/协议规则表与状态机.md` §7 后备清除段注明"后备 /clear 请求计入总请求与现实预算"。
|
||||||
|
|
||||||
|
## 测试与验收
|
||||||
|
- 每项修改配单测(新增约 10-12 项),全套 pytest 通过(95 → ~107 项)。
|
||||||
|
- 冒烟:`uv run mcm26b sim-check`;`uv run mcm26b run --problem 3/4 --self-test` 若干种子。
|
||||||
|
- 运行 `scripts/benchmark_q4.py` 记录修复后后备成本,输出落盘。
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
修改一(模拟器)→ 修改四(收尾)→ 修改六 → 修改七 → 修改二 → 修改三 → 修改五 → 文档同步 → 全量回归。
|
||||||
@@ -21,8 +21,8 @@ src/mcm26b/
|
|||||||
├── planner/ # P3 保证接收盘选点器 / P4 全向闭环 / P5 混合源定向闭环(已完工)
|
├── planner/ # P3 保证接收盘选点器 / P4 全向闭环 / P5 混合源定向闭环(已完工)
|
||||||
├── evaluation/ # 日志复算与统计(已完工)
|
├── evaluation/ # 日志复算与统计(已完工)
|
||||||
└── sim/scenes.py # 自检场景生成器:全向与混合源(测试端专用)
|
└── sim/scenes.py # 自检场景生成器:全向与混合源(测试端专用)
|
||||||
tests/ # pytest(兼容 unittest 风格),95 项
|
tests/ # pytest(兼容 unittest 风格),105 项
|
||||||
scripts/ # 采集、基准、可视化与校核脚本
|
scripts/ # 采集、基准、可视化与校核脚本(含问题四批量仿真 benchmark_q4.py)
|
||||||
output/ examples/ # 演练证据、算例与生成产物
|
output/ examples/ # 演练证据、算例与生成产物
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -215,11 +215,22 @@ P0–P5(自建部分)与 P2 均已交付:协议层(串行幂等客户端
|
|||||||
全向闭环与 `DirectionalExtension` 混合源定向闭环(49 点覆盖对定向源同样成立、
|
全向闭环与 `DirectionalExtension` 混合源定向闭环(49 点覆盖对定向源同样成立、
|
||||||
no_signal 联合约束基线不删候选、对跖+环探遮挡梯子、`fallback_clear_cells`
|
no_signal 联合约束基线不删候选、对跖+环探遮挡梯子、`fallback_clear_cells`
|
||||||
有限圆盘覆盖后备清除,格数与现实预算双硬顶下不盲扫)。自建验收:
|
有限圆盘覆盖后备清除,格数与现实预算双硬顶下不盲扫)。自建验收:
|
||||||
`uv run pytest`(95 项)与 `uv run mcm26b sim-check`;随机场景自检:
|
`uv run pytest`(105 项)与 `uv run mcm26b sim-check`;随机场景自检:
|
||||||
`uv run mcm26b run --robot-id <队号> --self-test`(问题4加 `--problem 4`)。
|
`uv run mcm26b run --robot-id <队号> --self-test`(问题4加 `--problem 4`)。
|
||||||
|
|
||||||
|
评审修订(2026-09-11):本地模拟器改为逐点固定误差场(同一"源×位置"读数
|
||||||
|
恒定,与官方演练实测一致);对跖复核探点公式修正(此前塌缩到 MEC 圆心 1m
|
||||||
|
内,浪费探点预算并推高后备清除);环探"≥180° 可见弧"依据删除(最坏可见弧
|
||||||
|
可退化为单点,梯子只是启发式,完备性由后备清除兜底);断连/协议错误中断后
|
||||||
|
仍尽力 /exit 并产出未完成 summary;库默认舍入裕量统一为 0.005°;后验评分
|
||||||
|
补 near 分支;问题一近平行细长区域保留远端顶点。后备清除成本用
|
||||||
|
`scripts/benchmark_q4.py` 批量种子扫描量化(结果落盘 `output/q4/`)。
|
||||||
|
|
||||||
下一步优先把两个闭环接入官方演练(问题3用全向、问题4用混合,先实测单请求
|
下一步优先把两个闭环接入官方演练(问题3用全向、问题4用混合,先实测单请求
|
||||||
延迟与总请求数,核对 1200s 现实预算与 2MB 日志体积),再做 P6 消融对照。
|
延迟与总请求数,核对 1200s 现实预算与 2MB 日志体积),再做 P6 消融对照。
|
||||||
|
顺序约束:上述模拟器与故障收尾修复先行合入,官方演练在修复后的版本上执行;
|
||||||
|
总请求口径必须计入后备清除(summary 的 `total_requests` 与
|
||||||
|
`fallback_clear_attempts`),仅统计检测次数会低估现实耗时。
|
||||||
基线量级供参照:10 源场景约 550–630 次检测、虚拟总时 1.4–1.7 万秒;16 源
|
基线量级供参照:10 源场景约 550–630 次检测、虚拟总时 1.4–1.7 万秒;16 源
|
||||||
场景约 200–320 次检测即触发数量上界停止。该数字说明共享测点与跳点策略
|
场景约 200–320 次检测即触发数量上界停止。该数字说明共享测点与跳点策略
|
||||||
(P6)有明确改进空间。不要重复开发几何内核或提前堆叠学习算法;以官方演练
|
(P6)有明确改进空间。不要重复开发几何内核或提前堆叠学习算法;以官方演练
|
||||||
|
|||||||
+1
-1
@@ -228,7 +228,7 @@
|
|||||||
|
|
||||||
性能层应在相同自建源位置与确定的空间误差场上,比较轴向点、两个横向点、有限候选最坏后验策略。误差场在同一位置保持固定;不得让每次调用重新独立生成误差。记录第二次测量类型、后验直径和包围半径、额外移动、计算耗时及是否达到20米清除阈值。至少包含误差边界、源域边界、接收半径1000米、近距离和近共线案例。没有这些实验前,不报告未经验证的性能提升比例。
|
性能层应在相同自建源位置与确定的空间误差场上,比较轴向点、两个横向点、有限候选最坏后验策略。误差场在同一位置保持固定;不得让每次调用重新独立生成误差。记录第二次测量类型、后验直径和包围半径、额外移动、计算耗时及是否达到20米清除阈值。至少包含误差边界、源域边界、接收半径1000米、近距离和近共线案例。没有这些实验前,不报告未经验证的性能提升比例。
|
||||||
|
|
||||||
目前 [planning.py](C:/Users/HelixCopex/code/MATH/geometry/planning.py) 中的 `first_bearing_candidate_disk` 已实现第5至6节的解析证书;问题一内核可用于纯半平面区域的顶点和最小圆计算。第9至11节描述的是完整选点器的数学方案,包括本次补充的保守区间评估方法,尚未在现有仓库中实现为完整第二问优化器,也没有相应正式成绩。该实现边界不影响解析候选区域的证明。
|
目前 [planning.py](C:/Users/HelixCopex/code/MATH/geometry/planning.py) 中的 `first_bearing_candidate_disk` 已实现第5至6节的解析证书;问题一内核可用于纯半平面区域的顶点和最小圆计算。**本仓库第二问交付的定位是"安全选点的启发式基线"**:解析安全候选盘证书已实现并证明(选中点必能收到信号),而选点评分采用边界采样点 × 读数区间端点的有限枚举近似(含 near 分支)。第9至11节描述的完整选点器数学方案,特别是第10节覆盖全部未来读数的保守区间上界,尚未在现有仓库中实现;采样最大值只用于候选点之间的相对比较与粗筛,不得当作严格最坏情形上界报告,也没有相应正式成绩。该实现边界不影响解析候选区域的证明。
|
||||||
|
|
||||||
对后续实现而言,应新增接收任意半平面约束的交集入口,或使用独立的多边形裁剪函数,再复用第一问的最小包围圆。不能把圆盘先验伪装成额外测向观测传入现有 `solve(measurements)` 接口。
|
对后续实现而言,应新增接收任意半平面约束的交集入口,或使用独立的多边形裁剪函数,再复用第一问的最小包围圆。不能把圆盘先验伪装成额外测向观测传入现有 `solve(measurements)` 接口。
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+15
-7
@@ -165,11 +165,13 @@ no_signal/near/direction(direction 附 svd_deg),`/clear` 仍只看 20m 半
|
|||||||
(`belief.update_measure` 对 no_signal 无操作);缺席证明仍只由完整覆盖
|
(`belief.update_measure` 对 no_signal 无操作);缺席证明仍只由完整覆盖
|
||||||
给出。唯一启用的推理:若 no_signal 发生在保证接收盘 B(mec, 1000-ρ) 内
|
给出。唯一启用的推理:若 no_signal 发生在保证接收盘 B(mec, 1000-ρ) 内
|
||||||
(该盘内任一全向源必然可收),则只能由朝向遮挡解释 [观测推理]——该频道
|
(该盘内任一全向源必然可收),则只能由朝向遮挡解释 [观测推理]——该频道
|
||||||
记为"观测到定向",进入探点升级梯子:对 MEC 圆心对跖复核一次,再绕
|
记为"观测到定向",进入探点升级梯子:对 MEC 圆心取对跖方向复核(偏移
|
||||||
MEC、半径 = ρ 的圆环按 45° 步进探点(环包围任意候选源,发射半平面在
|
裁进保证接收盘),再绕 MEC、半径 = ρ 的圆环按 45° 步进探点。**几何边界
|
||||||
环上截出 ≥180° 弧)。被遮挡探点不消耗定位步数,单轮由
|
必须如实陈述**:环上可见弧没有下界——源可位于环上(|g−c| = ρ)且朝向
|
||||||
`max_blocked_probes`(4 [工程])限流;梯子不构成接收保证,失败由后备
|
沿外切线,此时可见集退化为单个切点,固定步进环探可以全部漏过。梯子
|
||||||
清除兜底;
|
只提高提前命中概率,不构成接收或命中保证;完备性完全由后备清除兜底。
|
||||||
|
被遮挡探点不消耗定位步数,单轮被遮挡探点(首次选点、对跖复核、环探
|
||||||
|
合计)达 `max_blocked_probes`(4 [工程])即转后备清除;
|
||||||
- **后备清除的协议用法**:定位停滞(候选集有界但未达 §6 清除证书)时,
|
- **后备清除的协议用法**:定位停滞(候选集有界但未达 §6 清除证书)时,
|
||||||
对候选外包多边形构造 20m 有限圆盘覆盖(`fallback_clear_cells`:全局
|
对候选外包多边形构造 20m 有限圆盘覆盖(`fallback_clear_cells`:全局
|
||||||
半开网格索引、相交判定带正容差,格内任一点距某格心 ≤ 10√2 < 20m),
|
半开网格索引、相交判定带正容差,格内任一点距某格心 ≤ 10√2 < 20m),
|
||||||
@@ -180,12 +182,18 @@ no_signal/near/direction(direction 附 svd_deg),`/clear` 仍只看 20m 半
|
|||||||
(⌈w/20⌉+2)(⌈h/20⌉+2) 做规模检查,宽候选集(如仅一次读数的 750m
|
(⌈w/20⌉+2)(⌈h/20⌉+2) 做规模检查,宽候选集(如仅一次读数的 750m
|
||||||
截断角域,数千格)必须继续定位而非硬扫;
|
截断角域,数千格)必须继续定位而非硬扫;
|
||||||
- 现实预算:N × `real_rtt_p95_s` + `reserve_exit_s` ≤ 剩余现实时间。
|
- 现实预算:N × `real_rtt_p95_s` + `reserve_exit_s` ≤ 剩余现实时间。
|
||||||
虚拟耗时上界 ≈ 移动 L/5 + 3N(全失败)或 3(N-1)+5(末次成功);
|
虚拟耗时上界 ≈ 移动 L/5 + 3N(全失败)或 3(N-1)+5(末次成功)。
|
||||||
|
后备 /clear 请求**计入总请求口径**:summary 的 `total_requests`
|
||||||
|
(measures+clears)与 `fallback_clear_attempts`(后备逐格尝试数)
|
||||||
|
如实上报——仅统计检测次数会明显低估现实耗时;
|
||||||
- **真源不误删**:no_signal、失败的 `/clear`(含后备逐格全失败)均不删
|
- **真源不误删**:no_signal、失败的 `/clear`(含后备逐格全失败)均不删
|
||||||
空间候选;后备全失败按守恒性破坏记异常(格心距格内任一点 < 20m,
|
空间候选;后备全失败按守恒性破坏记异常(格心距格内任一点 < 20m,
|
||||||
理论上必命中);
|
理论上必命中);
|
||||||
- **停止条件与预算语义不变**:仍是清除数达 16、每频道均"已清除或覆盖
|
- **停止条件与预算语义不变**:仍是清除数达 16、每频道均"已清除或覆盖
|
||||||
证明不存在"、现实预算耗尽(记未证明完成)三种;
|
证明不存在"、现实预算耗尽(记未证明完成)三种;断连/协议错误/
|
||||||
|
守恒性破坏中断时同样尽力 /exit 并产出未完成 summary(`stop_reason`
|
||||||
|
记 `transport_failure`/`protocol_error`/`inconsistent_belief` 等,
|
||||||
|
附逐频道状态快照),不静默丢失会话;
|
||||||
- **验收**:`tests/test_loop_q4.py`(15 项:覆盖格无空洞与格线两侧保留、
|
- **验收**:`tests/test_loop_q4.py`(15 项:覆盖格无空洞与格线两侧保留、
|
||||||
退化多边形、格数上界公式;混合 10/16 源全清、朝向背离圆心的近边界源
|
退化多边形、格数上界公式;混合 10/16 源全清、朝向背离圆心的近边界源
|
||||||
不漏、批量种子回归;no_signal 不删候选;格数硬顶与预算跳过;后备
|
不漏、批量种子回归;no_signal 不删候选;格数硬顶与预算跳过;后备
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"config": {
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"bearing_margin_deg": 0.005
|
||||||
|
},
|
||||||
|
"games": 16,
|
||||||
|
"completed": 16,
|
||||||
|
"all_cleared": 16,
|
||||||
|
"measures_total": 9453,
|
||||||
|
"clears_total": 471,
|
||||||
|
"total_requests_total": 9924,
|
||||||
|
"fallback_attempts_total": 316,
|
||||||
|
"fallback_attempts_max": 165,
|
||||||
|
"virtual_time_s_max": 18756.107,
|
||||||
|
"mismatch_total": 0,
|
||||||
|
"anomaly_total": 0
|
||||||
|
},
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"seed": 0,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 623,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 633,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 17147.133,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 1,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 591,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 601,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16383.06,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 2,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 580,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 590,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16380.242,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 3,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 559,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 569,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 14575.032,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 4,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 576,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 586,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16128.325,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 5,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 609,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 619,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 15963.724,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 6,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 624,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 634,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16364.634,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 7,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 569,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 579,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 17010.511,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 8,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 599,
|
||||||
|
"clears": 12,
|
||||||
|
"total_requests": 611,
|
||||||
|
"fallback_cells": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"fallback_attempts": 3,
|
||||||
|
"virtual_time_s": 18756.107,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 9,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 671,
|
||||||
|
"clears": 13,
|
||||||
|
"total_requests": 684,
|
||||||
|
"fallback_cells": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"fallback_attempts": 4,
|
||||||
|
"virtual_time_s": 17391.766,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 10,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 613,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 623,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 17981.333,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 11,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 552,
|
||||||
|
"clears": 149,
|
||||||
|
"total_requests": 701,
|
||||||
|
"fallback_cells": [
|
||||||
|
142
|
||||||
|
],
|
||||||
|
"fallback_attempts": 140,
|
||||||
|
"virtual_time_s": 17099.436,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 12,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 543,
|
||||||
|
"clears": 174,
|
||||||
|
"total_requests": 717,
|
||||||
|
"fallback_cells": [
|
||||||
|
168
|
||||||
|
],
|
||||||
|
"fallback_attempts": 165,
|
||||||
|
"virtual_time_s": 17802.514,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 13,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 579,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 589,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16392.501,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 14,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 587,
|
||||||
|
"clears": 13,
|
||||||
|
"total_requests": 600,
|
||||||
|
"fallback_cells": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"fallback_attempts": 4,
|
||||||
|
"virtual_time_s": 16565.569,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 15,
|
||||||
|
"sources": 10,
|
||||||
|
"directional": 3,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "all_resolved",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 578,
|
||||||
|
"clears": 10,
|
||||||
|
"total_requests": 588,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 15288.608,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"config": {
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"bearing_margin_deg": 0.005
|
||||||
|
},
|
||||||
|
"games": 10,
|
||||||
|
"completed": 10,
|
||||||
|
"all_cleared": 10,
|
||||||
|
"measures_total": 3222,
|
||||||
|
"clears_total": 1024,
|
||||||
|
"total_requests_total": 4246,
|
||||||
|
"fallback_attempts_total": 872,
|
||||||
|
"fallback_attempts_max": 199,
|
||||||
|
"virtual_time_s_max": 18879.215,
|
||||||
|
"mismatch_total": 0,
|
||||||
|
"anomaly_total": 0
|
||||||
|
},
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"seed": 0,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 375,
|
||||||
|
"clears": 16,
|
||||||
|
"total_requests": 391,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 17651.199,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 1,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 356,
|
||||||
|
"clears": 16,
|
||||||
|
"total_requests": 372,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 17003.576,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 2,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 297,
|
||||||
|
"clears": 214,
|
||||||
|
"total_requests": 511,
|
||||||
|
"fallback_cells": [
|
||||||
|
204
|
||||||
|
],
|
||||||
|
"fallback_attempts": 199,
|
||||||
|
"virtual_time_s": 18137.964,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 3,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 346,
|
||||||
|
"clears": 16,
|
||||||
|
"total_requests": 362,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16885.059,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 4,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 372,
|
||||||
|
"clears": 177,
|
||||||
|
"total_requests": 549,
|
||||||
|
"fallback_cells": [
|
||||||
|
201,
|
||||||
|
172
|
||||||
|
],
|
||||||
|
"fallback_attempts": 163,
|
||||||
|
"virtual_time_s": 18525.209,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 5,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 330,
|
||||||
|
"clears": 145,
|
||||||
|
"total_requests": 475,
|
||||||
|
"fallback_cells": [
|
||||||
|
130,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"fallback_attempts": 131,
|
||||||
|
"virtual_time_s": 18089.132,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 6,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 187,
|
||||||
|
"clears": 209,
|
||||||
|
"total_requests": 396,
|
||||||
|
"fallback_cells": [
|
||||||
|
207
|
||||||
|
],
|
||||||
|
"fallback_attempts": 194,
|
||||||
|
"virtual_time_s": 15131.949,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 7,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 258,
|
||||||
|
"clears": 199,
|
||||||
|
"total_requests": 457,
|
||||||
|
"fallback_cells": [
|
||||||
|
188,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"fallback_attempts": 185,
|
||||||
|
"virtual_time_s": 18879.215,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 8,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 325,
|
||||||
|
"clears": 16,
|
||||||
|
"total_requests": 341,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 16301.93,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 9,
|
||||||
|
"sources": 16,
|
||||||
|
"directional": 6,
|
||||||
|
"completed": true,
|
||||||
|
"stop_reason": "sixteen_cleared",
|
||||||
|
"all_cleared": true,
|
||||||
|
"measures": 376,
|
||||||
|
"clears": 16,
|
||||||
|
"total_requests": 392,
|
||||||
|
"fallback_cells": [],
|
||||||
|
"fallback_attempts": 0,
|
||||||
|
"virtual_time_s": 15287.399,
|
||||||
|
"virtual_recompute_mismatches": 0,
|
||||||
|
"anomaly_count": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""问题四批量仿真基准:固定误差场下统计后备清除成本与总请求量。
|
||||||
|
|
||||||
|
逐局在进程内驱动 DirectionalExtension(本地模拟器;示向度误差场逐点
|
||||||
|
固定,与官方"同一地点误差不变"口径一致),记录 completed、measures、
|
||||||
|
clears、total_requests、fallback_clear_attempts、virtual_time_s 等指标,
|
||||||
|
落盘 JSON 并打印汇总表。用于:
|
||||||
|
- 验证后备清除成本(对跖探点修正后梯子命中率的现实校核);
|
||||||
|
- 为论文问题四结果表提供可复现数字(种子、配置、逐局指标全部留档)。
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
uv run python scripts/benchmark_q4.py --seeds 0-15 --sources 10 --directional 3
|
||||||
|
uv run python scripts/benchmark_q4.py --seeds 20260910 --sources 16 --directional 6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcm26b.config import ProblemConfig
|
||||||
|
from mcm26b.evaluation import recompute_virtual_time
|
||||||
|
from mcm26b.planner import DirectionalExtension
|
||||||
|
from mcm26b.protocol.client import SimulatorClient
|
||||||
|
from mcm26b.sim import LocalSimTransport, LocalSimulator
|
||||||
|
from mcm26b.sim.scenes import mixed_scene
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_seeds(spec: str) -> list[int]:
|
||||||
|
"""种子列表:"0-15" 区间、"0,5,9" 枚举或单个值。"""
|
||||||
|
seeds: list[int] = []
|
||||||
|
for part in spec.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if "-" in part:
|
||||||
|
lo, hi = part.split("-", 1)
|
||||||
|
seeds.extend(range(int(lo), int(hi) + 1))
|
||||||
|
else:
|
||||||
|
seeds.append(int(part))
|
||||||
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(seed: int, count: int, directional: int, margin: float) -> dict:
|
||||||
|
config = replace(ProblemConfig(),
|
||||||
|
search=replace(ProblemConfig().search,
|
||||||
|
bearing_rounding_margin_deg=margin))
|
||||||
|
sources = mixed_scene(seed=seed, count=count, directional=directional)
|
||||||
|
simulator = LocalSimulator(sources, robot_id="BENCH", config=config)
|
||||||
|
client = SimulatorClient("BENCH", config=config,
|
||||||
|
transport=LocalSimTransport(simulator))
|
||||||
|
loop = DirectionalExtension(client, config)
|
||||||
|
summary = loop.run()
|
||||||
|
report = recompute_virtual_time(client.log.action_events())
|
||||||
|
truth = sorted(s.channel for s in sources)
|
||||||
|
return {
|
||||||
|
"seed": seed,
|
||||||
|
"sources": count,
|
||||||
|
"directional": directional,
|
||||||
|
"completed": summary["completed"],
|
||||||
|
"stop_reason": summary["stop_reason"],
|
||||||
|
"all_cleared": sorted(summary["cleared_channels"]) == truth,
|
||||||
|
"measures": summary["measures"],
|
||||||
|
"clears": summary["clears"],
|
||||||
|
"total_requests": summary["total_requests"],
|
||||||
|
"fallback_cells": [entry["cells"] for entry in summary["fallback_clears"]],
|
||||||
|
"fallback_attempts": summary["fallback_clear_attempts"],
|
||||||
|
"virtual_time_s": summary["virtual_time_s"],
|
||||||
|
"virtual_recompute_mismatches": len(report["mismatches"]),
|
||||||
|
"anomaly_count": len(summary["anomalies"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--seeds", default="0-15",
|
||||||
|
help="种子,如 0-15 / 0,5,9 / 20260910")
|
||||||
|
parser.add_argument("--sources", type=int, default=10)
|
||||||
|
parser.add_argument("--directional", type=int, default=3)
|
||||||
|
parser.add_argument("--bearing-margin-deg", type=float, default=0.005)
|
||||||
|
parser.add_argument("--out", default="output/q4/benchmark.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
rows = [run_one(seed, args.sources, args.directional, args.bearing_margin_deg)
|
||||||
|
for seed in parse_seeds(args.seeds)]
|
||||||
|
completed = sum(1 for row in rows if row["completed"])
|
||||||
|
cleared = sum(1 for row in rows if row["all_cleared"])
|
||||||
|
summary = {
|
||||||
|
"config": {"sources": args.sources, "directional": args.directional,
|
||||||
|
"bearing_margin_deg": args.bearing_margin_deg},
|
||||||
|
"games": len(rows),
|
||||||
|
"completed": completed,
|
||||||
|
"all_cleared": cleared,
|
||||||
|
"measures_total": sum(row["measures"] for row in rows),
|
||||||
|
"clears_total": sum(row["clears"] for row in rows),
|
||||||
|
"total_requests_total": sum(row["total_requests"] for row in rows),
|
||||||
|
"fallback_attempts_total": sum(row["fallback_attempts"] for row in rows),
|
||||||
|
"fallback_attempts_max": max((row["fallback_attempts"] for row in rows),
|
||||||
|
default=0),
|
||||||
|
"virtual_time_s_max": max((row["virtual_time_s"] for row in rows),
|
||||||
|
default=0.0),
|
||||||
|
"mismatch_total": sum(row["virtual_recompute_mismatches"] for row in rows),
|
||||||
|
"anomaly_total": sum(row["anomaly_count"] for row in rows),
|
||||||
|
}
|
||||||
|
payload = {"summary": summary, "games": rows}
|
||||||
|
out_path = ROOT / args.out
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||||
|
header = ("seed", "done", "clear", "measures", "clears", "requests",
|
||||||
|
"fb_attempts", "virtual_s")
|
||||||
|
print("\t".join(header))
|
||||||
|
for row in rows:
|
||||||
|
print("\t".join(str(row[key]) for key in
|
||||||
|
("seed", "completed", "all_cleared", "measures", "clears",
|
||||||
|
"total_requests", "fallback_attempts", "virtual_time_s")))
|
||||||
|
print(f"\n结果已写入 {out_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -72,7 +72,9 @@ class SearchConfig:
|
|||||||
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000
|
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000
|
||||||
grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω
|
grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω
|
||||||
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
|
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
|
||||||
bearing_rounding_margin_deg: float = 0.0 # 默认不加舍入裕量;敏感性分析时显式设 0.005
|
# 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];
|
||||||
|
# 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。
|
||||||
|
bearing_rounding_margin_deg: float = 0.005
|
||||||
# ---- P3/P4 在线定位与闭环参数 [工程] ----
|
# ---- P3/P4 在线定位与闭环参数 [工程] ----
|
||||||
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m
|
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m
|
||||||
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
|
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
|
||||||
@@ -82,7 +84,8 @@ class SearchConfig:
|
|||||||
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
|
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
|
||||||
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
|
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
|
||||||
# ---- P5 定向扩展参数 [工程] ----
|
# ---- P5 定向扩展参数 [工程] ----
|
||||||
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算(对跖复核+环探)
|
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
|
||||||
|
# (首次选点+对跖复核+2 次环探;达限转后备清除)
|
||||||
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
|
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All calculations are floating point with normalized station coordinates.
|
All calculations are floating point with normalized station coordinates.
|
||||||
The reference algorithm deliberately favors auditability over asymptotic speed.
|
The reference algorithm deliberately favors auditability over asymptotic speed.
|
||||||
|
Near-parallel plane pairs are intersected instead of skipped (only exactly
|
||||||
|
parallel pairs are discarded), and the feasibility tolerance scales with the
|
||||||
|
candidate vertex magnitude, so legitimate far-away vertices of long thin
|
||||||
|
regions are never silently dropped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -180,6 +184,13 @@ def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
|
|||||||
so they possess a vertex, even if unbounded. Feasibility follows from vertex
|
so they possess a vertex, even if unbounded. Feasibility follows from vertex
|
||||||
enumeration; unboundedness follows from a nonzero recession direction.
|
enumeration; unboundedness follows from a nonzero recession direction.
|
||||||
Empty diameter is null, unbounded diameter is null with diameter_infinite=true.
|
Empty diameter is null, unbounded diameter is null with diameter_infinite=true.
|
||||||
|
|
||||||
|
Near-parallel plane pairs are intersected rather than skipped: their
|
||||||
|
intersection can be a legitimate vertex of a very long thin region
|
||||||
|
(e.g. stations 1cm apart with bearings 1 and 358.99999999999 deg give a
|
||||||
|
vertex ~5.7e10 m away). Only exactly-parallel pairs (det == 0) have no
|
||||||
|
intersection. The feasibility tolerance scales with the vertex magnitude,
|
||||||
|
since absolute residuals grow with coordinates.
|
||||||
"""
|
"""
|
||||||
if not isfinite(eps) or eps <= 0:
|
if not isfinite(eps) or eps <= 0:
|
||||||
raise ValueError("eps必须为有限正数")
|
raise ValueError("eps必须为有限正数")
|
||||||
@@ -197,15 +208,16 @@ def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
|
|||||||
planes = [h for m in local for h in halfplanes(m)]
|
planes = [h for m in local for h in halfplanes(m)]
|
||||||
|
|
||||||
def inside(p):
|
def inside(p):
|
||||||
return all(a * p[0] + b * p[1] <= c + eps for a, b, c in planes)
|
tol = eps * max(1.0, abs(p[0]), abs(p[1]))
|
||||||
|
return all(a * p[0] + b * p[1] <= c + tol for a, b, c in planes)
|
||||||
|
|
||||||
feasible_vertices = []
|
feasible_vertices = []
|
||||||
for (a, b, c), (d, e, f) in combinations(planes, 2):
|
for (a, b, c), (d, e, f) in combinations(planes, 2):
|
||||||
det = a * e - b * d
|
det = a * e - b * d
|
||||||
if abs(det) <= PARALLEL_EPS:
|
if det == 0.0:
|
||||||
continue
|
continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)
|
||||||
p = ((c * e - b * f) / det, (a * f - c * d) / det)
|
p = ((c * e - b * f) / det, (a * f - c * d) / det)
|
||||||
if inside(p):
|
if isfinite(p[0]) and isfinite(p[1]) and inside(p):
|
||||||
feasible_vertices.append(p)
|
feasible_vertices.append(p)
|
||||||
|
|
||||||
if not feasible_vertices:
|
if not feasible_vertices:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止
|
|||||||
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
|
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
|
||||||
发现 10 源、连续无信号、候选采样为空都不是停止条件;
|
发现 10 源、连续无信号、候选采样为空都不是停止条件;
|
||||||
现实预算耗尽退出记为未证明完成(completed=false),不与成功混记。
|
现实预算耗尽退出记为未证明完成(completed=false),不与成功混记。
|
||||||
|
断连/协议错误/守恒性破坏中断时同样尽力 /exit 并产出未完成 summary
|
||||||
|
(stop_reason 记中断类别,附逐频道状态快照),不静默丢失会话。
|
||||||
|
|
||||||
P5 定向扩展(问题4混合源闭环):
|
P5 定向扩展(问题4混合源闭环):
|
||||||
- 49 点覆盖的发现保证对定向源同样成立(源在单元凸包内,任意发射半平面
|
- 49 点覆盖的发现保证对定向源同样成立(源在单元凸包内,任意发射半平面
|
||||||
@@ -31,13 +33,15 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..belief import (BeliefState, CandidateRegion, ChannelStatus,
|
from ..belief import (BeliefState, CandidateRegion, ChannelStatus,
|
||||||
clip_halfplane)
|
InconsistentBelief, clip_halfplane)
|
||||||
from ..config import ProblemConfig, Vec2
|
from ..config import ProblemConfig, Vec2
|
||||||
from ..coverage import CoverageLedger
|
from ..coverage import CoverageLedger
|
||||||
from ..evaluation import recompute_virtual_time, summarize
|
from ..evaluation import recompute_virtual_time, summarize
|
||||||
from ..protocol.client import DeadlineExceeded, SimulatorClient
|
from ..protocol.client import (DeadlineExceeded, ProtocolError,
|
||||||
|
SimulatorClient)
|
||||||
from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL
|
from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL
|
||||||
from ..protocol.timing import move_time
|
from ..protocol.timing import move_time
|
||||||
|
from ..protocol.transport import TransportError
|
||||||
|
|
||||||
|
|
||||||
def bearing_deg(a: Vec2, b: Vec2) -> float:
|
def bearing_deg(a: Vec2, b: Vec2) -> float:
|
||||||
@@ -45,6 +49,11 @@ def bearing_deg(a: Vec2, b: Vec2) -> float:
|
|||||||
return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0
|
return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def _error_brief(exc: BaseException) -> dict:
|
||||||
|
"""异常摘要(summary 留档用):类型名 + 截断消息。"""
|
||||||
|
return {"type": type(exc).__name__, "message": str(exc)[:500]}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SecondPointPlan:
|
class SecondPointPlan:
|
||||||
"""下一检测点的候选与依据(P3 输出)。"""
|
"""下一检测点的候选与依据(P3 输出)。"""
|
||||||
@@ -91,11 +100,14 @@ def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) ->
|
|||||||
|
|
||||||
|
|
||||||
def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
|
def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
|
||||||
witnesses: list[Vec2], half_angle_deg: float) -> float:
|
witnesses: list[Vec2], half_angle_deg: float,
|
||||||
"""对候选点 q,遍历"目标边界点 × 读数区间端点"的最坏后验包围半径。
|
near_radius_m: float, disk_sides: int) -> float:
|
||||||
|
"""对候选点 q,遍历"目标边界点 × 读数分支"的最坏后验包围半径。
|
||||||
|
|
||||||
读数 θ' 与目标 g 一致当且仅当 |θ' - bearing(q→g)| <= δ,因此端点
|
读数分支:direction 的区间端点 φ(g)±δ(端点是每个 g 的最不利读数),
|
||||||
φ(g)±δ 是每个 g 的最不利读数。这是启发式近似,不构成最优性证明 [工程]。
|
以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)∩候选集。
|
||||||
|
保证接收盘内 no_signal 不可能发生,故不枚举该分支。
|
||||||
|
这是启发式近似,不构成最优性证明 [工程]。
|
||||||
"""
|
"""
|
||||||
posterior_radii = []
|
posterior_radii = []
|
||||||
for g in witnesses:
|
for g in witnesses:
|
||||||
@@ -105,6 +117,14 @@ def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
|
|||||||
probe.clip_wedge(q, reading, half_angle_deg)
|
probe.clip_wedge(q, reading, half_angle_deg)
|
||||||
if probe.polygon:
|
if probe.polygon:
|
||||||
posterior_radii.append(probe.mec()[1])
|
posterior_radii.append(probe.mec()[1])
|
||||||
|
near_probe = CandidateRegion(list(region.polygon), region.slack_m)
|
||||||
|
try:
|
||||||
|
near_probe.clip_disk(q, near_radius_m, disk_sides)
|
||||||
|
except InconsistentBelief:
|
||||||
|
pass # B(q, near) 与候选集不相交:near 分支不可能发生,跳过
|
||||||
|
else:
|
||||||
|
if near_probe.polygon:
|
||||||
|
posterior_radii.append(near_probe.mec()[1])
|
||||||
if not posterior_radii:
|
if not posterior_radii:
|
||||||
return region.mec()[1] # 无有效采样点时退回当前半径
|
return region.mec()[1] # 无有效采样点时退回当前半径
|
||||||
return max(posterior_radii)
|
return max(posterior_radii)
|
||||||
@@ -144,15 +164,17 @@ def select_measurement_point(region: CandidateRegion, from_position: Vec2,
|
|||||||
coarse = region.witnesses(search.witness_spacing_m)
|
coarse = region.witnesses(search.witness_spacing_m)
|
||||||
samples = candidate_points(center, safe_radius,
|
samples = candidate_points(center, safe_radius,
|
||||||
search.candidate_rings, search.candidate_angles)
|
search.candidate_rings, search.candidate_angles)
|
||||||
|
near_radius, sides = field.near_radius_m, search.polygon_sides
|
||||||
best_key, best_point, best_worst = None, None, None
|
best_key, best_point, best_worst = None, None, None
|
||||||
for point in samples:
|
for point in samples:
|
||||||
worst = _worst_posterior_radius(region, point, coarse, half_angle)
|
worst = _worst_posterior_radius(region, point, coarse, half_angle,
|
||||||
|
near_radius, sides)
|
||||||
key = (round(worst, 6), round(move_cost(point), 6))
|
key = (round(worst, 6), round(move_cost(point), 6))
|
||||||
if best_key is None or key < best_key:
|
if best_key is None or key < best_key:
|
||||||
best_key, best_point, best_worst = key, point, worst
|
best_key, best_point, best_worst = key, point, worst
|
||||||
dense = _worst_posterior_radius(region, best_point,
|
dense = _worst_posterior_radius(region, best_point,
|
||||||
region.witnesses(search.witness_spacing_m / 4),
|
region.witnesses(search.witness_spacing_m / 4),
|
||||||
half_angle)
|
half_angle, near_radius, sides)
|
||||||
reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;"
|
reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;"
|
||||||
f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m")
|
f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m")
|
||||||
return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)
|
return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)
|
||||||
@@ -190,19 +212,41 @@ class OmnidirectionalLoop:
|
|||||||
# 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上
|
# 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上
|
||||||
# 反复消耗 3 秒/次的失败清除。
|
# 反复消耗 3 秒/次的失败清除。
|
||||||
self._may_clear: set[int] = set()
|
self._may_clear: set[int] = set()
|
||||||
|
# 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。
|
||||||
|
self._abort_detail: BaseException | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------ 主流程
|
# ------------------------------------------------------------ 主流程
|
||||||
def run(self) -> dict:
|
def run(self) -> dict:
|
||||||
self.client.enter()
|
"""主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。"""
|
||||||
try:
|
try:
|
||||||
|
self.client.enter()
|
||||||
self._coverage_phase()
|
self._coverage_phase()
|
||||||
if self.stop_reason is None:
|
if self.stop_reason is None:
|
||||||
self._mark_absent_after_coverage()
|
self._mark_absent_after_coverage()
|
||||||
self.stop_reason = self._resolve_detected()
|
self.stop_reason = self._resolve_detected()
|
||||||
except DeadlineExceeded:
|
except DeadlineExceeded:
|
||||||
self.stop_reason = "real_budget_exhausted"
|
self.stop_reason = "real_budget_exhausted"
|
||||||
info = self.client.exit()
|
except (TransportError, ProtocolError, InconsistentBelief) as exc:
|
||||||
return self._summary(info)
|
self._abort_detail = exc
|
||||||
|
if isinstance(exc, TransportError):
|
||||||
|
self.stop_reason = "transport_failure"
|
||||||
|
elif isinstance(exc, InconsistentBelief):
|
||||||
|
self.stop_reason = "inconsistent_belief"
|
||||||
|
else:
|
||||||
|
self.stop_reason = "protocol_error"
|
||||||
|
except Exception as exc: # 意外异常同样收尾,不阻断 /exit 与结果汇总
|
||||||
|
self._abort_detail = exc
|
||||||
|
self.stop_reason = "unexpected_error"
|
||||||
|
info = None
|
||||||
|
exit_error = None
|
||||||
|
try:
|
||||||
|
info = self.client.exit()
|
||||||
|
except Exception as exc: # 接口不可用:记录后仍产出未完成状态
|
||||||
|
exit_error = exc
|
||||||
|
summary = self._summary(info)
|
||||||
|
if exit_error is not None:
|
||||||
|
summary["exit_error"] = _error_brief(exit_error)
|
||||||
|
return summary
|
||||||
|
|
||||||
# ------------------------------------------------------------ 覆盖阶段
|
# ------------------------------------------------------------ 覆盖阶段
|
||||||
def _pending_channels_at(self, index: int) -> list[int]:
|
def _pending_channels_at(self, index: int) -> list[int]:
|
||||||
@@ -321,6 +365,7 @@ class OmnidirectionalLoop:
|
|||||||
return "all_resolved" if not unresolved else "unresolved_channels"
|
return "all_resolved" if not unresolved else "unresolved_channels"
|
||||||
|
|
||||||
def _summary(self, info) -> dict:
|
def _summary(self, info) -> dict:
|
||||||
|
"""结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。"""
|
||||||
events = self.client.log.action_events()
|
events = self.client.log.action_events()
|
||||||
stats = summarize(events)
|
stats = summarize(events)
|
||||||
report = recompute_virtual_time(events)
|
report = recompute_virtual_time(events)
|
||||||
@@ -331,21 +376,37 @@ class OmnidirectionalLoop:
|
|||||||
if channel not in resolved]
|
if channel not in resolved]
|
||||||
completed = (self.stop_reason == "sixteen_cleared"
|
completed = (self.stop_reason == "sixteen_cleared"
|
||||||
or (self.stop_reason == "all_resolved" and not unresolved))
|
or (self.stop_reason == "all_resolved" and not unresolved))
|
||||||
return {
|
summary = {
|
||||||
"completed": completed,
|
"completed": completed,
|
||||||
"stop_reason": self.stop_reason,
|
"stop_reason": self.stop_reason,
|
||||||
"exit_reason": info.reason,
|
"exit_reason": "exit_unreachable" if info is None else info.reason,
|
||||||
"virtual_time_s": round(info.virtual_time_s, 3),
|
"virtual_time_s": round((info.virtual_time_s if info is not None
|
||||||
|
else self.client.state.virtual_time_s), 3),
|
||||||
"cleared_channels": cleared,
|
"cleared_channels": cleared,
|
||||||
"absent_channels": absent,
|
"absent_channels": absent,
|
||||||
"unresolved_channels": unresolved,
|
"unresolved_channels": unresolved,
|
||||||
"measures": stats["measures"],
|
"measures": stats["measures"],
|
||||||
"clears": stats["clears"],
|
"clears": stats["clears"],
|
||||||
"clear_success": stats["cleared"],
|
"clear_success": stats["cleared"],
|
||||||
|
"total_requests": stats["measures"] + stats["clears"],
|
||||||
"move_distance_m": round(stats["distance_m"], 1),
|
"move_distance_m": round(stats["distance_m"], 1),
|
||||||
"anomalies": self.anomalies,
|
"anomalies": self.anomalies,
|
||||||
"virtual_recompute_mismatches": len(report["mismatches"]),
|
"virtual_recompute_mismatches": len(report["mismatches"]),
|
||||||
|
"channel_status": self._channel_status_snapshot(),
|
||||||
}
|
}
|
||||||
|
if self._abort_detail is not None:
|
||||||
|
summary["abort_detail"] = _error_brief(self._abort_detail)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _channel_status_snapshot(self) -> dict:
|
||||||
|
"""逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。"""
|
||||||
|
snapshot = {}
|
||||||
|
for channel, belief in sorted(self.belief.channels.items()):
|
||||||
|
entry = {"status": belief.status.value}
|
||||||
|
if belief.region is not None:
|
||||||
|
entry["posterior_radius_m"] = round(belief.region.mec()[1], 3)
|
||||||
|
snapshot[str(channel)] = entry
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
class DirectionalExtension(OmnidirectionalLoop):
|
class DirectionalExtension(OmnidirectionalLoop):
|
||||||
@@ -389,7 +450,7 @@ class DirectionalExtension(OmnidirectionalLoop):
|
|||||||
return True
|
return True
|
||||||
self._may_clear.discard(channel) # 证书失败:补测后才可重试
|
self._may_clear.discard(channel) # 证书失败:补测后才可重试
|
||||||
continue
|
continue
|
||||||
if len(self.blocked_probes.get(channel, [])) > search.max_blocked_probes:
|
if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes:
|
||||||
break # 梯子用尽仍被遮挡:定位停滞,转后备清除
|
break # 梯子用尽仍被遮挡:定位停滞,转后备清除
|
||||||
point = self._next_probe(region, channel)
|
point = self._next_probe(region, channel)
|
||||||
outcome = self.client.measure(point, channel)
|
outcome = self.client.measure(point, channel)
|
||||||
@@ -424,11 +485,12 @@ class DirectionalExtension(OmnidirectionalLoop):
|
|||||||
"""定向遮挡下的探点升级梯子 [工程]。
|
"""定向遮挡下的探点升级梯子 [工程]。
|
||||||
|
|
||||||
- 未被遮挡过:worst-posterior 选点器(保证接收盘内);
|
- 未被遮挡过:worst-posterior 选点器(保证接收盘内);
|
||||||
- 第 1 次被遮挡:对 MEC 圆心取对跖点,仍在保证接收盘内;
|
- 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘;
|
||||||
- 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起
|
- 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起
|
||||||
每次 +45°。环包围任意候选源(|g-c| <= ρ),发射半平面在环上
|
每次 +45°。几何边界必须如实陈述:环上可见弧没有下界——
|
||||||
截出 >=180° 弧,等距采样大概率落入;环点距离不构成接收保证,
|
源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个
|
||||||
失败由后备清除兜底。
|
切点,固定步进环探可以全部漏过。梯子只提高提前命中概率,
|
||||||
|
不构成接收或命中保证;完备性完全由后备清除兜底。
|
||||||
"""
|
"""
|
||||||
blocked = self.blocked_probes.get(channel, [])
|
blocked = self.blocked_probes.get(channel, [])
|
||||||
center, radius = region.mec()
|
center, radius = region.mec()
|
||||||
@@ -441,10 +503,11 @@ class DirectionalExtension(OmnidirectionalLoop):
|
|||||||
dx, dy = center[0] - first[0], center[1] - first[1]
|
dx, dy = center[0] - first[0], center[1] - first[1]
|
||||||
norm = math.hypot(dx, dy)
|
norm = math.hypot(dx, dy)
|
||||||
if norm <= 1e-9:
|
if norm <= 1e-9:
|
||||||
probe = min(safe, 1.0)
|
# 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。
|
||||||
return (center[0] + probe, center[1])
|
return (center[0] + safe, center[1])
|
||||||
scale = min(1.0, safe / norm)
|
scale = min(1.0, safe / norm)
|
||||||
return (center[0] + dx / norm * scale, center[1] + dy / norm * scale)
|
# 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。
|
||||||
|
return (center[0] + dx * scale, center[1] + dy * scale)
|
||||||
base = math.atan2(first[1] - center[1], first[0] - center[0])
|
base = math.atan2(first[1] - center[1], first[0] - center[0])
|
||||||
azimuth = base + math.radians(45.0 * (len(blocked) - 1))
|
azimuth = base + math.radians(45.0 * (len(blocked) - 1))
|
||||||
return (center[0] + radius * math.cos(azimuth),
|
return (center[0] + radius * math.cos(azimuth),
|
||||||
@@ -496,6 +559,9 @@ class DirectionalExtension(OmnidirectionalLoop):
|
|||||||
report = super()._summary(info)
|
report = super()._summary(info)
|
||||||
report["directional_observed_channels"] = list(self.directional_observed)
|
report["directional_observed_channels"] = list(self.directional_observed)
|
||||||
report["fallback_clears"] = self.fallback_log
|
report["fallback_clears"] = self.fallback_log
|
||||||
|
# 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。
|
||||||
|
report["fallback_clear_attempts"] = sum(
|
||||||
|
entry.get("attempts", 0) for entry in self.fallback_log)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,16 @@
|
|||||||
|
|
||||||
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
|
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
|
||||||
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
|
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
|
||||||
|
误差场为逐点固定:同一(源频道,位置)的示向度误差由种子确定性决定,
|
||||||
|
重复测量读数不变 [题面/官方演练实测];不同位置误差独立(白噪声假设)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from math import atan2, degrees, hypot, isfinite
|
from math import atan2, degrees, hypot, isfinite
|
||||||
from random import Random
|
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from ..config import ProblemConfig
|
from ..config import ProblemConfig
|
||||||
@@ -58,7 +59,7 @@ class LocalSimulator:
|
|||||||
self.cost = config.cost
|
self.cost = config.cost
|
||||||
self.error_half_deg = (self.field.bearing_error_half_deg
|
self.error_half_deg = (self.field.bearing_error_half_deg
|
||||||
if error_half_deg is None else error_half_deg)
|
if error_half_deg is None else error_half_deg)
|
||||||
self._rng = Random(error_seed)
|
self.error_seed = error_seed
|
||||||
self._rtt_ms = rtt_ms
|
self._rtt_ms = rtt_ms
|
||||||
self.entered = False
|
self.entered = False
|
||||||
self.exited = False
|
self.exited = False
|
||||||
@@ -190,10 +191,21 @@ class LocalSimulator:
|
|||||||
if distance <= self.field.near_radius_m:
|
if distance <= self.field.near_radius_m:
|
||||||
return NEAR, None
|
return NEAR, None
|
||||||
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
|
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
|
||||||
error = self._rng.uniform(-self.error_half_deg, self.error_half_deg)
|
error = self._bearing_error(channel, pos)
|
||||||
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
|
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
|
||||||
return DIRECTION, svd
|
return DIRECTION, svd
|
||||||
|
|
||||||
|
def _bearing_error(self, channel: int, pos) -> float:
|
||||||
|
"""逐点固定误差场:同一(频道, 位置)误差恒定 [题面:同一地点误差不变]。
|
||||||
|
|
||||||
|
坐标按 1e-6 m 量化后与种子、频道一起哈希,映射到
|
||||||
|
[-error_half_deg, +error_half_deg);不同位置近似独立(白噪声假设)。
|
||||||
|
"""
|
||||||
|
key = f"{self.error_seed}|{channel}|{round(pos[0], 6)}|{round(pos[1], 6)}"
|
||||||
|
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
||||||
|
unit = int.from_bytes(digest[:8], "big") / 2.0 ** 64
|
||||||
|
return (2.0 * unit - 1.0) * self.error_half_deg
|
||||||
|
|
||||||
def _source(self, channel: int):
|
def _source(self, channel: int):
|
||||||
for source in self.sources:
|
for source in self.sources:
|
||||||
if source.channel == channel:
|
if source.channel == channel:
|
||||||
|
|||||||
+17
-1
@@ -109,10 +109,26 @@ class BeliefDirectionTests(TestCase):
|
|||||||
with_margin = BeliefState(CONFIG)
|
with_margin = BeliefState(CONFIG)
|
||||||
with_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
with_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
||||||
self.assertTrue(with_margin.candidate_set(1).contains(source, tol_m=0.5))
|
self.assertTrue(with_margin.candidate_set(1).contains(source, tol_m=0.5))
|
||||||
no_margin = BeliefState(ProblemConfig())
|
no_margin_config = replace(ProblemConfig(),
|
||||||
|
search=replace(ProblemConfig().search,
|
||||||
|
bearing_rounding_margin_deg=0.0))
|
||||||
|
no_margin = BeliefState(no_margin_config)
|
||||||
no_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
no_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
||||||
self.assertFalse(no_margin.candidate_set(1).contains(source, tol_m=0.5))
|
self.assertFalse(no_margin.candidate_set(1).contains(source, tol_m=0.5))
|
||||||
|
|
||||||
|
def test_default_config_carries_rounding_margin(self):
|
||||||
|
"""库默认配置必须自带 0.005° 舍入裕量,与 CLI run 默认一致。
|
||||||
|
|
||||||
|
在线 API 若按默认配置消费官方读数,"加误差再舍入"规则下
|
||||||
|
合法真源可能被裁掉;默认口径必须保守。
|
||||||
|
"""
|
||||||
|
self.assertEqual(ProblemConfig().search.bearing_rounding_margin_deg, 0.005)
|
||||||
|
default_belief = BeliefState(ProblemConfig())
|
||||||
|
source = (1200 * math.cos(math.radians(1.005)),
|
||||||
|
1200 * math.sin(math.radians(1.005)))
|
||||||
|
default_belief.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
||||||
|
self.assertTrue(default_belief.candidate_set(1).contains(source, tol_m=0.5))
|
||||||
|
|
||||||
def test_near_shrinks_to_five_meters(self):
|
def test_near_shrinks_to_five_meters(self):
|
||||||
belief = BeliefState(CONFIG)
|
belief = BeliefState(CONFIG)
|
||||||
belief.update_measure(2, (100.0, 100.0), NEAR, None)
|
belief.update_measure(2, (100.0, 100.0), NEAR, None)
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ class GeometryTests(unittest.TestCase):
|
|||||||
self.assertEqual(r['status'], 'polygon')
|
self.assertEqual(r['status'], 'polygon')
|
||||||
self.assertGreater(r['diameter_m'], 1e9)
|
self.assertGreater(r['diameter_m'], 1e9)
|
||||||
|
|
||||||
|
def test_near_antiparallel_thin_wedge_keeps_far_vertex(self):
|
||||||
|
"""近平行细长区域不得丢失远端顶点(审核复现案例)。
|
||||||
|
|
||||||
|
测站相距 1cm、示向度 1 与 358.99999999999:两条近平行边界相交出
|
||||||
|
~5.7e10 m 处的远端顶点。旧实现按绝对阈值 PARALLEL_EPS 跳过该平面
|
||||||
|
对,漏掉顶点后错误报出 0.143m 直径并给出清除保证。
|
||||||
|
"""
|
||||||
|
r = solve([Measurement(0, 0, 1), Measurement(0, 0.01, 358.99999999999)])
|
||||||
|
self.assertEqual(r['status'], 'polygon')
|
||||||
|
self.assertGreater(r['diameter_m'], 5.7e10)
|
||||||
|
self.assertFalse(r['guaranteed_clearable_at_mec_center'],
|
||||||
|
"细长区域的清除保证必须被拒绝")
|
||||||
|
# 远端方向上的可行源 (1000, 0) 必须仍落在最小包围圆内
|
||||||
|
mec = r['minimum_enclosing_circle']
|
||||||
|
self.assertLessEqual(distance(mec['center'], (1000.0, 0.0)),
|
||||||
|
mec['radius_m'] + 1e-6)
|
||||||
|
|
||||||
def test_circle_known_shapes(self):
|
def test_circle_known_shapes(self):
|
||||||
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
|
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
|
||||||
self.assertEqual(c, (1,1))
|
self.assertEqual(c, (1,1))
|
||||||
|
|||||||
@@ -112,6 +112,38 @@ class LoopAcceptanceTests(unittest.TestCase):
|
|||||||
self.assertEqual(sorted(simulator.cleared), truth)
|
self.assertEqual(sorted(simulator.cleared), truth)
|
||||||
|
|
||||||
|
|
||||||
|
class AbortFinalizationTests(unittest.TestCase):
|
||||||
|
"""断连超过重试上限:仍须 /exit、产出未完成 summary,不得静默崩溃。"""
|
||||||
|
|
||||||
|
def test_transport_failure_aborts_with_summary_and_exit(self):
|
||||||
|
"""首个检测动作三次断连:run() 正常返回,/exit 成功,stop_reason 留档。"""
|
||||||
|
summary, _, _ = _run_scene(
|
||||||
|
random_omni_scene(seed=5, count=2),
|
||||||
|
fault_script=[None, "drop_before", "drop_before", "drop_before"])
|
||||||
|
self.assertFalse(summary["completed"])
|
||||||
|
self.assertEqual(summary["stop_reason"], "transport_failure")
|
||||||
|
self.assertEqual(summary["exit_reason"], "user_exit",
|
||||||
|
"接口仍可用时必须尝试退出")
|
||||||
|
self.assertEqual(summary["abort_detail"]["type"], "TransportError")
|
||||||
|
self.assertLessEqual(summary["measures"] + summary["clears"],
|
||||||
|
summary["total_requests"])
|
||||||
|
self.assertEqual(len(summary["channel_status"]), 20,
|
||||||
|
"未完成状态须有逐频道快照")
|
||||||
|
|
||||||
|
def test_exit_failure_still_produces_summary(self):
|
||||||
|
"""检测与 /exit 均三次断连:产出 exit_unreachable 的未完成 summary。"""
|
||||||
|
summary, _, _ = _run_scene(
|
||||||
|
random_omni_scene(seed=7, count=2),
|
||||||
|
fault_script=[None,
|
||||||
|
"drop_before", "drop_before", "drop_before",
|
||||||
|
"drop_before", "drop_before", "drop_before"])
|
||||||
|
self.assertFalse(summary["completed"])
|
||||||
|
self.assertEqual(summary["stop_reason"], "transport_failure")
|
||||||
|
self.assertEqual(summary["exit_reason"], "exit_unreachable")
|
||||||
|
self.assertEqual(summary["exit_error"]["type"], "TransportError")
|
||||||
|
self.assertIn("channel_status", summary)
|
||||||
|
|
||||||
|
|
||||||
class CoverageLedgerIntegrationTests(unittest.TestCase):
|
class CoverageLedgerIntegrationTests(unittest.TestCase):
|
||||||
def test_grid_matches_plan_derivation(self):
|
def test_grid_matches_plan_derivation(self):
|
||||||
points = coverage_points(CONFIG)
|
points = coverage_points(CONFIG)
|
||||||
|
|||||||
@@ -81,6 +81,43 @@ class FallbackClearCellsTests(unittest.TestCase):
|
|||||||
self.assertLessEqual(len(cells), bound)
|
self.assertLessEqual(len(cells), bound)
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeLadderGeometryTests(unittest.TestCase):
|
||||||
|
"""对跖复核探点几何:圆心对称点裁进保证接收盘,不得塌缩到圆心附近。"""
|
||||||
|
|
||||||
|
def _probe_after_block(self, region, blocked_point):
|
||||||
|
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
|
||||||
|
client = SimulatorClient("T001", config=CONFIG,
|
||||||
|
transport=LocalSimTransport(simulator))
|
||||||
|
client.enter()
|
||||||
|
loop = DirectionalExtension(client, CONFIG)
|
||||||
|
loop.blocked_probes[7] = [blocked_point]
|
||||||
|
return loop._next_probe(region, 7)
|
||||||
|
|
||||||
|
def test_antipode_reflects_blocked_probe_through_center(self):
|
||||||
|
"""圆心为原点、原探点 (200,0):对跖复核应返回 (-200,0)。"""
|
||||||
|
region = CandidateRegion.disk((0.0, 0.0), 100.0, 192) # safe≈900>=200
|
||||||
|
probe = self._probe_after_block(region, (200.0, 0.0))
|
||||||
|
self.assertAlmostEqual(probe[0], -200.0, places=6)
|
||||||
|
self.assertAlmostEqual(probe[1], 0.0, places=6)
|
||||||
|
|
||||||
|
def test_antipode_clipped_into_safe_disk(self):
|
||||||
|
"""原探点距离超过安全盘半径时:落在距圆心 safe 处的对跖方向上。"""
|
||||||
|
region = CandidateRegion.disk((0.0, 0.0), 900.0, 192)
|
||||||
|
safe = safe_disk_radius(region, CONFIG)
|
||||||
|
self.assertLess(safe, 200.0)
|
||||||
|
probe = self._probe_after_block(region, (200.0, 0.0))
|
||||||
|
self.assertAlmostEqual(math.hypot(probe[0], probe[1]), safe, places=6)
|
||||||
|
self.assertLess(probe[0], 0.0, "应位于原探点的对跖方向")
|
||||||
|
|
||||||
|
def test_degenerate_center_block_walks_full_safe_radius(self):
|
||||||
|
"""被遮挡探点恰在圆心:沿 +x 走满安全盘半径,不再钳到 1m。"""
|
||||||
|
region = CandidateRegion.disk((0.0, 0.0), 100.0, 192)
|
||||||
|
probe = self._probe_after_block(region, (0.0, 0.0))
|
||||||
|
safe = safe_disk_radius(region, CONFIG)
|
||||||
|
self.assertAlmostEqual(probe[0], safe, places=6)
|
||||||
|
self.assertAlmostEqual(probe[1], 0.0, places=6)
|
||||||
|
|
||||||
|
|
||||||
class MixedLoopAcceptanceTests(unittest.TestCase):
|
class MixedLoopAcceptanceTests(unittest.TestCase):
|
||||||
def test_mixed_ten_sources_all_cleared(self):
|
def test_mixed_ten_sources_all_cleared(self):
|
||||||
sources = mixed_scene(seed=5, count=10, directional=3)
|
sources = mixed_scene(seed=5, count=10, directional=3)
|
||||||
|
|||||||
+20
-1
@@ -9,7 +9,8 @@ from mcm26b.belief import CandidateRegion
|
|||||||
from mcm26b.config import ProblemConfig
|
from mcm26b.config import ProblemConfig
|
||||||
from mcm26b.geometry.localization import Measurement
|
from mcm26b.geometry.localization import Measurement
|
||||||
from mcm26b.geometry.planning import first_bearing_candidate_disk
|
from mcm26b.geometry.planning import first_bearing_candidate_disk
|
||||||
from mcm26b.planner import (bearing_deg, clear_certificate, select_measurement_point,
|
from mcm26b.planner import (_worst_posterior_radius, bearing_deg,
|
||||||
|
clear_certificate, select_measurement_point,
|
||||||
select_second_point)
|
select_second_point)
|
||||||
from mcm26b.protocol.client import SimulatorClient
|
from mcm26b.protocol.client import SimulatorClient
|
||||||
from mcm26b.sim import LocalSimTransport, LocalSimulator
|
from mcm26b.sim import LocalSimTransport, LocalSimulator
|
||||||
@@ -71,6 +72,24 @@ class SelectionGuaranteeTests(TestCase):
|
|||||||
self.assertTrue(probe.polygon and probe.contains(g, tol_m=0.05),
|
self.assertTrue(probe.polygon and probe.contains(g, tol_m=0.05),
|
||||||
f"后验剪掉了生成读数的边界点 {g}")
|
f"后验剪掉了生成读数的边界点 {g}")
|
||||||
|
|
||||||
|
def test_posterior_scores_near_branch(self):
|
||||||
|
"""后验评分必须包含 near 分支,否则低报最坏后验半径。
|
||||||
|
|
||||||
|
候选集为线段 [-4,4]×{0}、测点为原点时,任意合法读数必为 near
|
||||||
|
(源距原点 <=4 < 5m),后验仍为整段线段(半径 4m);只枚举
|
||||||
|
direction 端点会错误报出 2m。
|
||||||
|
"""
|
||||||
|
region = CandidateRegion([(-4.0, 0.0), (4.0, 0.0)])
|
||||||
|
half = 1.005
|
||||||
|
witnesses = region.witnesses(60.0)
|
||||||
|
self.assertEqual(witnesses, [(-4.0, 0.0), (4.0, 0.0)])
|
||||||
|
worst = _worst_posterior_radius(region, (0.0, 0.0), witnesses, half,
|
||||||
|
CONFIG.field.near_radius_m,
|
||||||
|
CONFIG.search.polygon_sides)
|
||||||
|
self.assertGreaterEqual(worst, 4.0 - 1e-6,
|
||||||
|
"near 分支后验半径应逼近候选集半径 4m")
|
||||||
|
self.assertLessEqual(worst, 4.0 + region.slack_m + 0.1)
|
||||||
|
|
||||||
def test_move_cost_recorded_and_reason_explains(self):
|
def test_move_cost_recorded_and_reason_explains(self):
|
||||||
region = _k1_region()
|
region = _k1_region()
|
||||||
plan = select_measurement_point(region, (500.0, 500.0), CONFIG)
|
plan = select_measurement_point(region, (500.0, 500.0), CONFIG)
|
||||||
|
|||||||
@@ -227,6 +227,32 @@ class LocalSimObservationTests(unittest.TestCase):
|
|||||||
# 误差在 ±1° 内均匀分布,返回值再保留两位小数。
|
# 误差在 ±1° 内均匀分布,返回值再保留两位小数。
|
||||||
self.assertLessEqual(abs(outcome.svd_deg - true_bearing), 1.005)
|
self.assertLessEqual(abs(outcome.svd_deg - true_bearing), 1.005)
|
||||||
|
|
||||||
|
def test_same_position_error_is_fixed(self):
|
||||||
|
"""同一(源,位置)重复测量读数不变 [题面/官方演练实测]。"""
|
||||||
|
source = Source(channel=1, x=1000, y=300, r_eff_m=1500)
|
||||||
|
simulator = LocalSimulator([source], robot_id="T001")
|
||||||
|
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||||
|
client.enter()
|
||||||
|
first = client.measure((0, 0), 1)
|
||||||
|
second = client.measure((0, 0), 1) # 新 request_id,同位置
|
||||||
|
self.assertEqual(first.result, "direction")
|
||||||
|
self.assertEqual(first.svd_deg, second.svd_deg,
|
||||||
|
"同位置重复测量的示向度必须相同")
|
||||||
|
|
||||||
|
def test_error_field_deterministic_per_seed(self):
|
||||||
|
"""同种子误差场逐点一致;不同种子得到不同误差场(逐点白噪声)。"""
|
||||||
|
source = Source(channel=1, x=1000, y=0, r_eff_m=1500)
|
||||||
|
readings = {}
|
||||||
|
for label, seed in (("a", 1), ("b", 1), ("c", 2)):
|
||||||
|
simulator = LocalSimulator([source], error_seed=seed, robot_id="T001")
|
||||||
|
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||||
|
client.enter()
|
||||||
|
readings[label] = [client.measure((float(x), 0.0), 1).svd_deg
|
||||||
|
for x in (0, 100, 200, 300, 400)]
|
||||||
|
self.assertEqual(readings["a"], readings["b"])
|
||||||
|
self.assertNotEqual(readings["a"], readings["c"],
|
||||||
|
"不同种子应在至少一个测点产生不同读数")
|
||||||
|
|
||||||
|
|
||||||
class SessionLogTests(unittest.TestCase):
|
class SessionLogTests(unittest.TestCase):
|
||||||
def test_log_records_attempts_and_accepted(self):
|
def test_log_records_attempts_and_accepted(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user