Merge branch 'gpt' of git.simba-robotics.com:HelixCopex/mcm-26-b into gpt

This commit is contained in:
2026-09-11 02:34:16 +08:00
19 changed files with 971 additions and 45 deletions
@@ -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` 记录修复后后备成本,输出落盘。
## 执行顺序
修改一(模拟器)→ 修改四(收尾)→ 修改六 → 修改七 → 修改二 → 修改三 → 修改五 → 文档同步 → 全量回归。
+2 -2
View File
@@ -21,8 +21,8 @@ src/mcm26b/
├── planner/ # P3 保证接收盘选点器 / P4 全向闭环 / P5 混合源定向闭环(已完工)
├── evaluation/ # 日志复算与统计(已完工)
└── sim/scenes.py # 自检场景生成器:全向与混合源(测试端专用)
tests/ # pytest(兼容 unittest 风格),95 项
scripts/ # 采集、基准、可视化与校核脚本
tests/ # pytest(兼容 unittest 风格),105 项
scripts/ # 采集、基准、可视化与校核脚本(含问题四批量仿真 benchmark_q4.py)
output/ examples/ # 演练证据、算例与生成产物
```
+12 -1
View File
@@ -215,11 +215,22 @@ P0–P5(自建部分)与 P2 均已交付:协议层(串行幂等客户端
全向闭环与 `DirectionalExtension` 混合源定向闭环(49 点覆盖对定向源同样成立、
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`)。
评审修订(2026-09-11):本地模拟器改为逐点固定误差场(同一"源×位置"读数
恒定,与官方演练实测一致);对跖复核探点公式修正(此前塌缩到 MEC 圆心 1m
内,浪费探点预算并推高后备清除);环探"≥180° 可见弧"依据删除(最坏可见弧
可退化为单点,梯子只是启发式,完备性由后备清除兜底);断连/协议错误中断后
仍尽力 /exit 并产出未完成 summary;库默认舍入裕量统一为 0.005°;后验评分
补 near 分支;问题一近平行细长区域保留远端顶点。后备清除成本用
`scripts/benchmark_q4.py` 批量种子扫描量化(结果落盘 `output/q4/`)。
下一步优先把两个闭环接入官方演练(问题3用全向、问题4用混合,先实测单请求
延迟与总请求数,核对 1200s 现实预算与 2MB 日志体积),再做 P6 消融对照。
顺序约束:上述模拟器与故障收尾修复先行合入,官方演练在修复后的版本上执行;
总请求口径必须计入后备清除(summary 的 `total_requests` 与
`fallback_clear_attempts`),仅统计检测次数会低估现实耗时。
基线量级供参照:10 源场景约 550–630 次检测、虚拟总时 1.4–1.7 万秒;16 源
场景约 200–320 次检测即触发数量上界停止。该数字说明共享测点与跳点策略
(P6)有明确改进空间。不要重复开发几何内核或提前堆叠学习算法;以官方演练
+1 -1
View File
@@ -228,7 +228,7 @@
性能层应在相同自建源位置与确定的空间误差场上,比较轴向点、两个横向点、有限候选最坏后验策略。误差场在同一位置保持固定;不得让每次调用重新独立生成误差。记录第二次测量类型、后验直径和包围半径、额外移动、计算耗时及是否达到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)` 接口。
Binary file not shown.
+15 -7
View File
@@ -165,11 +165,13 @@ no_signal/near/direction(direction 附 svd_deg),`/clear` 仍只看 20m 半
(`belief.update_measure` 对 no_signal 无操作);缺席证明仍只由完整覆盖
给出。唯一启用的推理:若 no_signal 发生在保证接收盘 B(mec, 1000-ρ) 内
(该盘内任一全向源必然可收),则只能由朝向遮挡解释 [观测推理]——该频道
记为"观测到定向",进入探点升级梯子:对 MEC 圆心对跖复核一次,再绕
MEC、半径 = ρ 的圆环按 45° 步进探点(环包围任意候选源,发射半平面在
环上截出 ≥180° 弧)。被遮挡探点不消耗定位步数,单轮由
`max_blocked_probes`(4 [工程])限流;梯子不构成接收保证,失败由后备
清除兜底;
记为"观测到定向",进入探点升级梯子:对 MEC 圆心取对跖方向复核(偏移
裁进保证接收盘),再绕 MEC、半径 = ρ 的圆环按 45° 步进探点。**几何边界
必须如实陈述**:环上可见弧没有下界——源可位于环上(|g−c| = ρ)且朝向
沿外切线,此时可见集退化为单个切点,固定步进环探可以全部漏过。梯子
只提高提前命中概率,不构成接收或命中保证;完备性完全由后备清除兜底。
被遮挡探点不消耗定位步数,单轮被遮挡探点(首次选点、对跖复核、环探
合计)达 `max_blocked_probes`(4 [工程])即转后备清除;
- **后备清除的协议用法**:定位停滞(候选集有界但未达 §6 清除证书)时,
对候选外包多边形构造 20m 有限圆盘覆盖(`fallback_clear_cells`:全局
半开网格索引、相交判定带正容差,格内任一点距某格心 ≤ 10√2 < 20m),
@@ -180,12 +182,18 @@ no_signal/near/direction(direction 附 svd_deg),`/clear` 仍只看 20m 半
(⌈w/20⌉+2)(⌈h/20⌉+2) 做规模检查,宽候选集(如仅一次读数的 750m
截断角域,数千格)必须继续定位而非硬扫;
- 现实预算: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`(含后备逐格全失败)均不删
空间候选;后备全失败按守恒性破坏记异常(格心距格内任一点 < 20m,
理论上必命中);
- **停止条件与预算语义不变**:仍是清除数达 16、每频道均"已清除或覆盖
证明不存在"、现实预算耗尽(记未证明完成)三种;
证明不存在"、现实预算耗尽(记未证明完成)三种;断连/协议错误/
守恒性破坏中断时同样尽力 /exit 并产出未完成 summary(`stop_reason`
记 `transport_failure`/`protocol_error`/`inconsistent_belief` 等,
附逐频道状态快照),不静默丢失会话;
- **验收**:`tests/test_loop_q4.py`(15 项:覆盖格无空洞与格线两侧保留、
退化多边形、格数上界公式;混合 10/16 源全清、朝向背离圆心的近边界源
不漏、批量种子回归;no_signal 不删候选;格数硬顶与预算跳过;后备
+288
View File
@@ -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
}
]
}
+195
View File
@@ -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
}
]
}
+124
View File
@@ -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())
+5 -2
View File
@@ -72,7 +72,9 @@ class SearchConfig:
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 ⊇ Ω
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 在线定位与闭环参数 [工程] ----
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
@@ -82,7 +84,8 @@ class SearchConfig:
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
# ---- P5 定向扩展参数 [工程] ----
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算(对跖复核+环探)
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
# (首次选点+对跖复核+2 次环探;达限转后备清除)
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
+16 -4
View File
@@ -2,6 +2,10 @@
All calculations are floating point with normalized station coordinates.
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
@@ -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
enumeration; unboundedness follows from a nonzero recession direction.
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:
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)]
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 = []
for (a, b, c), (d, e, f) in combinations(planes, 2):
det = a * e - b * d
if abs(det) <= PARALLEL_EPS:
continue
if det == 0.0:
continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)
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)
if not feasible_vertices:
+88 -22
View File
@@ -14,6 +14,8 @@ P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
发现 10 源、连续无信号、候选采样为空都不是停止条件;
现实预算耗尽退出记为未证明完成(completed=false),不与成功混记。
断连/协议错误/守恒性破坏中断时同样尽力 /exit 并产出未完成 summary
(stop_reason 记中断类别,附逐频道状态快照),不静默丢失会话。
P5 定向扩展(问题4混合源闭环):
- 49 点覆盖的发现保证对定向源同样成立(源在单元凸包内,任意发射半平面
@@ -31,13 +33,15 @@ import math
from dataclasses import dataclass
from ..belief import (BeliefState, CandidateRegion, ChannelStatus,
clip_halfplane)
InconsistentBelief, clip_halfplane)
from ..config import ProblemConfig, Vec2
from ..coverage import CoverageLedger
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.timing import move_time
from ..protocol.transport import TransportError
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
def _error_brief(exc: BaseException) -> dict:
"""异常摘要(summary 留档用):类型名 + 截断消息。"""
return {"type": type(exc).__name__, "message": str(exc)[:500]}
@dataclass(frozen=True)
class SecondPointPlan:
"""下一检测点的候选与依据(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,
witnesses: list[Vec2], half_angle_deg: float) -> float:
"""对候选点 q,遍历"目标边界点 × 读数区间端点"的最坏后验包围半径。
witnesses: list[Vec2], half_angle_deg: float,
near_radius_m: float, disk_sides: int) -> float:
"""对候选点 q,遍历"目标边界点 × 读数分支"的最坏后验包围半径。
读数 θ' 与目标 g 一致当且仅当 |θ' - bearing(q→g)| <= δ,因此端点
φ(g)±δ 是每个 g 的最不利读数。这是启发式近似,不构成最优性证明 [工程]。
读数分支:direction 的区间端点 φ(g)±δ(端点是每个 g 的最不利读数),
以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)∩候选集。
保证接收盘内 no_signal 不可能发生,故不枚举该分支。
这是启发式近似,不构成最优性证明 [工程]。
"""
posterior_radii = []
for g in witnesses:
@@ -105,6 +117,14 @@ def _worst_posterior_radius(region: CandidateRegion, q: Vec2,
probe.clip_wedge(q, reading, half_angle_deg)
if probe.polygon:
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:
return region.mec()[1] # 无有效采样点时退回当前半径
return max(posterior_radii)
@@ -144,15 +164,17 @@ def select_measurement_point(region: CandidateRegion, from_position: Vec2,
coarse = region.witnesses(search.witness_spacing_m)
samples = candidate_points(center, safe_radius,
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
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))
if best_key is None or key < best_key:
best_key, best_point, best_worst = key, point, worst
dense = _worst_posterior_radius(region, best_point,
region.witnesses(search.witness_spacing_m / 4),
half_angle)
half_angle, near_radius, sides)
reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;"
f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m")
return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)
@@ -190,19 +212,41 @@ class OmnidirectionalLoop:
# 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上
# 反复消耗 3 秒/次的失败清除。
self._may_clear: set[int] = set()
# 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。
self._abort_detail: BaseException | None = None
# ------------------------------------------------------------ 主流程
def run(self) -> dict:
self.client.enter()
"""主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。"""
try:
self.client.enter()
self._coverage_phase()
if self.stop_reason is None:
self._mark_absent_after_coverage()
self.stop_reason = self._resolve_detected()
except DeadlineExceeded:
self.stop_reason = "real_budget_exhausted"
info = self.client.exit()
return self._summary(info)
except (TransportError, ProtocolError, InconsistentBelief) as exc:
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]:
@@ -321,6 +365,7 @@ class OmnidirectionalLoop:
return "all_resolved" if not unresolved else "unresolved_channels"
def _summary(self, info) -> dict:
"""结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。"""
events = self.client.log.action_events()
stats = summarize(events)
report = recompute_virtual_time(events)
@@ -331,21 +376,37 @@ class OmnidirectionalLoop:
if channel not in resolved]
completed = (self.stop_reason == "sixteen_cleared"
or (self.stop_reason == "all_resolved" and not unresolved))
return {
summary = {
"completed": completed,
"stop_reason": self.stop_reason,
"exit_reason": info.reason,
"virtual_time_s": round(info.virtual_time_s, 3),
"exit_reason": "exit_unreachable" if info is None else info.reason,
"virtual_time_s": round((info.virtual_time_s if info is not None
else self.client.state.virtual_time_s), 3),
"cleared_channels": cleared,
"absent_channels": absent,
"unresolved_channels": unresolved,
"measures": stats["measures"],
"clears": stats["clears"],
"clear_success": stats["cleared"],
"total_requests": stats["measures"] + stats["clears"],
"move_distance_m": round(stats["distance_m"], 1),
"anomalies": self.anomalies,
"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):
@@ -389,7 +450,7 @@ class DirectionalExtension(OmnidirectionalLoop):
return True
self._may_clear.discard(channel) # 证书失败:补测后才可重试
continue
if len(self.blocked_probes.get(channel, [])) > search.max_blocked_probes:
if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes:
break # 梯子用尽仍被遮挡:定位停滞,转后备清除
point = self._next_probe(region, channel)
outcome = self.client.measure(point, channel)
@@ -424,11 +485,12 @@ class DirectionalExtension(OmnidirectionalLoop):
"""定向遮挡下的探点升级梯子 [工程]。
- 未被遮挡过:worst-posterior 选点器(保证接收盘内);
- 第 1 次被遮挡:对 MEC 圆心取对跖点,仍在保证接收盘内;
- 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘;
- 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起
每次 +45°。环包围任意候选源(|g-c| <= ρ),发射半平面在环上
截出 >=180° 弧,等距采样大概率落入;环点距离不构成接收保证,
失败由后备清除兜底。
每次 +45°。几何边界必须如实陈述:环上可见弧没有下界——
源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个
切点,固定步进环探可以全部漏过。梯子只提高提前命中概率,
不构成接收或命中保证;完备性完全由后备清除兜底。
"""
blocked = self.blocked_probes.get(channel, [])
center, radius = region.mec()
@@ -441,10 +503,11 @@ class DirectionalExtension(OmnidirectionalLoop):
dx, dy = center[0] - first[0], center[1] - first[1]
norm = math.hypot(dx, dy)
if norm <= 1e-9:
probe = min(safe, 1.0)
return (center[0] + probe, center[1])
# 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。
return (center[0] + safe, center[1])
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])
azimuth = base + math.radians(45.0 * (len(blocked) - 1))
return (center[0] + radius * math.cos(azimuth),
@@ -496,6 +559,9 @@ class DirectionalExtension(OmnidirectionalLoop):
report = super()._summary(info)
report["directional_observed_channels"] = list(self.directional_observed)
report["fallback_clears"] = self.fallback_log
# 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。
report["fallback_clear_attempts"] = sum(
entry.get("attempts", 0) for entry in self.fallback_log)
return report
+16 -4
View File
@@ -5,15 +5,16 @@
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
误差场为逐点固定:同一(源频道,位置)的示向度误差由种子确定性决定,
重复测量读数不变 [题面/官方演练实测];不同位置误差独立(白噪声假设)。
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from dataclasses import dataclass
from math import atan2, degrees, hypot, isfinite
from random import Random
from time import time
from ..config import ProblemConfig
@@ -58,7 +59,7 @@ class LocalSimulator:
self.cost = config.cost
self.error_half_deg = (self.field.bearing_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.entered = False
self.exited = False
@@ -190,10 +191,21 @@ class LocalSimulator:
if distance <= self.field.near_radius_m:
return NEAR, None
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
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):
for source in self.sources:
if source.channel == channel:
+17 -1
View File
@@ -109,10 +109,26 @@ class BeliefDirectionTests(TestCase):
with_margin = BeliefState(CONFIG)
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))
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)
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):
belief = BeliefState(CONFIG)
belief.update_measure(2, (100.0, 100.0), NEAR, None)
+17
View File
@@ -58,6 +58,23 @@ class GeometryTests(unittest.TestCase):
self.assertEqual(r['status'], 'polygon')
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):
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
self.assertEqual(c, (1,1))
+32
View File
@@ -112,6 +112,38 @@ class LoopAcceptanceTests(unittest.TestCase):
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):
def test_grid_matches_plan_derivation(self):
points = coverage_points(CONFIG)
+37
View File
@@ -81,6 +81,43 @@ class FallbackClearCellsTests(unittest.TestCase):
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):
def test_mixed_ten_sources_all_cleared(self):
sources = mixed_scene(seed=5, count=10, directional=3)
+20 -1
View File
@@ -9,7 +9,8 @@ from mcm26b.belief import CandidateRegion
from mcm26b.config import ProblemConfig
from mcm26b.geometry.localization import Measurement
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)
from mcm26b.protocol.client import SimulatorClient
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),
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):
region = _k1_region()
plan = select_measurement_point(region, (500.0, 500.0), CONFIG)
+26
View File
@@ -227,6 +227,32 @@ class LocalSimObservationTests(unittest.TestCase):
# 误差在 ±1° 内均匀分布,返回值再保留两位小数。
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):
def test_log_records_attempts_and_accepted(self):