diff --git a/.zcode/plans/plan-sess_4d6694a9-3cc0-4ded-bca4-4a58061ec3fa.md b/.zcode/plans/plan-sess_4d6694a9-3cc0-4ded-bca4-4a58061ec3fa.md new file mode 100644 index 0000000..0f58ccc --- /dev/null +++ b/.zcode/plans/plan-sess_4d6694a9-3cc0-4ded-bca4-4a58061ec3fa.md @@ -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` → `>=`,`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` 记录修复后后备成本,输出落盘。 + +## 执行顺序 +修改一(模拟器)→ 修改四(收尾)→ 修改六 → 修改七 → 修改二 → 修改三 → 修改五 → 文档同步 → 全量回归。 \ No newline at end of file diff --git a/README.md b/README.md index e71cb62..fc7b85b 100644 --- a/README.md +++ b/README.md @@ -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/ # 演练证据、算例与生成产物 ``` diff --git a/docs/B题建模分析与实施计划.md b/docs/B题建模分析与实施计划.md index 85766de..2e90430 100644 --- a/docs/B题建模分析与实施计划.md +++ b/docs/B题建模分析与实施计划.md @@ -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)有明确改进空间。不要重复开发几何内核或提前堆叠学习算法;以官方演练 diff --git a/docs/algo-2.md b/docs/algo-2.md index f825154..bddb5c5 100644 --- a/docs/algo-2.md +++ b/docs/algo-2.md @@ -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)` 接口。 diff --git a/docs/paper/main.pdf b/docs/paper/main.pdf index 2cce2b1..b4598f2 100644 Binary files a/docs/paper/main.pdf and b/docs/paper/main.pdf differ diff --git a/docs/协议规则表与状态机.md b/docs/协议规则表与状态机.md index b278148..b30f19f 100644 --- a/docs/协议规则表与状态机.md +++ b/docs/协议规则表与状态机.md @@ -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 不删候选;格数硬顶与预算跳过;后备 diff --git a/output/q4/benchmark.json b/output/q4/benchmark.json new file mode 100644 index 0000000..65435b2 --- /dev/null +++ b/output/q4/benchmark.json @@ -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 + } + ] +} diff --git a/output/q4/benchmark_16src.json b/output/q4/benchmark_16src.json new file mode 100644 index 0000000..544c471 --- /dev/null +++ b/output/q4/benchmark_16src.json @@ -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 + } + ] +} diff --git a/scripts/benchmark_q4.py b/scripts/benchmark_q4.py new file mode 100644 index 0000000..92dbfe6 --- /dev/null +++ b/scripts/benchmark_q4.py @@ -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()) diff --git a/src/mcm26b/config.py b/src/mcm26b/config.py index 3743a85..9609641 100644 --- a/src/mcm26b/config.py +++ b/src/mcm26b/config.py @@ -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 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位) diff --git a/src/mcm26b/geometry/localization.py b/src/mcm26b/geometry/localization.py index 80e32a3..95eb7d0 100644 --- a/src/mcm26b/geometry/localization.py +++ b/src/mcm26b/geometry/localization.py @@ -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: diff --git a/src/mcm26b/planner/__init__.py b/src/mcm26b/planner/__init__.py index eae31b6..bb42494 100644 --- a/src/mcm26b/planner/__init__.py +++ b/src/mcm26b/planner/__init__.py @@ -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 diff --git a/src/mcm26b/sim/simulator.py b/src/mcm26b/sim/simulator.py index 657e7f8..21253a2 100644 --- a/src/mcm26b/sim/simulator.py +++ b/src/mcm26b/sim/simulator.py @@ -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: diff --git a/tests/test_belief.py b/tests/test_belief.py index 6bbadfb..bc55d16 100644 --- a/tests/test_belief.py +++ b/tests/test_belief.py @@ -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) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 9ae70bf..d869fcc 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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)) diff --git a/tests/test_loop_q3.py b/tests/test_loop_q3.py index 320fa8e..f031cd4 100644 --- a/tests/test_loop_q3.py +++ b/tests/test_loop_q3.py @@ -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) diff --git a/tests/test_loop_q4.py b/tests/test_loop_q4.py index 12afc1f..9f384cf 100644 --- a/tests/test_loop_q4.py +++ b/tests/test_loop_q4.py @@ -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) diff --git a/tests/test_planner.py b/tests/test_planner.py index a4ea5e9..67cee75 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -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) diff --git a/tests/test_protocol_client.py b/tests/test_protocol_client.py index 90e4a91..1ecc61d 100644 --- a/tests/test_protocol_client.py +++ b/tests/test_protocol_client.py @@ -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):