38 Commits
Author SHA1 Message Date
sloray fb0325f67f Merge branch 'q3-bayes-no-ml' of git.simba-robotics.com:HelixCopex/mcm-26-b into q3-bayes-no-ml 2026-09-13 10:55:45 +08:00
sloray 4f836fe123 重写tex 2026-09-13 10:53:22 +08:00
Horldsence 78e4420a3e iuh 2026-09-13 10:52:28 +08:00
sloray 2c63610f12 论文更改与图更改
Co-authored-by: Pengheng <Horldsence@users.noreply.github.com>
2026-09-13 10:30:05 +08:00
Horldsence 82311a47cc 123 2026-09-13 10:27:09 +08:00
Horldsence b810ec7777 1134 2026-09-13 10:27:04 +08:00
Horldsence 0a2d30702d 12 2026-09-13 08:54:06 +08:00
Horldsence 27f4d6e0d8 123 2026-09-13 08:52:23 +08:00
Horldsence dec57e3e29 123 2026-09-13 08:52:21 +08:00
Horldsence 71688b2ab2 082928 2026-09-13 08:29:30 +08:00
sloray f71d7d9288 Merge branch 'q3-bayes-no-ml' of git.simba-robotics.com:HelixCopex/mcm-26-b into q3-bayes-no-ml 2026-09-12 17:21:45 +08:00
sloray 102ca032fb 1 2026-09-12 17:21:41 +08:00
Horldsence a1500d25c1 q3 q4 baseline 2026-09-12 17:01:49 +08:00
Horldsence 892451dca9 baseline 2026-09-12 16:58:25 +08:00
sloray 1fa2264a17 Merge branch 'q3-bayes-no-ml' of git.simba-robotics.com:HelixCopex/mcm-26-b into q3-bayes-no-ml 2026-09-12 15:27:13 +08:00
sloray c2d1230562 1 2026-09-12 15:25:16 +08:00
Horldsence 45e48d4c91 yes fuking shit 2026-09-12 14:48:43 +08:00
Horldsence 61fd22598a revert: 移除全部机器学习代码,回退到无 ML 版本
删除 RL/HRL/训练前端及其文档、脚本、测试与依赖:
- src/mcm26b/{rl,hrl,training_ui}/、planner/{rl,hrl}_loop.py、process_control.py
- project/(PPO 策略、训练、环境、评估)
- scripts/{train_rl,train_hrl_*,benchmark_rl,benchmark_hrl,pretrain_*_expert 等}
- tests/{test_rl*,test_hrl*,test_training_ui*}、tests/js/training_ui_harness.cjs
- docs/{algo-rl,hrl-*,training-ui*} 与相关计划条目、论文 PDF 回退

CLI 恢复为 grid/seven/bayes + directional,移除 --strategy rl|hrl、
--q4-strategy、--rl-model、--hrl-* 等参数;pyproject 删除 rl 依赖组与
PyTorch CUDA index,uv.lock 同步重生成。

保留此后的非 ML 改动(referee 回放、Q1/Q3 脚本、代码清理等)。
验证:pytest 232 passed;Q3/Q4 --self-test 全清通过。
2026-09-12 08:47:51 +08:00
Horldsence d975309a81 holy fuking shit 2026-09-12 01:56:34 +08:00
Horldsence 36f354736c 1919810 2026-09-11 23:07:42 +08:00
Horldsence 5c96699b70 1 2026-09-11 21:07:21 +08:00
Horldsence 1725cd0891 114514 2026-09-11 21:00:39 +08:00
sloray 2a3ead5d3d Merge branch 'q3-bayes' of git.simba-robotics.com:HelixCopex/mcm-26-b into q3-bayes 2026-09-11 17:56:08 +08:00
sloray 4d224e586a 强化学习第二版 2026-09-11 17:56:03 +08:00
Horldsence 7026277a8d Merge branch 'q3-bayes' of https://git.simba-robotics.com/HelixCopex/mcm-26-b into q3-bayes 2026-09-11 17:25:45 +08:00
Horldsence b15db59ac3 14 2026-09-11 17:24:29 +08:00
sloray 30df22e8b2 13 2026-09-11 17:24:07 +08:00
sloray 4699b0d45f 强化学习第一版 2026-09-11 16:56:39 +08:00
Horldsence 2bc4c7ed82 Merge branch 'q3-bayes' of https://git.simba-robotics.com/HelixCopex/mcm-26-b into q3-bayes 2026-09-11 11:37:22 +08:00
Horldsence a18a139a1d 6 2026-09-11 11:36:08 +08:00
Horldsence 2d2a9a4e6a feat: 5 2026-09-11 11:35:47 +08:00
sloray fd48064000 第四次运行 2026-09-11 11:28:36 +08:00
sloray 7dedcd0629 4000s上限优化 2026-09-11 11:16:23 +08:00
sloray 9cf94281a9 论文/3batch 2026-09-11 09:52:05 +08:00
Horldsence f3dfa9c2a1 feat: 修复大粪扫描频率 2026-09-11 09:50:56 +08:00
Horldsence bd479b6d9d Merge remote-tracking branch 'origin/gpt' into q3-bayes 2026-09-11 09:12:24 +08:00
Horldsence 9c9ab451e5 feat(q3): 消融基准(14场景-19.5%)+Optuna调参脚本+文档;run 默认策略切 bayes 2026-09-11 09:11:21 +08:00
Horldsence e0665c73fb feat(q3): 贝叶斯网格粒子滤波闭环(PF负观测+信息增益选点+2-opt调度)
- particles.py: 网格幸存集滤波,Voronoi 裕量保证真源保留(缺席证明/网格清除证书/跳测规则)
- planner/bayes_loop.py: 统一任务循环(站点/定位/清除滚动 2-opt 排序)、期望后验半径选点
- planner/seven.py: 七点覆盖策略族提升为库代码
- cli: mcm26b run --strategy {grid,seven,bayes}
- BayesConfig 参数组;tests +24 项(真源保留/证书/端到端),129 全绿
2026-09-11 09:02:28 +08:00
433 changed files with 65724 additions and 66717 deletions
Vendored
BIN
View File
Binary file not shown.
+7
View File
@@ -12,6 +12,10 @@ wheels/
# Sim
Jammers*/
tmp/
# MATLAB 出图日志
scripts/matlab/*.log
# LaTeX build artifacts
docs/paper/build/
docs/paper/*.aux
@@ -24,3 +28,6 @@ docs/paper/*.synctex.gz
# pytest
.pytest_cache/
# 1
output/
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -33,5 +33,6 @@
"tools": ["latexmk (xelatex)"]
}
],
"latex-workshop.latex.recipe.default": "first"
"latex-workshop.latex.recipe.default": "latexmk (xelatex)",
"cmake.sourceDirectory": "/Users/peng/sample_project"
}
@@ -0,0 +1,80 @@
# 目标
在新分支 **`q3-bayes`**(基于当前 `gpt`)实现第三问新算法:**网格化贝叶斯粒子滤波(含负观测更新)+ 信息增益选点 + 开放路径 2-opt 任务排序 + 七点覆盖兜底 + Optuna 离线调参**。评分口径 = 虚拟总时间(A2 基线:官方演练 12 源 5232s、109 次检测);硬性要求全清率 100%、缺席证明正确、零异常。
# 关键设计
## 1. 粒子滤波 `src/mcm26b/particles.py`(新建)
与用户方案的差异点:本项目要求清除证书/缺席证明**严格**(A2 靠保守几何证书做到 100% 清除率),软核概率 PF 有删掉真源的风险。因此采用**网格幸存集滤波**(= 硬似然 PF + Voronoi 裕量,与用户"|θ̂−θ|>1° 权重置零"的设定一致但更严格):
- 每频道一组 Ω 上间距 h≈30m 的网格粒子(约 1.1 万格,alive 用整数索引集合;空间哈希加速邻域查询)
- 保守 kill 判定(margin = h·√2/2,保证真源所在格必活):
- `no_signal`@s:kill dist(g,s) ≤ 1000 − margin(题面 r_eff ≥ 1000 ⟹ 可严格排除该盘)
- `direction`@s 读数 θ:kill 角向偏差超出 δ_eff=1.005°(含余量)或 dist > 1500 + margin 的格
- `near`@s:kill dist(g,s) > 5 + margin
- 证书全部由此推导:清除证书 = MEC(alive).R + margin ≤ 20 − clear_margin;**缺席证明 = alive 为空集**(无需额外像素覆盖检查);跳测规则 = min dist(alive, s) > 1500 + margin ⟹ 必 no_signal,跳过省 6s/次
- 信息增益:对候选点用子采样(≤500 粒子)× K 个代表假设做 MC 期望熵减估计;复用 `smallest_enclosing_circle`(Welzl) 求 MEC
## 2. 新策略 `BayesLoop(OmnidirectionalLoop)`(`src/mcm26b/planner/bayes_loop.py`)
复用基类 run() 骨架、异常收尾、summary、anomaly、预算机制,重写阶段逻辑:
- **发现阶段**:原点起扫(频道排序省换频)→ 站点 = 七点环 ∪ PF 高价值点,按"期望发现数 − λ·移动时间"贪心选站;每站批量扫频时跳过"PF 判定必 no_signal"的 (站,频道) 对与已证缺席频道;每站扫完即服务已发现频道(SevenBatch 风格)
- **定位服务**:选点 = 保证接收盘内候选环采样 + 信息增益评分(ΔH − λ·移动 − μ·检测),有证书即清除;步数上限沿用 `max_localize_steps`
- **清除调度**:待服务任务(定位+清除 ≤20 节点)开放路径最近邻 + 2-opt,纯标准库,每个决策点滚动重排
- **收尾**:全部 CLEARED/ABSENT → all_resolved;清满 16 提前停;persistent_search 沿用现有配置语义(实现时对照 `run_q3_seven_practice.py` 保持官方演练停止行为一致)
## 3. 配置 `config.py`
新增 `BayesConfig` frozen dataclass([工程]):particle_spacing_m、λ/μ 权重、MC 代表数/子采样、skip_margin、站点环半径、2-opt 开关/迭代数等;挂到 `ProblemConfig.bayes`。默认值不改变现有策略行为。
## 4. 七点覆盖提升为库代码 `src/mcm26b/planner/seven.py`
把 `scripts/benchmark_q3_search.py` 的 `seven_points()/SevenImmediate/SevenBatch` 移入 src(旧脚本改 import,消融结果可复现),供 CLI 与新基准复用。
## 5. CLI `cli.py`
`mcm26b run --strategy {grid,seven,bayes}`,默认 grid;**若基准通过则默认改为 bayes**(用户已确认)。
## 6. 基准 `scripts/benchmark_q3_bayes.py`
配对消融 {seven_batch, bayes} ×(seeds 0..5 × 10/16 源 + 边界源 + 0 源纯缺席场景),复用现有 run_case/LocalSimulator 模式;输出 `output/q3/bayes-ablation/`(benchmark.json + report.md):全清率、correct_absence、异常、accounted/total 虚拟时间、配对降幅、实机 compute_wall_s。
## 7. Optuna 调参 `scripts/tune_q3_bayes.py`
- `uv add --group dev optuna`(仅调参脚本用,生产零新增依赖)
- TPE,目标 = 场景子集平均虚拟时间 + 10⁶×失败惩罚;场景与 error_seed 多样化防过拟合
- 搜索空间:h、λ/μ、skip margin、候选环、2-opt 开关等
- optuna 不可用时自动退化为随机搜索;最优参数写 JSON,复跑基准确认后合入 config.py 默认值
## 8. 测试
- `tests/test_particles.py`:真源保留性(随机场景 × LocalSimulator 误差场,任意更新序列后真源邻域格必活);证书正确性(alive=∅ ⟺ 真缺席;MEC 证书清除必成功);跳测规则永不漏检
- `tests/test_bayes_loop_q3.py`:端到端 LocalSimulator(10/16/0 源、边界源、预算耗尽、故障注入):全清 + 缺席正确 + 无异常 + 正常退出
- 现有 105 项测试保持绿
## 9. 文档
`docs/algo-3-bayes.md`(Voronoi 裕量严格性论证、信息增益与调度设计、与 A2 对比数据);README 策略表加行;`docs/algo-3.md` 加交叉引用。
# 执行顺序
1. `git checkout -b q3-bayes`
2. BayesConfig → particles.py(含单测)→ seven.py 提升
3. bayes_loop.py + CLI 接入 → pytest 全绿
4. 跑配对消融基准
5. Optuna 调参 → 回填参数复跑基准
6. 若达标(全清 100% 且配对均值更优):`mcm26b run` 默认切 bayes + 文档更新
7. 分支上分阶段 commit(不 push)
# 验收标准
- 基准全部场景:all_cleared 100%、correct_absence 100%、anomalies 空、normal_exit、单局实机计算 < 60s
- 配对平均虚拟时间优于 seven_batch(目标降幅 ≥5%;参考量级 A2=5232s/12 源)
- `uv run pytest` 全绿;`mcm26b run --self-test --problem 3` 通过
# 风险与对策
- PF 实机计算耗时 → 空间哈希 + MC 子采样,基准记录 compute_wall_s,超预算先降 MC 规模(实机预算 1200s,A2 仅用 3.4s,余量巨大)
- LocalSimulator 误差场确定性 → 调参与基准用多 error_seed
- 负观测删点的正确性 → Voronoi 裕量定理 + 随机化真源保留性测试兜底
+97 -15
View File
@@ -3,43 +3,98 @@
统一框架:有界误差几何定位、主动选点、带覆盖证明的搜索、按真实动作耗时调度。
分析依据 `issue/B题.pdf`、附件1《模拟器使用说明》、附件2《通信接口说明及编程指南》。
**文档总入口:[docs/README.md](docs/README.md)**(分类索引、阅读顺序与口径登记)。
- [总体思路与流程图(四问主线、端到端流程图、执行时间速查)](docs/总体思路与流程图.md)
- [执行时间与执行步骤(计时模型、状态机、附件示例推演、实测分解)](docs/执行时间与执行步骤.md)
- [几何计算示意图册(逐图说明:角域交会、覆盖、最小命中集)](docs/几何计算示意图册.md)
- [论文插图清单与复现方式](docs/figures/README.md)(`uv run python scripts/build_figure_data.py`)
- [四问建模分析与 P0–P7 实施计划](docs/B题建模分析与实施计划.md)
- [协议规则表、字段表与状态机(P0 交付)](docs/协议规则表与状态机.md)
- [问题一完整解答](docs/问题一解答.md)
- [问题三搜索与总耗时改进方案(含七点覆盖证明和自建消融)](docs/algo-3.md)
- [问题一算法说明(当前实现口径)](docs/algo-1.md)
- [问题二算法说明(保证接收的第二点选择)](docs/algo-2.md)
- [问题三算法说明(七点覆盖证明、贝叶斯闭环与滚动调度)](docs/algo-3.md)
- [问题四算法说明(定向覆盖最小命中集、联合信念与后备清除)](docs/algo-4.md)
- [第三、四问系统性架构优化与运行提速方案(运行历史复核、最小命中集、联合信念)](docs/第三四问架构优化与运行提速方案.md)
- [第三、四问图论与动态规划优化方案(定点实测、路由 gap、定向定位 DP、证书硬化)](docs/第三四问图论与动态规划优化方案.md)
- [1-4 问最优与基线实现对比框架](project/README.md)(`uv run python -m project.run`,结果在 `project/results/`)
- [最优与基线实现对比(论文口径说明)](docs/最优与基线实现对比.md)
- [论文](docs/paper/main.tex)
## 项目结构
```
referee/ # 独立评测机包(mcm26b-referee):模拟器引擎+机器狗接口+WebUI,
# 零第三方依赖、不依赖本队代码;题意符合性对照见 referee/README.md
src/mcm26b/
├── config.py # 参数中心:题面常量[题面] + 工程参数[工程],唯一字面量来源
├── protocol/ # P0/P1 字段表、计时模型、状态机、串行幂等客户端(已完工)
├── geometry/ # P2 角域交会、直径、MEC、问题二安全候选盘证书(已完工)
├── sim/ # 自建模拟器、故障注入、演练日志回放(测试专用,已完工)
├── belief/ # P3 逐频道候选集合 K_c:保守外包多边形 + 半平面裁剪(已完工)
├── coverage/ # P4 49点覆盖账本(网格生成 + 逐频道记录)
├── planner/ # P3 保证接收盘选点器 / P4 全向闭环 / P5 混合源定向闭环(已完工)
├── routing.py # 图论/DP 工具箱:Held-Karp 精确开放路径、2-opt/Or-opt、MST 下界、路由度量
├── evaluation/ # 日志复算与统计(已完工)
└── sim/scenes.py # 自检场景生成器:全向与混合源(测试端专用)
tests/ # pytest(兼容 unittest 风格),105 项
scripts/ # 采集、基准、可视化与校核脚本(含问题四批量仿真 benchmark_q4.py)
└── harness/ # 测试侧适配:评测机<->Transport 内存回环、故障注入、回放、场景
tests/ # pytest(含解耦守卫:运行时零导入 referee + 规则表不漂移)
project/ # 1-4 问"最优实现 / 基线实现"对比框架:逐问 optimal+baseline
# +独立参考,冻结基准集配对出数;见 project/README.md
scripts/ # 采集、基准、可视化与校核脚本(含问题四批量仿真 benchmark_q4.py、
# 运行历史计时分解 analyze_run_timing.py、第四问覆盖证书 verify_q4_cover.py、
# 论文插图 build_figure_data.py + scripts/matlab、日志归档 archive_replay_logs.py)
docs/figures/ # 论文插图(PNG)+ manifest.json(数据来源与 sha256)+ 图清单
output/ examples/ # 演练证据、算例与生成产物
```
## 快速开始
## 独立评测机与 WebUI(referee/)
Python ≥ 3.10,依赖仅 `requests`(测试另需 `pytest`)。
评测机已抽成独立包:`referee/`(uv workspace 成员,`uv sync` 自动挂载为 dev 依赖)。
参赛队运行时代码**零导入** referee,双方仅通过附件2 的 HTTP 协议耦合——
评测机可独立服务任何客户端,参赛队代码对接官方模拟器无需任何改动。
```bash
uv sync # 安装(可编辑挂载本包)
uv run pytest # 全部测试
uv run python -m referee # WebUI http://127.0.0.1:8026,接口 :2026
uv run python -m referee --port 2026 --ui-port 8026 --data-dir referee-data
uv run mcm26b run --robot-id 2026001 --local-judge # 一键:起评测机→开演练局→闭环→汇总
```
- **WebUI 完全按附件1 §4 复现官方界面语义**:四个测试模块(演练不限次/正式各3次、
启动即占用、正式前再次确认)、确认开始→数据准备→5秒倒计时→25分钟窗口开放接口、
"指令与反馈"默认最新1000条(100..5000 可调)、中止测试两次确认、结束原因显示、
案例编码、演练结束显示 总数/全向/定向(正式任何界面不显示真值)、行为日志导出
(2MB 上限告警)、空闲时可改端口、只监听本机回环。
- **回放器**("回放"Tab):以本局行为日志 JSONL 驱动,在 Ω 圆上动画复现运动与
扫描过程——移动按 5m/s 展开、检测以雷达扫描动画呈现(direction 附实测示向度
射线)、清除先光学定位再清除;支持播放/暂停、逐步±、1×~300× 倍速快进、
滚轮缩放/拖拽平移、带动作刻度的进度条任意定位;兼容 CSV 式逐事件信息面板。
**支持手动选定 JSONL 回放**:点击"选择 JSONL 文件"或直接拖拽导入任意行为日志
(评测机日志含真值;参赛队客户端会话日志取 accepted 事件,无真值),纯前端解析。
- 引擎与协议:`referee.simulator`(观测判定/幂等/并发409/微秒计时/截止断连)、
`referee.interface`(附件2 §5 完整语法边界,HTTP 服务器与内存直连共用),
一致性由 `referee/tests` 90 项测试与真实 HTTP 测试固化。
- 自建差异(题面未规定)均在 referee/README.md 声明:联网登录/官方校时/17:30
截止/加密上传属官方服务器侧策略,本地不实现;示向度误差场与场景分布是测试假设。
## 快速开始
Python ≥ 3.10,运行时依赖仅 `requests`;测试/自检另需 `pytest` 与独立评测机
包(`uv sync` 一并安装)。
```bash
uv sync # 安装(可编辑挂载本包与 referee)
uv run pytest # 全部测试(含 referee 90 项 + 解耦守卫)
uv run python -m referee # 起本地评测机(可选,配合 WebUI 回放)
uv run mcm26b sim-check # P0 自检:复现附件计时示例 105/111/194/199
uv run mcm26b run --robot-id 2026001 --self-test # 问题3自检:随机全向场景闭环(不连官方)
uv run mcm26b run --robot-id 2026001 --self-test --problem 4 # 问题4自检:混合定向源场景
# 自检默认:源数按题面 10~16 随机;实机预算 30 分钟(--real-budget-s 可改,仅自检生效);
# 找全后持续复扫未清除频道直到限时(--config 关闭 persistent_search 可回到"找全即退")
uv run mcm26b run --robot-id <队号> --log output/run/session.jsonl # 对接模拟器正式/演练
uv run mcm26b run --robot-id <队号> --base-url http://127.0.0.1:2026 # 对接本地 referee
uv run mcm26b run --robot-id 2026001 --local-judge # 一键本地评测:自动起 referee+开演练局
# 端口被占时主动询问换端口(非交互自动改空闲端口);WebUI 可实时观战并在"回放"Tab
# 逐步回看;结束自动汇总结束原因/真值统计/全清校验(--judge-no-ui 可关闭界面)
uv run mcm26b q1 examples/q1/normal.json # 问题一求解(等价 python -m mcm26b.geometry ...)
uv run python main.py examples/q1/triangle.json --rounding-margin-deg 0.005
```
@@ -67,9 +122,11 @@ uv run python scripts/benchmark_q1.py # MEC 基准与舍入敏感性
`/clear` 不切频道,虚拟时刻以 accepted 响应为准;
- `protocol.client.SimulatorClient`:串行发送、幂等重试(同 ID 同内容原样重发)、
连接/读超时、基于 `remaining_real_duration_s` 的本地截止时钟与退出预留、JSONL 会话日志;
- `sim.LocalSimulator`:按附件语义复现模拟器(观测门限、定向覆盖角、幂等回放、
整数微秒计时、现实/虚拟截止、并发409和请求校验),真值只对测试端开放;`ScriptedFaultTransport` 注入断连/超时/500,
`PlaybackTransport` 回放官方演练日志。
- 独立包 `referee`:按附件语义复现评测机(观测门限、定向覆盖角、幂等回放、
整数微秒计时、现实/虚拟截止、并发409和请求校验),真值只对评测机/测试端开放;
`harness.LocalSimTransport` 内存回环挂载(与 referee HTTP 服务器共用同一边界),
`harness.ScriptedFaultTransport` 注入断连/超时/500,`harness.PlaybackTransport`
回放官方演练日志。
本地模拟器默认执行至多1200秒程序预算、1500秒接口开放窗口,截止后通过
`SimulatorClosed` 模拟连接关闭,已登记动作可以完成。可注入 `clock`、`wall_clock`
@@ -94,15 +151,40 @@ client.exit()
| P2 几何内核(角域交、直径、MEC、证书) | ✅ 完成(迁入 `mcm26b.geometry`) |
| P3 问题二选点(K1、候选区域 Q、采样评估) | ✅ 完成(belief + 选点器,测试固化) |
| P4 问题3全向闭环(49点覆盖、定位队列、清除、停止) | ✅ 官方问题3演练单案例通过(10/10全清,[验证报告](output/q3/practice/20260911-port2026/report.md)) |
| P5 问题4定向扩展(联合约束、后备清除、预算) | ✅ 自建完成(官方演练待执行) |
| P6 加速与消融 | 🦴 未开始 |
| P5 问题4定向扩展(联合约束、后备清除、预算) | ✅ 自建完成(官方演练待执行;[算法说明](docs/algo-4.md)) |
| P6 加速与消融 | ✅ 七点批量(`--strategy seven`,官方演练 12/12)+ 贝叶斯闭环(`--strategy bayes` 默认,自建消融 −30.0%,[算法说明](docs/algo-3.md)、[设计细节](docs/algo-3-bayes.md)) |
| P7 图论/动态规划优化(S1–S5) | ✅ 已落地([实测回填](docs/第三四问图论与动态规划优化方案.md#8-本轮实测回填2026-09-12-实施结果)):路由度量入库、覆盖证书硬化(15 m/72 缺口 566→0)、Q4 后备盲清 125.7→4.3 次/局;同时修复联合信念 `no_signal` 朝向规则反向缺陷 |
### 图论/DP 轮(2026-09-12,S1–S5)要点
- **S1 度量**:`scripts/run_suite.py` 逐局新增 `route_gap_vs_offline`、
`route_lower_bound_s`(MST 下界)、`distinct_service_points`、
`coverage_actual_visited`、`probe_no_signal_ratio`、`plan_verify_wall_s`;
图算法在 `src/mcm26b/routing.py`(Held-Karp 精确 / 2-opt / Or-opt / Prim)。
- **S2 证书**:`q4_mincover_v2`(31 点,`r_design=965 m` + 前向角裕量 5°,
15 m/72 档 325.6 万 (p,h) 缺口 0,最坏命中 990 m),取代粗采样过拟合的
`q4_mincover_v1`(25 m/48 档 125 缺口)。
- **S3 探点**:默认口径改为"可见候选里移动最省"(`probe_score_mode=nearest_visible`);
后备盲清 125.7 → 4.3 次/局,Q4 core 均值 8676 → 8602 s、最坏 10891 → 9630 s。
- **S4/S5 负面结论**:后备清除 Or-opt、Held-Karp 精确任务序、路线感知探点
在本模拟器上均为净负收益,默认关闭,代码与开关保留(见方案 §8.6)。
- **正确性修复**:`belief/joint.py` 的 `no_signal` 朝向裁剪原先规则反向且漏计格内
角裕量,会删掉真源朝向档;已修复并由 `tests/test_joint_belief.py` 的随机观测
序列保留性测试固化。
## 采集与正式测试约束
改进版 A2(七点覆盖+本站批量扫频)已通过官方案例 `J4ER-Y9EP-Q379-UPFA`:
12/12源全清,109次检测,虚拟耗时5232.132109秒,现实耗时3.406秒。
[本局报告与运行方法](output/q3/practice/20260911-seven-batch-01/report.md)。
独立入口为 `scripts/run_q3_seven_practice.py`,默认 `mcm26b run` 策略仍保持原样。
独立入口为 `scripts/run_q3_seven_practice.py`。
贝叶斯闭环 `bayes`(网格粒子滤波+负观测更新+信息增益选点+滚动 2-opt
调度,参数经 Optuna 回填)在自建模拟器配对消融中相对 SevenBatch 平均
虚拟时间再降 30.0%(14/14 场景全清零异常),算法与数据见
[algo-3-bayes.md](docs/algo-3-bayes.md),已是 `mcm26b run` 默认策略。
虚拟时间即成绩口径:找全证明后立即 `/exit`(`--persistent` 可显式开启
"扫到限时"的复扫保险模式)。
2026-09-11 已通过本机 2026 端口完成一次官方问题3演练:案例
`DHFK-PHD2-SR2P-FXK9`,10/10 源全清,540 次检测、10 次清除,现实耗时
-237
View File
@@ -1,237 +0,0 @@
# B 题建模分析与实施计划
根据你们对工程实践和物理模型的熟悉程度,确定研究 B 题“无线电干扰源的快速自动定位与清除”。推荐采用**有界误差几何定位、主动选点、具有覆盖证明的搜索、按实际动作耗时调度**的统一框架。AI 辅助推导、实现与实验分析,策略只依赖题目允许的观测。
本文件已结合本次评审修订。问题一的几何内核、离线可视化和官方演练观测回放已经完成;问题二新增了安全候选圆盘证书,但主动选点优化器、问题三和四的全清闭环尚未完成。文中的覆盖结论是根据题设推导的,耗时估算不是演练成绩。当前工作仅离线复算既有演练记录,不使用正式测试。
依据:`issue/B题.pdf` 全文、`issue/附件/附件1.docx`《模拟器使用说明》、`issue/附件/附件2.docx`《模拟器通信接口说明及编程指南》。文档中的登录、测试和日志要求在这里作为交付约束分析,不作为自动执行正式测试的指令。
## 本次建议的取舍
| 建议 | 处理结论 |
| --- | --- |
| 四问复用几何内核、有界误差、覆盖证明、严格停止、幂等协议 | 保留,作为主线与验收条件 |
| 问题二的候选集合加入 $\Omega$ 和 1500 米上界 | 采纳,但仅加入在线候选集合 $K$,不改变问题一纯角域多边形 $P$ |
| 问题二首次测向后安全区域可能为空 | 对一般 $K$ 成立;本题首次窄角域可直接证明非空,见下文 |
| $Q$ 为空就去最小包围圆圆心并结合交会角 | 仅是一般情形的探索退化;$\rho>1000$ 时不保证接收,不能冒充保底方案 |
| 枚举所有直径对判断直径圆 | 简化为任意一组最远点对检查一次;失败时给出圆外顶点 |
| 定向无信号不直接删位置、失去信号后覆盖清除 | 采纳;执行前必须计算覆盖格数、路径和现实请求预算 |
| 现实20分钟优先控制请求数 | 采纳为硬约束;全部清除后仍按题目虚拟时间优化 |
| P0–P7分期与尽早正式测试 | 采纳分期;演练不限定3次,正式问题3/4各3次由团队另行安排 |
| `/detect` | 修正为附件规定的 `/measure` |
## 1. 四问应当串成一条实现主线
| 问题 | 要回答的实质问题 | 推荐抽象 | 必须交付的结果 |
| --- | --- | --- | --- |
| 1 | 带误差的多个方位如何形成定位区域,其直径如何计算,直径圆是否覆盖区域 | 扇形角域的半平面交、凸多边形直径、最小包围圆 | 算法、边界情形、覆盖判断及反例 |
| 2 | 仅有一次测向时,下一步去哪里更有利 | 在未知目标位置条件下进行主动测量设计 | 明确的候选区域、选点目标和比较实验 |
| 3 | 数量未知的全向源如何全部找到并尽快清除 | 全域覆盖、逐频道候选区域、局部定位与路径调度 | 演练统计、3 次正式结果、原名正式日志 |
| 4 | 有未知朝向的定向源时,如何避免遗漏 | 加入朝向不确定性的观测模型、半平面覆盖、失去信号后的处理 | 演练统计、另外 3 次正式结果、正式日志 |
第 1 问的定位区域和包围圆直接服务第 2 问选点与第 3、4 问清除;第 3 问的工程框架延伸到第 4 问。模型不必为每问重新搭建。
## 2. 用题面明确的观测能力约束物理抽象
目标区域为 $\Omega=\{(x,y):x^2+y^2\le 1800^2\}$。每个频道最多有一个源,源总数为 10–16,频道为 1–20。对一个存在的源 $c$,用未知位置 $g_c$、接收半径 $R_c\in[1000,1500]$、类型和定向朝向描述;全向源不需要朝向参数。
机器狗状态只需要位置 $p$、当前测向频道 $k$、虚拟时间、现实剩余时间、各频道的观测历史和处理状态。每个频道维护:未知/已发现待定位/可清除/已清除/已证明不存在,以及尚可能包含源的集合 $K_c$。
| 规则 | 数学或程序含义 | 容易出现的错误 |
| --- | --- | --- |
| 只返回 no_signal、near、direction;direction 附带示向度 | 这是带距离可见性门限的方位观测问题 | 构造接口并未提供的 RSSI、距离或场强数据 |
| 同地点误差固定,幅度不超过 $1^\circ$ | 用有界误差集合,保留空间相关性 | 原地重复测量取平均,假定可消除误差 |
| 示向度保留两位小数 | 题设模型取 $\pm 1^\circ$;实现可另设 $\pm 1.005^\circ$ 保守裕量 | 把额外舍入裕量写成题面规定 |
| 定向源覆盖朝向两侧各 $90^\circ$,包含边界 | 检测点须处于以源为界的发射半平面内 | 把无信号直接判定为没有源 |
| 可到目标圆域外检测与清除 | 目标位置受 $\Omega$ 限制,机器狗正常搜索不受 $\Omega$ 限制 | 所有搜索点强制留在圆域内,遗漏朝外发射的边界源 |
| 清除半径 20 米,不受定向朝向限制 | 清除可成为失去信号时的可靠后备手段 | 认为背面接收不到信号就无法清除 |
| /clear 不切换测向频道 | 保存独立的当前测向频道状态 | 清除后错误更新频道,算错下一次检测耗时 |
不需要重新拟合电磁传播衰减公式:接口只暴露接收门限与方位,额外物理参数无法由题目观测充分辨识。工程经验主要用于设计可见性分析、测向几何和闭环决策。
精确的虚拟时间目标是:
$$
T=\frac{L}{5}+N_{\mathrm{switch}}+5N_{\mathrm{measure}}
+5N_{\mathrm{clear,success}}+3N_{\mathrm{clear,fail}}.
$$
$L$ 包括检测和清除引发的所有移动,切换次数只由被接受的检测动作决定。先满足全部清除,再减少 $T$;不能通过少清几个源来改善平均时间。程序运行时间是另一个硬约束,不能把虚拟检测的 5 秒实现为现实 `sleep(5)`。
## 3. 定位与选点的统一几何标准
在 $s_i$ 测到 $\theta_i$ 后,定义 $W_i$ 为从 $s_i$ 出发、方向范围 $[\theta_i-\delta,\theta_i+\delta]$ 的前向角域。它由两条线性半平面约束表示,必须正确处理 $0^\circ/360^\circ$ 跨界。
第 1 问的定位多边形 $P$ 为这些角域的交。求各边界直线交点并筛选可行点、排序成凸多边形后,小规模下直接枚举顶点对即可计算直径:
$$
D(P)=\max_{i,j}\lVert v_i-v_j\rVert.
$$
先用简单可靠的顶点对枚举作基准,规模确有需要时再换旋转卡壳。算法必须区分空集、单点、线段、有限多边形和无界区域;不能靠任意大方框截断后声称得到无界区域的真实直径。旋转卡壳的背景可参考 [McGill 计算几何研究页面](https://www-cgrl.cs.mcgill.ca/~godfried/research/calipers.html)。
**直径为 $D$ 的圆不一定覆盖直径为 $D$ 的区域。**例如边长为 $D$ 的等边三角形,最小包围圆半径为 $D/\sqrt{3}$,大于 $D/2$。后续保证清除应使用:
$$
\rho(K)=\min_z\max_{g\in K}\lVert g-z\rVert\le 20.
$$
到最小包围圆圆心清除是充分选择,并非唯一选择。所有可保证清除的位置组成 $C_{20}(K)=\bigcap_{g\in K}B(g,20)$;只要该集合非空,就可进一步选择距离机器狗最近的可行点来节省移动。$D\le 40$ 只是必要条件,不是充分条件。最小包围圆是已有标准计算几何问题,可参考 [CGAL Min_circle_2 文档](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html)。实现时给 20 米阈值保留数值余量;数值阈值附近返回谨慎判断,不把浮点容差当作精确算术证明。
直径圆覆盖只需任意一组最远顶点 $A,B$:包含距离为 $D$ 的两点且半径为 $D/2$ 的圆,其圆心只能是 $(A+B)/2$。因此检查一次全部顶点即可;若失败,换另一组直径对也不可能产生覆盖圆。问题一实现现输出最坏超出距离、圆外顶点索引和一个具体反例顶点。
在线定位还可用 $\Omega$ 和“收到信号意味着距离 $\le 1500$”约束 $K$。圆盘交角域可能包含曲线边界;若用多边形近似,必须构造外包近似,才能维持“不丢失真源”的保证。用圆周采样点连成的内接多边形不能直接作为保守外包。
第 2 问建议先明确“较好的定位效果”:以新观测后的最坏包围圆半径为主要几何指标,再考虑移动和检测成本。交会角大、面积小可作为候选点筛选指标,但不是对所有未知位置都最优的证明。
第一次收到 `direction` 后,明确取 $K_1=\Omega\cap W(s_1,\theta_1\pm\delta)\cap B(s_1,1500)$。保留整个距离圆盘是保守处理,忽略 `direction` 还隐含的近距离排除不会丢失真源。对全向源,第二检测点的安全候选区域为:
$$
\mathcal{Q}=\left\{q:\max_{g\in K_1}\lVert q-g\rVert\le 1000\right\}.
$$
在这里测量,不论实际目标位于 $K_1$ 何处,接收距离都满足题目最小接收半径;可能返回 `direction`,也可能返回 `near`。若已知外包圆 $K_1\subseteq B(c,r)$,且 $r\le 1000$,则 $B(c,1000-r)$ 是充分候选区域,等号时是单点。不要误写成“距第一次检测点不超过 1000 米”。宽松外包圆的 $r>1000$ 不能证明真实 $\mathcal{Q}$ 为空。
本题还有比“检查 $\mathcal{Q}$ 是否为空”更强的解析结论。令 $R=1500$,$u$ 为第一次示向方向,$\delta\le 45^\circ$,定义
$$
a=\frac{R}{2\cos\delta},\qquad c=s_1+au.
$$
整个截断角域 $W\cap B(s_1,R)$ 包含在 $B(c,a)$ 内。证明:任意候选 $g=s_1+tu_\varphi$ 满足 $0\le t\le R$、$|\varphi-\theta_1|\le\delta$,因此
$$
\lVert g-c\rVert^2=t^2+a^2-2at\cos(\varphi-\theta_1).
$$
该式关于 $t$ 是凸函数,最大值在 $0$ 或 $R$ 取得;两端都不超过 $a^2$。于是 $K_1\subseteq B(c,a)$,从而 $B(c,1000-a)\subseteq\mathcal{Q}$。$\delta=1.005^\circ$ 时,$a\approx 750.115391$ 米,安全圆盘半径约 $249.884609$ 米,严格为正;题设 $\delta=1^\circ$ 同样成立。即使检测点或 $c$ 在 $\Omega$ 外也合法,因为机器狗不受源位置圆域限制。实现见 `geometry/planning.py::first_bearing_candidate_disk`。
因此,在本题一致的首次测向条件下无需依赖“$\mathcal{Q}$ 空集退化”。对后续更一般的非空紧集 $K$,$\mathcal{Q}$ 非空当且仅当 $\rho(K)\le 1000$;若确实 $\rho>1000$,去 MEC 圆心只是最小化最坏接收距离的探索动作,必须允许 `no_signal`。若 $K$ 为空,应先核查频道、数据、误差界与几何计算,不给空集编造中心。上述证书只保证距离;不能直接用于定向源的朝向可见性保证。
在上述安全圆盘内,围绕测向轴两侧取候选点,计算后验包围半径与动作代价。最坏情形目标可写为 $\sup_{\theta'}\rho\bigl(K_1\cap W(q,\theta')\bigr)$,其中 $\theta'$ 遍历与 $K_1$ 和误差界一致的读数;实现先用目标位置与误差区间端点采样,并加密检查。若返回 `near`,则候选源位于当前点 5 米以内,可直接在当前位置尝试清除该频道。交会角仅作筛选或同分择优,不为改善交会角越出已证明安全的候选区域。采样是启发式,不能称为严格最坏情形最优解;圆弧先验如需离散,使用外包切线多边形并记录逼近误差。
## 4. 搜索完整性和停止条件要有可验证的依据
第 3 问中,在 $s$ 对尚未清除频道测得 `no_signal`,可以排除以 $s$ 为中心、半径 1000 米的圆盘内存在该全向源。超过 1000 米仍可能存在接收半径较小的源,不能一律排除到 1500 米。
第 4 问中,同样的 `no_signal` 约束的是“位置、半径、朝向”的联合假设。若位置和朝向暂不联合维护,最简单的可靠基线就是不利用这一观测直接删除空间候选点,只记录覆盖检测已完成。后续加速版本再维护朝向可行集合。
可以先构造一个对全向、定向均适用的完整发现基线:
$$
S=\{(700i,700j):i,j=-3,-2,\ldots,3\},\quad |S|=49.
$$
这些点覆盖 $[-2100,2100]^2$,包含目标圆域,并允许到圆域外访问。对于位于一个网格单元内部的源 $g$,该单元 4 个顶点到 $g$ 的距离都不超过 $700\sqrt{2}\approx 989.95$ 米。$g$ 在这些顶点的凸包内部,所以经过 $g$ 的任意发射半平面总包含至少一个顶点。该顶点满足接收距离与发射角条件。源恰在网格边或顶点时,取相邻单元的顶点;由于目标圆域与外框有余量,两侧相邻点都存在,仍可选择距离合格、且不与源重合的可见点。
因此,对每个尚未解决的频道完成这些点的测量,能够保证发现该频道存在的源。实际实现可跳过已经清除或已经具有有效定位任务的频道,但必须保存逐频道的覆盖记录,不能把“路过某点”当作“测过所有频道”。
作为规模检查,49点全部扫描20频道最多需要980次检测。若从原点去网格一角,再按逐行折返路线扫完,纯扫描路程约36569.85米;每点检测成本上界120秒,纯扫描虚拟总耗时上界约13193.97秒,即3.665小时。**该估算不含定位、清除及其绕行,也不保证满足现实20分钟窗口。**它说明覆盖基线在100小时虚拟上限内有设计空间,现实可行性仍要测接口延迟、总请求数和日志大小。
发现源后,先通过主动测向缩小候选集合。若定向源再次失去信号,可对已知有界候选集合构造半径小于 20 米的有限圆盘覆盖,逐点调用 `/clear`,作为不依赖朝向的后备清除方法。边长 20 米方格中心距格内任何点不超过 $10\sqrt{2}$ 米;保留与候选集合相交的格子即可保守覆盖。格子按统一半开区间索引,源恰在网格线上时至少归入一个格子;若相交判定数值不确定则保留格子。
有限不等于现实预算可行。设候选外包框宽为 $w$、高为 $h$,使用全局 20 米网格枚举时,上界可取 $N\le(\lceil w/20\rceil+2)(\lceil h/20\rceil+2)$,再以实际相交格子列表得到精确请求上限。生成路径长度 $L$ 后,单源全失败清除耗时为 $L/5+3N$;若最后一次成功则为 $L/5+3(N-1)+5$。现实代价用 $N$ 乘实测单请求高分位耗时加本地计算时间估计。不满足剩余预算时应继续有希望的定位、选择其它任务或按预算退出,不能声称后备清除保证 20 分钟内成功。即使已知源落在半径 1500 米圆盘,按外包框枚举仍有约 $2.3$ 万个格子的量级,故宽候选集合不能直接盲扫。
停止成功的依据为以下任一项:
- 已成功清除16个不同频道的源,达到题设数量上界。
- 每个频道都已有清除成功记录,或通过完整覆盖证明不存在;所有已发现源均已处理完毕。
发现10个源、连续若干次无信号、候选采样点恰好为空,都不是充分停止条件。现实预算不足而退出应记为未证明完成,不能与成功退出混记。
## 5. 在可靠基线之上增加加速策略
先做“发现一个、定位清除一个”的简单闭环,保证功能可运行;再增加以下独立可开关的策略,以便消融比较:
| 策略 | 所解决的问题 | 需要验证的收益 |
| --- | --- | --- |
| 共享检测位置 | 一次移动后顺便测多个未知频道 | 是否减少总移动时间,而非仅减少单目标测量数 |
| 目标队列和短路径排序 | 多个已发现源处理顺序不佳 | 路程减少是否抵消局部定位变差 |
| 第二点主动选取 | 共线或小交会角导致候选区域狭长 | 同等观测预算下包围圆半径、清除耗时改善 |
| 自适应覆盖 | 固定49点有冗余 | 跳点之后每个频道的覆盖证明是否仍成立 |
| 朝向可行集合 | 第4问盲区导致反复失去信号 | 是否减少无效检测,同时不误删真源 |
| 提前尝试清除 | 再测一次可能不如直接尝试便宜 | 失败的3秒和绕行是否值得;保留失败后的正确更新 |
调度可以先采用“预期信息改善/增量虚拟耗时”之类的启发式,但要记录每项含义和参数,性能结论来自演练。不预设目标均匀分布,不假定测向误差独立或均匀分布。若研究统计模型,明确它是实验场景假设。
AI 的适合用法是把这些模块逐一变成可测试程序、生成边界案例、分析消融结果,以及在自建环境中搜索策略参数。先形成可解释基线,再判断学习方法是否有额外收益;避免把未建立的奖励与训练分布直接用于正式测试。
## 6. 抽象标准和验收标准
采用“来源—输入—状态—动作—观测—约束—代价—输出—验证”的统一模块记录格式。它是本项目建议的工程标准,不是竞赛另行规定的官方评分标准。每个假设注明是题面事实、保守近似还是实验启发式;每条结论注明是数学证明还是演练观察。
| 模块 | 最小接口职责 | 验收标准 |
| --- | --- | --- |
| 协议与状态 | 串行发送动作,更新位置、频道、两类时间 | 附件计时示例逐步得到105、111、194、199秒 |
| 观测与几何 | 根据 direction、near、clear结果更新候选集合 | 自建已知真值案例中真源始终保留;跨角度边界不出错 |
| 直径与包围圆 | 给出直径、圆心、包围半径 | 与顶点对枚举对照;等边三角形反例通过 |
| 主动选点 | 输出下一测量位置及选择依据 | 实际可见性、最坏误差与移动成本均有记录 |
| 覆盖与停止 | 管理每频道的覆盖记录和成功依据 | 边界朝外源不漏搜;10源和16源场景都能正确退出 |
| 后备清除 | 对有界候选集合建立20米以内圆盘覆盖 | 覆盖无空洞、每次失败后能继续、预算有上界估计 |
| 演练统计 | 从自身动作日志与演练结束真值统计结果 | 清除比例、平均时间、总时间与分项耗时可独立复算 |
| 正式交付 | 绑定案例编码、程序版本、统计与官方原始日志 | 问题3和4各3次;日志原名保留,核查上传与导出 |
协议必须同时检查 HTTP 状态和 accepted。accepted=false 时不得更新状态,返回的 virtual_time_s=0 不能重置本地虚拟时钟。网络超时重试同一动作必须复用原 request_id 和完全相同的内容;处理重试响应也不能重复累加本地成功数与费用。新动作使用新ID,不并发调用不同动作。
用/enter返回的remaining_real_duration_s建立本地单调时钟截止时间;后续响应若提供剩余时间则校准,不能把首次剩余时间当成永远不变。记录每次请求的现实往返耗时,预留退出与故障恢复余量。例如先预留60秒,再由演练高分位延迟调节;60秒是初始工程参数,不是官方规定。估计剩余请求数时同时计入测量、失败重试、清除和退出,禁止并发动作。
980次检测在单次0.5–1秒的假设下占490–980秒(约8.17–16.33分钟),还没有定位清除余量。这个延迟范围不是实测结论;本地接口和模拟器计算的实际耗时应测量。当前14个动作的单次短演练不足以估计高分位延迟或全程稳定性。已清除频道跳过,已发现频道暂停覆盖并进入定位队列,移动到同一点后共享频道测量;自适应跳点必须保留逐频道覆盖证据。根据附件,正式上传日志不超过2MB,需监测官方日志体积;自建JSON大小不能冒充加密日志的大小。
基准实验至少覆盖:全向与混合源;数量 10 和 16;接收半径 1000 和 1500 米;目标贴近圆域边界且朝外;测向接近 $0^\circ/360^\circ$;误差取界值;检测点距离 5 米及清除距离 20 米的边界;重复请求、拒绝动作及短暂断连。样例数量按剩余时间和运行成本安排,不把有限样例通过等同于数学保证。
比较策略时先看是否全清,再比较同一批场景的总虚拟时间、平均每源时间、中位数与高分位耗时、程序时间、请求数。自建环境可用固定场景做配对比较;官方案例随机且不能重读真值,演练成绩单独记录,不声称是在完全相同的隐藏案例上比较。
## 7. 建议实施次序与阶段交付
下面按P0–P7列出可执行交付与当前状态。排期以验收门槛推进,避免把“几何代码完成”误认为“全清策略完成”。
| 阶段 | 交付与执行任务 | 验收门槛 | 当前状态 |
| --- | --- | --- | --- |
| P0 规则固定 | 规则表、状态机、四类API字段与成本 | 示例逐步105/111/194/199秒;clear不切换频道 | 已完成:`protocol/rules+timing+state` 工程化,`sim-check` 与 `tests/test_protocol_timing.py` 固化 |
| P1 协议基线 | enter/measure/clear/exit客户端;串行、幂等、截止时间 | accepted=false和超时重试不污染状态;主动退出;故障注入回放 | 已完成:`protocol/client`,`ScriptedFaultTransport`/`PlaybackTransport` 回放,`tests/test_protocol_client.py` 全过 |
| P2 几何内核 | 问题一解答、区域分类、直径圆证据、MEC、清除余量 | 跨界、退化、无界、解析反例、独立算法对照 | 已实现并优化,20项自动化测试通过 |
| P3 问题二选点 | $K_1$ 先验、安全候选盘、后验半径采样与 `near` 分支 | 距离证书有效;采样加密;不把探索当接收保证 | 已实现:`belief` 保守候选集 + `planner` 保证接收盘选点器(worst-posterior 采样、1/4 间距加密复核);策略对比实验待做 |
| P4 全向闭环 | 49点覆盖记录、定位队列、清除、停止、日志统计 | 10/16源、边界源和错误响应案例;官方演练全清并正常退出 | 自建闭环已完成:`planner.OmnidirectionalLoop`(10/16/0 源、近边界源、预算耗尽、故障注入测试通过);官方演练全清待团队执行 |
| P5 定向扩展 | no_signal联合约束、覆盖发现、有限清除后备与预算 | 朝外边界源不漏;真源不误删;格数与退出预算检查 | 已实现:`planner.DirectionalExtension`(49点覆盖对定向源成立、保证接收盘内 no_signal 记为观测到定向、对跖+环探梯子、`fallback_clear_cells` 有限圆盘覆盖后备清除带格数/预算双硬顶);`tests/test_loop_q4.py` 全过;官方混合源演练待执行 |
| P6 加速消融 | 共享测点、自适应覆盖、目标排序、提前清除 | 配对案例全清不退化,时间/请求数改善可复算 | 可删减;闭环基线数据已可用于消融对照 |
| P7 正式与论文 | 固定版本、论文表1、日志原名、上传核查 | 问题3/4各3份正式记录与程序版本对应 | 团队后续安排;本任务不执行正式测试 |
按2026年9月10日起的剩余时间,建议10日补齐P1并衔接P3,11日完成P4自建边界案例与全向演练,12日完成P5及混合源演练;未达验收就删减P6并优先修正闭环。演练不限3次,正式机会为问题3和4各3次。团队在版本稳定后自行安排正式记录,留出失败恢复和论文整理时间。附件建议9月13日15:30前完成正式测试,17:30后不能启动新的演练或正式测试;已进入上传队列的日志仍会继续上传。此处引用所给附件安排,运行前核对模拟器界面公告;这份排期不改变当前禁止使用正式测试的限制。
若时间不足,优先删减加速策略,保留可解释定位、覆盖证明、可靠协议和完整日志。开发组织可按几何算法、工程接口、实验论文分工,具体人员由团队安排。
建议后续代码划分为 protocol、geometry、belief、coverage、planner、local_simulator、evaluation 七个模块。官方客户端与自建环境使用同一动作接口;自建真值只对测试端开放,策略端只能看到观测,防止评估时意外使用隐藏答案。
## 8. 当前已落实的结论与下一步
已完成题面与附件核对、49点覆盖推导及扫描成本复算;此前497160个位置与朝向组合的辅助数值校核未发现覆盖遗漏,但采样不代替证明。问题一已通过官方演练三点观测离线验证;该次只有enter、measure、exit,没有清除动作,不能当作全清策略成绩。
本次将 MEC 从全支撑点枚举改为带固定随机种子的 Welzl 增量算法,保留原枚举法作独立对照;对外输出增加直径圆反例顶点、超出距离和 20 米清除余量。CLI 可显式追加 $0.005^\circ$ 舍入裕量。真实演练默认 $\delta=1^\circ$ 时 $D=23.996723$ 米、$\rho=12.068583$ 米;$\delta=1.005^\circ$ 时 $D=24.119088$ 米、$\rho=12.130377$ 米,两种口径下直径圆都不覆盖,但 MEC 半径都小于 20 米。原始演练记录未改写。
可复现报告见 `output/q1/optimization_report.json`,问题一证明、输入输出及数值限制见 `docs/问题一解答.md`。局部MEC基准只反映本地几何计算,不能据此宣称请求延迟、完整策略或官方成绩得到同样提升。
P0–P5(自建部分)与 P2 均已交付:协议层(串行幂等客户端、状态机、预算截止、
故障回放)、几何内核、belief 保守候选集、保证接收盘选点器、`OmnidirectionalLoop`
全向闭环与 `DirectionalExtension` 混合源定向闭环(49 点覆盖对定向源同样成立、
no_signal 联合约束基线不删候选、对跖+环探遮挡梯子、`fallback_clear_cells`
有限圆盘覆盖后备清除,格数与现实预算双硬顶下不盲扫)。自建验收:
`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)有明确改进空间。不要重复开发几何内核或提前堆叠学习算法;以官方演练
的实际请求、日志和耗时决定 P6 加速投入。
+71
View File
@@ -0,0 +1,71 @@
# docs 索引
仓库文档分四类:**总览与流程**、**逐问算法**、**验证与对比**、**论文与插图**。
阅读顺序建议按下面的表格从上到下。
## 总览与流程
| 文档 | 内容 |
| --- | --- |
| [`总体思路与流程图.md`](总体思路与流程图.md) | 四问主线、端到端流程图(mermaid)、执行步骤与执行时间速查、交付物与验收锚点 |
| [`执行时间与执行步骤.md`](执行时间与执行步骤.md) | 计时模型、会话状态机与动作回合、附件示例逐步推演、实测日志分解、时间预算与安全阀 |
| [`B题建模分析与实施计划.md`](B题建模分析与实施计划.md) | 四问建模取舍与 P0–P7 实施计划(最早的设计文档) |
| [`协议规则表与状态机.md`](协议规则表与状态机.md) | 指令字段表、结果码、计时模型、状态机(P0 交付) |
## 逐问算法
| 文档 | 内容 |
| --- | --- |
| [`algo-1.md`](algo-1.md) | 问题一实现口径:半平面交、顶点对直径、直径圆充要判定、MEC 清除证书 |
| [`问题一解答.md`](问题一解答.md) | 问题一论文式解答:模型边界、算法可复现步骤、反例构造、演练验证 |
| [`algo-2.md`](algo-2.md) | 问题二:候选集 K1、保证接收盘、安全盘 B(c, 1000−a)、最坏后验选点 |
| [`algo-3.md`](algo-3.md) | 问题三:三层在线架构、七点解析覆盖、覆盖账本、任务调度与停止条件 |
| [`algo-3-bayes.md`](algo-3-bayes.md) | 问题三 `bayes` 策略:网格幸存集滤波、信息增益选点、动态 TSP、Optuna 调参 |
| [`algo-4.md`](algo-4.md) | 问题四:`forward_half_disk` 最小命中集、位置×朝向联合信念、后备盲清 |
## 优化与验证
| 文档 | 内容 |
| --- | --- |
| [`第三四问架构优化与运行提速方案.md`](第三四问架构优化与运行提速方案.md) | 运行历史复核、分层目标架构、P0–P2 优化项与实施路线 |
| [`第三四问图论与动态规划优化方案.md`](第三四问图论与动态规划优化方案.md) | 图论/DP 引入方案与实测回填(含未采用的改动与原因) |
| [`算法审核与优化-20260911.md`](算法审核与优化-20260911.md) | 一次外部口径的算法审核记录与结论 |
| [`最优与基线实现对比.md`](最优与基线实现对比.md) | 1–4 问「最优实现 / 基线实现」对比框架与结果(论文口径说明) |
| [`B题问题3-4相关论文列表.md`](B题问题3-4相关论文列表.md) | 题 3、4 相关文献清单 |
## 论文与插图
| 路径 | 内容 |
| --- | --- |
| [`paper/main.tex`](paper/main.tex) | 论文正文(`latexmk -xelatex main.tex`);插图经 `\graphicspath` 取自 [`figures/`](figures/README.md) |
| [`paper/README.md`](paper/README.md) | 论文编译方式、目录结构与待办标记 |
| [`figures/README.md`](figures/README.md) | 插图清单、配色与字体约定、数据来源与复现命令 |
| [`figures/manifest.json`](figures/manifest.json) | 每张图的数据来源与 sha256、生成命令 |
| [`../project/README.md`](../project/README.md) | 对比框架目录结构与运行方式;结果表由 `scripts/build_paper_support.py` 转入论文第 11 节 |
## 口径登记
同一事实在多份文档中出现时,以下列来源为准;发现不一致请先改文档,再复核图。
| 事实 | 权威来源 |
| --- | --- |
| 题面常量(Ω、速度、各动作耗时、源与频道范围) | `src/mcm26b/config.py`(带 `[题面]` / `[工程]` 标注) |
| 指令字段与结果码 | `src/mcm26b/protocol/rules.py` + [`协议规则表与状态机.md`](协议规则表与状态机.md) |
| 问题一数值(D、ρ、E) | `mcm26b.geometry.solve` 的返回,或 [`问题一解答.md`](问题一解答.md) |
| 覆盖计划与证书 | `src/mcm26b/guarantee/plans/*.json`(`q4_mincover_v2.json` 为默认) |
| 实测虚拟时间 | 行为日志 `virtual_time_s` 字段与 `evaluation.recompute_virtual_time` |
| 冻结基准集对比结果 | `project/results/*.md`(由 `uv run python -m project.run` 生成);论文表格为 `docs/paper/data/project-*.tex`,由 `scripts/build_paper_support.py` 生成 |
已知的两处需要随环境更新的引用:
1. [`问题一解答.md`](问题一解答.md) 引用的 `output/q1/figures/*.png` 属于早期
离线插图(`output/` 未入库);现行插图在 [`figures/`](figures/README.md)。
2. 多份文档提到的官方演练报告位于 `output/q3/practice/`、`output/q4/`,
同样未入库;需要时用 `scripts/verify_q3_practice.py` 对留档目录复算。
## 写作约定
- 只写结论与依据,不使用比喻、排比与自评式措辞(例如「唯一权威」「关键洞察」)。
- 数字必须能在代码或数据中找到出处;图内不放标题与解释段落,
说明写在正文,图名与清单见 [`figures/README.md`](figures/README.md)。
- 中文引号统一使用「」,避免与代码中的 ASCII 引号混淆。
+225 -144
View File
@@ -1,185 +1,266 @@
# 问题一:有界测向误差下定位区域的直径与圆覆盖
# 问题一算法说明:有界测向误差下的角域交会、直径与最小包围圆
## 摘要
> 本文按**当前仓库实现**重写,替代早期只描述理论方案的版本。
> 问题一生产入口为 `src/mcm26b/geometry/localization.py::solve`,CLI 为
> `python -m mcm26b.geometry`;问题二至四只复用其中的
> `smallest_enclosing_circle` 与同一套角域半平面语义,并不直接调用
> `solve`(复用与差异见 §7)。
针对同一干扰源在多个检测点处的带误差方位观测,建立前向角域交集模型,将定位区域表示为二维线性不等式组的可行域。首先推导角域的半平面表达,给出兼顾空集、点、线段和无界区域的几何构造算法;随后证明,有界定位区域的直径必由一对顶点取得,且判断是否存在直径等于区域直径的覆盖圆,只需检验任意一组最远顶点的中点。通过符合题设测向条件的等边三角形反例,说明直径圆一般不能覆盖定位区域。进一步以最小包围圆连接定位精度和后续清除条件,并给出算法复杂度、数值处理原则及既有演练观测的复算结果。全部结论以有界误差模型为前提,不引入误差独立、正态或均匀分布假设。
## 1. 问题与实现入口
**关键词:** 交会定位;有界误差;半平面交;凸多边形直径;最小包围圆。
同一频道的同一静止干扰源,在若干检测点 $s_i=(s_{ix},s_{iy})$ 处得到示向度
$\theta_i$。题设测向误差不超过 $\pm1^\circ$,同一地点重复测量误差不变;
示向度从正东逆时针计量。问题一要求定位区域的直径,并判断“以该直径为
直径的圆”能否覆盖定位区域。
## 1. 问题界定与思路来源
题目要求由若干检测点坐标及同一干扰源的示向度,求交会定位多边形的直径,并判断以该直径为直径的圆能否覆盖定位区域。题面规定测向误差在 $[-1^\circ,1^\circ]$ 内,同一地点重复测量的误差不变;示向度从正东沿逆时针方向计量。[题面第1页、附录2第(1)、(3)项](C:/Users/HelixCopex/code/MATH/issue/B题.pdf)
由此得到三步建模思路。第一,单次观测不能确定一条精确射线,却可以确定一个包含真实方向的角域,因此采用集合相交而非直线最小二乘。第二,角域可以写成半平面交,问题随之转化为凸几何问题。第三,“两点最远距离”和“覆盖全部位置所需半径”是不同的优化量,应分别计算直径和最小包围圆。
半平面、凸组合和凸集交运算属于标准凸分析工具,其背景见 Boyd 与 Vandenberghe 的《Convex Optimization》第2章;本文针对测向模型给出独立推导,不将一般理论本身视为新的研究贡献。[文献2](https://web.stanford.edu/~boyd/cvxbook/bv_cvxbook.pdf)
本文严格区分两个集合:问题一的 $P$ 仅表示测向角域之交;第二问及在线搜索中的 $K$ 可进一步结合源位置圆域和接收距离先验。向 $P$ 加入圆盘后,边界可能出现圆弧,不能继续把它当成原问题中的纯多边形。
## 2. 符号及基本假设
| 符号 | 含义 |
| --- | --- |
| $s_i=(s_{ix},s_{iy})$ | 第 $i$ 个检测点,坐标单位为米 |
| $\theta_i$ | 第 $i$ 次有效示向度 |
| $\delta_i$ | 误差半角,题设取 $1^\circ$ |
| $g=(x,y)$ | 未知干扰源位置 |
| $W_i$ | 第 $i$ 次测向对应的闭前向角域 |
| $P$ | 所有角域的交集 |
| $V=\{v_1,\ldots,v_m\}$ | 非空有界 $P$ 的顶点集 |
| $D(P)$ | 区域内任意两点距离的上确界 |
| $B(c,r)$ | 中心为 $c$、半径为 $r$ 的闭圆盘 |
| $\rho(P)$ | 覆盖 $P$ 的最小圆盘半径 |
假设各次有效示向度对应同一频道的同一个静止源。仅把 `direction` 观测代入角域模型,不能给 `near` 或 `no_signal` 人为赋予角度。理论推导允许 $0\le\delta_i<90^\circ$,用于讨论退化情况,题目主模型始终采用 $\delta_i=1^\circ$。
程序可额外计入两位小数舍入对应的 $0.005^\circ$ 裕量,但必须显式声明。题设的 $1^\circ$ 误差界与实现中的浮点容差是两个概念,均不能在结果解释时省略。
## 3. 由角域推导半平面约束
定义方向向量 $u(\alpha)=(\cos\alpha,\sin\alpha)$,二维叉积为 $[a,b]=a_xb_y-a_yb_x$。三角函数计算前将角度换成弧度。
令 $u_i^-=u(\theta_i-\delta_i)$、$u_i^+=u(\theta_i+\delta_i)$。当 $0<\delta_i<90^\circ$ 时,真实位置位于两条边界射线之间,等价于 $[u_i^-,g-s_i]\ge0$ 和 $[u_i^+,g-s_i]\le0$。第一条要求 $g-s_i$ 在下边界的左侧,第二条要求其在上边界的右侧。
将第一条展开,得到 $\sin(\theta_i-\delta_i)x-\cos(\theta_i-\delta_i)y\le\sin(\theta_i-\delta_i)s_{ix}-\cos(\theta_i-\delta_i)s_{iy}$。
将第二条展开,得到 $-\sin(\theta_i+\delta_i)x+\cos(\theta_i+\delta_i)y\le-\sin(\theta_i+\delta_i)s_{ix}+\cos(\theta_i+\delta_i)s_{iy}$。
同时保留前向约束 $u(\theta_i)\cdot(g-s_i)\ge0$。该约束在正半角情形下冗余,但当 $\delta_i=0$ 时,前两条只限定一条直线,加入它才得到正确的射线。角域顶点本身也包含在闭集合内,这是保守几何处理,并不表示该点仍能获得示向度。
因此,$P=\bigcap_{i=1}^{n}W_i=\{g:Ag\le b\}$。采用向量不等式后,$359^\circ$ 与 $0^\circ$ 跨界无需分段比较角度。每个 $W_i$ 是闭凸集,故 $P$ 也是闭凸集;它可能为空、为单点、为线段、为有界凸多边形,也可能无界。
## 4. 定位区域的构造与分类
### 4.1 边界交点与可行性
把约束统一写成 $a_jx+b_jy\le c_j$。两条边界的行列式为 $\Delta_{jk}=a_jb_k-a_kb_j$。当 $\Delta_{jk}\ne0$ 时,交点坐标为 $x=(c_jb_k-c_kb_j)/\Delta_{jk}$、$y=(a_jc_k-a_kc_j)/\Delta_{jk}$。保留满足全部约束的交点,再去重并构造凸包。
**命题1:** 至少有一次观测时,非空的角域交集一定存在极点,因此可通过可行边界交点枚举判空。
**证明:** 取第一个角域的轴向量 $u_1$,其中任意 $g$ 满足 $u_1\cdot(g-s_1)\ge\cos\delta_1\,\lVert g-s_1\rVert$,且 $\cos\delta_1>0$。在非空 $P$ 上最小化线性函数 $u_1\cdot(g-s_1)$;以任一可行点的函数值截取下水平集,得到非空紧集,故最小值能够取得。最小化点集位于一条支撑直线上,且有界,所以为点或线段。取该点或线段端点即得到 $P$ 的极点。二维线性约束的极点至少有两条法向量线性无关的有效边界,否则沿所有有效边界的公共切向可作双向小扰动,与极点定义矛盾。因此极点包含在上述交点枚举中。证毕。
无观测时,$P=\mathbb R^2$,应直接判为无界,不能用“没有交点”判断为空集。
### 4.2 无界性判定
对非空 $P$ 定义衰退锥 $\mathcal R=\{d:Ad\le0\}$。若存在 $d\ne0$,则任意 $g_0\in P$ 与 $t\ge0$ 都满足 $g_0+td\in P$,所以 $P$ 无界。
反之,若 $P$ 无界,可取 $g_k\in P$ 使 $\lVert g_k-g_0\rVert\to\infty$。单位向量 $(g_k-g_0)/\lVert g_k-g_0\rVert$ 存在收敛子列,极限 $d$ 满足 $\lVert d\rVert=1$。将 $Ag_k\le b$ 减去 $Ag_0$,再除以距离并取极限,得到 $Ad\le0$。因此 $P$ 有界当且仅当 $\mathcal R=\{0\}$。
本模型的衰退锥被一个小于 $180^\circ$ 的闭角域约束,若包含非零向量,必含一条极射线,该射线平行于某条有效约束边界。故只需检查每个法向量 $(a_j,b_j)$ 的两个切向方向 $(b_j,-a_j)$、$(-b_j,a_j)$ 是否满足所有齐次不等式。
### 4.3 分类约定
可行交点为空时返回空集;存在非零衰退方向时返回无界。否则求可行交点的凸包,按顶点数和仿射维数区分单点、线段和二维多边形。空集的直径在本文约定为未定义;无界集合的直径为 $+\infty$。程序使用空值及单独的无穷标志表示二者,避免写入非标准 JSON 数值。
显示窗口只用于绘图裁剪。任意大矩形都不能取代衰退锥判定,更不能把截断图形的直径称为原无界区域的直径。
## 5. 直径的顶点定理
**定理1:** 若 $P=\operatorname{conv}(V)$ 非空且有界,则 $D(P)=\max_{i,j}\lVert v_i-v_j\rVert$。
**证明:** 任取 $x,y\in P$,可写成 $x=\sum_i\lambda_i v_i$、$y=\sum_j\mu_jv_j$,其中权重非负且分别求和为1。于是 $x-y=\sum_{i,j}\lambda_i\mu_j(v_i-v_j)$。由三角不等式,$\lVert x-y\rVert\le\sum_{i,j}\lambda_i\mu_j\lVert v_i-v_j\rVert\le\max_{i,j}\lVert v_i-v_j\rVert$。最远顶点对又属于 $P$,上界可以达到,结论成立。证毕。
因此,小规模问题中直接枚举所有顶点对即可,计算复杂度为 $O(m^2)$。单点的直径为0,线段的直径为端点距离。这里的直接枚举不是近似算法;其缺点仅在于大规模时计算量较高。
## 6. 直径圆覆盖的充要条件
**定理2:** 取任意一组最远顶点 $a,b$,令 $D=\lVert a-b\rVert$、$o=(a+b)/2$。存在直径为 $D$ 的覆盖圆,当且仅当所有顶点均满足 $\lVert v_i-o\rVert\le D/2$。
**证明:** 若半径为 $D/2$、中心为 $z$ 的圆同时包含 $a,b$,则 $D\le\lVert a-z\rVert+\lVert z-b\rVert\le D$。两个不等式必须同时取等号,所以 $z$ 在两点连线上,且到两点距离相等,只能是中点 $o$。当 $D=0$ 时结论直接成立。另一方面,圆盘是凸集,包含全部顶点等价于包含它们的凸包。因此只需在这个唯一可能的圆心处检查全部顶点。证毕。
该定理不仅回答“以某条直径线段为直径的圆是否覆盖”,也回答“能否移动一个同样大小的圆使其覆盖”。若检查失败,移动圆心或更换最远顶点对均不能解决。
定义最大超出量 $E=\max_i\lVert v_i-o\rVert-D/2$。在精确算术下,$E=0$ 表示覆盖成立,$E>0$ 表示失败;不会出现严格负值,因为最远点对就在圆周上。取达到最大值的顶点,即得到一份可以独立复核的覆盖失败证据。
## 7. 符合测向条件的反例
取误差半角均为 $1^\circ$,三个检测点及读数如下。
| 检测点 | 坐标(米) | 示向度 |
| 层次 | 文件 | 内容 |
| --- | --- | --- |
| $s_1$ | $(-1000,0)$ | $1^\circ$ |
| $s_2$ | $(540,-500\sqrt3)$ | $121^\circ$ |
| $s_3$ | $(520,520\sqrt3)$ | $241^\circ$ |
| 生产内核 | `src/mcm26b/geometry/localization.py` | `Measurement`、`halfplanes`、`convex_hull`、`diameter`、`smallest_enclosing_circle`、`solve` |
| CLI | `src/mcm26b/geometry/__main__.py` | 读 JSON、可加两位小数舍入裕量、输出结果 |
| 统一入口 | `src/mcm26b/cli.py` | `mcm26b q1` |
| 对比框架 | `project/q1/{optimal,baseline,reference,cases,compare}.py` | 生产实现 / 朴素基线 / 独立大框参考解 |
| 回归 | `tests/test_geometry.py`、`tests/test_geometry_certificates.py` | 解析形状、真值保留、随机对拍、证书边界 |
| 结果 | `examples/q1/*.json`、`project/results/q1.md` | 固定解析算例与 40 个随机交会算例的配对结果 |
每个角域的下边界方向分别为 $0^\circ,120^\circ,240^\circ$。对应的三个左半平面是 $y\ge0$、$\sqrt3x+y\le40\sqrt3$、$y\le\sqrt3x$,交集为顶点 $A=(0,0)$、$B=(40,0)$、$C=(20,20\sqrt3)$ 的等边三角形。
```bash
.venv/bin/python -m mcm26b.geometry examples/q1/triangle.json
.venv/bin/python -m mcm26b.geometry examples/q1/normal.json --rounding-margin-deg 0.005
```
还需验证每个角域的另一条边不会切去这个三角形。以 $s_1$ 为例,到三个顶点的方向分别为 $0^\circ,0^\circ,\arctan(20\sqrt3/1020)$,最后一项约为 $1.945^\circ<2^\circ$,均在 $[0^\circ,2^\circ]$ 内。其余两站由绕三角形中心旋转 $120^\circ$ 的对称性成立。因此三个完整前向角域之交恰为上述三角形。
`--rounding-margin-deg` 默认 $0$;库内在线求解默认把服务器两位小数舍入的
$\le0.005^\circ$ 偏差计入有效半角,这是**实现口径**,不是题面新增误差。
问题一 CLI 只有显式传参才附加该裕量,以便同时报告纯 $1^\circ$ 与
$1.005^\circ$ 两套敏感性结果。
将真实源取为三角形中心 $g_*=(20,20\sqrt3/3)$。以第一站为例,真实方向约为 $0.649^\circ$,与 $1^\circ$ 的读数偏差小于 $1^\circ$;检测距离约为1020米,小于1500米。另两站同理。该反例符合题设方位误差和可能的有效接收半径。
## 2. 模型:把角域写成半平面交
此时 $D=40$ 米,而以 $AB$ 为直径的圆中心为 $(20,0)$,到 $C$ 的距离为 $20\sqrt3>20$ 米,不能覆盖。三角形外接圆即最小包围圆,其中心为 $(20,20\sqrt3/3)$,半径为 $40/\sqrt3\approx23.094011$ 米。这也证明了“直径不超过40米”不足以保证一次清除。
令方向向量 $u(\alpha)=(\cos\alpha,\sin\alpha)$,二维叉积
$[a,b]=a_xb_y-a_yb_x$。设误差半角为 $\delta$(题设主模型 $\delta=1^\circ$),
真实源 $g$ 属于闭前向角域
## 8. 最小包围圆及其清除含义
$$
W(s,\theta,\delta)=\{g:\ [u(\theta-\delta),g-s]\ge0,\
[u(\theta+\delta),g-s]\le0,\ u(\theta)\cdot(g-s)\ge0\}.
$$
### 8.1 存在性、唯一性与支撑点
对应实现中的三条半平面 $a x+b y\le c$ 为
定义 $f(z)=\max_{v\in V}\lVert v-z\rVert$,则 $\rho(P)=\min_z f(z)$。由于 $V$ 有限,$f$ 连续且随 $\lVert z\rVert\to\infty$ 而趋于无穷,最小值存在。
| 约束 | $a$ | $b$ | $c$ |
| --- | --- | --- | --- |
| 下边界左侧 | $\sin(\theta-\delta)$ | $-\cos(\theta-\delta)$ | $a s_x+b s_y$ |
| 上边界右侧 | $-\sin(\theta+\delta)$ | $\cos(\theta+\delta)$ | $a s_x+b s_y$ |
| 前向 | $-\cos\theta$ | $-\sin\theta$ | $a s_x+b s_y$ |
若两个不同中心 $c_1,c_2$ 都以半径 $r$ 覆盖 $V$,中点 $c$ 对每个顶点满足 $\lVert v-c\rVert^2=(\lVert v-c_1\rVert^2+\lVert v-c_2\rVert^2)/2-\lVert c_1-c_2\rVert^2/4<r^2$,与最小性矛盾。因此最小包围圆唯一。
第三条前向约束在 $0<\delta<90^\circ$ 时冗余,但在 $\delta=0$ 时必须保留,
否则两条边界只确定一条直线而非一条射线。角域顶点按闭集处理;这是保守的
几何含义,不表示边界点仍能返回方向读数。
在最优中心 $c$ 处,令 $T$ 为圆周上的顶点集。若 $c$ 不在 $T$ 的凸包中,可沿严格分离方向小幅移动中心,使所有圆周点的距离同时减小;其余有限个点有严格余量,仍被稍小圆覆盖,产生矛盾。故 $c\in\operatorname{conv}(T)$。二维凸组合经仿射相关权重消元,最多保留3个点。因此最小包围圆由至多3个圆周顶点支撑。
$P=\bigcap_i W_i$ 是有限条闭半平面之交,因而是闭凸集;可能为空、单点、
线段、有界多边形或无界多边形。实现不做正态、均匀或独立误差假设,
只使用题面给定的有界误差。
更具体地,若支撑点 $p_j$ 的权重满足 $c=\sum_j\lambda_jp_j$,则对任意 $z$ 有 $\sum_j\lambda_j\lVert p_j-z\rVert^2=r^2+\lVert c-z\rVert^2\ge r^2$。这给出了任何覆盖圆半径不能小于 $r$ 的直接证据。
## 3. 精确算法流程
### 8.2 求解方法与来源
`solve(measurements, eps)` 的步骤如下。
由支撑点性质,可以枚举单点零半径圆、两点直径圆和三点外接圆,再检查全体顶点。这是便于核查的基准方法,最坏复杂度为 $O(m^4)$。
### 3.1 坐标归一化与边界交点枚举
当前实现采用 Welzl 的随机增量算法:打乱点序,若当前圆不包含新点,则在该点成为边界点的约束下递归求解较小问题;边界点至多3个。程序用显式栈避免递归深度限制,并固定随机种子以复现结果。理论期望线性依赖随机排列分析,不等于任意固定排列的最坏时间保证。3个强制边界点应使用外接圆,不能替换成其无约束最小圆。算法来源为 Welzl 的原论文,CGAL 文档提供了定义与实现背景。[文献3](https://people.inf.ethz.ch/emo/PublFiles/SmallEnclDisk_LNCS555_91.pdf)、[文献4](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html)
先对所有检测点做平移和统一缩放,使数值量级接近单位坐标;结果再换回原坐标。
### 8.3 与一次清除的关系
1. 生成 $k=3n$ 条半平面。
2. 枚举全部边界直线对($\binom{k}{2}$ 对),用行列式
$\Delta=ae-bd$ 求交点;仅在 $\Delta$ 恰为 $0$ 时跳过,近平行对也保留,
因为其交点可能是细长区域的合法远端顶点。
3. 保留满足全部约束的交点;可行性判定使用随坐标量级缩放的位置容差
`eps * max(1, |x|, |y|)`,避免大坐标下绝对残差被误判为不可行。
所有能够保证一次清除的位置构成 $C_{20}(P)=\bigcap_{g\in P}B(g,20)=\bigcap_{v\in V}B(v,20)$。第二个等号来自圆盘凸性。该集合非空当且仅当 $\rho(P)\le20$。
### 3.2 空集、有界与无界
因此,最小包围圆中心是保证清除的充分选择,但不一定是移动距离最短的选择。若后续需要优化路径,可以在 $C_{20}(P)$ 内寻找距离机器狗最近的位置。相比之下,$D(P)\le40$ 只是必要条件。
- 无观测:$P=\mathbb R^2$,返回 `unbounded`。
- 没有任何可行边界交点:返回 `empty`。
- 否则检查衰退锥 $\mathcal R=\{d:Ad\le0\}$ 是否存在非零方向。二维情况下
该锥若非零必含一条与某条有效边界平行的极射线;实现只需检查每条约束的
两个切向 $(b,-a)$、$(-b,a)$ 是否满足全部齐次不等式
$a d_x+b d_y\le 2\times10^{-14}$。阈值取得比位置容差紧得多,避免把
极细长有界区域误判成无界。
- 存在非零衰退方向时返回 `unbounded`,直径为 `null` 且显式置
`diameter_infinite=true`,不伪造有限数值。
## 9. 算法流程与计算量
### 3.3 凸包与状态分类
**算法1:角域交、直径及覆盖证据。**
将可行交点去重后,用 Andrew 单调链求凸包(逆时针、无重复首尾点)。
按顶点数与维数返回:
1. 校验观测并进行坐标平移、尺度归一化,保留原误差半角。
2. 生成每次观测的三条半平面约束。
3. 枚举非平行边界交点,保留满足全部约束的点;无观测和空集分别处理。
4. 检验非零衰退方向;存在时报告无界并停止有限圆计算。
5. 对可行点去重并求凸包,识别点、线段或多边形。
6. 枚举顶点对求直径及任意一组最远点。
7. 检查其中点到全部顶点的距离,输出最大超出量与失败顶点。
8. 求最小包围圆,换回原坐标,重新检查顶点包含性,输出清除半径余量。
| `status` | 含义 | 直径 | 最小包围圆 |
| --- | --- | --- | --- |
| `empty` | 观测不相容 | 未定义(`null`) | 不计算 |
| `unbounded` | 存在非零衰退方向 | $+\infty$(`null` + 标志) | 不计算 |
| `point` | 单点 | 0 | 半径 0 |
| `segment` | 线段 | 两端点距离 | 半径 = 直径 / 2 |
| `polygon` | 二维有界凸多边形 | 见 §3.4 | 见 §3.6 |
令半平面数 $k=3n$,顶点数为 $m$。边界交点枚举及全约束检查为 $O(k^3)$,衰退方向检查为 $O(k^2)$,交点排序上界为 $O(k^2\log k)$,直径计算为 $O(m^2)$,覆盖证据检查为 $O(m)$。随机增量最小圆在随机排列下期望为 $O(m)$;整个算法仍由半平面交点枚举主导,不能因此称为总体线性算法。
### 3.4 直径:只需枚举顶点对(已证明)
## 10. 数值实现与已有观测复核
**定理 1.** 若 $P=\operatorname{conv}(V)$ 非空有界,则
$D(P)=\max_{v_i,v_j\in V}\lVert v_i-v_j\rVert$。
数学结论使用精确不等式。浮点实现先平移、归一化,再用位置容差、平行判定阈值和衰退方向容差分别处理不同问题。接近平行或退化输入应进行精度与容差敏感性检查;任意追加一个固定小数裕量都不能替代严格的误差传播上界。
证明:任一 $x,y\in P$ 写成凸组合后,
$x-y=\sum_{i,j}\lambda_i\mu_j(v_i-v_j)$,由三角不等式有
$\lVert x-y\rVert\le\max_{i,j}\lVert v_i-v_j\rVert$,而最远顶点对本身
属于 $P$,故上界可达。实现直接枚举顶点对,复杂度 $O(m^2)$;单点返回 0,
线段返回端点距离。
当前程序输出 `diameter_circle` 中的圆心、半径、最大超出量和反例顶点,以及 `minimum_enclosing_circle`。清除判断采用 $\rho+\varepsilon_m\le20$,其中 $\varepsilon_m$ 是工程数值裕量。它是观测及几何计算可靠这一前提下的决策条件,不是一次真实清除成功记录。
### 3.5 直径圆覆盖:唯一候选圆心是直径中点(已证明)
既有官方演练案例 `U5ZW-P9KN-TCAG-R7ST` 的三次不同位置观测如下。
**定理 2.** 取任意一组最远顶点 $a,b$,$D=\lVert a-b\rVert$,
$o=(a+b)/2$。存在直径为 $D$ 的覆盖圆,当且仅当所有顶点 $v_i$ 满足
$\lVert v_i-o\rVert\le D/2$。
| 检测点 | $x$ / 米 | $y$ / 米 | 示向度 / 度 |
| --- | ---: | ---: | ---: |
| 1 | 0 | 0 | 131.52 |
| 2 | -613.7618853216 | 391.5307754525 | 34.92 |
| 3 | -314.2721344680 | 656.6833525352 | 227.19 |
证明:若半径 $D/2$ 的圆心 $z$ 同时包含 $a,b$,则
$D\le\lVert a-z\rVert+\lVert z-b\rVert\le D$,两个不等式都必须取等号,
故 $z$ 只能在两点中点。圆盘是凸集,检查全部顶点即检查整个凸包。
| 误差半角 | 直径 / 米 | 最小包围半径 / 米 | 直径圆最大超出 / 米 |
实现输出 `diameter_circle`:圆心、半径、全部顶点到圆心的最大距离、
`covers` 布尔值、最大超出量 `max_excess_m`、圆外顶点索引
`outside_vertex_indices` 和反例顶点 `witness_vertex`。`witness_vertex`
是一份可独立复核的覆盖失败证据;`max_excess_m` 不能为负,因为最远顶点
本身在圆周上(精确算术下)。
### 3.6 最小包围圆与 20 m 清除证书
对凸包求最小包围圆 $\rho(P)$。实现采用 Welzl 随机增量算法的显式栈版本:
固定随机种子使结果可复现;三个强制边界点使用外接圆;输出前再对全部
顶点复核一次。内部有 $O(m^4)$ 的 1/2/3 点支撑圆枚举参考实现
(`_enclosing_circle_reference`)用于单测对拍。
在最小包围圆中心 $m$、半径 $\rho$ 处,若
$\rho+\varepsilon_m\le20$,则可直接以 $m$ 为清除点:任意真源
$g\in P\subseteq B(m,\rho)$,到 $m$ 的距离不超过 $\rho$。实现输出
`minimum_enclosing_circle`、`guaranteed_clearable_at_mec_center` 以及
`clearance_certificate`(含数值余量和剩余裕量)。
注意:直径 $D\le40$ 只是“可能一次清除”的必要条件,**不是充分条件**;
三角形反例中 $D=40$ 而 $\rho=23.094>20$。
## 4. 伪代码
```text
solve(measurements):
if measurements 为空: return unbounded
平移/缩放,生成每条读数的三条半平面
V = []
for 每一对边界直线:
求交点 p
if p 满足全部半平面: V.append(p)
if V 为空: return empty
if 存在非零衰退切向 d: return unbounded
H = convex_hull(V)
D, (a,b) = argmax_{v_i,v_j∈H} |v_i-v_j|
o = (a+b)/2
covers = max_{v∈H} |v-o| <= D/2
输出 diameter、diameter_circle、witness
(m,rho) = Welzl_MEC(H)
输出 minimum_enclosing_circle 与 rho+eps<=20 清除证书
```
## 5. 复杂度与数值实现
| 步骤 | 复杂度 |
| --- | --- |
| 生成半平面 | $O(k)$,$k=3n$ |
| 边界交点枚举与可行性判定 | $O(k^3)$ |
| 凸包 | $O(k^2\log k)$ 最坏 |
| 直径 | $O(m^2)$ |
| 直径圆检查 | $O(m)$ |
| Welzl 最小圆 | 随机排列下期望 $O(m)$;固定排列不声称最坏线性 |
主要数值约定:
- 坐标归一化后计算,输出换回原坐标并重新复核包含性;
- 仅对**精确平行**直线对跳过求交;近平行对保留;
- 位置可行性容差随坐标幅值缩放;衰退锥判定使用独立紧阈值;
- `solve` 主动限制最多 100 个检测点,超过时要求改用半平面交算法;
- 所有输出用 `null`/显式标志表示空集和无界,不写非法 JSON 浮点数。
## 6. 已验证算例与结果
固定解析算例(`examples/q1`,当前代码实测):
| 算例 | 状态 | 直径 (m) | MEC 半径 (m) | 直径圆覆盖 | 最大超出 (m) | MEC 可直接清除 |
| --- | --- | ---: | ---: | --- | ---: | --- |
| `normal` 三点交会 | polygon | 26.986 | 13.943 | 否(2 个圆外顶点) | 3.964 | 是 |
| `triangle` 等边三角形反例 | polygon | 40.000 | 23.094 | 否(1 个圆外顶点) | 14.641 | 否 |
| `segment` 零误差退化线段 | segment | 100.000 | 50.000 | 是 | 0.000 | 否 |
| `poor` 近共线检测点 | unbounded | — | — | — | — | — |
| `empty` 不相容观测 | empty | — | — | — | — | — |
`triangle` 的三个顶点是 $(0,0)$、$(40,0)$、$(20,20\sqrt3)$:以直径
$AB$ 为直径的圆圆心 $(20,0)$、半径 20,到 $C$ 的距离为 $20\sqrt3>20$,
故不能覆盖;最小包围圆半径 $40/\sqrt3\approx23.094$,也不能一次清除。
其中 $A=(0,0)$ 坐标是浮点下的 $-6.4\times10^{-13}$,上表按解析值取整。
既有官方演练观测(案例 `U5ZW-P9KN-TCAG-R7ST`,不公开源坐标、未执行清除)
的复算结果:
| 误差半角 | 直径 (m) | MEC 半径 (m) | 直径圆最大超出 (m) |
| --- | ---: | ---: | ---: |
| $1^\circ$ | 23.996723 | 12.068583 | 0.604002 |
| $1.005^\circ$ | 24.119088 | 12.130377 | 0.608486 |
两种口径下直径圆均不覆盖区域,最小包围半径均小于20米。该演练未公开源坐标,且没有执行清除,故只用于观测交会和计算一致性复核,不能据此声称实测定位误差或全清成功率。数据见[原始三点观测](C:/Users/HelixCopex/code/MATH/output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json),敏感性数值见[已有复算报告](C:/Users/HelixCopex/code/MATH/output/q1/optimization_report.json)。
两种口径下直径圆都不覆盖定位区域,MEC 半径都小于 20 m,但该演练没有
真实清除动作,不能据此声称实测定位误差或全清成功率。
## 11. 结论与实现对应
`project/q1` 的 40 个随机交会算例(冻结算例与独立参考解对拍):
问题一的准确答案为:将测向角域表示为半平面交,先判断非空性及有界性,再由最远顶点对得到直径;以该直径为直径的圆一般不能覆盖定位区域,覆盖的充要条件是任意一组最远顶点的中点到全部顶点的距离均不超过半径。最小包围圆提供独立且适合后续清除的精度指标。
| 方法 | 状态准确率 | 直径平均绝对误差 | 直径圆覆盖准确率 | 平均耗时 |
| --- | ---: | ---: | ---: | ---: |
| 生产 `solve` | 100% | 0.000 m | 100% | 0.086 ms |
| 朴素基线(网格 + 包围盒对角线) | 100% | 4.583 m | 52.5% | 15.932 ms |
对应实现为 [localization.py](C:/Users/HelixCopex/code/MATH/geometry/localization.py)。在项目根目录可运行 `python -m geometry examples/q1/triangle.json`;追加 `--rounding-margin-deg 0.005` 可复算额外舍入裕量。本文整理理论与已有计算结果,不改变原始观测或既有程序。
## 7. 与问题二/三/四当前实现的复用关系和差异(核对结果)
问题一给出的几何内核是后续所有几何决策的基础,但**复用方式是“语义 + 最小圆函数”,
不是直接调用 `solve`**。代码层面的核对结果如下。
| 后续问题 | 实际复用了什么 | 实际没有复用 / 做了什么替换 |
| --- | --- | --- |
| 问题二(`planner.select_second_point`) | 同一套角域半平面语义;`smallest_enclosing_circle` 求候选集 MEC;用 MEC 半径构造保证接收盘 | 不调用 `solve`;候选集不是纯角域交,而是 $K_1=\Omega\cap W\cap B(s,1500)$;用 192 边形外包圆盘 + Sutherland–Hodgman 裁剪,而不是枚举直线交点;不计算直径,也不使用“直径中点圆”判据 |
| 问题三(`belief.CandidateRegion`、`planner.bayes_loop`) | 复用 `smallest_enclosing_circle`;角域边界公式与问题一相同;MEC 作为清除证书 | 在线信念是有界凸外包多边形,圆盘边界是外切多边形而非精确曲线;不再判无界;no_signal 的负观测由网格粒子滤波处理(问题一模型里没有负观测);不用直径,而用“MEC 半径 + 外包余量 + 工程余量 $\le20$” |
| 问题四(`planner.DirectionalExtension`、`belief.joint`) | 继续复用问题一的 MEC 与角域语义;(位置 × 朝向)联合信念也建立在同一网格上 | 定向源的 no_signal 不能排除位置,不能沿用问题一/三的纯角度或全向距离解释;覆盖语义改为 `forward_half_disk`(见 `algo-4.md`) |
代码证据:
- `src/mcm26b/belief/__init__.py` 只从 `geometry.localization` 导入
`smallest_enclosing_circle`;
- `src/mcm26b/particles.py` 同样只导入 `smallest_enclosing_circle`;
- `生产/运行代码中 `geometry.localization.solve` 的调用者只有
`geometry/__main__.py` 和 `project/q1/optimal.py`,即问题一 CLI 与其
对比框架;测试文件会直接调用它做解析与随机对拍,不在此列;
- 在线 `clip_wedge` 的半平面系数与问题一 `halfplanes` 一致,但作用在
凸多边形裁剪流程上;`solve` 的“纯角域 + 精确顶点枚举 + 无界判定”
是针对问题一的一站式分析工具,不适合直接嵌入带圆盘、负观测和状态更新
的在线闭环。
**差异一句话总结:** 问题一回答“固定角域观测下源可能在哪里、直径圆能否覆盖”,
是纯几何精确解;问题二至四把其中的角域半平面和最小圆升级为“带距离先验的
保守信念 + 主动动作 + 证据证明”的闭环,问题三再加非凸负观测,问题四再加
朝向维度。
## 8. 结论与适用边界
问题一的当前实现是:三条半平面 → 可行顶点枚举/凸包 → 衰退锥判有界 →
顶点对精确直径 → 任意直径中点唯一候选的覆盖检验 → Welzl 最小包围圆与
20 m 证书。其结论只依赖题面有界误差,不引入误差分布假设。空集、无界、
点、线段和多边形状态都有明确输出。
未解决问题一的数值敏感性时,不应把在线闭环里的外包多边形半径直接当成
问题一的精确直径;两者口径不同。反过来,在线闭环也不能把 `solve` 直接套在
$K_1$ 上,因为 $K_1$ 含圆盘和距离上界,边界可能含圆弧。
## 参考文献
1. 全国大学生数学建模竞赛组委会. 2026年高教社杯全国大学生数学建模竞赛B题:无线电干扰源的快速自动定位与清除[Z]. 2026. [本地题面](C:/Users/HelixCopex/code/MATH/issue/B题.pdf),第1页、附录2。
2. BOYD S, VANDENBERGHE L. Convex Optimization[M]. Cambridge: Cambridge University Press, 2004. [作者公开版本](https://web.stanford.edu/~boyd/cvxbook/bv_cvxbook.pdf),第2章。用于凸几何背景,本文的题目化证明在正文给出。
3. WELZL E. Smallest enclosing disks (balls and ellipsoids)[C]//MAURER H. New Results and New Trends in Computer Science. Lecture Notes in Computer Science, vol. 555. Berlin: Springer, 1991: 359–370. [作者公开论文](https://people.inf.ethz.ch/emo/PublFiles/SmallEnclDisk_LNCS555_91.pdf)。用于随机增量最小包围圆算法来源。
4. CGAL Project. CGAL::Min_circle_2: Bounding Volumes[EB/OL]. [在线文档](https://doc.cgal.org/latest/Bounding_volumes/classCGAL_1_1Min__circle__2.html),访问日期:2026-09-10。用于最小圆及支撑点定义的交叉核对。
1. 全国大学生数学建模竞赛组委会. 2026 年高教社杯全国大学生数学建模竞赛 B 题:无线电干扰源的快速自动定位与清除[Z]. 2026. 本地题面 `issue/B题.pdf`。
2. BOYD S, VANDENBERGHE L. Convex Optimization[M]. Cambridge University Press, 2004. 凸集、半平面与凸组合背景。
3. WELZL E. Smallest enclosing disks (balls and ellipsoids)[C]//New Results and New Trends in Computer Science, LNCS 555, 1991: 359–370. 随机增量最小包围圆。
4. 本项目. `docs/algo-2.md`(问题二当前实现). 问题二对本文半平面语义与最小圆的复用说明。
5. 本项目. `docs/algo-3.md`、`docs/algo-4.md`. 在线闭环中的保守信念、负观测与联合信念差异。
+238 -174
View File
@@ -1,248 +1,312 @@
# 问题二:具有接收保证的第二检测点选择
# 问题二算法说明:保证接收的第二检测点选择(当前实现)
## 摘要
> 本文按**当前仓库实际运行路径**重写。生产入口是
> `src/mcm26b/planner.select_second_point`,其核心选点器是
> `select_measurement_point`;`src/mcm26b/geometry/planning.py` 中的
> `first_bearing_candidate_disk` 是解析充分证书与独立校核工具,
> 并不在运行时被 `select_second_point` 调用(见 §6 核对结果)。
针对只获得一次全向干扰源示向度的情形,提出以接收保证为约束、以后验最小包围半径为主要指标的主动选点方法。首先将源位置圆域、首次角域与有效接收距离上界相交,构造包含真实源的位置候选集;随后把第二检测点的安全条件写成一族闭圆盘的交,并证明其非空性等价于候选集最小包围半径不超过最小有效接收半径。利用首次角域狭窄的题设特征,进一步推导截断角域的最小包围圆,得到半径约250米、能够直接计算的安全候选圆盘。为比较候选点的定位效果,建立兼顾 `direction` 和 `near` 的保守后验模型,推导交会角的局部几何意义,并给出采用外包多边形及观测区间划分的有限候选评估算法。严格接收保证与启发式性能优化分别陈述,不将有限采样结果解释为连续问题的全局最优解。
## 1. 问题、实现入口与交付物
**关键词:** 主动测向;鲁棒选点;安全候选区域;最坏后验半径;保守几何近似。
问题二给定一个全向干扰源的首次有效示向度 $(s,\theta)$,要求给出第二个
检测点 $q$ 的选择策略、定位效果与候选区域。两个约束必须同时满足:
## 1. 问题界定与思路来源
1. **接收保证**:$q$ 到所有仍可能为真源的位置都不超过有效接收半径下界
$R_-=1000$ m,否则第二次测量可能返回 `no_signal`,几何收益落空;
2. **定位效果**:在保证接收的候选点中,使第二次测量后的保守位置集合
尽量小。当前实现以“最坏后验最小包围半径”为主指标,以移动耗时为次指标。
题目第二问给定某全向干扰源在一个检测点处的示向度,要求提出第二检测点的选择策略、说明其定位效果并给出候选区域。这里的源类型明确为全向,不能混入第四问的朝向可见性条件。[题面第1页问题2](C:/Users/HelixCopex/code/MATH/issue/B题.pdf)
| 层次 | 文件 | 内容 |
| --- | --- | --- |
| 在线入口 | `src/mcm26b/planner/__init__.py::select_second_point` | 由首次 `direction` 构造 $K_1$,返回 `SecondPointPlan` |
| 核心选点 | 同文件 `select_measurement_point` | 任意候选集的保证接收盘内选点 |
| 几何证书 | `src/mcm26b/geometry/planning.py::first_bearing_candidate_disk` | 截断角域解析外包圆与安全候选盘 |
| 信念构造 | `src/mcm26b/belief/__init__.py::BeliefState`、`CandidateRegion` | $K_1$ 的保守凸外包与更新 |
| 对比框架 | `project/q2/{optimal,baseline,reference,region,cases,compare}.py` | 生产选点器 / 两种朴素基线 / 保证盘内网格参考 |
| 回归 | `tests/test_planner.py`、`tests/test_geometry_certificates.py`、`tests/test_belief.py` | 接收保证、后验收缩、near 分支、解析证书 |
| 结果 | `project/results/q2.{md,json}` | 30 个随机首次示向度算例配对结果 |
本问有两个先后不同的问题:第二点是否仍能收到信号;在能收到信号的点中,如何使新测量更有价值。如果只追求较大交会角而不控制到真实源的距离,第二次测量可能返回 `no_signal`,预期几何收益无法实现。因此先构造保证接收的可行域,再优化测向后的不确定区域。
## 2. 首次观测后的候选集 $K_1$
方法来源分为三部分。候选位置集合来自题设有界误差与接收距离,沿用问题一的角域约束;安全选点来自“对每个可能源位置都满足距离条件”的鲁棒可行性;定位收益来自主动测量设计,以最坏后验半径代替未经题面支持的平均误差。凸函数与鲁棒建模的一般背景参见 Boyd 与 Vandenberghe 的《Convex Optimization》第3章、第6.4节;本题的安全圆盘公式、扇形推导及区间评估方法在下文逐步建立。[文献3](https://web.stanford.edu/~boyd/cvxbook/bv_cvxbook.pdf)
### 2.1 精确定义
需要强调:本题没有给出源位置概率分布,也没有给出可直接使用的独立高斯测向噪声模型。因此不能把卡尔曼滤波、协方差或费舍尔信息计算直接作为严格保证。交会角仍有明确几何意义,但应作为局部解释和候选筛选依据。
记首次检测点为 $s$、首次示向度为 $\theta$、误差半角
$\delta_{\rm eff}=1^\circ+0.005^\circ$(题设误差 + 两位小数舍入裕量),
有效接收半径上界 $R_+=1500$ m,源位置圆域 $\Omega=B(0,1800)$。则
## 2. 符号、题设事实与建模约定
$$
K_1=\Omega\cap W(s,\theta,\delta_{\rm eff})\cap B(s,R_+).
$$
| 符号 | 含义 |
| --- | --- |
| $s$、$\theta$ | 首次检测位置与示向度 |
| $\delta$ | 误差半角,默认 $1^\circ$ |
| $g$ | 未知的全向干扰源位置 |
| $\Omega=B(0,1800)$ | 干扰源位置圆域 |
| $R_g\in[R_-,R_+]$ | 未知有效接收半径,$R_-=1000$、$R_+=1500$ 米 |
| $K_1$ | 首次观测后的保守位置候选集 |
| $q$ | 第二检测点 |
| $\mathcal Q(K_1)$ | 对 $K_1$ 内所有位置均保证接收的第二点区域 |
| $\rho(K)$ | 非空有界集合 $K$ 的最小包围半径 |
| $J(q)$ | 第二点的最坏保守后验半径 |
真实源在合法观测下必属于 $K_1$。注意 $B(s,R_+)$ 的半径是**接收半径上界**:
`direction` 读数本身就隐含测得距离不超过 $R_+$;而 $B(s,R_-)$ 不在 $K_1$
之中,因为第二次是否收到信号正是本问要保证的事。
采用以下题设事实:有效接收半径在1000至1500米之间;检测点到全向源距离不超过5米时无法得到示向度,可直接光学定位并清除;清除距离不超过20米;机器狗移动速度为5米/秒,检测需要5秒,同一频道连续测量不发生额外频道切换。[题面附录2第(2)、(4)至(9)项](C:/Users/HelixCopex/code/MATH/issue/B题.pdf)
### 2.2 实际保守表示
1800米圆域限制的是源位置,不是机器狗的搜索位置。检测点可以位于该圆域外;接口坐标分量的绝对值上限属于数值防护限制,不能误解成要求 $q\in\Omega$。[题面第4页](C:/Users/HelixCopex/code/MATH/issue/B题.pdf)
在线实现不直接处理圆弧边界,而用以下外切多边形表示 $K_1$:
如果把读数舍入额外计入误差预算,可显式采用 $\delta=1.005^\circ$。下文解析推导对 $0\le\delta\le45^\circ$ 成立,但这一扩展区间不是题目误差范围。
- 先取 $\Omega$ 的 192 边外切正多边形 $P_M(0,1800)$;
- 再与 $B(s,1500)$ 的 192 边外切正多边形求交;
- 最后用 Sutherland–Hodgman 逐半平面裁剪两条测向边界。
## 3. 首次观测对应的源位置集合
设圆盘外切多边形相对圆盘的最大径向超出为 $R(1-\cos(\pi/M))$。实现把每次
圆盘裁剪的该量累加到 `CandidateRegion.slack_m`,因此维护的多边形满足
$K_1\subseteq P_{\rm out}$,且 `slack_m` 是可审计的外包半径余量。
设 $u=u(\theta)=(\cos\theta,\sin\theta)$。由第一次有效示向度,真实位置属于前向角域 $W(s,\theta,\delta)$;由已经收到信号,必有 $\lVert g-s\rVert\le R_g\le R_+$;由源位置限制,又有 $g\in\Omega$。故定义 $K_1=\Omega\cap W(s,\theta,\delta)\cap B(s,R_+)$。
这条路径与问题一的区别:问题一在纯角域上枚举边界直线交点,精确处理
空集/无界;问题二候选集一开始就被 $\Omega$ 和 $B(s,R_+)$ 限成有界集,
因此可以只用凸多边形裁剪。角域系数与问题一 `halfplanes` 完全一致:
$\sin(\theta-\delta)x-\cos(\theta-\delta)y\le c$ 与
$-\sin(\theta+\delta)x+\cos(\theta+\delta)y\le c$。
该集合为闭凸紧集。真实观测一致时 $g\in K_1$,所以它非空。首次返回 `direction` 还意味着 $\lVert g-s\rVert>5$,但若把这个开圆盘排除,会破坏凸性。本文暂不使用该排除信息,保留包含真源的凸外包集合;由此得到的保证可能更保守,不会误删真实位置。
## 3. 保证接收区域
这一区别决定了后续结论的口径:本文给出的保证对较大的 $K_1$ 中所有位置成立,因而对实际源成立;但在 $K_1$ 上定义的最坏后验指标可能比利用全部观测细节的精确模型更悲观。
### 3.1 一般集合形式
若计算得到 $K_1=\varnothing$,说明输入、频道、误差界或数值处理与题设不一致。此时应返回不一致状态,不能继续给空集计算圆心。问题一的纯角域求解器也不能直接把上述圆弧边界当作直线边界处理。
对非空紧候选集 $K$,定义
## 4. 安全候选区域的精确定义及基本性质
$$
F_K(q)=\max_{g\in K}\lVert q-g\rVert,\qquad
\mathcal Q(K)=\{q:F_K(q)\le R_-\}=\bigcap_{g\in K}B(g,R_-).
$$
### 4.1 接收距离条件
$F_K$ 是凸函数且 1-Lipschitz,$\mathcal Q(K)$ 是闭凸集。
若第二检测点满足 $\lVert q-g\rVert\le R_-$,由于 $R_g\ge R_-$,即可保证全向源信号能够接收。因此定义 $F_K(q)=\max_{g\in K}\lVert q-g\rVert$,并令 $\mathcal Q(K)=\{q:F_K(q)\le R_-\}=\bigcap_{g\in K}B(g,R_-)$。
**定理 1.** $\mathcal Q(K)\ne\varnothing$ 当且仅当
$\rho(K)=\min_q F_K(q)\le R_-$,其中 $\rho(K)$ 是 $K$ 的最小包围半径。
这里限制的是第二点到所有可能源位置的距离,而不是到第一次检测点的距离。保证接收也不等于保证返回示向度,因为距离不超过5米时可能返回 `near`;该分支的定位效果更好,不能视为策略失败。
证明:$\min_q F_K(q)$ 就是最小包围半径;存在 $q$ 使 $F_K(q)\le R_-$ 与
其最小值不超过 $R_-$ 等价。证毕。
### 4.2 凸性、稳定性和非空条件
### 3.2 多边形外包给出的充分安全盘
**命题1:** 对任意非空紧集 $K$,函数 $F_K$ 为凸函数,且满足 $\lvert F_K(q_1)-F_K(q_2)\rvert\le\lVert q_1-q_2\rVert$。
设在线维护的多边形 $P_{\rm out}\supseteq K$,其最小包围圆为
$B(m,\rho_{\rm out})$。任取
**证明:** 对每个固定的 $g$,距离函数关于 $q$ 凸;逐点取最大值保持凸性。又由三角不等式,$\lVert q_1-g\rVert\le\lVert q_2-g\rVert+\lVert q_1-q_2\rVert$,对 $g$ 取最大值得到一个方向的不等式,再交换两点即得结论。证毕。
$$
q\in B(m,\ R_- - \rho_{\rm out}-\eta),\qquad \eta=10^{-3}\ {\rm m},
$$
因此 $\mathcal Q(K)$ 是闭凸集。非空时,它包含在任一 $B(g_0,R_-)$ 内,所以也有界。
则对任意真源 $g\in K$:
**定理1:** $\mathcal Q(K)\ne\varnothing$ 当且仅当 $\rho(K)\le R_-$。
$$
\lVert q-g\rVert\le\lVert q-m\rVert+\lVert m-g\rVert
\le (R_--\rho_{\rm out}-\eta)+\rho_{\rm out}
=R_--\eta<1000.
$$
**证明:** 最小包围半径的定义为 $\rho(K)=\min_q F_K(q)$。存在 $q$ 使 $F_K(q)\le R_-$,与其最小值不超过 $R_-$ 完全等价。最小值存在,因为 $F_K$ 连续且随 $\lVert q\rVert\to\infty$ 趋于无穷。证毕。
实现中 `safe_disk_radius(region)` 直接返回
$R_--\rho_{\rm out}-10^{-3}$;因为 $\rho_{\rm out}$ 已是包含真实候选集
的多边形最小包围半径,这个盘内的点到真实源的三角不等式上界严格小于
1000 m。1 mm 的数值余量避免浮点贴边被 $r_{\rm eff}=1000$ 的源判成
`no_signal`。若该半径 $<0$,说明当前凸外包的 MEC 半径已超过 1000 m:
当 $\rho(K)=R_-$ 时,安全区域仅包含唯一最小包围圆中心;唯一性可由两个候选中心的中点距离恒等式证明,见[问题一第8节](C:/Users/HelixCopex/code/MATH/docs/algo-1.md)。因此应使用不严格不等式,不能把等号情形误判为空。
- 这**不能**证明真实 $\mathcal Q(K)$ 为空,只说明这个充分证书不够紧;
- 实现退化为探索 MEC 圆心,并在 `reason` 中明确标注“可能 `no_signal`”。
### 4.3 外包圆给出的充分区域
### 3.3 首次截断角域的解析候选盘
若已知 $K\subseteq B(c,r)$ 且 $r\le R_-$,则任意满足 $\lVert q-c\rVert\le R_--r$ 的点,都有 $\lVert q-g\rVert\le\lVert q-c\rVert+\lVert c-g\rVert\le R_-$。因此 $B(c,R_--r)\subseteq\mathcal Q(K)$。
完全去掉 $\Omega$ 后,完整的截断角域
$S=W(s,\theta,\delta)\cap B(s,R_+)$ 有一个精确最小包围圆:
这给出了容易计算的充分候选区域,但一般不等于整个安全区域。一个宽松外包圆若满足 $r>R_-$,只能说明该外包圆不能提供证书,不能证明真实的 $\mathcal Q(K)$ 为空。
$$
c=s+a\,u(\theta),\qquad a=\frac{R_+}{2\cos\delta},\qquad
S\subseteq B(c,a),
$$
## 5. 首次截断角域的解析最小包围圆
对 $0\le\delta\le45^\circ$ 成立。简证:设
$g=s+t\,u(\theta+\varphi)$,$0\le t\le R_+$,
$|\varphi|\le\delta$,则
$\lVert g-c\rVert^2=t^2+a^2-2at\cos\varphi$。固定 $\varphi$ 时该式
关于 $t$ 是凸二次函数,最大值在 $t=0$ 或 $t=R_+$ 处:前者为 $a^2$,
后者不超过 $R_+^2+a^2-2aR_+\cos\delta=a^2$(代入
$a=R_+/(2\cos\delta)$),故 $S\subseteq B(c,a)$。最小性由三点
$s$、$s+R_+u(\theta\pm\delta)$ 的加权距离恒等式给出:权重
$\lambda_\pm=1/(4\cos^2\delta)$、
$\lambda_0=1-1/(2\cos^2\delta)$ 非负且和为 1,且
$c=\lambda_0s+\lambda_+p_++\lambda_-p_-$,故任何覆盖这三点的圆半径
至少为 $a$。于是
### 5.1 坐标变换
$$
\mathcal Q_0=B(c,\ R_- - a)
$$
暂时去掉只会缩小集合的 $\Omega$ 约束,考虑完整截断角域 $S=W(s,\theta,\delta)\cap B(s,R_+)$。平移并旋转,使 $s$ 成为原点、$u$ 成为横轴。其点可写成 $g=s+t\,u(\theta+\varphi)$,其中 $0\le t\le R_+$、$-\delta\le\varphi\le\delta$。
是保证接收的充分候选区域。题设 $\delta_{\rm eff}\le1.005^\circ$ 时:
令 $a=R_+/(2\cos\delta)$,并取 $c=s+au$。
### 5.2 包含性证明
**定理2:** 对 $0\le\delta\le45^\circ$,截断角域 $S$ 的最小包围圆为 $B(c,a)$。
**证明的第一部分:** 对任意 $g\in S$,有 $\lVert g-c\rVert^2=t^2+a^2-2at\cos\varphi$。固定 $\varphi$ 时,右侧关于 $t$ 为凸二次函数,区间最大值在端点取得。当 $t=0$ 时等于 $a^2$;当 $t=R_+$ 时,由 $\cos\varphi\ge\cos\delta$ 得其不超过 $R_+^2+a^2-2aR_+\cos\delta=a^2$。故整个 $S$ 被该圆覆盖。
### 5.3 最小性证明
取角域中的三个点 $p_0=s$、$p_+=s+R_+u(\theta+\delta)$、$p_-=s+R_+u(\theta-\delta)$。它们均在 $B(c,a)$ 的圆周上。
令 $\lambda_+=\lambda_-=1/(4\cos^2\delta)$,$\lambda_0=1-1/(2\cos^2\delta)$。由于 $\delta\le45^\circ$,三权重非负且和为1,直接代入可得 $c=\lambda_0p_0+\lambda_+p_++\lambda_-p_-$。
对任意中心 $z$,利用加权距离恒等式,有 $\sum_j\lambda_j\lVert p_j-z\rVert^2=a^2+\lVert c-z\rVert^2\ge a^2$。若半径为 $r$ 的圆覆盖这三个点,则左侧不超过 $r^2$,故 $r\ge a$。任何覆盖 $S$ 的圆都必须覆盖这三个点,结合第一部分的包含性,得到最小半径恰为 $a$。当 $\delta=0$ 时两个远端点重合,结论退化为长度 $R_+$ 的线段直径圆,仍成立。证毕。
该推导不依赖源位置先验,也不依赖位置概率分布。它给出的是完整截断角域的精确最小圆;与 $\Omega$ 相交后,真实 $K_1$ 的最小圆可能更小。
## 6. 本题可直接采用的第二点候选区域
由 $K_1\subseteq S\subseteq B(c,a)$ 和第4.3节,可取 $\mathcal Q_0=B(c,b)$,其中 $c=s+au$、$a=1500/(2\cos\delta)$、$b=1000-a$。
| 误差半角 | 轴向中心距离 $a$ / 米 | 安全候选半径 $b$ / 米 |
| 误差半角 | 轴向中心距离 $a$ / m | 安全候选半径 $R_- - a$ / m |
| --- | ---: | ---: |
| $1^\circ$ | 750.114246 | 249.885754 |
| $1.005^\circ$ | 750.115391 | 249.884609 |
两种口径下 $b>0$,故本题首次有效测向后的安全候选区域可以直接证明非空。无需将“若安全区域为空就去最小圆圆心”作为正常流程的保底步骤。
该解析盘由 `first_bearing_candidate_disk` 实现并测试。它给出的是
$S$ 的最小圆;由于 $K_1\subseteq S$,$B(c,R_- - a)\subseteq\mathcal Q(K_1)$
是严格充分的。实际在线路径使用比 $S$ 更紧的 $K_1$ 凸外包,因此其安全盘
通常大于该解析下界(例:`q2-4200` 的 $\rho_{\rm out}=674.59$ m,
安全盘半径 325.41 m,而解析下界约 249.89 m)。
几何上,$\mathcal Q_0$ 位于首测示向方向前方约750米,以约250米为半径。位于圆盘轴向前后端的点、两侧横向点以及圆心都保证接收。横向点只是可供比较的候选点,并未被证明总是最优。
## 4. 实际选点算法
为抵消坐标计算和动作提交中的工程舍入,可取缩小区域 $\mathcal Q_\eta=B(c,b-\eta)$,其中 $0<\eta<b$。若圆心和提交位置合计偏差不超过 $\eta$,三角不等式保证仍有 $\lVert q-g\rVert\le1000$。例如先取 $\eta=0.01$ 米属于可调工程选择,不是题面要求,也不是未分析数值误差的自动上界。
### 4.1 入口伪代码
若有更精细的 $K_1$ 外包圆,可得到更大的充分区域;若精确处理其圆弧边界,也可直接搜索 $\mathcal Q(K_1)$。这些扩展是性能优化,不影响 $\mathcal Q_0$ 的正确性。
```text
select_second_point(client, s, theta):
region = CandidateRegion.initial(s) # Ω ∩ B(s,1500) 的外包
region.clip_wedge(s, theta, 1.005°) # 交入首次角域
return select_measurement_point(region, client.position)
## 7. “较好的定位效果”的数学定义
select_measurement_point(region, from_position):
(m, rho) = region.mec() # Welzl 最小包围圆
safe = 1000 - rho - 1e-3
if safe < 0: return MEC 圆心(探索,可能 no_signal)
候选 = {m} ∪ {m + f*safe*(cos ψ, sin ψ)}
f ∈ {0.4, 0.75, 1.0}, ψ 取 12 个均匀方向
对每个候选 q:
最坏后验 = max( 方向分支、near 分支 )
分数 = (最坏后验, 移动时间)
取分数最小的 q
用 1/4 间距加密复核 q 的最坏后验
return q、最坏后验半径、移动耗时、理由
```
### 7.1 保守后验集合
当前 `search.candidate_rings=(0.0,0.4,0.75,1.0)`、`candidate_angles=12`,
去重后实际是 **37 个候选点**(圆心 + 3 环 × 12 方向)。所有点严格属于
保证接收盘,因此第二次测量不会因距离不足返回 `no_signal`。
对候选点 $q\in\mathcal Q_0$,若返回 `direction` 及示向度 $\beta$,定义保守后验 $K_d(q,\beta)=K_1\cap W(q,\beta,\delta)$。这里继续忽略距离大于5米所提供的非凸排除信息。真实源一定属于 $K_d$。
### 4.2 最坏后验评估
若返回 `near`,定义 $K_n(q)=K_1\cap B(q,5)$,故 $\rho(K_n(q))\le5$。在此分支直接以当前位置进行光学定位和清除,满足20米的清除条件,不必强行再取得一个角度。
对候选点 $q$,最坏后验半径按以下有限枚举计算:
设圆周角距离为 $d_{\mathbb S^1}(\alpha,\beta)=\min_{k\in\mathbb Z}\lvert\alpha-\beta+360^\circ k\rvert$。对 $\lVert g-q\rVert>5$ 的候选位置,令真实方位为 $h_q(g)=\operatorname{atan2}(g_y-q_y,g_x-q_x)$。可能出现的读数集合定义为 $\mathcal B(q)=\{\beta:\exists g\in K_1,\ \lVert g-q\rVert>5,\ d_{\mathbb S^1}(\beta,h_q(g))\le\delta\}$。
1. 在候选集边界上以 `witness_spacing_m=60` m 采样见证点
$g$(`region.witnesses`,含全部顶点);
2. 对每个 $g$,真实方位 $\phi=\operatorname{bearing}(q,g)$,枚举允许的
读数区间端点 $\phi\pm\delta_{\rm eff}$;
3. 对每个端点,复制候选多边形并用 `clip_wedge(q, 读数, δ_eff)` 得到后验
凸外包,记录其 MEC 半径;
4. 额外处理 `near` 分支:以 $B(q,5)$ 裁剪候选集,若交集非空,记录其 MEC
半径;它代表“源离第二点不超过 5 m,直接光学定位清除”的分支;
5. 以上全部有效半径取最大值,就是该候选点的评估值。
这里用所有允许误差描述可能读数,不是假设各地点的误差可以随机独立重抽样。同一个位置的重复检测不产生新的独立信息;所构造的 $\mathcal Q_0$ 与首测位置相距至少约500米,天然避免原地重复测量。
该评估是**启发式上界近似**:它只对目标边界采样和读数区间端点取值,
没有覆盖连续集合上所有可能读数;但它可在候选点之间做一致比较。
1/4 间距(15 m)的加密复核只提高输出数值的可信度,不改变“采样启发式”
这一事实。定理 1 保证接收,选点最优性没有全局证明。
### 7.2 最坏后验半径指标
### 4.3 清除与 `near`
定义 $J(q)$ 为所有可能 `direction` 分支的 $\rho(K_d(q,\beta))$,以及非空 `near` 分支的 $\rho(K_n(q))$ 的上确界。等价地,它是未来所有允许观测下最坏的保守最小包围半径;不存在的观测分支不参与取最大值。
若第二次测量返回 `near`,源在 $q$ 的 5 m 内。由 `clear_certificate`:
候选集 $B(q,5)$ 的 MEC 半径至多略大于 5 m(外包多边形余量很小),
工程余量后仍远小于 20 m,因此可以在当前位置直接 `/clear`,不需要再取
第三个测向读数。若返回 `direction`,则用新角域更新 $K$ 并继续按
问题三/四的定位流程处理;后验半径若仍大于 20 m,**不能**声称两次定位
已经完成。
选点的主要目标为在安全候选区域中降低 $J(q)$。该指标直接服务于一次清除要求:若 $J(q)\le20$,则每个可能分支都有半径不超过20米的保守定位圆。它比仅比较面积更符合任务,因为面积较小的集合仍可能十分狭长。
## 5. 参数与复杂度
对于任意有效分支,后验都是 $K_1$ 的子集。集合包含关系给出 $\rho(K_2)\le\rho(K_1)$,故 $J(q)\le\rho(K_1)$。但不一定严格小于,因此不能仅由“增加一次观测”推断定位精度必然严格改善,更不能保证只需两次测向便达到20米阈值。
| 参数 | 默认值 | 作用 |
| --- | ---: | --- |
| `polygon_sides` | 192 | 圆盘外切多边形边数;$\Omega$ 处径向超出约 0.24 m |
| `bearing_rounding_margin_deg` | 0.005° | 两位小数舍入的显式裕量 |
| `witness_spacing_m` | 60 m | 粗评边界见证间距 |
| `candidate_rings` | (0, 0.4, 0.75, 1.0) | 安全盘内半径比例 |
| `candidate_angles` | 12 | 每环方向数;连同圆心共 37 个候选 |
| `near_radius_m` | 5 m | `near` 分支判定 |
| `clear_margin_m` | 0.5 m | 清除证书的额外工程余量 |
### 7.3 移动成本的引入
若候选集顶点数为 $m$、候选点数为 $N_q$、边界见证数为 $N_g$,单次选点的
粗评估约为 $O(N_qN_g\cdot{\rm clip}+N_q{\rm MEC})$,`clip` 为
Sutherland–Hodgman 裁剪的 $O(m)$ 代价;再加一次 1/4 间距加密复核。
这是明显可并行/缓存的小规模在线计算,不追求全局最优。
连续测量同一频道时,本次动作的虚拟时间增量为 $C(q)=\lVert q-s\rVert/5+5$ 秒。推荐先按后验半径择优,再在几何效果接近的候选点中比较移动成本,以避免任意混合米和秒。
## 6. 实现核对结果:文档旧说法与当前运行路径的差异
用 $\widehat J(q)$ 表示实际计算的评估量,在第10节的保守方案中取为上界 $\overline J(q)$。设有限候选点中的最小评估值为 $J_{\min}$。可先保留满足 $\widehat J(q)\le J_{\min}+\tau$ 的点,再选择其中 $C(q)$ 最小者,其中 $\tau\ge0$ 是显式给定的定位容忍量,单位为米;$\tau=0$ 对应严格的字典序选择。本文不预设一个未经实验支持的最优 $\tau$。
早期说明只写到 `geometry/planning.py` 的解析候选盘,容易让人误以为
`select_second_point` 直接调用它。当前代码的实际情况是:
## 8. 交会角的局部几何解释
- `select_second_point` 构造 `CandidateRegion`($\Omega\cap B(s,1500)$ 的
192 边形外包)并用 `clip_wedge` 交入首次角域;
- 真正的选点发生在 `select_measurement_point`:它在上述凸外包的 MEC
保证盘内做 37 点候选评估;
- `first_bearing_candidate_disk` 是解析证书函数,用于证明
$B(s+au,1000-a)$ 的充分性,并被
`tests/test_geometry_certificates.py` 以稠密角域采样校核;
- 生产路径不调用 `first_bearing_candidate_disk`,但两者给出同一性质的
保证:选出的点必能收到 `direction`/`near`。生产路径通常比解析盘更紧,
因为它利用了 $K_1$ 完整形状而不是仅用其外包扇区。
在某个假定真实位置 $g$ 附近,令两站距离为 $r_1,r_2$,真实视线单位向量为 $u_1,u_2$,对应法向量为 $n_1,n_2$。当角误差很小且位置扰动相对站距较小时,方位函数的一阶展开给出 $n_i\cdot\Delta g\approx r_i e_i$,其中 $e_i$ 用弧度表示,满足 $\lvert e_i\rvert\le\delta_{\rm rad}$。
因此,问题二的**严格保证**来自“安全盘点到候选集所有点的距离 $\le R_-$”,
解析盘是充分但不唯一的一种实现方式;**最优性**来自有限候选评估,
两者必须在说明中分开陈述。
于是局部不确定区域近似由两条带状约束限定:$\lvert n_1\cdot\Delta g\rvert\le r_1\delta_{\rm rad}$、$\lvert n_2\cdot\Delta g\rvert\le r_2\delta_{\rm rad}$。若视线交会角为 $\phi$,法向量矩阵的行列式绝对值为 $\lvert\sin\phi\rvert$。对法向坐标中的矩形作线性逆变换,得到近似面积 $A_{\rm loc}\approx4r_1r_2\delta_{\rm rad}^2/\lvert\sin\phi\rvert$。
## 7. 已验证结果
这解释了两点:近于平行或反向的视线使误差显著放大;在距离不变的局部比较下,接近直角的交会较有利。然而真实源位置未知,$r_2$ 和 $\phi$ 随 $g$ 改变,且有限角域并非无限带状区域。因此不能由该近似推出“总应横移固定距离”或“在某个假定源位置形成90度就是全局最优”。本文只将其用于优先考虑轴线两侧候选点,最终仍以后验集合指标评价。
`project/q2` 在 30 个随机首次示向度算例上,用同一 $K_1$、同一评估器
对四种方法配对比较(初始 $\rho$ 均值 681.47 m):
## 9. 圆弧先验的保守离散
| 方法 | 保证接收率 | 最坏后验均值 | 终值比 $\rho'/\rho$ | 平均移动 | 结论 |
| --- | ---: | ---: | ---: | ---: | --- |
| 生产选点器(保证盘内最小最坏后验) | **100%** | **73.89 m** | **0.104** | 170.4 s | 保证接收且后验最小 |
| 基线① 垂直 1000 m 最大交会角 | **0%** | 80.11 m | 0.116 | 200.0 s | 忽略接收距离,真源在远端必 `no_signal` |
| 基线② 直接取 MEC 圆心 | 100% | 340.75 m | 0.500 | 136.3 s | 保证接收但几乎不收缩 |
| 独立参考:保证盘内 9×9 网格枚举 | 100% | 75.63 m | 0.106 | 170.3 s | 生产采样接近网格最优 |
### 9.1 外包切线多边形
代表性单例 `q2-4200`:
直接将圆周采样点连接得到的是内接多边形,会错误排除圆盘边缘的真实位置。为了复用第一问的半平面工具,选择 $M\ge3$ 个均匀法向量 $v_j=u(2\pi j/M)$,定义 $P_M(o,R)=\{x:v_j\cdot(x-o)\le R,\ j=0,\ldots,M-1\}$。
| 指标 | 数值 |
| --- | ---: |
| $K_1$ MEC 圆心 | (1172.85, −225.68) |
| $K_1$ MEC 半径 $\rho$ | 674.59 m |
| 保证接收盘半径 | 325.41 m |
| 选中第二点 | (1257.07, 88.64) |
| 最坏后验半径 | 54.45 m |
| 移动耗时 | 181.42 s |
由柯西不等式,任意 $x\in B(o,R)$ 都满足所有约束,因此 $B(o,R)\subseteq P_M(o,R)$。相邻切线交点到圆心的距离为 $R\sec(\pi/M)$,故单个圆盘外包的最大径向超出不超过 $R[\sec(\pi/M)-1]$。
## 8. 与问题一及问题三/四的关系(核对结果)
令 $\widetilde K_1=P_M(0,1800)\cap W(s,\theta,\delta)\cap P_M(s,1500)$,便有 $K_1\subseteq\widetilde K_1$。对任何第二读数再求半平面交,仍然保留真实源。上述单圆径向误差不能未经论证直接当成最终定位半径的误差界;靠近切触或退化时尤其需要加密检查。
| 对比 | 问题一 | 问题二(本文) | 问题三/四中的去向 |
| --- | --- | --- | --- |
| 信息模型 | 纯角域 $W$ | 角域 $W$ + 距离先验 $K_1=\Omega\cap W\cap B(s,R_+)$ | 问题三在任意后验 $K_c$ 上重复使用;问题四再叠加朝向维度 |
| 求解方式 | 枚举半平面边界交点、凸包、精确直径 | 圆盘外切多边形 + Sutherland–Hodgman 裁剪 | 问题三/四沿用 `CandidateRegion` 凸外包 |
| 决策类型 | 对固定观测做几何分析 | 主动选择下一个测量点 | 问题三是多源闭环;问题四是混合源闭环 |
| 主要输出 | 直径与直径圆覆盖 | `SecondPointPlan`(位置、最坏后验、移动耗时) | 问题三把它推广到定位子过程;问题四把它作为联合信念选点的基础候选之一 |
| 保证口径 | 几何精确(在输入模型内) | 接收严格保证;最坏后验是采样启发式 | 清除证书严格;选点与调度是启发式 |
安全选点仍使用第6节的解析 $\mathcal Q_\eta$。即使粗糙外包多边形的最小圆偏大,也不能据此推翻解析安全区域的非空结论。
关键区别:
### 9.2 多边形安全区域的可选扩展
1. 问题二不直接复用问题一的 `solve`,而是复用其角域语义和
`smallest_enclosing_circle`;候选集多出圆盘和距离上界,不能当作
“纯角域问题一”处理。
2. 问题一的输出是直径和直径圆,问题二的清除判断改用最小包围半径
$\rho\le20$;直径圆本身不参与在线清除。
3. 问题三对同一个 `select_measurement_point` 做任意后验集上的复用,
但同时引入网格粒子滤波处理 $K$ 无法表示的负观测(`no_signal`);
问题二没有负观测更新,因为它假设第二次测量点已在保证接收盘内。
4. 问题四不能直接使用“安全盘内必收到信号”的结论:定向源在安全盘内
也可能因朝向遮挡返回 `no_signal`。问题四保留问题二的安全盘作为
基础候选,但新增(位置 × 朝向)联合信念来判断可见性(见
`algo-4.md`)。
若采用任意有界凸外包多边形 $\widetilde K=\operatorname{conv}\{v_j\}$,则 $\max_{g\in\widetilde K}\lVert q-g\rVert=\max_j\lVert q-v_j\rVert$,证明与第一问的凸组合论证相同。因此其保证接收区域由有限个约束 $\lVert q-v_j\rVert\le1000$ 给出,是圆盘交。
## 9. 结论
也可先解最小包围圆问题,再构造充分安全圆盘。由于 $\widetilde K$ 是外包集合,得到的区域可能更小,但其中每个点仍安全。若该离散区域为空,优先加密或回到解析候选盘,不把离散失败误报为真实问题无解。
问题二的当前实现可以概括为:
## 10. 未来读数的保守区间评估
- **候选集**:$K_1=\Omega\cap W(s,\theta,\delta_{\rm eff})\cap B(s,R_+)$,
用 192 边形外包圆盘 + 半平面裁剪保守维护;
- **保证接收**:$B(m,R_--\rho_{\rm out}-10^{-3})$ 内的点必不返回
`no_signal`;解析扇区最小圆给出可独立复核的充分候选盘
$B(s+au,1000-a)$,$a=1500/(2\cos\delta)$;
- **选点**:在保证盘内取 37 个候选,以边界见证点 × 读数端点的最坏后验
包围半径为第一指标、移动时间为第二指标,选出后再做 1/4 间距加密复核;
- **清除**:若第二次返回 `near`,直接就地清除;否则把新角域交入后验,
未达到 20 m 证书前继续测量。
直接枚举少量假定源位置和误差端点,只得到被枚举情形的最大值,可能漏掉真正最坏读数。为了使评估输出具有上界意义,可以划分全部可能方位角,而非仅点采样。
将整周按圆周意义划分为若干区间 $I_j$,中心为 $\beta_j$,宽度为 $h_j$,要求 $\delta+h_j/2<90^\circ$。跨越 $0^\circ$ 的区间按圆周距离处理。
**命题2:** 对任意 $\beta\in I_j$,有 $W(q,\beta,\delta)\subseteq W(q,\beta_j,\delta+h_j/2)$。
**证明:** 对左侧任意非顶点位置,其方向到 $\beta$ 的圆周距离不超过 $\delta$;而 $\beta$ 到区间中心的距离不超过 $h_j/2$。由圆周距离的三角不等式,其方向到中心的距离不超过两者之和。顶点在两个闭角域中均被保留。证毕。
因此定义 $U_j(q)=\widetilde K_1\cap W(q,\beta_j,\delta+h_j/2)$,则任何落在该区间的真实读数所对应的保守后验,都包含于 $U_j(q)$,从而 $\rho(K_d(q,\beta))\le\rho(U_j(q))$。
对于 `near` 分支,可以取统一上界 $\min\{5,\rho(\widetilde K_1)\}$。该上界不需要精确计算圆弧交集;即使某点实际不会触发 `near`,纳入此上界只增加保守性。
令 $\overline J(q)$ 等于所有非空 $U_j(q)$ 的最小包围半径,以及上述 `near` 上界的最大值,即得到 $J(q)\le\overline J(q)$。该结论在精确几何计算下成立。浮点实现还需给出包含性和数值误差处理,不能把无误差预算的计算值称为机器可验证的严格上界。
通过将某个角区间二分,子区间扩张后的角域包含于父区间的扩张角域,所以相应方向上界不会增大。通过保留旧切线并增加新切线,也可使外包多边形嵌套缩小。这给出稳定的自适应加密方向,但没有在本文中证明连续选点的统一收敛速率。
该评估可能较慢,实际可先用少量读数采样粗筛,再对入围点使用完整角区间上界。前一阶段仅决定计算资源分配;最后报告的保守上界必须来自完整覆盖的区间,不能来自粗筛样本最大值。
## 11. 可执行的第二点选择算法
**算法2:安全区域内的有限候选主动测向。**
1. 输入 $s,\theta,\delta$,核验首次观测为同一全向源的有效示向度,构造 $K_1$ 或其保守表示。
2. 计算 $a=1500/(2\cos\delta)$、$c=s+au(\theta)$、$b=1000-a$,选择满足误差预算的缩小半径 $b-\eta$。
3. 构造有限候选集:包含圆心 $c$,并取 $q_{\ell j}=c+r_\ell[\cos\psi_j\,u+\sin\psi_j\,u_\perp]$,其中 $0<r_\ell\le b-\eta$、$\psi_j=2\pi j/N_\psi$、$u_\perp=(-\sin\theta,\cos\theta)$。所有候选点自动满足接收距离保证。
4. 对每个候选点构造第9节的外包后验,按第10节评估 $\overline J(q)$,保存最坏角区间及对应包围圆作为评估依据。
5. 优先选择 $\overline J$ 较小的点;按预先声明的 $\tau$ 筛选近优点,再按移动检测代价选择,必要时用固定顺序消除完全同分。
6. 在现实计算预算内,对入围候选附近加密位置、细分最坏角区间并增加外包切线。保留当前已验证安全的可用点,预算耗尽时返回当前结果。
7. 在所选位置测量同一频道。若为 `near`,在当前位置清除;若为 `direction`,用实际读数更新候选集合并复算最小包围圆;若其半径仍大于20米,继续后续测向,不能宣称两次定位已经完成。
8. 若在距离保证条件下意外得到 `no_signal`,核查动作是否被接受、频道是否正确、源是否仍存在及坐标和模型是否一致。未知原因解决前不按正常 `direction` 分支更新位置候选。
例如可从3层半径、每层16个方向及1个圆心开始,共49个候选点;这是位置搜索的数值分辨率示例,与第三问49点全域覆盖网格无关,也不是已优化参数。外包切线数、角区间宽度、加密次数及 $\tau$ 均应在论文实验部分显式记录。
设候选数为 $N_q$、角区间数为 $N_\beta$、每个后验的半平面数为 $k$。复用第一问的边界枚举时,主计算量约为 $O(N_qN_\beta k^3)$,另加顶点直径或最小圆计算。该方法提供清楚的正确性结构,但需通过缓存首次约束、局部加密和计算预算控制实现成本。不能用“只需一次新的测量”忽略选点器自身的运行时间。
算法输出保证的是:所选点位于已证明安全的区域,并在给定有限候选及评估精度下按既定规则择优。本文未证明 $q\mapsto J(q)$ 是凸函数,也未证明有限候选的最优点就是整个 $\mathcal Q(K_1)$ 中的全局最优点。
## 12. 边界情形与适用范围
| 情形 | 正确处理 |
| --- | --- |
| $K_1$ 为空 | 报告模型或数据不一致,不构造虚假圆心 |
| 首次结果已经为 `near` | 直接清除,不再套用“已知示向度”的第二问流程 |
| 一般集合满足 $\rho(K)=1000$ | 安全区域为最小包围圆中心这个单点 |
| 一般集合满足 $\rho(K)>1000$ | 不存在对该集合全部位置保证接收的点;圆心仅是最小化最坏距离的探索选择 |
| 外包圆半径大于1000 | 只能判定该证书不足,不能直接判真实安全区域为空 |
| 测量轴接近 $0^\circ/360^\circ$ | 使用向量约束和圆周区间,避免角度断点错误 |
| 两次测向交会较差 | 保留完整后验并继续测量,不将小面积当成小直径 |
| 源类型为定向 | 距离证书仍成立,但接收还受朝向限制,不能直接沿用本问的接收保证 |
对于一般集合无安全点的情形,最小包围圆中心确实解 $\min_q\max_{g\in K}\lVert q-g\rVert$,但其最坏距离仍可能大于1000米。改善交会角若要求偏离该中心,还会牺牲最坏距离最优性,必须明确是另一个多目标探索策略,不能把两项互相冲突的要求同时称为已保证。
## 13. 验证设计及当前实现边界
本问的验证分为数学证书与策略性能两层。数学层检查:截断角域被解析圆覆盖;安全候选到所有允许位置的距离不超过1000米;外包离散不删除真实点;角区间扩张包含所有区间内读数。数值抽样只能辅助检查这些证明,不能取代它们。
性能层应在相同自建源位置与确定的空间误差场上,比较轴向点、两个横向点、有限候选最坏后验策略。误差场在同一位置保持固定;不得让每次调用重新独立生成误差。记录第二次测量类型、后验直径和包围半径、额外移动、计算耗时及是否达到20米清除阈值。至少包含误差边界、源域边界、接收半径1000米、近距离和近共线案例。没有这些实验前,不报告未经验证的性能提升比例。
目前 [planning.py](C:/Users/HelixCopex/code/MATH/geometry/planning.py) 中的 `first_bearing_candidate_disk` 已实现第5至6节的解析证书;问题一内核可用于纯半平面区域的顶点和最小圆计算。**本仓库第二问交付的定位是"安全选点的启发式基线"**:解析安全候选盘证书已实现并证明(选中点必能收到信号),而选点评分采用边界采样点 × 读数区间端点的有限枚举近似(含 near 分支)。第9至11节描述的完整选点器数学方案,特别是第10节覆盖全部未来读数的保守区间上界,尚未在现有仓库中实现;采样最大值只用于候选点之间的相对比较与粗筛,不得当作严格最坏情形上界报告,也没有相应正式成绩。该实现边界不影响解析候选区域的证明。
对后续实现而言,应新增接收任意半平面约束的交集入口,或使用独立的多边形裁剪函数,再复用第一问的最小包围圆。不能把圆盘先验伪装成额外测向观测传入现有 `solve(measurements)` 接口。
## 14. 结论
第二问的候选区域可以明确取为前方约750米处、半径约250米的圆盘,其解析式为 $\mathcal Q_0=B(s+au(\theta),1000-a)$,其中 $a=1500/(2\cos\delta)$。这一候选区域对题设全向源提供接收保证,且在首次有效测向条件下严格非空。
在该区域内,以最坏保守后验最小包围半径为主指标、移动代价为次指标,采用有限候选和逐步加密进行选择。交会角解释局部几何优劣,外包多边形和读数区间扩张保证不低估相关后验集合;全局最优和“两次即可清除”均不作为未经证明的结论。
严格保证与启发式性能优化在文档和代码中始终分开:接收保证有证明,
全局最优性没有证明;后者依赖自建的有限候选与固定评估口径。
## 参考文献
1. 全国大学生数学建模竞赛组委会. 2026年高教社杯全国大学生数学建模竞赛B题:无线电干扰源的快速自动定位与清除[Z]. 2026. [本地题面](C:/Users/HelixCopex/code/MATH/issue/B题.pdf),问题2、附录2及第4页坐标约束。
2. 模拟器配套材料. 附件2:模拟器通信接口说明及编程指南[Z]. 2026. [本地附件](C:/Users/HelixCopex/code/MATH/issue/附件/附件2.docx)。用于 `direction`、`near`、`no_signal` 等程序观测字段与动作接口的对应。
3. BOYD S, VANDENBERGHE L. Convex Optimization[M]. Cambridge: Cambridge University Press, 2004. [作者公开版本](https://web.stanford.edu/~boyd/cvxbook/bv_cvxbook.pdf),第3章、第6.4节。用于凸性及鲁棒建模的一般背景,不作为本题候选圆盘公式的出处。
4. 本项目. 问题一:有界测向误差下定位区域的直径与圆覆盖[Z]. 2026. [algo-1.md](C:/Users/HelixCopex/code/MATH/docs/algo-1.md)。用于角域半平面表示、顶点判定和最小包围圆的衔接。
**来源说明:** 第5节的截断角域最小圆及第6节的安全圆盘,是结合题设参数在本文中推导的结果;第8节的交会角关系由方位函数一阶展开推得;第9至11节是据集合包含关系构造的保守计算方案。以上陈述说明本项目的推导路径,不宣称这些一般几何思想在既有文献中首次出现。
1. 全国大学生数学建模竞赛组委会. 2026 年高教社杯全国大学生数学建模竞赛 B 题[Z]. 2026. 本地题面 `issue/B题.pdf`。
2. 模拟器配套材料. 附件 2:模拟器通信接口说明及编程指南[Z]. 2026. 本地附件 `issue/附件/附件2.docx`。
3. BOYD S, VANDENBERGHE L. Convex Optimization[M]. Cambridge University Press, 2004. 凸性、最坏情形与鲁棒可行性背景。
4. 本项目. `docs/algo-1.md`(问题一当前实现与复用差异)。
5. 本项目. `docs/algo-3.md`、`docs/algo-4.md`(选点器在后续问题中的推广与边界)。
+144
View File
@@ -0,0 +1,144 @@
# 算法说明:问题3 贝叶斯闭环(`bayes` 策略)
> **历史补充文档.** 当前规范算法、最新默认参数与现行成绩口径见
> [`algo-3.md`](algo-3.md);本文保留 2026-09-11 的设计与消融细节,
> 其中 `lambda_move_s=0.0105`、站点环半径 1200 m 等数字可能已被
> 后续回填(现行默认见 `config.BayesConfig`,λ=0.05、环半径 1159.7 m)。
> 两者冲突时以代码与 `algo-3.md` 为准。
对应实现:`src/mcm26b/particles.py`(感知层)、`src/mcm26b/planner/bayes_loop.py`
(决策层)、`scripts/benchmark_q3_bayes.py`(消融)、`scripts/tune_q3_bayes.py`
(Optuna 调参)。基线对比:`docs/algo-3.md` 的七点批量(`seven`,A2)。
## 1. 建模定位
问题3是部分可观察的在线决策问题:频道数未知(10–16/20)、位置未知、有效
接收半径未知(1000–1500m)、测向误差 ±1°(服务器保留两位小数)。策略层
只能拿到 `/measure` 与 `/clear` 的响应,目标是最小化"发现—定位—清除"的
虚拟总时间。`bayes` 策略把它分解为三层滚动时域闭环:
1. **感知层**:每频道一个贝叶斯后验(网格幸存集),融合全部正/负观测;
2. **决策层**:站点扫频、定位测量、清除三类任务统一建表,开放路径
最近邻 + 2-opt 排序,每个动作后滚动重排(动态 TSP);
3. **选点层**:默认按期望log幸存占比和“到检测点、再接近源”的移动代价评分。
已停靠位置可逐频道补交会观测,真实清除始终由完整角域证书批准。
2026-09-11 当前修订的审核与配对验收见 [三四问算法审核与优化](算法审核与优化-20260911.md)。
本文件第5节是早期版本的历史结果,不应当作当前默认参数的复算结果。
## 2. 网格幸存集滤波与真源保留定理
每频道维护 Ω 上间距 h 的网格(h=14.65m,约 4.7 万格),`alive` 为幸存格
集合。Voronoi 裕量 m = √2/2·h 是格心到格内任一点的最大距离;所有删除
判定都内缩 m,即**仅当整格与可行集不相交时才删格**:
| 观测 | 题面语义 | 删除判定 |
|---|---|---|
| `no_signal`@s | 距离 > r_eff ≥ 1000 | 删格心距 s ≤ 1000 − m 的格 |
| `direction`@s | 距离 ≤ 1500,\|读数−真值\| ≤ δ_eff | 删角向超出 δ_eff + asin(m/(d−m)) 或距 s > 1500 + m 的格 |
| `near`@s | 距离 ≤ 5 | 删格心距 s > 5 + m 的格 |
**定理(真源保留)**:真源 P 满足全部题面约束;P 所在格的格心 g 满足
|g−P| ≤ m,故"整格不相交"判定永不命中该格。因此任意观测序列后 P ∈
幸存格的并集,且:
- **缺席证明**:幸存集为空 ⟹ 可行集为空 ⟹ 该频道无源(严格);
- **清除证书**:MEC(幸存).R + m ≤ 20 − 工程余量 ⟹ 真源距清除点 ≤ 20;
- **跳测规则**:幸存格到测点最小距离 > 1500 + m ⟹ 该测量必 no_signal
且无新排除,可跳过。
性质测试:`tests/test_particles.py`(随机源 × 随机测点 × 随机合法误差,
真源格永不被删;`tests/test_bayes_loop_q3.py` 端到端零异常)。
与 `belief.CandidateRegion`(保守凸外包)的分工:凸表示负责**清除证书**
(精确角域裁剪、收敛后触发),网格负责凸表示做不到的三件事——非凸负
观测挖洞(no_signal 排除盘)、缺席提前证毕、跳测判定。两者同读一套
响应流(`_record_measure`),任何一方证毕即收尾。
## 3. 信息增益选点
候选点集(二选一,按保证接收盘是否非空):
- **保证接收盘** B(MEC_poly 圆心, 1000 − ρ_poly)(盘内必收 direction/near,
几何同 `docs/algo-2.md` 定理1):环形采样(`search.candidate_rings`×12 方向);
- **宽候选集**(ρ_poly > 1000 时):PF MEC 的 1.0/1.5/2.0 倍探索环。
评分(`BayesConfig.score_mode`):
- `radius`(可选):期望后验半径 —— 对 K 个代表假设(幸存格均匀抽样)
各自模拟一次测量的幸存分支,分支半径用幸存子采样的质心最大距离近似,
r_eff~U(1000,1500) 的不确定性解析积分(p_no_signal = (d−1000)/500);
总分 = E[半径] + λ·移动秒数(λ=0.1);
- `entropy`(当前默认,λ=0.0105):期望log幸存占比,加上λ乘以
当前点→候选检测点→候选源中心的移动秒数。来向对称候选补充固定全局方向网格。
这里的均匀先验、零误差代表读数和均匀接收半径是选点评分的工程假设,
不属于题面给定的概率模型;只有完整±1.005°角域与距离界参与安全证明。
λ 与半径同量纲,量级必须小:λ 过大时终局阶段"走 300m"的代价压过
"半径 35m→25m"的收益,选点器原地重测、收敛停滞(消融实测 λ≥0.5 时
单源定位步数翻倍)。
## 4. 统一任务循环(动态 TSP)
每个决策点构建任务表并排序执行首个任务,随后滚动重排:
- **站点任务**:七站(原点 + 当前1159.7m环六点)上的UNKNOWN频道;
构造时验证全向覆盖解析界小于1000m,跳测规则与覆盖账本过滤;
- **定位任务**:DETECTED 且无证书的频道,位置由信息增益选点产生;
- **清除任务**:有证书(凸或网格)的频道,可沿当前位置方向移动证书圆心,
但平移量与原半径之和不得超过清除证书阈值,节省最后一段接近路程。
排序 = 开放路径最近邻打底 + 2-opt 改进(`two_opt_passes` 轮,成本含
移动 + 换频 + 动作耗时;换频按路径顺序逐步传递当前频道,clear保持频道;
终点自由不回起点)。这是 A2"站内最近邻服务
队列"的推广:消融显示距离缩短约 24% 是主要收益来源。
停止条件与基线一致:清满 16(数量上界)或全部频道 CLEARED/ABSENT,
`stop_reason=all_resolved` 即停并 `/exit`——**虚拟时间即评测成绩口径**
[用户决策修订 2026-09-11]。复扫保险模式(`mcm26b run --persistent`,
原默认行为):找全后继续只复扫未清除频道直到清除数达 16、实机预算
耗尽或虚拟安全余量(`search.virtual_reserve_s=10000s`)不足,供需要
"防缺席证明假设偏差"保险的场景显式开启。
## 5. 消融结果(自建模拟器,persistent 关,对齐找全口径)
12 随机场景(6 种子 × 10/16 源)+ 2 边界场景,与 SevenBatch 配对;
bayes 参数为 Optuna 回填后的库默认值:
| 指标 | SevenBatch | bayes |
|---|---|---|
| 有效运行(全清+缺席正确+零异常+正常退出) | 14/14 | **14/14** |
| 平均虚拟时间 | 5352.7s | **3726.2s(−30.0%)** |
| 配对降幅(最差 / 最好) | — | +7% / −41% |
| 平均移动距离 | 23413m | **15050m(−36%)** |
| 平均检测次数 | 102.4 | 110.0 |
| 单局实机计算墙钟 | 1.3s | 1.9s |
复现:`uv run python scripts/benchmark_q3_bayes.py --seeds 0,1,2,3,4,5`
(逐场景数据与场景 JSON 见 `output/q3/bayes-ablation/`)。
## 6. Optuna 调参
`scripts/tune_q3_bayes.py`:TPE(dev 依赖 `optuna`,缺失时自动退化为
随机搜索),目标 = 场景子集平均虚拟时间 + 10⁶×失败惩罚。搜索空间:
λ、代表点数、子采样规模、网格间距、站点环半径、2-opt 轮数、评分模式。
最优组合(entropy 评分、λ=0.0105、h=14.65m、代表点 7、2-opt 4 轮)
已按验收流程回填为 `config.BayesConfig` 默认值:全量基准 3726s/−30.0%
优于回填前的 4277s/−19.5%。两处人工修订:
- `station_ring_radius_m` 保留 1200(Optuna 建议 1159.7 虽再快约 60s,
但七站排除盘对最外圈格心的覆盖差 3.3m,PF 无法独立证明缺席,
只能靠覆盖账本兜底——设计性质优先于 1.7% 的均值差异);
- 评分模式在 entropy(信息增益)与 radius(期望后验半径)之间由调参
数据决定,两者实现均保留(`BayesConfig.score_mode`)。
调参记录见 `output/q3/bayes-tuning/best_params.json`。
## 7. 已知边界
- 检测次数略多于 A2(109.6 vs 102.4):定位收敛多用 1-2 次测量换距离
大幅缩短,净收益为正;跳测规则在 UNKNOWN 阶段(先验全域)暂无收益
面,收益集中在定位与复扫阶段;
- 网格清除证书(h=25m 时阈值 ≤1.8m)极少触发,作为凸证书的补位保险;
- 调参与消融基于自建模拟器(逐点固定 ±1° 误差场),不代表官方误差
分布;真源保留定理只依赖题面界(±1°、r_eff∈[1000,1500]),不依赖
误差分布假设。
+423 -375
View File
@@ -1,408 +1,456 @@
# 问题三:具有覆盖证明的多源搜索与总耗时优化
# 问题三算法说明:全向多源搜索的覆盖证明、贝叶斯闭环与任务调度(当前实现)
## 摘要
> 本文汇总当前仓库中问题三的全部实际算法,生产默认策略为
> `src/mcm26b/planner/bayes_loop.py::BayesLoop`(CLI 策略名 `bayes`)。
> `grid`(`OmnidirectionalLoop`,49 点)与 `seven`
> (`SevenBatch`,七点解析覆盖 + 本站批量)保留为可复现对照。
> 问题三当前生产不包含任何机器学习策略;历史 HRL/RL 代码已移除,
> 自建消融中手写基线稳定优于学习策略(见 `docs/第三四问架构优化与运行提速方案.md`)。
针对源数为10至16、频道互异、位置未知的全向无线电干扰源,建立“发现—定位—清除—完整性证明”的在线决策模型,以全部清除为约束、以移动、检测、换频道和清除的虚拟总耗时为优化目标。沿用问题一的角域交与最小包围圆、问题二的安全接收区域,将改进重点放在全域覆盖规模与多源任务调度。首先从官方演练日志识别移动与无源频道验证的主要开销;随后证明中心加六个环形检测点即可覆盖题设圆域,并提出同站批量扫频、发现任务入队、就近定位清除的可执行基线。进一步规划利用无信号观测维护剩余区域、复用顺路检测点、优化安全清除位置及滚动比较剩余任务耗时的方法。给出停止条件、几何保守性、算法复杂度和分阶段验收方案。12个自建场景的配对消融表明,七点批量策略全部清除成功,平均逐局虚拟时间降幅为58.55%;该结果用于选择下一步实现方向,不作为官方成绩或全局最优性证明。
## 1. 问题定位与策略族
**关键词:** 全向源搜索;覆盖证书;集合定位;任务调度;虚拟总耗时;滚动规划。
问题三中每局有 $10\sim16$ 个全向干扰源,位置未知、频道互异(1..20),
源位于 $\Omega=B(0,1800)$ 内;有效接收半径 $r_{\rm eff}$ 未知,但满足
$1000\le r_{\rm eff}\le1500$ m。策略端只能看到协议响应
(`direction` / `near` / `no_signal`),目标是发现、定位并清除全部源,
使虚拟总耗时尽量小,同时给出“不存在漏源”的证明或明确未完成。
## 1. 问题界定与思路来源
| 策略 | 类 | 发现保证 | 定位/决策 | 角色 |
| --- | --- | --- | --- | --- |
| `grid` | `OmnidirectionalLoop` | 700 m 方格 49 点,单元对角 $700\sqrt2\approx989.95\le1000$ | 发现后立即就近定位,无信息增益 | 历史基线 |
| `seven` | `SevenBatch` | 原点 + 半径 1200 m 六等分环,解析覆盖界 968.90 m | 本站先扫频,再按候选集 MEC 圆心最近服务队列 | A2 中间对照 |
| `bayes` | `BayesLoop` | 七站环(默认半径 1159.7 m,界 984.54 m)+ PF 负观测 | 网格粒子滤波 + 信息增益选点 + 滚动任务排序 | **生产默认** |
第三问要求在不知道干扰源位置和具体数量的条件下,自动发现并清除全部全向源,尽量缩短总时间。每个频道最多一个源,因此本题的数据关联由频道确定;机器狗无需解决无法区分多个信号身份的问题。源位于半径1800米的圆域内,最小有效接收半径为1000米。这两个尺度之间的关系使稀疏、有证明的覆盖成为可能。[题面](C:/Users/HelixCopex/code/MATH/issue/B题.pdf)、[接口规则](C:/Users/HelixCopex/code/MATH/docs/协议规则表与状态机.md)
七站族还有 `SevenImmediate`(A1,发现即离站)与 `SevenSweep`
(A3,先扫完全域再统一服务)。它们的差异只在发现/定位的调度顺序,
七点覆盖证明与距离语义相同。
本文与前两问分工如下:问题一回答“当前观测允许源在哪里、能否一次清除”;问题二回答“下一次去哪里测才安全且有效”;问题三回答“先搜哪些位置、扫哪些频道、何时中断搜索去清除,以及何时能够确信任务结束”。全局调度会调用前两问的几何内核,但不能只把第二问的局部精度目标逐个重复就视为总时间优化。
### 1.1 统一计时模型
参考本项目[问题3—4论文列表](C:/Users/HelixCopex/code/MATH/docs/B题问题3-4相关论文列表.md),选取并核对以下相关思想。
忽略微秒量化时,一次动作回合的虚拟耗时为
| 来源 | 本文借鉴的内容 | 与本题的区别 |
$$
T=\frac{L}{5}+5N_m+N_s+5N_c^++3N_c^-,
$$
其中 $L$ 为移动距离(速度 5 m/s),$N_m$ 为检测次数,$N_s$ 为换频次数,
$N_c^+$ 为成功清除次数,$N_c^-$ 为失败清除次数。`/clear` 本身不切换
测向频道,`/exit` 不增加虚拟时间,访问顺序是起点固定、终点自由的**开放
路径问题**,不是必须回到原点的 TSP 回路。官方内部按整数微秒累计,项目用
`evaluation.recompute_virtual_time` 逐动作复算并校验。
## 2. 三层在线架构
`BayesLoop` 在 `OmnidirectionalLoop` 的会话/预算/收尾骨架上重构为三层:
1. **保证层**:七点覆盖账本 `CoverageLedger` + 解析覆盖定理;负责
“全测无信号 $\Rightarrow$ 该频道不存在源”。
2. **信念层**:
- 凸候选集 `CandidateRegion`(正观测方向/近距离约束,用于清除证书);
- 网格幸存集 `ChannelParticles`(正/负观测统一更新,用于缺席证明、
跳测与信息增益);
- 两者同读一套响应流,任何一方证毕即结束该频道。
3. **决策层**:统一任务表(站点扫频 / 定位测量 / 清除),开放路径
最近邻 + 2-opt 滚动重排;每执行一个任务后用真实响应重规划。
问题三不复用问题四的(位置 × 朝向)联合信念;`no_signal` 在全向源上
可以排除 $B(s,1000)$。
## 3. 证据层:逐频道候选集与负观测
### 3.1 凸候选集 `CandidateRegion`
对每个已收到过有效信号的频道 $c$ 维护凸多边形 $P_c^{\rm out}$:
- 初始为 $\Omega$ 的 192 边外切正多边形;
- 每次 `direction`:
1. 与 $B(s,1500)$(192 边外切多边形)求交;
2. 与角域 $W(s,\theta,\delta_{\rm eff})$ 求交,$\delta_{\rm eff}=1^\circ+0.005^\circ$;
- `near`:与 $B(s,5)$ 外切多边形求交;
- `no_signal`:**不收缩凸候选集**。对全向源,负观测信息由 `ChannelParticles`
处理;让凸表示保留所有正观测候选,可以避免因模型误差误删真源。
圆盘外切多边形的径向超出逐次累加到 `slack_m`,所有裁剪容差只向
“保留更多候选”的方向倾斜,因此真源始终包含在 $P_c^{\rm out}$ 内。
清除证书使用 $P_c^{\rm out}$ 的最小包围圆 $B(m_c,\rho_c)$:
$$
\rho_c+\text{slack}_c+0.5\le20
\quad\Longrightarrow\quad
\text{在 }m_c\text{ 处一次 `/clear` 必命中真源}.
$$
其中 $0.5$ m 是工程余量,slack 是外包多边形的半径余量;该判据是保守的
充分条件,而不是“必须达到的最优半径”。
### 3.2 网格幸存集 `ChannelParticles`
每个频道维护 $\Omega$ 外扩裕量上的方格网格,间距
$h=14.65$ m,约 4.7 万格;Voronoi 裕量
$$
m=\frac{\sqrt2}{2}h\approx10.359\ {\rm m}
$$
是格心到格内任一点的最大距离。所有删除判定都内缩 $m$:**只有整个格子与
可行集不相交时才删除该格**。
| 观测 | 位置/距离更新 | 实现 |
| --- | --- | --- |
| Bayram、Vander Hook、Isler,2016,列表第10项 | 联合规划测向位置和访问顺序,同时计入移动时间与测量时间 | 原文以GDOP描述不确定度;其近似比不能转移为本文有界角域和清除任务的近似保证 |
| Olofsson等,2020,列表第22项 | 根据已观测区域分配后续搜索资源,在有限预算下规划路径 | 原文使用PHD和期望发现数;本文用确定性的逐频道剩余区域,不用概率很小替代无源证明 |
| Perera等,2023,列表第4项 | 将稀疏覆盖路线与无线电源定位结合,并分别评价路径与采样代价 | 原文依赖RSSI;本题接口没有RSS/RSSI数值,不能直接使用其估计器 |
| `no_signal` @ $s$ | 距离 $>r_{\rm eff}\ge1000$,删除格心距 $s\le1000-m$ 的格 | `_kill_disk(s,1000-m)` |
| `direction` @ $s$,读数 $\theta$ | 距离 $\le1500$;角向偏差不超过 $\delta_{\rm eff}$ + 格内角裕量 | 先删距 $s>1500+m$ 的格,再删角向超出的格 |
| `near` @ $s$ | 距离 $\le5$ | 删格心距 $s>5+m$ 的格 |
上述内容分别见[大学研究记录及论文摘要](https://experts.umn.edu/en/publications/gathering-bearing-data-for-target-localization-6/)、[作者公开论文](https://torarnj.folk.ntnu.no/AURO2020a.pdf)、[arXiv原文入口](https://arxiv.org/abs/2312.03493)。这里只迁移任务组织方法,以下七点几何证明和题目化调度公式在本文独立推导。
**定理(真源保留).** 真源 $P$ 所在格的格心 $g$ 满足
$|g-P|\le m$;与真值一致的观测不会让该格与可行集完全不相交,因此
“整格不相交”判定永不删除该格。于是:
论文列表中建议的RSS梯度逼近不能直接用于本题:`/measure` 只返回无信号、近距离或示向度,没有连续场强值。Atanasov等的源搜索也依赖场信号、随机梯度或测量模型,其收敛结论不证明本题仅测向闭环收敛。[原论文摘要](https://arxiv.org/abs/1402.0051) 本文也不假定源位置均匀分布、每次误差独立重抽样或某一交会角在所有场景下最优。
- **缺席证明**:幸存集为空 $\Longrightarrow$ 可行集为空
$\Longrightarrow$ 该频道无源;
- **跳测规则**:若某测点到所有幸存格心的最小距离 $>1500+m$,则在该点
测量必为 `no_signal` 且不产生任何新排除,可直接跳过,省 5 秒检测和
1 秒换频;
- **PF 清除证书**:若幸存集 MEC 半径 $\rho_{\rm pf}$ 满足
$\rho_{\rm pf}+m+0.5\le20$,则以该 MEC 圆心清除必命中真源。
## 2. 现有方案的实测瓶颈
`BayesLoop` 在每次测量后同步更新凸候选集与 PF,PF 只在频道尚未
CLEARED/ABSENT 时接收新证据。
官方问题3演练 `DHFK-PHD2-SR2P-FXK9` 已证明现有实现能在该局全清10个源,完整数据见[演练验证报告](C:/Users/HelixCopex/code/MATH/output/q3/practice/20260911-port2026/report.md)。该局在策略运行结束后才读取官方源数,源真值没有进入决策。
### 3.3 数量上界证明
| 分项 | 本局数值 | 对改进的含义 |
| --- | ---: | --- |
| 总虚拟时间 | 16217.454814秒 | 作为官方基线,不与自建场景混算 |
| 移动距离 / 耗时 | 64737.274米 / 12947.454814秒 | 移动约占79.84%,应优先减少站点和往返 |
| 检测次数 / 耗时 | 540次 / 2700秒 | 无源频道各扫49点,共490次,占全部检测90.74% |
| 换频道耗时 | 520秒 | 可以优化,但节省一次换频仅相当于少走5米 |
| 清除耗时 | 50秒,10次全部成功 | 清除可靠性已得到单局支持,无需先追求更小定位半径 |
| 最后一个源清除时刻 | 8454.332399秒 | 其后7763.122415秒用于剩余任务,约占总时间47.87% |
题面给定每局源总数 $10\sim16$ 且每频道至多一个源。若
`DETECTED`/`CLEARED` 的不同频道数达到 16,则所有仍未出现有效信号的
`UNKNOWN` 频道都不可能有源,可直接标记为 `ABSENT`,证据来源记为
`count_bound`。这条规则与位置无关,是严格计数证明,不需要等七站扫完。
`summary.completion_basis` 会区分 `count_cap` 与 `per_channel_proof`,
避免把“达到数量上界”误读成“逐频道缺席证明”。
现有 `OmnidirectionalLoop._coverage_phase` 的结构还引入额外往返:在一个覆盖站按频道扫描,遇到 `direction` 就立即定位清除,再回原覆盖站继续下一频道。由原始动作日志统计,从非网格点返回网格检测点共10次,累计约9996.714米、1999.343秒。这是实际行程分类,不能全部当成可直接删除的收益,因为改序后仍需访问下一任务;但它明确说明同站任务应先批量处理。
## 4. 发现保证:七点解析覆盖与覆盖账本
当前49点网格同时服务第三问和第四问。它的定向覆盖证明要求更强的几何条件;第三问无需为未知发射朝向付出同样的采样代价。改进第三问时,应显式区分全向策略和第四问策略,不能全局替换公用网格。
### 4.1 `full_disk` 语义
## 3. 状态、信息与优化目标
全向源在测点 $s$ 返回 `no_signal`,意味着真实距离严格大于
$r_{\rm eff}\ge1000$。因此若集合 $S$ 满足
### 3.1 符号与可用信息
$$
\Omega\subseteq\bigcup_{s\in S}B(s,1000),
$$
| 符号 | 含义 |
则任何合法源必在某个 $s\in S$ 的 1000 m 内,在该点不可能返回
`no_signal`。于是“某频道在全部 $S$ 点都测得 `no_signal`”构成严格的
缺席证明。
`CoverageLedger` 按 `(channel, point_index)` 二维记录,只有所有点都已测
(或显式跳过)才允许 `coverage_complete(channel)=True`;“路过某点”不算
“测过某频道”。
### 4.2 七点构造与解析界
取原点加半径 $a$ 的六等分环:
$$
S(a)=\{(0,0)\}\cup\left\{a(\cos(k\pi/3),\sin(k\pi/3)):\ k=0,\dots,5\right\}.
$$
**定理 1.** 当 $0<a/\sqrt3\le R$ 时,
$$
\max_{g\in\Omega}\min_{s\in S(a)}\lVert g-s\rVert
\le D(a):=
\max\left\{\frac a{\sqrt3},\ \sqrt{R^2+a^2-\sqrt3Ra}\right\},
$$
其中 $R=1800$。证明:任意 $g=(r\cos\varphi,r\sin\varphi)$ 与最近环站的
夹角不超过 $\pi/6$。若 $r\le a/\sqrt3$,选原点;否则选最近环站,由
余弦定理和 $\cos\varphi\ge\cos(\pi/6)=\sqrt3/2$ 得
$\lVert g-s\rVert^2\le r^2+a^2-\sqrt3ar$。右端关于 $r$ 是凸二次函数,
在区间端点取最大值,分别得 $a^2/3$ 和 $R^2+a^2-\sqrt3Ra$。证毕。
代入可验证:$a=1200$ 时 $D=968.901572$ m;当前 `bayes` 默认
`station_ring_radius_m=1159.7` m 时 $D=984.539136$ m;两者都小于 1000,
从站上有 15.5 m 以上的几何余量。$a$ 的可行下界为
$a_{\min}\approx1122.955832$ m(零余量)或 $1125.254906$ m(1 m 余量)。
七点巡回长度 $6a$;$a=1200$ 时纯覆盖路线约 7200 m。
### 4.3 49 点基线
网格 $S=\{(700i,700j):i,j\in[-3,3]\}$ 覆盖 $[-2100,2100]^2\supseteq\Omega$;
每个单元的对角线 $700\sqrt2\approx989.95\le1000$,因此任意源都在某个
网格点的 1000 m 内。该证明不需要朝向信息,对问题三的七点方案是更强的
充分条件,但会发现阶段检测次数多一个数量级(频道 × 49 次);问题四仍
需要它作为 legacy 对照(见 `algo-4.md`)。
### 4.4 计划可验证性
`guarantee/cover.py` 提供两套校核:
- `verify_full_disk(points)`:在 $\Omega$ 内按网格采样,校核
“每个采样位置都在某个点的 1000 m 内”;
- `verify_forward_half_disk(...)`:第四问语义,问题三不使用。
七点计划走解析证明;49 点计划走 `full_disk` 校核。运行期不使用未校核的
点集来宣称“已证明无源”。
## 5. 定位与清除
### 5.1 问题二选点器的推广(基线与安全后备)
`planner.select_measurement_point` 对**任意** `CandidateRegion` 工作:
给定当前后验外包多边形 $P$,令其 MEC 为 $B(m,\rho)$,则安全接收盘为
$$
B\left(m,\ 1000-\rho-10^{-3}\right)
$$
($\rho>1000$ 时退化为探索 MEC 圆心,可能 `no_signal`)。盘内取
37 个候选点(圆心 + 3 环 × 12 方向),以“边界见证点 × 读数区间端点 +
near 分支”的最坏后验 MEC 半径为主指标、移动时间为次指标;选出后再按
1/4 间距加密复核。该函数最初为问题二实现,在问题三中服务于
`SevenBatch`、`OmnidirectionalLoop` 的定位子循环,以及 `BayesLoop`
的阻塞式安全后备 `_localize_and_clear`。
### 5.2 `bayes` 的信息增益选点
`BayesLoop._select_point` 在 PF 幸存集上选择定位测点,候选集分两种:
- 凸候选集安全盘非空:取 `select_measurement_point` 的环形候选,并额外
加入以当前位置到 MEC 圆心方向为轴的对称候选,以及零移动的当前位置;
- $\rho>1000$、无保证安全盘:取 PF MEC 的 $1.0/1.5/2.0$ 倍探索环和
PF MEC 圆心。
对候选 $q$,默认评分模式 `entropy` 是期望对数幸存占比:
$$
\text{score}(q)=
\mathbb E[\log(\text{后验幸存占比})]
+\lambda\left(\frac{\lVert p-q\rVert}{5}
+\frac{\lVert q-m_{\rm MEC}\rVert}{5}\right),
\qquad \lambda=0.05.
$$
其中期望对代表假设(从幸存格均匀抽样)和 $r_{\rm eff}\sim U(1000,1500)$
解析积分:
- `no_signal` 分支(概率 $(d-1000)/500$):幸存格收缩到
$B(q,1000-m)$ 之外;
- `direction` 分支:以代表点方位为期望读数,用 $\pm\delta_{\rm eff}$
角域裁剪后的子采样估计幸存占比;
- `near` 分支($d\le5+m$):后验为 5 m 盘,占比趋近零。
可选 `score_mode=radius` 直接最小化期望后验 MEC 半径。
`score_subsample=800`、`mc_representatives=7` 是工程参数;均匀源分布、
零误差代表读数和 $r_{\rm eff}$ 均匀分布都只是**选点评分假设**,不参与
任何安全证明。安全证明只使用题面给定的 $\pm1.005^\circ$ 角域与距离界。
当前配置中以下“更强的耦合”均已实现但实测为净负收益,默认关闭:
`fuse_localize_into_tour=False`(站点顺带定位)、`exact_order_limit=0`
(Held-Karp 精确任务序)、`route_aware_probe=False`(路线感知探点)。
代码与开关保留,便于复现与后续研究。
### 5.3 清除动作
`BayesLoop._best_certificate` 的优先级:
1. **凸证书**:`clear_certificate(region)`;
2. **PF 证书**(`grid_cert_enabled=True`):负观测挖洞使 PF 幸存集更紧时,
若 PF MEC 半径 $+m+0.5\le20$,用 PF MEC 圆心作为清除点。
得到证书后,`_near_clear_target` 还会把清除点从证书圆心向当前位置方向
平移一小段:设当前证书半径 $r$、限值 $limit$,最多平移
$\Delta=\min(\text{当前距离},limit-r-10^{-6})$,新目标点仍保证所有候选
源在 $r+\Delta\le limit$ 内。该平移可省去最后一段接近路程,不放松证书。
定位子循环每频道最多 `max_localize_steps=8` 次追加测量;若在安全接收盘内
出现 `no_signal`,记录异常 `no_signal_in_safe_disk`,不静默忽略。
## 6. 决策层:统一任务表与滚动重排
### 6.1 任务类型
`BayesLoop._build_tasks` 在每次决策点构建:
| 任务 | 位置 | 服务时间 | 触发条件 |
| --- | --- | --- | --- |
| `station` | 七站之一 | $5\times$待测频道数 + 换频 | 该站仍有 `UNKNOWN` 频道未测,且跳测规则未排除 |
| `locate` | 信息增益选点 | 5 s + 可能的换频 | 频道 `DETECTED` 且暂无清除证书,且未委托给站点 |
| `clear` | 证书中心(可平移) | 5 s 成功(失败 3 s) | 频道有清除证书且未处于“清除失败后待补测”状态 |
站点任务的服务时间计算当前频道优先:若机器人当前测向频道包含在待测集合,
则少一次换频。清除动作不改变测向频道。
### 6.2 跳测规则
`guaranteed_no_signal(station)`:若该频道所有幸存格心到站点的距离都
大于 $1500+m$,则该次测量必为 `no_signal` 且不会产生新排除,任务构造时
直接跳过。跳测缓存按 PF `version` 生效;幸存集单调收缩,因此判定单调。
这条规则是负观测给问题三带来的主要检测节省之一。
### 6.3 开放路径排序
任务表规模通常为 6~12 项,代价包含移动、检测、换频,终点自由。默认:
1. 最近邻构造初始序;
2. 开放路径 2-opt 改进,最多 `two_opt_passes=4` 轮;
3. 换频代价按实际频道状态沿路径逐步传递,而不是固定服务费;
4. 执行排序后的**第一个任务**,拿到响应后立即回到决策点重新构建/排序
(滚动时域)。
可选的 Held-Karp 精确 DP(含“已访问集合 × 结束时频道”状态)与
`route_aware_probe` 路线感知精修均已实现;在当前冻结基准集上,
精确 DP 与 2-opt 差距在噪声内(甚至略差),默认关闭。
### 6.4 已停靠位置的机会测量
`_measure_at_stop` 在每次决策点评估当前位置是否值得顺带补测已发现频道:
只对尚未在该 0.5 m 点测过、无证书、到凸候选集所有顶点距离均不超过
$1000-10^{-3}$ m、且预测后验半径至少收缩
`opportunistic_radius_ratio=0.5` 的频道追加一次测量。每次仍支付 5 秒与
实际换频成本,只是省去移动;它不是“免费检测”。
## 7. 停止、预算与异常收尾
### 7.1 完成证明
循环停止后,`summary.completed` 的依据分两类:
- `count_cap`:清除数达到 16(题面源数上界);
- `per_channel_proof`:每个频道状态都是 `CLEARED` 或 `ABSENT`。
缺席证据在 `summary.absence_evidence` 中逐频道记录来源:
| 证据 | 条件 | 严格性 |
| --- | --- | --- |
| `pf_empty` | 某频道 PF 幸存集为空 | 严格(Voronoi 真源保留) |
| `coverage` | 七点/49 点全部测得 `no_signal`(或计划上显式跳过) | 严格(解析/校核覆盖) |
| `count_bound` | 已有 16 个不同频道的有效存在证据 | 严格(题面数量上界) |
`persistent_search` **默认 False**:一旦“每频道已清除或证明不存在”就立即
`/exit`,因为虚拟时间是成绩口径。显式 `--persistent` 会继续复扫未清除
频道直到清除数达 16、实机预算耗尽或虚拟安全余量不足;历史官方演练证明
无条件复扫会浪费大量时间,因此该项目只作为可审计的保险开关保留。
### 7.2 预算与中断
- 虚拟安全余量 `virtual_reserve_s=10000` s:剩余虚拟时间不足以完成
一个保守单动作上界时停止发起新动作,避免触及 100 小时上限;
- 现实预算以服务器 `/enter` 返回的 `remaining_real_duration_s` 为准;
`DeadlineExceeded` 时 stop_reason 记为 `real_budget_exhausted`;
- 传输、协议或守恒性异常都会尽力 `/exit` 并输出未完成 summary,
不静默丢会话;`completed=False` 与具体 `stop_reason` 同时留档。
## 8. 基线与自建消融
### 8.1 调度变体
| 版本 | 发现/定位关系 | 说明 |
| --- | --- | --- |
| A0 `grid` | 49 点,发现后立即离站定位 | 历史生产基线 |
| A1 `SevenImmediate` | 七点,发现后立即离站定位 | 只替换覆盖点 |
| A2 `SevenBatch` | 七点,本站先扫完再按最近候选圆心服务队列 | 中间对照,曾用于官方演练 |
| A3 `SevenSweep` | 七点,先扫完全部必要站点再统一服务 | 批量调度的另一极端 |
A2 相对 A0 的收益主要来自“减少站间往返 + 每站批量扫频 + 无源频道只需
7 次而不是 49 次检测”。在早期 12 个自建场景上,A0/A1/A2/A3 平均虚拟
时间分别为 13788.04 / 7476.09 / 5496.20 / 6094.73 秒,A2 逐局平均降幅
58.55%;更晚的 14 场景消融中,`bayes` 相对 A2 再降 30.0%
(3726.2 s vs 5352.7 s)。这些数字来自自建模拟器与固定误差场,不是官方成绩。
### 8.2 冻结基准集当前结果
`project/q3` 在 18 个冻结 core 场景(seeds 100–105 × 10/13/16 源,
`harness/suites.py` 版本化清单)上的配对结果:
| 方法 | 有效局 | 全清率 | 虚拟时间均值 | 中位 | 最大 | 检测均值 | 请求均值 | 位移均值 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `bayes` | 18/18 | 100% | **3188.7 s** | 3111.3 s | 3663.7 s | 111.1 | 124.1 | 12338 m |
| `seven` | 18/18 | 100% | 5361.7 s | 5367.5 s | 6462.4 s | 100.8 | 113.8 | 23508 m |
| `grid` | 18/18 | 100% | 14935.7 s | 15275.1 s | 16691.4 s | 430.6 | 443.6 | 61550 m |
配对变化:`bayes` 相对 `grid` 虚拟时间 **−78.4%**,18/18 场景更快;
所有 54 局零缺席误判、零异常、正常退出且计时复算一致。另有边界场景
(源在 $\Omega$ 边界、$r_{\rm eff}=1000$)验证七点覆盖的扇区接缝。
### 8.3 官方演练记录(历史口径)
- 旧 49 点实现通过官方问题三演练案例 `DHFK-PHD2-SR2P-FXK9`:
10/10 全清、540 次检测、10 次清除,虚拟总时间 16217.45 s,
现实 7.629 s;
- A2 七点批量通过官方案例 `J4ER-Y9EP-Q379-UPFA`:12/12 全清、
109 次检测、虚拟 5232.13 s、现实 3.406 s;
- 后续一次官方问题三演练(`3BW5-9R54-NZNA-MYB9`)记录 13/13 全清、
虚拟 4058.71 s。
以上案例的源场景、实现版本、误差场均不同,不能直接横向比较,也不能把
历史官方数字当成当前 `bayes` 同案例重跑结果。当前默认 `bayes` 的官方向
成绩仍需新的演练验证。
## 9. 复杂度、参数与保守性边界
| 模块 | 计算量(近似) |
| --- | --- |
| $\Omega=B(0,R)$,$R=1800$ | 源位置区域,单位米 |
| $R_-=1000$,$R_+=1500$ | 有效接收半径下界、上界 |
| $p,f,t$ | 当前机器人位置、测向频道、官方虚拟时刻 |
| $K_c$ | 已发现频道 $c$ 的保守位置候选集 |
| $E_c$ | 尚未发现频道 $c$ 仍可能存在源的位置集合 |
| $\mathcal U,\mathcal D,\mathcal L,\mathcal C,\mathcal A$ | 未发现、待定位、可保证清除、已清除、已证明无源频道集 |
| $\mathcal P$ | 曾获得有效存在证据的不同频道集,包含已清除频道 |
| $\delta=1.005^\circ$ | 题设1°误差加两位小数舍入裕量 |
| $S$ | 具有全域覆盖证明的检测站集合 |
| $L,N_m,N_s,N_c^+,N_c^-$ | 移动长度、检测数、换频数、成功与失败清除数 |
源静止、同位置同频道的测向误差固定。只有该频道实际收到的观测才能更新其状态;经过一个位置不代表在那里扫过所有频道。不同频道的无信号证据不能互相替代。
### 3.2 统一的时间口径
忽略微秒量化时,整局虚拟耗时为
$$T=\frac{L}{5}+5N_m+N_s+5N_c^++3N_c^-.$$
`/clear` 的目标频道不改变测向机频道,因而不引入换频代价;`/exit` 不要求回到原点,也不增加虚拟时间。因此访问顺序是从当前位置出发、终点自由的**开放路径**问题,不能默认使用需要返回起点的旅行商回路。
官方内部按微秒累计,验收时按每次移动耗时量化到整数微秒,再累加检测、换频和清除的整数秒。连续实数公式用于规划,逐动作微秒公式用于复算。原演练汇总的541处偏差已查明为累计浮点与微秒精度口径不同,详见[独立审计](C:/Users/HelixCopex/code/MATH/output/q3/practice/20260911-port2026/verification.json)。
优化目标按优先级定义为:保持真源候选和有效完成条件;在现实1200秒、虚拟360000秒上限内全清;在可完成策略中降低 $T$。现实计算和网络耗时单独约束,不加进虚拟时间公式。由于源场景和误差场未知,理想模型可写为对允许场景集合 $\Xi$ 的 $\inf_\pi\sup_{\xi\in\Xi}T(\pi,\xi)$,但本文不声称解出了这个连续在线极小极大问题,也不声称已证明任意合法场景都能在两个预算内完成。
## 4. 逐频道证据与安全停止
### 4.1 观测更新
首次 `direction` 在 $s$ 返回 $\theta$ 时,建立
$$K_c=\Omega\cap B(s,R_+)\cap W(s,\theta,\delta).$$
以后每次有效测向再交入相应距离上界和角域。`near` 时交入 $B(s,5)$,当前检测点本身就满足20米清除条件。角域的半平面表达、保守圆盘外包和最小包围圆计算沿用[问题一](C:/Users/HelixCopex/code/MATH/docs/algo-1.md)、[问题二](C:/Users/HelixCopex/code/MATH/docs/algo-2.md)。
全向源在 $s$ 返回 `no_signal`,意味着若该频道仍存在未清除源,则其距离严格大于实际接收半径,进而大于1000米。因此对未发现频道可维护
$$E_c=\Omega\setminus\bigcup_{s\in\mathcal N_c}B(s,R_-),$$
其中 $\mathcal N_c$ 只含该频道实际测得无信号的位置。有限精度实现可只排除 $B(s,R_- -\eta)$,$\eta>0$ 是有依据的数值裕量;少删不会漏源。该负观测更新仅用于第三问,不能直接用于第四问的定向源。
圆盘差通常非凸。不能把扣除圆盘后的集合强行表示成单个凸多边形,更不能丢弃狭窄或小面积分量。第一阶段保留当前凸定位内核,仅在未知频道的覆盖账本中使用负观测;后续如把无信号用于已发现源的精定位,应使用多片区域或再取保守凸外包。
### 4.2 完成与计数推理
安全停止条件是 $\mathcal C\cup\mathcal A=\{1,\ldots,20\}$,且本局 `/exit` 已被接受。无源证明可来自完整覆盖、剩余集合的可靠判空,或下述数量上界。
由于每频道至多一个源且总数不超过16,当 $|\mathcal P|=16$ 时,所有未出现过有效信号的频道即可由数量上界判为无源,**不必等待16个都清除才停止扫未知频道**。随后仍须清除 $\mathcal P\setminus\mathcal C$,不能直接退出。成功清除16个则立即满足全清条件。
只发现或清除10个不够,因为10是下界。反过来,若无源证据已排除10个频道,则其余10个频道都必须有源,但仍需获取其定位观测;这种计数推理不能替代位置搜索。若已知存在与无源证据互相矛盾,或可存在频道数不足10,应报告协议、模型或数值不一致,不能把矛盾解释为成功。
## 5. 用七个检测站替代49点网格
### 5.1 布点
取原点及半径 $a$ 圆周上的六个等角点:
$$S(a)=\{(0,0)\}\cup\{a(\cos(k\pi/3),\sin(k\pi/3)):k=0,\ldots,5\}.$$
推荐首个工程版本固定 $a=1200$ 米。站点坐标为 $(0,0)$、$(1200,0)$、$(600,\pm600\sqrt3)$、$(-600,\pm600\sqrt3)$、$(-1200,0)$。这些点全部位于源圆域内;允许域外检测这一规则仍然保留,但本方案不需要依赖它。
### 5.2 连续覆盖证明
**定理1:** 若 $0<a/\sqrt3\le R$,则 $S(a)$ 对 $B(0,R)$ 的覆盖距离满足
$$\max_{g\in\Omega}\min_{s\in S(a)}\|g-s\|\le D(a):=\max\left\{\frac a{\sqrt3},\sqrt{R^2+a^2-\sqrt3Ra}\right\}.$$
**证明:** 任意 $g$ 写成极坐标 $(r,\theta)$。它与最近环上站点的夹角 $\varphi$ 不超过 $\pi/6$。令 $r_0=a/\sqrt3$。若 $r\le r_0$,选择原点,距离至多 $r_0$。若 $r\in[r_0,R]$,选择最近环站,有
$$\|g-s\|^2=r^2+a^2-2ar\cos\varphi\le r^2+a^2-\sqrt3ar.$$
右侧关于 $r$ 为凸二次函数,在闭区间最大值出现在端点。代入 $r_0$ 得 $a^2/3$,代入 $R$ 得第二项平方,从而得到结论。证毕。
代入 $R=1800$、$a=1200$,得到 $r_0=692.820323$ 米,$D(a)=968.901572$ 米,距离接收下界有31.098428米余量。因此每个合法全向源至少可在七站之一被检测到,不依赖位置概率、源数或源频道。
这是连续圆域的证明,不是“抽样没看到空洞”。此外用130501个极坐标采样点复算,最大最近站距为968.901572米,与解析值一致;该数值检查仅辅助发现公式或实现错误,见[覆盖校核数据](C:/Users/HelixCopex/code/MATH/output/q3/search-ablation/benchmark.json)。
**推论:** 对某频道七站全部测得无信号,就能证明该频道不存在源。若真实有源,覆盖定理给出一个距其小于1000米的站,在该站不可能返回无信号,矛盾。对已发现或已清除频道可以停止剩余覆盖检测,必须在逐频道账本中保存其原因。
### 5.3 环半径与时间的权衡
在本七点族内,若要求覆盖距离至多 $d\le1000$,外边界约束为
$$a^2-\sqrt3Ra+R^2-d^2\le0,$$
同时要求 $a/\sqrt3\le d$。当 $d\ge R/2$ 时,环半径的外边界允许区间为
$$R\cos(\pi/6)-\sqrt{d^2-R^2/4}\le a\le R\cos(\pi/6)+\sqrt{d^2-R^2/4}.$$
只按1000米边界、零裕量计算,最小 $a$ 约为1122.955832米;保留1米覆盖裕量时为1125.254906米。1200米不是最短路线的解析最优参数,而是在站距与覆盖裕量之间采用的透明工程选择。较小 $a$ 缩短站间路线,却改变后续发现时的交会几何和定位路程;应在独立场景上比较,不能仅由扫描路长选全局最优值。
从原点先到任一环站,再依次访问相邻五站,不返回原点,纯站间路长为 $6a=7200$ 米。每站最坏扫描20个频道,发现阶段最多140次检测,顺序扫描的换频次数至多139。因此这一**不含定位清除插入**的纯覆盖路线有上界
$$T_{\rm cover}\le7200/5+140\times5+139=2279\text{秒}.$$
不能把2279秒当成全清总耗时上界;定位插入还会改变站间行程。对本次官方案例的10个无源频道,完整覆盖所需检测可从490次降为70次,差420次、2100秒检测时间。这是覆盖账本的解析差额,不是已在相同官方案例实测的新算法收益。
七点既未被证明是最少站数,也未被证明在全部可行布点中路线最短。第四问边界朝外的定向源可看不到这些内侧站点,所以该定理仅进入第三问专用策略。
## 6. 同站批量扫频与定位任务队列
### 6.1 先完成本站,再离站
推荐的第一阶段策略如下:到达一个覆盖站,先对尚需在该站搜索的全部未知频道各测一次。若当前测向频道在待测集合中,将其排第一以节省一次换频,其余频道按固定顺序扫描。`direction` 只更新候选集并加入定位队列;`near` 可以在当前站立即清除,因为不需增加移动。完成本站的频道批次后,再决定离站执行哪些定位任务。
这保留每次请求的串行语义,也避免“查出一个频道—离站—返回—再查下一个频道”的结构性往返。全局频道分组,即先把某频道扫遍全域再换下一频道,虽然省换频,却可能把路径重复十余遍;每次检测移动只有超过5米,其耗时就已超过一次换频,应以空间批处理优先。
### 6.2 队列中的基本排序
第一版对待定位队列按当前点到候选集最小包围圆中心的距离选择最近频道,完成该频道的安全测向和清除后重新排序,然后访问仍有未知频道待测的最近覆盖站。圆心距离只是一种便宜的排序代理,不是实际完成该频道的精确剩余成本。
相比按频道编号处理,重新排序能考虑清除后的新位置;相比扫完七站才定位,同站发现后的批量服务能避免把所有局部信息拖到远处再处理。第12节的消融支持先采用这一方案,但最近优先没有全局最优或对所有场景更快的保证。
同一轮每个待定位频道最多调用一次既有定位子循环;该子循环每轮最多追加8次检测。未解决的频道保留在队列,不能忙等或重复清除同一份失败证书。覆盖阶段结束后再处理未解决任务;预算不足时明确记为未完成。
### 6.3 后续将队列代价改成“插入耗时”
设当前点为 $p$,下一计划覆盖站为 $b$。若某待清除目标允许的位置集合为 $\mathcal C_c$,其插入路径代价可写为
$$\Delta L_c=\min_{q\in\mathcal C_c}\bigl(\|p-q\|+\|q-b\|\bigr)-\|p-b\|.$$
再加5秒清除时间,即可与直接前往下一覆盖站比较。待定位目标用一次或两次安全测向的短程预演估计对应代价。存在多个目标时,采用最近插入、开放路径2-opt或限宽束搜索重排;测量产生新候选集后只执行当前最佳方案的第一步,再重规划。缺少未来目标真值时,这些都是显式的在线启发式。
“把所有新发现目标都做完再去下一站”是第一版规则,后续可允许代价过高的目标暂存,但要设置可审计的最大延后次数,并在搜索终止后清空队列,防止某个难目标长期得不到处理。
## 7. 无信号区域复用与自适应跳站
七点是固定的完成保证,负观测区域允许进一步减少实际检测。在定位、清除行程的已到达位置,可以评估是否顺路测量少量未知频道;每个额外频道仍需5秒,不能把“已到达”解释为“免费探测”。
### 7.1 具有保守性的区域表示
对每个未知频道以方形单元并集 $\widetilde E_c$ 外包 $E_c$。初始保留与 $\Omega$ 可能相交的单元。设单元中心为 $z$、半对角线为 $h$,只有满足 $\|z\|-h>1800$ 的单元才可直接判为域外;边界相交不确定时继续保留或细分。
一次该频道无信号观测位于 $s$,若整个单元满足
$$\|z-s\|+h\le1000-\eta,$$
则单元全部落入保证可收盘,可以安全删除。不得只检查单元中心,也不得用近似覆盖面积达到100%作为判空证据。若单元仍与真实残余相交,就保留它,直至可给出整单元删除证书。
**命题2:** 以上规则维持 $E_c\subseteq\widetilde E_c$,故 $\widetilde E_c=\varnothing$ 足以证明无源。反过来,$\widetilde E_c\ne\varnothing$ 可能只是离散保守性,不能据此断言有源。
七点有31.098米几何余量;可使最终方格边长不超过20米,其直径不超过28.285米。对任意与 $\Omega$ 相交的单元,取其中一个域内点,该点距某站不超过968.902米;单元全部点距该站不超过997.186米。因此在工程裕量小于约2.81米且正确处理边界相交的情况下,七站无信号足以最终删除这些单元。这里用整单元的最大距离证书;若只用 $\|z-s\|+h$ 的较松证书仍未通过,继续细分或直接使用七点账本证明,不错误地停在粗格残片上。
### 7.2 候选点与频道选择
新增候选点来自:未完成的七个锚点、当前位置、已规划的定位点/清除点、残余区域边界附近点。对 $q$ 和频道集合 $F$,定义
$$G(q,F)=\sum_{c\in F}\operatorname{area}\bigl(\widetilde E_c\cap B(q,1000-\eta)\bigr),$$
并以 $G(q,F)/(\|p-q\|/5+5|F|+N_{\rm switch}(F))$ 作为探索排序指标。面积是搜索潜力代理,不是发现概率或保证的信息增益;面积计算可近似,但只影响候选排序。无源判定仍由整区域覆盖证书给出。对极小、线状或退化残余,还要保留单元数、最远未覆盖距离及锚点完成优先级,不能因面积接近零而永久忽略。
在当前位置按每个候选频道的 $G(q,\{c\})$ 与5秒检测、可能1秒换频排序,只有优于当前预演方案时才添加。未来站点若要删除,必须重新证明该频道的剩余 $\widetilde E_c$ 被保留站点的保证接收盘覆盖;一次扫描所得“没有发现”只删该次盘内区域,不能整片按经验跳过。
### 7.3 完备后备
七点中未完成的“站点—频道”记录始终保留。自适应探索若连续不产生新的可靠覆盖、计算超时或动态证书不确定,则恢复固定七点访问。设置有限的额外探测额度或要求每次替换都完成至少一个新的覆盖记录,使启发式不能无限拖延覆盖完成。
这给出确定性的搜索后备:自适应部分只允许节省已证明冗余的动作,不允许删除尚未证明冗余的覆盖责任。该设计尚未进入当前生产实现,也未包含在第12节的七点消融中。
## 8. 定位和清除也应优化剩余时间
### 8.1 定位到“够清除”就停止
设保守多边形 $\widetilde K_c$ 的最小包围圆为 $B(m_c,\rho_c)$。清除阈值使用 $r_{\rm safe}=20-\varepsilon$,其中 $\varepsilon$ 覆盖实际计算和坐标提交误差。若 $\rho_c\le r_{\rm safe}$,就具有一次清除证书;没有必要为了获得更小半径继续增加检测或横移。
外包多边形本身已经包含真集,它的多余面积使决策更保守;不能把“外包多边形与原圆盘的径向差”未经传播分析当作浮点误差保证。现有实现还扣除了 `slack_m` 和工程清除余量,本次消融完整保留其行为;后续统一外包与数值误差口径时另行验收。
### 8.2 在所有保证清除点中选择短路径位置
若 $V_c$ 为 $\widetilde K_c$ 的顶点,则
$$\mathcal C_c=\bigcap_{v\in V_c}B(v,r_{\rm safe})$$
是凸集,非空当且仅当 $\rho(\widetilde K_c)\le r_{\rm safe}$。圆盘凸性保证覆盖全部顶点等价于覆盖整个多边形,故任意 $q\in\mathcal C_c$ 都能清除真源。
无后续任务时,选择 $\arg\min_{q\in\mathcal C_c}\|q-p\|$;已计划下一站 $b$ 时选择 $\arg\min_{q\in\mathcal C_c}(\|p-q\|+\|q-b\|)$。两者都是凸优化问题,可用二阶锥形式表达,也可在圆盘交的候选边界上求解后逐顶点复核。
轻量实现可先使用内部安全圆盘 $B(m_c,r_{\rm safe}-\rho_c)\subseteq\mathcal C_c$:将 $p$ 投影到该圆盘即可得到一个不远于前往 $m_c$ 的保证清除点。这只是对该充分圆盘的最近点,并非对整个 $\mathcal C_c$ 最优。若圆盘半径为0就退化到 $m_c$。
### 8.3 安全测点的目标由半径改为任务耗时
问题二提供的安全区域为 $\mathcal Q(K_c)=\bigcap_{g\in K_c}B(g,1000)$,可用解析盘或保守多边形构造充分区域。选点仍必须先满足接收保证,不能为走近一个猜测坐标而接受未经评估的失联风险。
在可安全测量的候选点中,使用以秒计量的短程代价
$$J_H(q)=\frac{\|p-q\|}{5}+5+\mathbf1_{f\ne c}+\max_{z\in\mathcal Z(q)}\widehat V_{H-1}(K_c\mid q,z;q,c),$$
其中 $z$ 包含 `near` 及允许的示向度,$\widehat V$ 估计完成该源及返回后续任务的时间。若某分支已能保证清除,其终端代价包括移动至 $\mathcal C_c$ 的时间和5秒清除;若未能清除,可继续一层安全测向,或赋予经过标定的剩余定位代价。未来测点、清除位置与访问顺序共同影响 $\widehat V$。
这个形式解释了为什么“后验半径更小”不一定“完成更快”:走远很久将半径降到5米,可能劣于近处测量后已经达到19米。所有实际比较项统一为秒,避免随意相加米、面积和时间。有限候选和有限视野只给出预演策略的启发式排序。
若要把 $\max_z$ 报告为严格上界,必须采用问题二第10节覆盖全部读数的角区间扩张,并对区域误差与终端代价也作保守上界。当前“边界位置采样×误差端点”并无该保证,不能仅因取了样本最大值就声称最坏总时间已受控。推荐先保留现有局部选点,验证搜索调度收益,再单独启用这一成本模型。
## 9. 保守性、有限结束与预算边界
**命题3(不漏发现):** 固定七点对每个仍未知频道完成扫描,或由可靠的残余覆盖/数量证书替代相应扫描,必能发现所有合法全向源。
**证明:** 固定点由定理1保证;动态替代保持该频道覆盖域包含所有仍可能位置;数量证书只在已有16个不同源的存在证据时排除其他频道。因此真源不可能被当作无源跳过。证毕。
**命题4(证书清除):** 若维护的外包集合包含真源,实际提交点满足对该外包的20米清除约束,则在题设规则下 `/clear` 必成功。几何证书通过而官方失败时必须记录异常并检查模型、数值及协议状态,不能在同一证书上循环重试。
上述命题不证明任意安全测点序列都会有限步收缩到20米。现有每轮8次测量是工程限额,不能作为收敛定理。拟在后续增加有限后备:对已发现频道的有界外包区域,用边长20米方格的中心覆盖所有相交格,任意格内点至格心不超过 $10\sqrt2<20$ 米;遍历该有限清除候选即可在模型成立时清除源。单元相交计算必须向保留更多格的方向保守。
后备仅在定位停滞时使用,并须先检查实际枚举格数、路径和预算。设当前到首格及格间的计划路长为 $L_f$,共 $M_f$ 格,最迟第 $M_f$ 格成功的虚拟耗时上界为
$$T_f\le L_f/5+3(M_f-1)+5.$$
使用相交格数硬顶、现实高分位单请求估计 $M_f\,t_{\rm RTT}^{95}$ 加退出预留,以及虚拟剩余预算共同筛选。现有第四问中的后备格算法可供复用,但不能跳过第三问的验证。硬顶或预算不允许时,保留“未完成”,不将耗尽预算说成证明无源。
因此本文区分三层结论:覆盖与证书的数学安全性;预算足够且正确遍历有限后备时的有限完成性;现实/虚拟双预算内的性能,目前由实验评价,尚无所有合法场景的统一完成上界。
## 10. 推荐算法与伪代码
**算法3A:七点覆盖+同站批量扫频+就近定位清除(优先落地版本)。**
```text
读取第三问参数,生成七点 S(1200),初始化逐频道观测和覆盖账本
调用 /enter,记录官方时间预算
while 尚有未知频道的站点记录未完成:
若预算不足:标记未完成,转退出
选择仍有未知频道待测的最近站点 s
在 s 批量测量待查频道(当前测向频道若在其中,优先测)
no_signal:记录该频道在 s 的覆盖证据
direction:更新该频道 K,加入待定位队列
near:在 s 清除并依据响应更新状态
对本站结束后的待定位队列:
按当前位置到候选圆心距离选最近频道
调用问题二安全选点与问题一清除证书完成其定位子循环
清除成功后重新排序;未完成者保留,单轮不无限重试
若已清除16个:结束任务
根据逐频道七点账本标记无源;继续处理尚未清除的已发现频道
仅在全频道均已清除/有无源证明时标记完成
调用 /exit;退出响应失败单独记录,不能宣称正常结束
```
**算法3B:在3A之上逐项增加的完整改进规划。** 每次 `direction/near` 更新存在集合 $\mathcal P$,达到16时提前结束未知频道搜索;在各实际测量点更新负观测区域并进行有证书的站点替代;用插入耗时比较“先搜下一站”与“先做某个源”;对候选路线重排;以预计剩余时间选择安全测点,在可清除区域中选择省路位置;定位停滞时按预算调用有限后备。每执行一个动作就用真实响应更新状态,禁止整段盲执行预测的后续动作。
```mermaid
flowchart TD
A[进入并读取预算] --> B[更新逐频道证据与完成条件]
B -->|已全清且无遗漏证明完整| X[退出并保存日志]
B -->|预算不足| Y[按未完成退出]
B -->|仍有任务| C[候选覆盖站与定位清除任务]
C --> D[比较短程总耗时并保持覆盖责任]
D --> E[同站批量扫描或执行一次定位清除动作]
E --> F[接收官方响应并验证候选及证书]
F --> B
```
第一版和后续规划分别命名,防止把3B尚未实现的负观测跳站、时间型选点和后备算法算进3A的测试成绩。
## 11. 计算量与工程实现
七点固定账本最多 $7\times20=140$ 个布尔记录。最近站选择检查7个点,每次按至多20个频道统计待测项;16个源的贪心任务队列重排规模很小。固定站路线的开放路径最优排序如需精确解,可使用子集动态规划,规模为 $O(k^2 2^k)$、$k\le7$;但加入观测依赖的定位任务后不再是同一个静态问题。
定位计算仍主要消耗在多个候选点、候选源见证点、读数分支的几何裁剪和最小圆计算。假设候选测点 $Q$ 个、读数区间 $B$ 个、外包顶点 $V$ 个,单层保守评估包含 $QB$ 次裁剪和最小圆计算,可表示为 $O(QB[V+T_{\rm MEC}(V)])$;两层分支不能简单称为线性。复用候选集缓存、先粗筛少量入围点,再完整区间验证,能够控制现实计算预算。
残余区域若使用 $H$ 个单元,20频道掩码空间为 $O(20H)$,一次频道负观测朴素检查 $O(H)$;可以预计算七锚点的单元覆盖掩码,并通过空间索引限制动态点的更新范围。分辨率只控制证书的保守程度,不得因内存不足删除尚未证实的残余格。
| 模块 | 当前事实 | 建议改动 |
| --- | --- | --- |
| `coverage` | 共用49点与逐频道账本 | 新增第三问七点构造;保留第四问网格及其独立证明 |
| `planner.OmnidirectionalLoop` | 发现后立即离站定位 | 把“本站扫频”与“队列服务”拆开,引入第三问策略选项 |
| `belief` | 无信号不裁剪候选 | 增加第三问独立残余覆盖层,避免破坏第四问朝向语义 |
| `geometry/planning`、`planner` | 有安全选点与最小圆证书 | 后续增加最近安全清除点和时间型候选评分 |
| `evaluation` | 浮点累计、固定微秒容差 | 单独兼容官方逐步微秒时钟和自建连续时钟,保留两种原始口径 |
| `protocol` | 串行、幂等、预算退出、JSONL日志 | 延用;增加的动作仍走同一客户端,不能并发测多个频道 |
实际路径分别位于 [coverage](C:/Users/HelixCopex/code/MATH/src/mcm26b/coverage/__init__.py)、[planner](C:/Users/HelixCopex/code/MATH/src/mcm26b/planner/__init__.py)、[belief](C:/Users/HelixCopex/code/MATH/src/mcm26b/belief/__init__.py)、[evaluation](C:/Users/HelixCopex/code/MATH/src/mcm26b/evaluation/__init__.py)。前两问文档中部分旧路径仍写为根目录 `geometry`,复用时以当前 `src/mcm26b/geometry` 为准。
## 12. 已完成的自建配对消融
### 12.1 实验口径
为验证优先级,新增独立脚本 [benchmark_q3_search.py](C:/Users/HelixCopex/code/MATH/scripts/benchmark_q3_search.py),只连接进程内 `LocalSimulator`,不连接官方端口、不修改生产策略。数据保存在 [search-ablation](C:/Users/HelixCopex/code/MATH/output/q3/search-ablation/benchmark.json),同时逐局保存源场景、固定误差场种子、接受动作日志与哈希。
共12个场景:源数10和16各一组,每组包含 `random_omni_scene` 种子0、1、2、3、4的5个场景,以及1个源位于1800米边界、接收半径均为1000米的场景。随机生成器径向采样不是圆域面积均匀分布,并特意含近边界源,不将其称为真实场景分布。各变体使用同一局源坐标、接收半径、频道和固定空间误差场;不同位置的合成误差机制不代表官方误差场。边界例包含扇区接缝附近的源。
比较四种版本,均沿用现有安全选点与清除内核、舍入裕量0.005°和8次追加检测限额:
| 版本 | 唯一涉及的搜索与调度变化 |
| PF 更新 | 与删除格/角向扫描的幸存格数相关;粗格索引与向量化(numpy 可选)控制常数 |
| 七点覆盖 | 最多 $7\times20=140$ 个 `(站, 频道)` 记录 |
| 任务排序 | 最近邻 $O(n^2)$ + 2-opt $O(n^2)$/轮;精确 DP 为 $O(2^nn\cdot|C|)$,默认关闭 |
| 凸候选集 | 192 边形外切 + $O(m)$ Sutherland–Hodgman 裁剪;MEC 用 Welzl |
| 选点评分 | $O(N_q\times(\text{子采样}))$;候选约 37 个,子采样 800 |
保守性边界:
- 所有“证毕”结论依赖题面观测语义和保守外包/内缩;若接口语义、
接收半径范围或误差界改变,必须重新校核;
- 性能结论依赖自建场景分布、固定误差场和排序启发式,不构成对全部合法
场景的最坏时间保证;
- PF 的均匀先验与信息增益评分是选点启发式,不是官方概率模型;
- `persistent_search` 关闭时,如果官方场景违反题面数量上界或存在模型外
行为,不能由本算法保证发现;官方接口语义变化时必须记录异常而不是
强行把未完成解释为成功。
## 10. 与问题一、二、四当前实现的复用关系和差异(核对结果)
| 复用/差异 | 具体情况 |
| --- | --- |
| A0:49点即时定位 | 当前生产基线 |
| A1:七点即时定位 | 只把覆盖点换成七点,仍发现即离站 |
| A2:七点本站批量 | 本站扫频完成后,按最近候选圆心服务队列;当前频道优先 |
| A3:七点全域先扫 | 扫完所有必要覆盖站后,再按最近候选圆心定位;near仍原地清除 |
| 问题一几何内核 | `belief/__init__.py` 与 `particles.py` 只从 `geometry.localization` 导入 `smallest_enclosing_circle`;在线 `clip_wedge` 复刻同一半平面公式,但工作在凸多边形裁剪流程,不调用 `solve` |
| 问题二选点器 | `select_measurement_point` 直接从问题二推广而来,问题三用它做基线定位与安全后备;`safe_disk_radius` 同样复用 |
| 问题一没有的 | 负观测 `no_signal`、PF 非凸挖洞、覆盖缺席证明、数量上界、任务排序、滚动时域、预算/异常治理 |
| 问题二没有的 | 多频道数据关联、发现覆盖、清除动作、在线顺序决策;问题二只做一次第二点选择 |
| 问题四的差异 | 问题四不能用七点 `full_disk` 缺席证明;`no_signal` 可能由朝向遮挡造成,因此改用 `forward_half_disk` 31 点覆盖 +(位置 × 朝向)联合信念 + 预算化后备清除(见 `algo-4.md`) |
A2/A3都包含当前频道优先这一小优化,因此A1与A2之差不是“纯粹只改变一个排序变量”的严格单因素实验。后续应再拆分消融换频顺序。3B的动态残余区域、16个已发现源提前停止搜索、时间型选点、最近安全清除点及后备均未在这组实验启用。
一句话概括:**问题三 = 问题一的保守几何 + 问题二的保证接收选点 + 逐频道非凸负观测 + 覆盖/数量缺席证明 + 开放路径滚动调度**。
### 12.2 全部12个场景结果
## 11. 复现与参考
| 版本 | 全清且正常完成 | 平均虚拟时间 / 秒 | 平均检测数 | 平均移动距离 / 米 | 平均逐局时间降幅 |
| --- | ---: | ---: | ---: | ---: | ---: |
| A0 | 12/12 | 13788.04 | 418.00 | 56183.95 | — |
| A1 | 12/12 | 7476.09 | 100.42 | 34146.70 | 43.25% |
| A2 | 12/12 | 5496.20 | 100.92 | 24177.66 | **58.55%** |
| A3 | 12/12 | 6094.73 | 101.67 | 27164.04 | 53.90% |
```bash
# 冻结基准集:bayes vs seven_batch(runner 内置策略名)
uv run python scripts/run_suite.py --problem 3 --suite core --jobs 6 \
--variants bayes,seven_batch --label q3-core
每个版本共清除156个源,48局合计624个源全部清除,无失败清除、无错误缺席判定、无定位异常。A2相对A0的逐局降幅范围为37.50%至70.84%。降幅按 $\frac1n\sum_i(1-T_{A2,i}/T_{A0,i})$ 计算,不能与两个均值之比混称同一个指标。
# 项目对比框架(含 grid 基线,生成 project/results/q3.{md,json})
uv run python -m project.run --question 3
另以保存的自建场景真值独立复核48局动作日志,逐步检查正观测后的候选保留真源、清除证书及实际清除距离、全清集合、计时、检测数、退出响应与日志哈希,全部通过,见[证据复核结果](C:/Users/HelixCopex/code/MATH/output/q3/search-ablation/evidence-audit.json)。这些真值只用于离线验收,不进入策略。
| 源数(每组6场景) | A0平均时间 / 秒 | A1平均时间 / 秒 | A2平均时间 / 秒 | A3平均时间 / 秒 |
| --- | ---: | ---: | ---: | ---: |
| 10 | 15516.58 | 6313.47 | **4836.11** | 5467.38 |
| 16 | 12059.50 | 8638.71 | **6156.28** | 6722.08 |
10源时,未知频道的无源证明开销较大,所以七点替换收益明显;16源时,可通过清除数量上界提前停止,原网格未必扫完,但批量调度仍显著减少移动。A1与A2检测数相近,A2平均移动距离进一步下降,表明减少往返比单纯少测一次更有价值。这组自建证据支持优先实现A2,而非先投入复杂概率图、强化学习或全局非线性优化。
四版本平均本地计算墙钟约1.7至1.8秒/局,记录在数据中;它不含官方网络、真实程序截止或官方UI开销,不能当作新方案官方现实耗时。原官方动作日志只能验证旧路径上的响应,不能用它推断新测点的读数。只有在官方新演练执行新策略后,才能报告官方提速。
### 12.3 复现
```powershell
.venv/Scripts/python.exe scripts/benchmark_q3_search.py --seeds 0,1,2,3,4
# 回归测试
uv run pytest tests/test_particles.py tests/test_bayes_loop_q3.py \
tests/test_planner.py tests/test_coverage_plan.py tests/test_safety_valves.py -q
```
也可使用 `uv run python` 调用同一脚本。输出包含原始逐局数据,失败不会从汇总中剔除。当前实验是小规模方案筛选,不能证明所有合法场景、所有固定误差场或不同环半径下的性能。
主要文件:
## 13. 分阶段改进与验收计划
- `src/mcm26b/planner/bayes_loop.py`:`bayes` 决策层;
- `src/mcm26b/planner/__init__.py`:`OmnidirectionalLoop`、问题二选点器、清除证书;
- `src/mcm26b/planner/seven.py`:七点策略族与覆盖校验;
- `src/mcm26b/particles.py`:网格幸存集与信息增益;
- `src/mcm26b/belief/__init__.py`:凸候选集;
- `src/mcm26b/coverage/__init__.py`、`src/mcm26b/guarantee/cover.py`:覆盖账本与校核;
- `src/mcm26b/routing.py`:开放路径 DP/2-opt/MST 下界;
- `src/mcm26b/planner/bayes_loop.py::_exact_order`:可选 Held-Karp。
| 阶段 | 交付 | 必须通过的验收 |
| --- | --- | --- |
| I:七点+本站批量 | 将A2接入第三问CLI选项,保留A0可切换;原始日志格式兼容 | 七点连续覆盖证明与坐标误差预算;10/16源及边界例全清;第四问覆盖策略未被改动 |
| II:存在计数与扫频次序 | $|\mathcal P|=16$ 提前停止未知频道扫描;同站频道优先独立消融 | 已发现数、已清除数不混淆;发现10个不能停;clear不改变测向频道 |
| III:残余覆盖与顺路观测 | 保守单元账本、负观测复用、有证书跳站 | 边界及单元接缝不漏;未知频道各有独立覆盖责任;启发式停滞自动恢复七点 |
| IV:时间型局部动作 | 最近安全清除点、短程任务预演、插入排序 | 实际提交点满足全部外包顶点约束;near和跨0°读数正确;采样评分不冒充严格上界 |
| V:定位停滞与预算后备 | 有界区域有限清除覆盖,明确上限与退出 | 清除格子覆盖全部候选;格数/两种时间预算不足均记未完成;故障与失败可回放 |
每个阶段都在相同场景和同一固定误差场上进行配对比较。建议将种子0—4保留为本次开发集;最终验证另生成独立种子100—199,并分别覆盖10至16个源、接收半径1000和1500米、边界源、团簇源、同站near、接近共线读数、误差恰在±1°及空间相关误差场。所有超时、矛盾和未全清样例进入结果表,不允许只报成功局。
主要指标为全清且正常退出比例、平均/中位/P95/最大虚拟总时间、移动长度、检测数、换频数、清除失败数、证明无源的成本;同时报告最后清除时刻、现实计算时间与总请求数。小样本不确定性用逐局配对差分及其范围表达,增加样本后可在声明抽样假设的前提下给出区间估计;不从当前12例推出总体成功率为100%。
官方验收继续使用 socket 接口,逐条保存请求响应,最后保存官方结果及加密日志;优先使用官方支持的同案例复现功能进行配对,如果无法复现同一案例,就分别报告案例差异,不将两次随机局的时间差当成纯算法收益。本轮只完成方案研究与自建实验,没有再次启动官方演练。
## 14. 结论与实现边界
第三问首先应把覆盖责任从49点缩减为已证明足够的七点,并将覆盖站内扫频和离站定位拆开。该改进直接针对官方日志中的主要开销,在保持几何定位方法不变时,已经得到12个自建场景的配对支持。推荐先落地A2,再独立验证存在数量上界、残余覆盖和时间型测点/清除点优化。
初稿形成时,七点与三种调度变体存在于离线消融脚本,官方仅验证A0;随后A2已通过下述一次官方演练。生产 `src/mcm26b` 默认策略仍未修改,A2由独立运行脚本调用。3B给出了可实现的进一步规划,但没有将其预期收益写成已测结果。固定覆盖、观测集合和清除证书负责可靠性;候选评分、任务排序和半径调参负责效率,二者的保证范围分别陈述。
## 15. 补充:A2官方演练已通过
在方案研究之后,用户启动新的问题3演练,于2026-09-11通过2026端口实际执行A2。案例 `J4ER-Y9EP-Q379-UPFA` 的官方源数为12,12个全部清除,109次检测、12次清除无失败,现实耗时3.406秒,总虚拟耗时5232.132109秒(1小时27分12秒),移动22645.661米。其余8个频道各有完整七点无信号记录,最终正常退出。
123个请求响应已严格离线回放,清除证书和官方微秒计时全部通过复核。原始数据、官方产物、运行代码哈希及复现入口见[本局验证报告](C:/Users/HelixCopex/code/MATH/output/q3/practice/20260911-seven-batch-01/report.md)。因此A2现已有自建配对实验及一个官方案例的有效性证据。
相较之前A0官方10源案例的16217.454814秒,本局虚拟时间少67.74%,但案例和源数不同,不能把该差异称为严格算法提速率;第12节的58.55%仍仅指12个自建场景的配对平均结果。3B的进一步改进尚未启用。
延伸阅读:`docs/algo-3-bayes.md`(早期贝叶斯闭环设计与消融细节)、
`docs/第三四问架构优化与运行提速方案.md`(运行历史与瓶颈)、
`docs/第三四问图论与动态规划优化方案.md`(图论/DP 轮实测与负面结论)。
## 参考文献
1. 全国大学生数学建模竞赛组委会. 2026年高教社杯全国大学生数学建模竞赛B题:无线电干扰源的快速自动定位与清除[Z]. 2026. [题面](C:/Users/HelixCopex/code/MATH/issue/B题.pdf);模拟器配套材料《附件2:模拟器通信接口说明及编程指南》[本地文件](C:/Users/HelixCopex/code/MATH/issue/附件/附件2.docx)。
2. 本项目. 问题一:有界测向误差下定位区域的直径与圆覆盖;问题二:具有接收保证的第二检测点选择[Z]. 2026. [algo-1](C:/Users/HelixCopex/code/MATH/docs/algo-1.md)、[algo-2](C:/Users/HelixCopex/code/MATH/docs/algo-2.md)。
3. BAYRAM H, VANDER HOOK J, ISLER V. Gathering Bearing Data for Target Localization[J]. IEEE Robotics and Automation Letters, 2016, 1(1): 369–374. DOI: 10.1109/LRA.2016.2521387. [作者所在大学的研究记录](https://experts.umn.edu/en/publications/gathering-bearing-data-for-target-localization-6/)。核对题目、作者、页码及移动与测量联合代价;未套用其GDOP近似比。
4. OLOFSSON J, HENDEBY G, LAUKNES T R, JOHANSEN T A. Multi-Agent Informed Path Planning Using the Probability Hypothesis Density[J]. Autonomous Robots, 2020. [作者公开稿](https://torarnj.folk.ntnu.no/AURO2020a.pdf),第1—2节。用于未观测区域与预算路径的建模对照。
5. PERERA A, TRAN V P, ANAVATTI S, KASMARIK K, GARRATT M. Radio Source Localization using Sparse Signal Measurements from Uncrewed Ground Vehicles[EB/OL]. arXiv:2312.03493, 2023. [原文入口](https://arxiv.org/abs/2312.03493)。用于稀疏覆盖与实验设计的对照,RSSI估计器未迁移。
6. ATANASOV N A, LE NY J, PAPPAS G J. Distributed Algorithms for Stochastic Source Seeking with Mobile Robot Networks: Technical Report[EB/OL]. arXiv:1402.0051v2, 2014. [原文入口](https://arxiv.org/abs/1402.0051)。用于说明场强梯度和测量模型假设的适用边界,不引用其结论证明本算法收敛。
在线来源核对日期:2026-09-11。本文的七点构造、覆盖界和具体调度规划来自对题设参数的推导与本项目实验,不宣称一般几何和路径规划思想在文献中首次出现。
1. 全国大学生数学建模竞赛组委会. 2026 年高教社杯全国大学生数学建模竞赛 B 题[Z]. 2026. 本地题面 `issue/B题.pdf`。
2. 模拟器配套材料. 附件 1《模拟器使用说明》、附件 2《通信接口说明及编程指南》[Z]. 2026. 本地 `issue/附件/`。
3. 本项目. `docs/algo-1.md`、`docs/algo-2.md`、`docs/algo-4.md`。
4. 本项目. `docs/算法审核与优化-20260911.md`、`docs/第三四问架构优化与运行提速方案.md`、`docs/第三四问图论与动态规划优化方案.md`。
+485
View File
@@ -0,0 +1,485 @@
# 问题四算法说明:混合全向/定向源搜索与清除闭环(当前实现)
> 本文汇总当前仓库中问题四的全部实际算法。生产策略是
> `src/mcm26b/planner/__init__.py::DirectionalExtension`,CLI 策略名
> `directional`,入口为 `mcm26b run --problem 4`。
> 与问题三不同,问题四的源可能包含定向源;`no_signal` 不再等价于
> “距离超过接收半径”,因此问题三的七站全向缺席证明**不能直接搬用**。
> 当前仓库尚无官方问题四演练记录;本文性能数字均来自自建
> `LocalSimulator` 冻结基准集,明确标注为测试假设。
## 1. 问题定位与核心难点
问题四每局有 $10\sim16$ 个源,其中一部分为定向源,其余为全向源;
位置在 $\Omega=B(0,1800)$ 内,频道互异。定向源有一个朝向
$h$,只有当接收点位于其**前向半平面**内、且距离不超过 $r_{\rm eff}$
时才可能返回有效读数:
$$
r=\lVert q-P\rVert\le r_{\rm eff}\quad\text{且}\quad
\operatorname{angular\_gap}\big(\operatorname{bearing}(P\to q),h\big)
\le90^\circ .
$$
其中 $r_{\rm eff}$ 对每个源未知,满足 $1000\le r_{\rm eff}\le1500$ m。
若 $r>r_{\rm eff}$ 或朝向不覆盖接收点,结果为 `no_signal`;若可见且
$r\le5$ m,结果为 `near`;若可见且 $r>5$ m,结果为 `direction`,
数值为接收点到源的方位加有界误差。
因此问题四的困难不是一般的“覆盖搜索”,而是两个额外的证明/决策问题:
1. **发现**:如何用有限的访问点证明“某个频道没有源”,即使该源是
朝外发射的定向源;
2. **定位与清除**:定向源的 `no_signal` 可能只是朝向遮挡,不能像全向源
那样删除位置候选;需要推理“源可能朝哪边”,并在定位停滞时仍能可靠
清除。
当前实现的办法是:
- 发现层用 `forward_half_disk` 语义的 31 点最小命中集覆盖计划
(`q4_mincover_v2`);
- 感知层在原有位置网格上叠加 24 档朝向掩码,得到(位置 × 朝向)联合
可行集 `JointBelief`;
- 决策层用联合信念的可见性概率筛选探点,失败时按预算进入 ≤20 m 的
有限格后备清除;
- 调度层用“本站批量扫频 + 按候选 MEC 圆心最近服务队列”的滚动顺序,
避免发现后立即远行、再返回同站扫描的往返。
| 层次 | 文件/类 | 作用 |
| --- | --- | --- |
| 覆盖计划 | `src/mcm26b/guarantee/cover.py`、`guarantee/plans/q4_mincover_v2.json` | `forward_half_disk` 最小命中集与运行期校核 |
| 位置网格 | `src/mcm26b/particles.py::ChannelParticles` | 位置幸存集与 Voronoi 保守裕量 |
| 朝向联合信念 | `src/mcm26b/belief/joint.py::JointBelief` | 每格 24 档朝向掩码、可见性/期望后验半径 |
| 决策闭环 | `src/mcm26b/planner/__init__.py::DirectionalExtension` | 批量扫频、可见性探点、后备清除、收尾 |
| 对比框架 | `project/q4/{optimal,baseline,compare}.py` | `directional` vs `bayes`/`grid` 直接搬用 |
| 回归 | `tests/test_loop_q4.py`、`tests/test_joint_belief.py`、`tests/test_coverage_plan.py` | 真源保留、朝外边界源、后备纪律、证书校核 |
| 结果 | `project/results/q4.{md,json}` | 11 个 core+boundary 场景配对结果 |
## 2. 发现覆盖:`forward_half_disk` 与最小命中集
### 2.1 覆盖语义
设访问点集合为 $S$。对全向源,`no_signal` 在 $s$ 处排除 $B(s,1000)$,
因此完整覆盖只需
$$
\Omega\subseteq\bigcup_{s\in S}B(s,1000).
$$
对定向源,还需要朝向条件。严格的缺席证明要求:
$$
\forall P\in\Omega,\ \forall h\in\mathbb S^1,\quad
\exists s\in S:\quad
\lVert s-P\rVert\le1000
\ \wedge\ (s-P)\cdot u(h)\ge0 .
$$
也就是说,对任意位置和任意朝向,至少有一个被访问点同时落在该源的
1000 m 距离内和 90° 前向半平面内。这个语义称为 `forward_half_disk`。
问题三的七站环不满足该语义:`tests/test_coverage_plan.py` 中有专门用例
验证“七站点集在 `forward_half_disk` 下有缺口”。
### 2.2 49 点 legacy 充分条件
旧实现使用 $S=\{(700i,700j):i,j\in[-3,3]\}$ 的 49 个点:
- 任意 $P\in[-2100,2100]^2$ 到某个网格点的欧氏距离不超过
$700\sqrt2\approx989.95\le1000$;
- 因此对该网格点,无论源朝向如何(前向半平面总是 $180^\circ$),
距离条件都成立。
这是 49 点充分条件,保证发现但代价很高:最近邻开放巡回约 36.4 km;
对无源频道还要在 49 个点各测一次。它现在保留为 legacy 对照与回退计划
`square49`。
### 2.3 31 点最小命中集 `q4_mincover_v2`
当前默认计划由离线贪心集合覆盖搜索得到,并带有可运行期校验证书。
生成参数:搜索分辨率 50 m / 48 档,设计距离裕量
$r_{\rm design}=965$ m,前向角裕量 $5^\circ$;随后用证书分辨率
15 m / 72 档($3\,256\,200$ 个 $(P,h)$ 采样)做精细剪枝,每删一个点都
用同一证书口径复核。
`q4_mincover_v2` 的证书摘要:
| 字段 | 值 |
| --- | ---: |
| 点数 | 31 |
| 覆盖语义 | `forward_half_disk` |
| 校核分辨率 | 15 m / 72 朝向档 |
| 采样目标数 | 3 256 200 |
| 缺口数 | 0 |
| 最坏命中距离 | 990.051 m |
| 最近邻开放巡回 | 23 883.8 m(约 4776.8 s 虚拟移动) |
| 生成器 SHA-256 | `adf4fe6e…434c0e1d`(完整值见 JSON) |
旧 `q4_mincover_v1`(31 点)在粗采样 75 m/24 档下看似零缺口,但在
25 m/48 档有 125 个缺口、15 m/72 档有 566 个缺口,属于采样过拟合;
`q4_mincover_v2` 修正了这一点,点数不变而细网格缺口归零。
**严格连续空间证书的已知残余风险.** 15 m/72 采样格上的零缺口加上设计
裕量可以覆盖相邻未采样 $(P,h)$,但默认 v2 的最坏命中距离 990.051 m
略高于严格连续界要求的约 989.4 m(需满足
$r_{\rm verify}+15\sqrt2/2\le1000$,即 $r_{\rm verify}\le989.4$ m)。
仓库另有 36 点备选计划(最坏命中 973.7 m、巡回约 25.4 km,见
`output/q4/cover-certificate-v3.json`)可关闭该连续性缺口;当前默认的
v2 在 15 m/72 离散证书上零缺口,连续空间残余约 0.65 m,已作为已知
风险登记。若论文要求严格连续证明,应改用 36 点方案并重新验收性能。
### 2.4 运行期校验
`load_plan(name, verify=True)` 会读取计划 JSON 中的证书字段,并用
**证书自带的采样分辨率**重新校核;缺口不为 0 直接拒绝启动。默认
`search.verify_coverage_plan=True`。校核成本在进程内按计划名缓存一次,
15 m/72 档约数秒(见 `scripts/run_suite.py` 的 `plan_verify_wall_s`)。
运行期不会用比证书更粗的采样去“自证”。
## 3. (位置 × 朝向)联合信念 `JointBelief`
### 3.1 网格与朝向档
位置维度复用 `ChannelParticles`:间距 $h=14.65$ m、Voronoi 裕量
$m=\sqrt2/2\,h\approx10.359$ m;朝向维度把整周分为
24 档(每档 $15^\circ$,半档 $7.5^\circ$)。每个位置格附带一个 24 位
掩码,表示该格仍可能具有的朝向;缺省掩码为全档。
联合集合的保守性目标是:**真源 (位置, 朝向) 永不被删除**(在该离散
表示意义下,即真源所在格的朝向至少保留一档,且位置格不被删)。因此
所有判定都向“保留更多”的一侧取整,并计入格内角裕量
$$
\alpha_{\rm cell}=\arcsin\!\left(\frac{m}{\max(d-m,1)}\right),
$$
即格内任一点相对格心到测点方位的最大偏差上界。
### 3.2 观测更新规则
| 观测 | 位置维度 | 朝向维度 |
| --- | --- | --- |
| `direction` @ $s$,读数 $\beta$ | 删除距 $s>1500+m$ 的格;删除角向偏离读数超过 $\delta_{\rm eff}+\alpha_{\rm cell}$ 的格 | 朝向必须**可能覆盖** $s$:保留与方位 $\operatorname{bearing}(g\to s)$ 夹角不超过 $90^\circ+7.5^\circ+1.5^\circ+\alpha_{\rm cell}$ 的档 |
| `near` @ $s$ | 删除距 $s>5+m$ 的格 | 同 `direction`:朝向必须可能覆盖 $s$ |
| `no_signal` @ $s$ | **位置不删**(可能是距离不足或朝向遮挡) | 仅对“整格必在 $r_{\rm eff,min}=1000$ 圆盘内”的格,保留与方位夹角大于 $90^\circ-7.5^\circ-1.5^\circ-\alpha_{\rm cell}$ 的档,即只保留**确定看不见** $s$ 的朝向档;掩码变空则删除该位置格 |
其中 `1.5°` 是朝向查表分辨率带来的保守余量,$7.5^\circ$ 是半档。
`no_signal` 的距离阈值使用 $1000-m$(保守侧):只有在确信整格都在
1000 m 内时,才把静默解释为朝向遮挡;否则保留全部朝向档,避免删掉
真源。
> 2026-09-12 修复记录:早期实现把 `no_signal` 分支的朝向掩码方向写反,
> 会删除真源朝向档;现已改为“只保留确定看不见的档”,并由
> `tests/test_joint_belief.py::test_true_source_survives_consistent_observations`
> 以随机观测序列固化。
联合集一旦为空(所有位置格都被删除),就给出该频道不存在的严格缺席
证明,`absence_evidence` 记为 `joint_empty`。这与覆盖证明是两类独立证据。
### 3.3 可见性与值函数
`JointBelief` 提供两个只用于选点排序的启发式量:
1. `prob_detect(q)`:在测点 $q$ 能看见该频道任一可行
(位置, 朝向) 的概率质量。对抽样格心 $P$:
- 距离概率:$d\le1000$ 时为 1;$d\ge1500$ 时为 0;中间线性插值
(对应 $r_{\rm eff}\sim U[1000,1500]$);
- 朝向覆盖比例:该格掩码中与“$P\to q$ 方位”夹角不超过
$90^\circ$ 的档位占比;
- 两者相乘后对格取平均。
2. `expected_posterior_radius(q)`:一次测量后的期望后验 MEC 半径(D1
值函数)。对每个代表格:
$$
p_{\rm vis}=(1-p_{\rm silent\_range})\cdot\text{cover\_ratio},
\quad
p_{\rm ns}=1-p_{\rm vis},
$$
- `direction` 分支:后验半径用“以 $P\to q$ 方位为读数、$\pm\delta_{\rm eff}$
角域裁剪后的子采样质心最大距离”近似;
- `no_signal` 分支:位置候选**不缩小**,半径保持当前 PF MEC 半径;
- `near` 分支:后验为 5 m 盘。
最后对代表假设取平均。
这两个量的先验(均匀格点、$r_{\rm eff}$ 均匀分布)是工程假设,不承担
任何安全责任;安全责任由覆盖证书、清除证书和联合真源保留定理承担。
## 4. 决策闭环 `DirectionalExtension`
### 4.1 覆盖阶段:本站批量扫频 + 最近队列服务
```text
while 未达到数量上界且未停止:
index = 尚有 UNKNOWN 待测的最近覆盖点(31 点计划)
if 不存在: break
pending = 该点尚未测过的 UNKNOWN 频道
pending 按“当前测向频道优先”排序
for channel in pending:
outcome = /measure(point, channel)
observe(channel, point, outcome) # 更新凸信念 + JointBelief
if outcome == near 且候选集有清除证书:
就地 /clear
queue = 所有 DETECTED 且无证书的频道
while queue:
channel = 距其候选集 MEC 圆心最近的频道
从 queue 移除并调用 _localize_and_clear(channel)
```
与问题三 `SevenBatch` 的结构类似,但覆盖点集、缺席语义和定位逻辑完全不同。
`observe` 对 `direction`/`near` 更新凸候选集和联合信念;对 `no_signal`
只更新联合信念的朝向维度,绝不用于删除凸候选集的位置。
覆盖阶段结束后(所有计划点上的 `UNKNOWN` 频道都测过):
`_mark_absent_after_coverage` 把覆盖账本完整的 `UNKNOWN` 频道标为
`ABSENT`;然后 `_resolve_detected` 再对剩余 `DETECTED` 频道做第二轮
定位。
### 4.2 定位子循环
`DirectionalExtension._localize_and_clear(channel)`:
```text
步数 steps = 0,被遮挡探点表 blocked = []
while steps < max_localize_steps=8:
if 凸/PF 清除证书已成立: 尝试 /clear;成功则返回
if len(blocked) >= max_blocked_probes=4: break # 定位停滞 -> 后备
q = _next_probe(region, channel)
within_range = (|q - region.MEC 圆心| <= safe_disk_radius(region))
outcome = /measure(q, channel)
observe(...)
if outcome == no_signal:
if within_range: 标记 directional_observed
blocked.append(q); continue # 不消耗 steps
else: # direction 或 near
blocked.clear(); steps += 1
循环结束后再检查证书;若仍不可清除 -> _fallback_clear(channel)
```
要点:
- 只有**在保证接收盘内**出现 `no_signal` 才标记“观测到定向”,避免把
距离不足误诊为朝向遮挡;
- 被遮挡探点不重复选取;最多 4 次,随后转入后备清除;
- `direction`/`near` 读数才计入定位步数;
- 每一步后都重新评估证书,滚动推进。
### 4.3 `_next_probe`:联合信念选点与后备梯子
候选点族 `_probe_candidates` 包含:
1. `select_measurement_point` 给出的保证接收盘内最坏后验最优候选
(复用问题二/问题三的选点器);
2. 若已有被遮挡点,取关于 MEC 圆心的对跖方向点,并裁进保证接收盘;
3. 抵近点序列 `center + α*(current-center)`(代码保留,默认
`approach_fractions=()` 未启用);
4. 以 MEC 圆心为心、半径 $0.5\rho,\ 1.0\rho,\ 1.5\rho$ 的 8 方向环探。
候选去重到 0.5 m,并可排除已测过且被遮挡的点。
若联合信念可用,优先调用 `_visibility_probe`(默认口径
`probe_score_mode=nearest_visible`):
1. 对每个候选计算 `prob_detect(q)`;
2. 只保留概率不低于最大值 0.7 倍的候选
(`probe_visibility_ratio=0.7`),避免选中“期望上有点信息、实际必被
朝向遮挡”的点;
3. 在保留候选中选择
$\text{move}(p\to q)+\text{move}(q\to m_{\rm MEC})+6$ 秒最小者
(6 = 检测 5 s + 换频 1 s)。
若联合信念不可用/为空或候选为空,则退回确定性梯子:
未被遮挡过用最坏后验选点器;第一次被遮挡用对跖点;之后绕 MEC 圆心
半径 $\rho$ 的环上每次 +45° 试探。几何边界必须如实说明:环上可见弧
没有正的下界(源可在环上且朝向沿外切线,可见集退化为切点),因此
梯子只是提高命中概率,**完备性由后备清除兜底**。
### 4.4 后备清除:有限格盲清
定位停滞时,`_fallback_clear` 对凸候选多边形生成 20 m 格心的有限覆盖:
1. 用全局 20 m 半开网格与候选多边形求交(带正容差,宁可多保留格);
2. 格内任一点到格心不超过 $10\sqrt2\approx14.14\le20$ m,因此若真源
仍在候选多边形内,逐格 `/clear` 至少命中一次;
3. 格数上界
$(\lceil w/20\rceil+2)(\lceil h/20\rceil+2)$;
4. 若格数超过 `fallback_max_cells=400`,记录
`fallback_cell_cap_exceeded` 并跳过,不硬扫;
5. 若现实剩余预算不足以覆盖
`格数 × real_rtt_p95_s + reserve_exit_s`,记录 `skipped_budget`
并跳过;
6. 通过检查时,按最近邻开放路径逐格 `/clear`;命中成功即标记清除;
全部失败则记录 `fallback_exhausted_without_success`,**不删除候选集**。
后备清除只依赖“清除与朝向无关”这一题面事实,是定向源定位失败时唯一
不依赖可见性的完备性底线;但它受格数上限和现实预算约束,预算不足时
会诚实地保持未完成。`fallback_optimize_tour=False` 为默认:实测把
格间路径排得更短反而会让“命中即停”的真源顺序变差,故不启用。
## 5. 调度、完成条件与预算
### 5.1 与问题三调度层的关系
`DirectionalExtension` 继承 `OmnidirectionalLoop` 的会话、预算、异常
和 summary 骨架,但不使用 `BayesLoop` 的统一任务表/信息增益调度;
它采用“每站批量扫频 + 本站服务队列”的滚动策略(类似 A2 的推广)。
因此:
- 检测/换频/清除的计时口径与问题三完全一致;
- 覆盖点、缺席证据、定位方法、清除证书不同;
- 默认不启用 Held-Karp/2-opt 任务表;后备清除走最近邻。
### 5.2 完成条件
- 清除数达到 16:直接停止并按 `count_cap` 完成;
- 否则要求每个频道 `CLEARED` 或 `ABSENT`,按 `per_channel_proof` 完成;
- 缺席证据来源:
- `coverage`:该频道在 31 点计划上全部测得 `no_signal`;
- `joint_empty`:联合可行集为空;
- 定位/清除尚未完成或预算耗尽:`completed=False`,
`stop_reason=unresolved_channels` / `real_budget_exhausted` /
`virtual_time_cap` 等,不把未完成说成成功。
## 6. 关键参数
| 参数 | 默认值 | 说明 |
| --- | ---: | --- |
| `q4_coverage_plan` | `q4_mincover_v2` | 31 点定向最小命中集 |
| `verify_coverage_plan` | `True` | 运行期按证书分辨率校核 |
| `directional_joint_belief` | `True` | 启用(位置 × 朝向)联合信念 |
| `probe_score_mode` | `nearest_visible` | 可见性过滤 + 移动最省 |
| `probe_visibility_ratio` | 0.7 | 可见概率保留比例 |
| `probe_exclude_blocked` | `True` | 不重复测已遮挡点 |
| `approach_fractions` | `()` | 抵近点默认关闭 |
| `max_localize_steps` | 8 | 每轮定位成功读数上限 |
| `max_blocked_probes` | 4 | 被遮挡探点上限,超限转后备 |
| `fallback_clear_grid_m` | 20 m | 后备清除格边长;$10\sqrt2<20$ |
| `fallback_max_cells` | 400 | 后备格数硬顶 |
| `fallback_optimize_tour` | `False` | 后备走格 NP+2-opt 默认关闭 |
| `persistent_search` | `False` | 找全证明后立即退出 |
## 7. 已验证结果与失效机理
### 7.1 冻结基准集
`project/q4` 在 11 个冻结 core+boundary 场景上的配对结果:
| 方法 | 有效局 | 全清率 | 缺席误判 | 虚拟时间均值 | 中位 | 最大 | 后备清除均值 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `directional` | 11/11 | **100%** | **0** | 9069.9 s | 8803.3 s | 12081.1 s | 24.8 |
| `bayes` 直接搬用 | 0/11 | 77% | **24** | 3890.8 s | 3646.2 s | 5054.4 s | 0 |
| `grid` 直接搬用 | 0/11 | 67% | 0 | 18179.2 s | 18345.2 s | 20457.9 s | 0 |
两条搬用基线的失效机理不同:
- `bayes` 使用七站**全向**缺席证明;在朝外发射、$r_{\rm eff}=1000$ m 的
边界定向源场景中会把真源直接判成 `ABSENT`。它“快而错”,累计 24 次
缺席误判,虚拟时间不能作为有效优势;
- `grid` 的 49 点覆盖对定向源发现是完备的,但没有朝向推理与后备清除,
定位停滞时只能停在 `DETECTED`,属于“稳而不全”。
`directional` 在 11/11 场景全清、零缺席误判;代价是时间更长,平均
24.8 次后备清除。纯 core 9 场景(不含朝外边界压力)的均值约 8602.0 s、
最大 9630.4 s、后备 4.3 次/局;boundary 场景(朝外发射源)会把后备
推到数十至上百次,单局最大 12081.1 s(16 源 8 定向边界场景)。
因此第四问的时间对“源是否背离圆心发射”非常敏感,不存在对所有合法
场景的均匀性能保证。
### 7.2 覆盖计划消融
| 计划 | 点数 | 15 m/72 缺口 | 最坏命中 | NN 巡回 |
| --- | ---: | ---: | ---: | ---: |
| `square49` | 49 | 0 | ≤1000 m(构造) | 36.4 km |
| `q4_mincover_v1` | 31 | 566 | 1000.0 m | 22.9 km |
| `q4_mincover_v2`(默认) | 31 | **0** | 990.051 m | 23.9 km |
| 36 点严格连续备选 | 36 | 0 | 973.7 m | 25.4 km |
改用最小命中集后,覆盖点减少了 37%,巡回长度从 36.4 km 降到 23.9 km;
但第四问时间的大头仍是 23.9 km 巡回(约 4776.8 s)和逐频道缺席检测。
把七站式的“有源提前停止”规则用于 31 点计划可在一定程度上减少检测,
但最小命中集的巡回与检测开销仍是该问题的成本地板(详见
`docs/第三四问图论与动态规划优化方案.md` §2、§8)。
### 7.3 回归测试
问题四相关测试覆盖:
- 联合信念真源 (位置, 朝向) 在随机观测序列后不丢;
- 朝外发射的边界定向源不漏、不被误判缺席;
- 保证接收盘内 `no_signal` 不删空间候选、判向不越界;
- 后备清除格覆盖候选多边形每个见证点;格数上限/预算检查跳过而非硬扫;
- 全清、零异常、逐微秒计时复算一致。
当前全仓 `pytest` **268 项通过**(2026-09-12 本轮复核)。
## 8. 与问题一、二、三当前实现的复用关系和差异(核对结果)
| 对比 | 复用 | 差异 |
| --- | --- | --- |
| 问题一 | 角域半平面语义、Welzl 最小包围圆 `smallest_enclosing_circle`、MEC 清除证书思路 | 问题四不计算直径/直径圆;`no_signal` 不能解释为距离排除;覆盖语义是 `forward_half_disk` 而非纯角度交集 |
| 问题二 | `select_measurement_point`、`safe_disk_radius` 作为基础候选与“是否在保证接收盘内”的判据;清除点可向当前位置平移 | 定向源在安全盘内仍可能因朝向 `no_signal`,因此问题二的“盘内必收到信号”保证不成立;新增联合信念和可见性筛选 |
| 问题三 | PF 网格位置维度、覆盖账本、会话/预算/异常 summary、清除动作 | 问题三的负观测可删位置候选(`full_disk` 七点缺席证明);问题四的负观测只约束朝向,覆盖计划改用 31 点 `forward_half_disk`,不使用七站全向缺席证明;问题四也不使用 `BayesLoop` 的信息增益任务表 |
| 问题四独有 | — | 24 档朝向掩码、可见性概率选点、对跖/环探后备梯子、20 m 格后备清除及其格数/预算纪律 |
关键结论:**问题四不是“问题三加一点朝向”,而是发现证明、负观测语义和
清除兜底三处都必须重做**。直接套用问题三的 `bayes`(七站缺席证明)会在
朝外定向源上产生缺席误判;直接套用 `grid`(49 点全向)可以发现但无法
可靠定位清除。当前 `directional` 的路线是:最小命中集保证发现、
联合信念推理可见性、预算化后备清除保证清除底线。
## 9. 适用边界与风险声明
- **覆盖证书**:默认 v2 是 15 m/72 档离散证书加设计裕量,零缺口;
严格连续空间证书还存在约 0.65 m 的已知残余,正式论文若要求严格连续
证明应改用 36 点备选计划;
- **联合信念**:真源保留证明依赖评测机观测语义与题面误差界一致;
先验分布、朝向均匀抽样和可见性概率都只是决策启发式;
- **后备清除**:完备性只在格数不超过 400、现实预算足够且候选集确实
包含真源时成立;任一条件不满足就诚实地保持未完成;
- **性能**:11/11 全清来自自建模拟器与冻结场景,不是官方成绩;
当前仓库尚无官方问题四演练。第四问对定向源朝向分布敏感,不能把
core 均值 8602 s 或边界均值 9069.9 s 外推为官方期望;
- **调度**:站点批量与最近队列是贪心,不是全局最优;图论/DP 方案
中的插入、Or-opt、精确任务序等在本场景实测为负收益或未推广到问题四,
默认关闭。
## 10. 复现
```bash
# 冻结基准集:directional vs 问题三策略直接搬用
uv run python scripts/run_suite.py --problem 4 --suite all --jobs 6 \
--variants directional --label q4-current
# 项目对比框架(生成 project/results/q4.{md,json})
uv run python -m project.run --question 4
# 覆盖证书生成与细网格校核(默认计划的来源)
uv run python scripts/verify_q4_cover.py --spacing 100 --radius 2850 \
--target-step 50 --headings 48 --design-radius 965 --heading-margin 5 \
--verify-step 15 --verify-headings 72 --fine-prune \
--write-plan q4_mincover_v2 --out output/q4/cover-certificate-v2.json
# 回归
uv run pytest tests/test_joint_belief.py tests/test_loop_q4.py \
tests/test_coverage_plan.py tests/test_particles.py -q
```
主要文件:
- `src/mcm26b/planner/__init__.py::DirectionalExtension`、`fallback_clear_cells`、
`fallback_cell_bound`:闭环、探点与后备清除;
- `src/mcm26b/belief/joint.py`:联合信念;
- `src/mcm26b/particles.py`:位置幸存集;
- `src/mcm26b/guarantee/cover.py`、`guarantee/plans/q4_mincover_v2.json`:
覆盖计划与证书;
- `src/mcm26b/routing.py`:后备/离线巡回度量。
延伸阅读:`docs/第三四问架构优化与运行提速方案.md`(第四问失败机理与
P0-2/P0-3 实测)、`docs/第三四问图论与动态规划优化方案.md`(联合信念
修复记录与 D1 探点口径消融)、`docs/algo-3.md`(问题三全向闭环)。
## 参考文献
1. 全国大学生数学建模竞赛组委会. 2026 年高教社杯全国大学生数学建模竞赛 B 题[Z]. 2026. 本地题面 `issue/B题.pdf`。
2. 模拟器配套材料. 附件 2《通信接口说明及编程指南》[Z]. 2026. 本地附件 `issue/附件/附件2.docx`。
3. 本项目. `docs/algo-1.md`、`docs/algo-2.md`、`docs/algo-3.md`。
4. 本项目. `docs/第三四问架构优化与运行提速方案.md`、`docs/第三四问图论与动态规划优化方案.md`、`docs/算法审核与优化-20260911.md`。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
# docs/figures —— 论文插图
每张图只输出 PNG。数据由 `scripts/build_figure_data.py` 从行为日志、覆盖计划与生产求解器
算好写入 `docs/figures/.data/*.mat`,再由 MATLAB(`scripts/matlab/*.m`)排版出图;
`manifest.json` 记录每张图的数据来源(路径 + sha256 前 12 位 + 行数)与生成命令。
本目录的 11 张 PNG 全部进入论文正文:`docs/paper/main.tex` 用
`\graphicspath{{../figures/}}` 直接引用,不在论文里重绘,也不手抄图内数字。
其中回放画布两张与实测计时一张取自裁判机演练留档,
论文图注均标注「演练、非正式成绩」;问题一的三点交会算例取自演练观测。
```bash
uv run python scripts/build_figure_data.py # 全部 11 张(数据 + 出图)
uv run python scripts/build_figure_data.py --prepare # 只写 .mat 数据
uv run python scripts/build_figure_data.py --list # 图名
uv run python scripts/build_figure_data.py --only geo-q3-seven-point-coverage
```
MATLAB 内手动出图:`cd scripts/matlab` 后执行 `run_all_figures`(每张图单独一个会话,
本机图形子系统连续渲染多张会崩)。
依赖:`numpy`、`scipy`(写 `.mat`)与 MATLAB R2025a。
## 约定
- **除回放画布(点运动图 `replay-webui-*`)外,全部使用 MATLAB 默认图形样式**:
默认配色(`defaultAxesColorOrder`)、默认字体与字号、默认坐标轴与图例;
不引入自定义调色板,不手绘图例色块。
- 图内不放标题、图注与解释性文字,只保留坐标轴、图例与必要的元素标注;
图的含义由文件名与下表承担,解释写在论文正文。
- 图例一律用 MATLAB `legend` 放在坐标区外(`southoutside`),不压数据;
散点标签按候选方位自动避让,保证互不重叠。
- 输出 PNG(`exportgraphics`,300 dpi、白底),导出前关闭坐标轴工具条。
## 图清单
| 文件 | 内容 | 数据来源 | 论文位置 |
| --- | --- | --- | --- |
| `flow-overall-architecture.png` | 四层主线:题面抽象(L0)、在线三层(L1)、四问主线(L2)、三条闭环(L3) | `src/mcm26b/config.py`、`docs/B题建模分析与实施计划.md` | 图 1(问题分析) |
| `steps-state-machine.png` | 会话状态机(IDLE / 倒计时 / OPEN / CLOSED) | `docs/协议规则表与状态机.md`、`src/mcm26b/protocol/client.py` | 图 2(计时模型) |
| `timing-attachment-example-105-111-194-199.png` | 附件计时示例:逐动作耗时构成与单步合计 | `docs/协议规则表与状态机.md` | 图 3(计时模型) |
| `timing-measured-q3-10-sources.png` | 实测一局:累计虚拟时间按动作类型的堆叠构成 | `referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl` | 图 11(时间分解,演练局) |
| `geo-q1-convex-intersection-zoom.png` | 问题一:角域交会总览 + 定位区域放大(直径圆与最小包围圆) | `output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json` | 图 4(a)(问题一,演练观测) |
| `geo-q1-diameter-circle-counterexample.png` | 问题一:直径圆不覆盖定位区域的反例(等边三角形布站) | `examples/q1/triangle.json` | 图 4(b)(问题一解析反例) |
| `geo-q2-guaranteed-reception-disk.png` | 问题二:截断角域外接圆 `B(c, a)` 与安全候选盘 `B(c, 1000 − a)`,右侧为半径分解 | `src/mcm26b/config.py`、`docs/algo-2.md` | 图 5(问题二) |
| `geo-q3-seven-point-coverage.png` | 问题三:七个 1000 m 接收盘覆盖 Ω,标出最差点 | `src/mcm26b/planner/seven.py`、`docs/algo-3.md` | 图 6(问题三) |
| `geo-q4-minimum-cover-31-points.png` | 问题四:31 点最小命中集与 ±90° 前向半盘语义 | `src/mcm26b/guarantee/plans/q4_mincover_v2.json` | 图 8(问题四) |
| `replay-webui-q3-omnidirectional.png` | 回放画布(点运动图,全向局):世界视图 + 事件流水 + 虚拟时间轴 | `referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl` | 图 7(问题三,演练局) |
| `replay-webui-q4-directional.png` | 回放画布(点运动图,定向源局):世界视图(含定向源 ±90° 前向半盘)+ 事件流水 + 虚拟时间轴 | `referee-data/sessions/GHSN-M38E-74GD-YLUD/log.jsonl` | 图 9(问题四,演练局) |
后两张是唯一保留自定义画布的图:把回放器画布上的 Ω 与真值源、移动轨迹、
检测示向度射线、定向源朝向与清除标记,连同右侧事件流水与底部虚拟时间轴一起导出;
定向源局额外画出每个定向源的 **±90° 前向半盘**(半径取该源 `r_eff_m`,朝向箭头即半盘半径)。
q4 回放固定用演练局 `GHSN-M38E-74GD-YLUD`(`build_figure_data.py::Q4_REPLAY_CASE`);
本机没有该局留档时回退到自动挑选,因此 `examples/replay/` 里归档了这一局。
## 数据来源与复现
- `timing-measured-*`、`replay-webui-*` 读评测机行为日志(JSONL,含真值)。
`referee-data/` 被 `.gitignore` 忽略,因此另归档两局到 `examples/replay/`;
没有评测机数据时脚本自动改读归档,图一致。
```bash
uv run python scripts/archive_replay_logs.py --list # 本机有哪些局
uv run python scripts/archive_replay_logs.py <案例编码> # 追加归档
```
- `geo-q1-convex-intersection-zoom.png` 读官方演练留档
`output/q1/practice/*_observations.json`(同样被忽略)。文件缺失时改读
`examples/q1/triangle.json` 反例算例,图仍可用,只是案例不同。
- 问题一的几何量由生产求解器 `mcm26b.geometry.solve` 现算;
问题三、问题四直接读覆盖计划与证书,不手抄数字。
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+168
View File
@@ -0,0 +1,168 @@
{
"generator": "scripts/build_figure_data.py(数据)+ scripts/matlab/*.m(MATLAB 出图)",
"command": "uv run python scripts/build_figure_data.py",
"manual": "MATLAB 中 cd scripts/matlab 后执行 run_all_figures",
"figures": [
{
"stem": "flow-overall-architecture",
"png": "docs/figures/flow-overall-architecture.png",
"matlab": "scripts/matlab/fig_flow_overall.m",
"sources": [
{
"path": "src/mcm26b/config.py",
"sha256_12": "c513450dc892",
"lines": 213
},
{
"path": "docs/B题建模分析与实施计划.md",
"sha256_12": "537aaa5790f3",
"lines": 237
}
]
},
{
"stem": "steps-state-machine",
"png": "docs/figures/steps-state-machine.png",
"matlab": "scripts/matlab/fig_steps_state_machine.m",
"sources": [
{
"path": "docs/协议规则表与状态机.md",
"sha256_12": "27c8f7051b26",
"lines": 215
},
{
"path": "src/mcm26b/protocol/client.py",
"sha256_12": "0e9797a85cd7",
"lines": 333
}
]
},
{
"stem": "timing-attachment-example-105-111-194-199",
"png": "docs/figures/timing-attachment-example-105-111-194-199.png",
"matlab": "scripts/matlab/fig_timing_attachment.m",
"sources": [
{
"path": "docs/协议规则表与状态机.md",
"sha256_12": "27c8f7051b26",
"lines": 215
}
]
},
{
"stem": "timing-measured-q3-10-sources",
"png": "docs/figures/timing-measured-q3-10-sources.png",
"matlab": "scripts/matlab/fig_timing_measured.m",
"sources": [
{
"path": "referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl",
"sha256_12": "5a308e53ed09",
"lines": 133,
"case_code": "BBM9-HZG2-77N5-NKKC",
"module": "q3_practice"
}
]
},
{
"stem": "geo-q1-convex-intersection-zoom",
"png": "docs/figures/geo-q1-convex-intersection-zoom.png",
"matlab": "scripts/matlab/fig_geo_q1_meetup.m",
"sources": [
{
"path": "output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json",
"sha256_12": "d2cba13464fd",
"lines": 87
}
]
},
{
"stem": "geo-q1-diameter-circle-counterexample",
"png": "docs/figures/geo-q1-diameter-circle-counterexample.png",
"matlab": "scripts/matlab/fig_geo_q1_counterexample.m",
"sources": [
{
"path": "examples/q1/triangle.json",
"sha256_12": "eeb0c92ae21c",
"lines": 29
}
]
},
{
"stem": "geo-q2-guaranteed-reception-disk",
"png": "docs/figures/geo-q2-guaranteed-reception-disk.png",
"matlab": "scripts/matlab/fig_geo_q2_safe_disk.m",
"sources": [
{
"path": "src/mcm26b/config.py",
"sha256_12": "c513450dc892",
"lines": 213
},
{
"path": "docs/algo-2.md",
"sha256_12": "24f3e844deb9",
"lines": 312
}
]
},
{
"stem": "geo-q3-seven-point-coverage",
"png": "docs/figures/geo-q3-seven-point-coverage.png",
"matlab": "scripts/matlab/fig_geo_q3_coverage.m",
"sources": [
{
"path": "src/mcm26b/planner/seven.py",
"sha256_12": "6ac65bd483f8",
"lines": 113
},
{
"path": "docs/algo-3.md",
"sha256_12": "5638192b8669",
"lines": 456
}
]
},
{
"stem": "geo-q4-minimum-cover-31-points",
"png": "docs/figures/geo-q4-minimum-cover-31-points.png",
"matlab": "scripts/matlab/fig_geo_q4_mincover.m",
"sources": [
{
"path": "src/mcm26b/guarantee/plans/q4_mincover_v2.json",
"sha256_12": "71e54616ae21",
"lines": 162
},
{
"path": "docs/algo-4.md",
"sha256_12": "1774f8bf85db",
"lines": 485
}
]
},
{
"stem": "replay-webui-q3-omnidirectional",
"png": "docs/figures/replay-webui-q3-omnidirectional.png",
"matlab": "scripts/matlab/fig_replay.m",
"sources": [
{
"path": "referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl",
"sha256_12": "5a308e53ed09",
"lines": 133,
"case_code": "BBM9-HZG2-77N5-NKKC"
}
]
},
{
"stem": "replay-webui-q4-directional",
"png": "docs/figures/replay-webui-q4-directional.png",
"matlab": "scripts/matlab/fig_replay.m",
"sources": [
{
"path": "referee-data/sessions/GHSN-M38E-74GD-YLUD/log.jsonl",
"sha256_12": "280696d901b4",
"lines": 429,
"case_code": "GHSN-M38E-74GD-YLUD"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+32 -49
View File
@@ -1,65 +1,48 @@
# 论文(docs/paper)
# 论文与支撑材料
2026 年高教社杯全国大学生数学建模竞赛 B 题论文 LaTeX 工程,格式依据
`issue/论文格式/附件3. 全国大学生数学建模竞赛论文格式规范.pdf`(2026 年修订稿)。
论文依据 issue 题目、2026 年格式规范及所给往届参考 PDF 编写,四问算法对应 docs/algo-1.md 至 algo-4.md。版式采用项目原始 mcm26b-paper.cls。
## 编译
## 交付文件
需要 TeX Live(含 XeLaTeX 与 ctex)。在本目录执行:
- main.pdf:论文、正式数据清单与完整源码附录。
- main.tex、sections/:可编辑 LaTeX 源文件。
- data/formal-results.json、formal-results.csv、audit.md:六份正式测试数据、复算结果与校验说明。
- support-materials.zip:源码、六份正式原始日志、统计快照、论文工程及逐文件哈希清单。
- REWRITE_NOTES.md:本次修订及验证说明。
```bash
latexmk -xelatex -outdir=build main.tex
# 或
xelatex main.tex && xelatex main.tex
```
## 正式数据
输出 PDF:`build/main.pdf`(用第二种方式则为 `main.pdf`)。
数据来自 Jammers-simulator-full/test/q3 和 test/q4,两问各三次。
通过完整包 SHA-256、结果侧文件和公开票据哈希与官方统计队列核对。
六次累计清除 75 个源,清除失败为零;问题三、四按清除数量加权的平均时间分别为 249.873 和 736.268 s/个。
评价指标与统计扩展分别见论文实验结果和未来工作。
## 目录结构
## 重算与编译
| 路径 | 内容 |
| --- | --- |
| `main.tex` | 论文源文件(摘要、正文、附录及源码引用) |
| `mcm26b-paper.cls` | 论文版式模板(宏包、字体、标题、代码排版与通用命令) |
| `fonts/` | 从本机提取的中文字体(宋体 SC 常规/加粗、黑体 SC Light/Medium)与 Times New Roman 四体,约 150 MB,编译自包含 |
| `figures/` | 插图目录 |
| `build/` | 编译产物(已 gitignore) |
在项目根目录执行:
VS Code 工作区已在 `.vscode/settings.json` 中将 LaTeX Workshop 的默认配方设为
`xelatex`;也提供 `latexmk (xelatex)` 配方用于自动处理交叉引用。
python scripts/audit_paper_formal.py
python scripts/build_paper_support.py
## 规范要点与工程实现
第一条需要原电脑的官方统计队列。使用随包快照重算时执行:
- A4、四边距 2.5cm:`geometry` 宏包。
- 页码从摘要专用页起、页脚中部、从 1 连续编号:摘要页 `\setcounter{page}{1}`。
- 正文不要目录、不超过 30 页:未生成目录,正文当前远少于 30 页。
- **电子版第一页必须是摘要专用页**(不含承诺书与编号专用页):默认
不启用 `paper` class option。打印纸质版时将 `main.tex` 的
`\documentclass[zihao=-4,a4paper]{mcm26b-paper}` 改为
`\documentclass[zihao=-4,a4paper,paper]{mcm26b-paper}`,自动加入承诺书与编号专用页
(占位文字,须替换为组委会当年发布的专用页原文)。
- 摘要页含标题与关键词,不超过一页,无需英文。
- 摘要页、正文、附录中不得出现姓名、学校、赛区等信息。
- 附录含支撑材料文件列表与完整源码:源码以 `\lstinputlisting` 引用仓库内
`src/mcm26b/geometry/` 等模块(相对路径 `../../`),提交前须确认与支撑材料
压缩包一致。
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
## 待办(正文中红色【待填/待补】标记)
快照模式重算统计并校验现有正式包的哈希;来源匹配需官方统计队列。
- 问题 3、4 演练统计值与正式测试结果(表 1 两张表)。
- 问题 1 定位区域图、问题 2 候选区域示意图与选点对比实验。
- 支撑材料文件列表更新;定稿前删除全部红色批注(`\todonote`)。
进入 docs/paper 执行:
## 字体说明
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
`fonts/` 下中文 字体自 macOS 系统字体(Songti.ttc、STHeiti)提取简体字面而来,
授权仅限本队参赛使用。若在 Windows/Overleaf 编译,可改用系统自带中易宋体/黑体,
将导言区字体设置替换为:
输出为 main.pdf。VS Code 默认配方为 latexmk (xelatex),自动完成交叉引用所需的多遍编译。
需要 TeX Live 的 XeLaTeX、ctex、TikZ,以及原项目 docs/paper/fonts/ 目录。
```latex
\setCJKmainfont{SimSun}
\setCJKsansfont{SimHei}
\setmainfont{Times New Roman}
```
在项目根目录生成支撑包:
若仓库体积敏感,可将 `fonts/` 加入 `.gitignore`(此时每位队员需自备字体)。
python scripts/build_paper_support.py --package
## 排版与复算
正文采用原模板的字体、标题、页边距及页码设置。源码附录通过 data/listing-source/ 的显示副本处理数学符号的 LaTeX 转义,支撑包保留原始可执行源码。
统计表、汇总量和耗时图由同一快照生成;几何图取正文给定参数。
正文使用集合估计、凸外包、清除充分条件、集合覆盖、信息增益和最近邻路径排序等概念描述模型。
+35
View File
@@ -0,0 +1,35 @@
# 本次修订与验证
## 正式数据补充
加入问题三第三次 UVUZ-B4MQ-XSUV-HQFP 和问题四第三次 K58B-V5VC-E4EF-CRTW。
两份文件均通过原始导出、结果侧文件和官方统计记录的对应校验。
| 案例 | 清除数 | 平均定位清除时间(s/个) | 程序运行时间(s) |
| --- | ---: | ---: | ---: |
| Q3-3 | 10 | 290.686 | 2.680 |
| Q4-3 | 14 | 614.884 | 6.051 |
六次累计清除 75 个源。问题三合计 38 个,问题四合计 37 个;
按清除数量加权的平均时间分别为 249.873 和 736.268 s/个。
摘要、正式结果表、汇总公式、动作耗时图、数据清单与支撑包统一使用六份记录。
## 数学表述
用“清除充分条件”“半径判据”替换“证书”;
用“检测状态矩阵”替换“账本”;
用“最近邻任务排序”替换“调度骨架”;
采用集合估计、关联矩阵、离散集合覆盖、网格候选集等已有概念。
实现扩展及评价限制集中于未来工作。数学证明中的必要条件、反例与适用假设保持明确。
信息增益目标中的移动代价恢复为加法项,与源码的加权求和一致。
可见性权重按位置子样本、候选朝向位数和初始 24 档定义。
算法的可执行源码保持原样,数学符号继续采用 LaTeX 转义排版。
## 模板与验证
mcm26b-paper.cls 保持与原仓库模板逐字节一致,文档沿用原模板的黑体标题及版式。
使用 latexmk 自动多遍编译并检查文献、公式、图表引用。
正式统计按官方整数微秒和毫秒计算,支撑包提供 SHA-256 清单供复核。
本轮修订前的 PDF 和支撑包保存在 tmp/paper-rewrite/main-before-six-records.pdf 与 support-before-six-records.zip。
+19
View File
@@ -0,0 +1,19 @@
# 正式测试数据核对
本次汇总 `Jammers-simulator-full/test` 中的 6 份正式导出包。
利用公开头部的案例信息、包哈希及票据哈希,与原始导出、结果侧文件和官方正式统计数据库建立对应关系。
| 案例 | 正式序号 | 原件一致 | 包 SHA-256 |
| --- | --- | --- | --- |
| 9HU7-UWJR-BPTR-VYAT | Q3-1 | 是 | `fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911` |
| 7TCG-C9EC-53VF-HZ5H | Q3-2 | 是 | `5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef` |
| UVUZ-B4MQ-XSUV-HQFP | Q3-3 | 是 | `eeffe419c3df005d0ea78c7ec2c10afed29c4bec782327538537dd7ba7914424` |
| VQUM-ETQ5-74PP-ZQ3J | Q4-1 | 是 | `9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3` |
| 5K5T-TRWZ-BPR2-ECAV | Q4-2 | 是 | `370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1` |
| K58B-V5VC-E4EF-CRTW | Q4-3 | 是 | `1c7f1d111f8fd1a1d0f608d9847c0946b1b0a45bdb7b3f487e0c6d4f4586885f` |
虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。
移动时间由总时长减去检测、换频、成功及失败清除耗时计算。
`confirmed` 表示统计提交确认,`user_exit` 表示主动退出。
实验评价采用清除数量、逐案例平均定位清除时间、程序运行时间和动作耗时分解。
扩展评价所需的源总数与配对场景设计见论文未来工作。
+6
View File
@@ -0,0 +1,6 @@
Q3-1 & 105 & 96 & 0 & 3094.978 & 2408.978 & 77.84\% \\
Q3-2 & 117 & 108 & 0 & 3493.335 & 2725.335 & 78.02\% \\
Q3-3 & 117 & 107 & 0 & 2906.863 & 2164.863 & 74.47\% \\
Q4-1 & 349 & 275 & 0 & 9362.226 & 7277.226 & 77.73\% \\
Q4-2 & 361 & 315 & 0 & 9271.302 & 7101.302 & 76.59\% \\
Q4-3 & 264 & 213 & 0 & 8608.382 & 7005.382 & 81.38\% \\
+17
View File
@@ -0,0 +1,17 @@
\begin{longtable}{p{1.15cm}p{13.3cm}}
\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\
\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead
\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endhead
Q3-1 & \pathtext{formal-p3-1-9HU7-UWJR-BPTR-VYAT.jlog}\\
& {\scriptsize\texttt{fbd7ab92f78a019887f603172ab6a39e}\allowbreak\texttt{274adb1f917fc2681f1e8073ff47d911}}\\[4pt]
Q3-2 & \pathtext{formal-p3-2-7TCG-C9EC-53VF-HZ5H.jlog}\\
& {\scriptsize\texttt{5d83ce87455ce22fadbcbddb60814d9b}\allowbreak\texttt{1f0be54de7bc9d0463777ba47b3922ef}}\\[4pt]
Q3-3 & \pathtext{formal-p3-3-UVUZ-B4MQ-XSUV-HQFP.jlog}\\
& {\scriptsize\texttt{eeffe419c3df005d0ea78c7ec2c10afe}\allowbreak\texttt{d29c4bec782327538537dd7ba7914424}}\\[4pt]
Q4-1 & \pathtext{formal-p4-1-VQUM-ETQ5-74PP-ZQ3J.jlog}\\
& {\scriptsize\texttt{9a102295d324b5fb5aeed1c6036d13ae}\allowbreak\texttt{7e08a7d7fddb60ceb8dead24e62b15d3}}\\[4pt]
Q4-2 & \pathtext{formal-p4-2-5K5T-TRWZ-BPR2-ECAV.jlog}\\
& {\scriptsize\texttt{370aac9c2c5d324dd382e57e541ef0da}\allowbreak\texttt{7c5dd4759660d9ea0cba8373ce0ea9a1}}\\[4pt]
Q4-3 & \pathtext{formal-p4-3-K58B-V5VC-E4EF-CRTW.jlog}\\
& {\scriptsize\texttt{1c7f1d111f8fd1a1d0f608d9847c0946}\allowbreak\texttt{b1b0a45bdb7b3f487e0c6d4f4586885f}}\\[4pt]
\bottomrule\end{longtable}
+13
View File
@@ -0,0 +1,13 @@
\newcommand{\QThreeCount}{38}
\newcommand{\QThreeTotal}{9495.176084}
\newcommand{\QThreeWeighted}{249.873}
\newcommand{\QThreeMean}{253.884}
\newcommand{\QFourCount}{37}
\newcommand{\QFourTotal}{27241.909918}
\newcommand{\QFourWeighted}{736.268}
\newcommand{\QFourMean}{754.062}
\newcommand{\FormalClearCount}{75}
\newcommand{\FormalMovementMin}{74.47}
\newcommand{\FormalMovementMax}{81.38}
\newcommand{\FormalRunMin}{2.680}
\newcommand{\FormalRunMax}{7.668}
+4
View File
@@ -0,0 +1,4 @@
% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding.
测试 1 & \texttt{9HU7-UWJR-BPTR-VYAT} & 13 & 238.075 & 5.596 \\
测试 2 & \texttt{7TCG-C9EC-53VF-HZ5H} & 15 & 232.889 & 3.024 \\
测试 3 & \texttt{UVUZ-B4MQ-XSUV-HQFP} & 10 & 290.686 & 2.680 \\
+4
View File
@@ -0,0 +1,4 @@
% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding.
测试 1 & \texttt{VQUM-ETQ5-74PP-ZQ3J} & 13 & 720.171 & 7.668 \\
测试 2 & \texttt{5K5T-TRWZ-BPR2-ECAV} & 10 & 927.130 & 5.724 \\
测试 3 & \texttt{K58B-V5VC-E4EF-CRTW} & 14 & 614.884 & 6.051 \\
+7
View File
@@ -0,0 +1,7 @@
problem_no,formal_index,case_code,entered,end_reason,cleared_jammer_count,measure_accepted_count,virtual_time_us,program_run_duration_ms,channel_switch_count,clear_failure_count,state,package_sha256,virtual_time_s,average_clear_time_s,program_run_duration_s,inferred_movement_time_s,inferred_distance_m,movement_share_percent,source_total,clear_fraction
3,1,9HU7-UWJR-BPTR-VYAT,1,user_exit,13,105,3094978068,5596,96,0,confirmed,fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911,3094.978068,238.075236,5.596,2408.978068,12044.890340,77.835061,,
3,2,7TCG-C9EC-53VF-HZ5H,1,user_exit,15,117,3493335140,3024,108,0,confirmed,5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef,3493.33514,232.889009,3.024,2725.33514,13626.67570,78.015279,,
3,3,UVUZ-B4MQ-XSUV-HQFP,1,user_exit,10,117,2906862876,2680,107,0,confirmed,eeffe419c3df005d0ea78c7ec2c10afed29c4bec782327538537dd7ba7914424,2906.862876,290.686288,2.680,2164.862876,10824.314380,74.474200,,
4,1,VQUM-ETQ5-74PP-ZQ3J,1,user_exit,13,349,9362225736,7668,275,0,confirmed,9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3,9362.225736,720.171210,7.668,7277.225736,36386.128680,77.729655,,
4,2,5K5T-TRWZ-BPR2-ECAV,1,user_exit,10,361,9271302080,5724,315,0,confirmed,370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1,9271.30208,927.130208,5.724,7101.30208,35506.51040,76.594442,,
4,3,K58B-V5VC-E4EF-CRTW,1,user_exit,14,264,8608382102,6051,213,0,confirmed,1c7f1d111f8fd1a1d0f608d9847c0946b1b0a45bdb7b3f487e0c6d4f4586885f,8608.382102,614.884436,6.051,7005.382102,35026.910510,81.378615,,
1 problem_no formal_index case_code entered end_reason cleared_jammer_count measure_accepted_count virtual_time_us program_run_duration_ms channel_switch_count clear_failure_count state package_sha256 virtual_time_s average_clear_time_s program_run_duration_s inferred_movement_time_s inferred_distance_m movement_share_percent source_total clear_fraction
2 3 1 9HU7-UWJR-BPTR-VYAT 1 user_exit 13 105 3094978068 5596 96 0 confirmed fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911 3094.978068 238.075236 5.596 2408.978068 12044.890340 77.835061
3 3 2 7TCG-C9EC-53VF-HZ5H 1 user_exit 15 117 3493335140 3024 108 0 confirmed 5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef 3493.33514 232.889009 3.024 2725.33514 13626.67570 78.015279
4 3 3 UVUZ-B4MQ-XSUV-HQFP 1 user_exit 10 117 2906862876 2680 107 0 confirmed eeffe419c3df005d0ea78c7ec2c10afed29c4bec782327538537dd7ba7914424 2906.862876 290.686288 2.680 2164.862876 10824.314380 74.474200
5 4 1 VQUM-ETQ5-74PP-ZQ3J 1 user_exit 13 349 9362225736 7668 275 0 confirmed 9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3 9362.225736 720.171210 7.668 7277.225736 36386.128680 77.729655
6 4 2 5K5T-TRWZ-BPR2-ECAV 1 user_exit 10 361 9271302080 5724 315 0 confirmed 370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1 9271.30208 927.130208 5.724 7101.30208 35506.51040 76.594442
7 4 3 K58B-V5VC-E4EF-CRTW 1 user_exit 14 264 8608382102 6051 213 0 confirmed 1c7f1d111f8fd1a1d0f608d9847c0946b1b0a45bdb7b3f487e0c6d4f4586885f 8608.382102 614.884436 6.051 7005.382102 35026.910510 81.378615
+228
View File
@@ -0,0 +1,228 @@
{
"schema": "paper-formal-audit-v1",
"selection": "Only Jammers-simulator-full/test/**/*.jlog",
"statistics_source": "Official formal-statistics-queue.sqlite3; matched by SHA-256 of the public activation ticket",
"coverage": {
"3": [
1,
2,
3
],
"4": [
1,
2,
3
]
},
"measurement_scope": "Per-case official action counts, virtual duration and program duration",
"limitations": [
"Source totals, source types, solver commit and trajectories require additional evidence.",
"Queue state confirmed records a statistics submission acknowledgement."
],
"records": [
{
"problem_no": 3,
"formal_index": 1,
"case_code": "9HU7-UWJR-BPTR-VYAT",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 13,
"measure_accepted_count": 105,
"virtual_time_us": 3094978068,
"program_run_duration_ms": 5596,
"channel_switch_count": 96,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q3/formal-p3-1-9HU7-UWJR-BPTR-VYAT.jlog",
"size_bytes": 106656,
"package_sha256": "fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911",
"activation_ticket_sha256": "41563c714553abf202fc0012fcde2503c64c6cec27a84c7229c487602b957516",
"created_at_utc": "2026-09-12T07:16:06.377Z",
"ended_at_utc": "2026-09-12T07:16:06.373Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "3094.978068",
"average_clear_time_s": "238.075236",
"program_run_duration_s": "5.596",
"inferred_movement_time_s": "2408.978068",
"inferred_distance_m": "12044.890340",
"movement_share_percent": "77.835061",
"source_total": null,
"clear_fraction": null
}
},
{
"problem_no": 3,
"formal_index": 2,
"case_code": "7TCG-C9EC-53VF-HZ5H",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 15,
"measure_accepted_count": 117,
"virtual_time_us": 3493335140,
"program_run_duration_ms": 3024,
"channel_switch_count": 108,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q3/formal-p3-2-7TCG-C9EC-53VF-HZ5H.jlog",
"size_bytes": 106326,
"package_sha256": "5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef",
"activation_ticket_sha256": "5f6d5ff4a369418f9f683ef0384398190bfb0b81bc71ce1c1bd6f728ff1fdf41",
"created_at_utc": "2026-09-12T07:47:20.065Z",
"ended_at_utc": "2026-09-12T07:47:20.061Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "3493.33514",
"average_clear_time_s": "232.889009",
"program_run_duration_s": "3.024",
"inferred_movement_time_s": "2725.33514",
"inferred_distance_m": "13626.67570",
"movement_share_percent": "78.015279",
"source_total": null,
"clear_fraction": null
}
},
{
"problem_no": 3,
"formal_index": 3,
"case_code": "UVUZ-B4MQ-XSUV-HQFP",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 10,
"measure_accepted_count": 117,
"virtual_time_us": 2906862876,
"program_run_duration_ms": 2680,
"channel_switch_count": 107,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q3/formal-p3-3-UVUZ-B4MQ-XSUV-HQFP.jlog",
"size_bytes": 107396,
"package_sha256": "eeffe419c3df005d0ea78c7ec2c10afed29c4bec782327538537dd7ba7914424",
"activation_ticket_sha256": "0134f356b107b6706dfaba48fd00b86c5e3a4fa7ef295d17bbd4080111d112b3",
"created_at_utc": "2026-09-13T02:32:31.689Z",
"ended_at_utc": "2026-09-13T02:32:31.686Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "2906.862876",
"average_clear_time_s": "290.686288",
"program_run_duration_s": "2.680",
"inferred_movement_time_s": "2164.862876",
"inferred_distance_m": "10824.314380",
"movement_share_percent": "74.474200",
"source_total": null,
"clear_fraction": null
}
},
{
"problem_no": 4,
"formal_index": 1,
"case_code": "VQUM-ETQ5-74PP-ZQ3J",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 13,
"measure_accepted_count": 349,
"virtual_time_us": 9362225736,
"program_run_duration_ms": 7668,
"channel_switch_count": 275,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q4/formal-p4-1-VQUM-ETQ5-74PP-ZQ3J.jlog",
"size_bytes": 150315,
"package_sha256": "9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3",
"activation_ticket_sha256": "d02cfdf2eed65dbe3bb4c7768a263f8bf57b56bc323b6453c1dfa2aec6e24810",
"created_at_utc": "2026-09-12T07:38:51.368Z",
"ended_at_utc": "2026-09-12T07:38:51.365Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "9362.225736",
"average_clear_time_s": "720.171210",
"program_run_duration_s": "7.668",
"inferred_movement_time_s": "7277.225736",
"inferred_distance_m": "36386.128680",
"movement_share_percent": "77.729655",
"source_total": null,
"clear_fraction": null
}
},
{
"problem_no": 4,
"formal_index": 2,
"case_code": "5K5T-TRWZ-BPR2-ECAV",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 10,
"measure_accepted_count": 361,
"virtual_time_us": 9271302080,
"program_run_duration_ms": 5724,
"channel_switch_count": 315,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q4/formal-p4-2-5K5T-TRWZ-BPR2-ECAV.jlog",
"size_bytes": 151668,
"package_sha256": "370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1",
"activation_ticket_sha256": "4918e4ca6a77367dd06cd40afb28d4415246ef0437c68c052bddf613f4f914b0",
"created_at_utc": "2026-09-12T07:42:29.854Z",
"ended_at_utc": "2026-09-12T07:42:29.851Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "9271.30208",
"average_clear_time_s": "927.130208",
"program_run_duration_s": "5.724",
"inferred_movement_time_s": "7101.30208",
"inferred_distance_m": "35506.51040",
"movement_share_percent": "76.594442",
"source_total": null,
"clear_fraction": null
}
},
{
"problem_no": 4,
"formal_index": 3,
"case_code": "K58B-V5VC-E4EF-CRTW",
"client_version": "1.1.0",
"entered": 1,
"end_reason": "user_exit",
"cleared_jammer_count": 14,
"measure_accepted_count": 264,
"virtual_time_us": 8608382102,
"program_run_duration_ms": 6051,
"channel_switch_count": 213,
"clear_failure_count": 0,
"state": "confirmed",
"file": "test/q4/formal-p4-3-K58B-V5VC-E4EF-CRTW.jlog",
"size_bytes": 140251,
"package_sha256": "1c7f1d111f8fd1a1d0f608d9847c0946b1b0a45bdb7b3f487e0c6d4f4586885f",
"activation_ticket_sha256": "9cdd910c14e58fca08e3769f880a6b543de2bbdd21479094dc9cd2df725f0036",
"created_at_utc": "2026-09-13T02:34:32.695Z",
"ended_at_utc": "2026-09-13T02:34:32.692Z",
"content_encryption": "aes-256-gcm-chunked",
"identity_and_ticket_content_omitted": true,
"same_as_official_original": true,
"derived": {
"virtual_time_s": "8608.382102",
"average_clear_time_s": "614.884436",
"program_run_duration_s": "6.051",
"inferred_movement_time_s": "7005.382102",
"inferred_distance_m": "35026.910510",
"movement_share_percent": "81.378615",
"source_total": null,
"clear_fraction": null
}
}
]
}
@@ -0,0 +1,206 @@
"""Audit only the formal exports selected in the official simulator's test folder.
Match the public activation-ticket digest to the official statistics queue,
and the complete package digest to its result sidecar. Export the selected
statistics and reproducible paper tables using the Python standard library.
"""
from __future__ import annotations
import argparse
import base64
import csv
import hashlib
import json
import sqlite3
import struct
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIELDS = (
"entered", "end_reason", "cleared_jammer_count", "measure_accepted_count",
"virtual_time_us", "program_run_duration_ms", "channel_switch_count",
"clear_failure_count", "state",
)
def sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def public_header(path: Path) -> tuple[dict, str]:
data = path.read_bytes()
if data[:8] != b"JMBFLOG1" or len(data) < 14:
raise ValueError(f"Unsupported formal envelope: {path.name}")
version, size = struct.unpack(">HI", data[8:14])
if version != 1 or not 0 < size <= len(data) - 14:
raise ValueError(f"Invalid envelope header: {path.name}")
header = json.loads(data[14:14 + size])
if header["package_type"] != "formal_behavior_log":
raise ValueError("Practice logs are not admitted")
return header, sha(data)
def fixed(value: Decimal, digits: int = 3) -> str:
return str(value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
def derive(row: dict) -> dict:
count = row["cleared_jammer_count"]
total = Decimal(row["virtual_time_us"]) / 1_000_000
movement = total - (5 * row["measure_accepted_count"]
+ row["channel_switch_count"] + 5 * count
+ 3 * row["clear_failure_count"])
if count <= 0 or movement < 0:
raise ValueError("Invalid counts or negative inferred movement time")
return {
"virtual_time_s": str(total),
"average_clear_time_s": fixed(total / count, 6),
"program_run_duration_s": fixed(Decimal(row["program_run_duration_ms"]) / 1000),
"inferred_movement_time_s": str(movement),
"inferred_distance_m": str(5 * movement),
"movement_share_percent": fixed(100 * movement / total, 6),
"source_total": None,
"clear_fraction": None,
}
def collect(sim: Path) -> list[dict]:
selected = sorted((sim / "test").rglob("*.jlog"))
if not selected:
raise ValueError("No formal exports in test/")
queue = sim / "JammersSimulatorData/formal-statistics-queue.sqlite3"
original = sim / "JammersSimulatorData/behavior-logs"
# Read the live queue including its WAL, without writing to the simulator.
with sqlite3.connect(queue.resolve().as_uri() + "?mode=ro", uri=True) as db:
db.row_factory = sqlite3.Row
query = "SELECT activation_ticket_sha256," + ",".join(FIELDS) + " FROM statistics_tasks"
statistics = {r["activation_ticket_sha256"]: dict(r) for r in db.execute(query)}
records = []
for path in selected:
header, digest = public_header(path)
encoded = header["server_ticket_b64"]
ticket_hash = sha(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
sidecar = json.loads((original / (path.stem + ".result.json")).read_text("utf-8"))
for field in ("problem_no", "formal_index", "case_code"):
if header[field] != sidecar[field]:
raise ValueError(f"Mismatched case: {path.name}")
if digest != sidecar["package_sha256"] or digest != sha((original / path.name).read_bytes()):
raise ValueError(f"Mismatched formal package: {path.name}")
matched = statistics[ticket_hash]
row = {name: header[name] for name in ("problem_no", "formal_index", "case_code", "client_version")}
row.update({name: matched[name] for name in FIELDS})
row.update({
"file": path.relative_to(sim).as_posix(), "size_bytes": path.stat().st_size,
"package_sha256": digest, "activation_ticket_sha256": ticket_hash,
"created_at_utc": header["created_at_utc"],
"ended_at_utc": sidecar["ended_at_utc"],
"content_encryption": header["content_encryption"],
"identity_and_ticket_content_omitted": True,
"same_as_official_original": True,
})
row["derived"] = derive(row)
records.append(row)
keys = [(r["problem_no"], r["formal_index"]) for r in records]
if len(set(keys)) != len(keys):
raise ValueError("Duplicate formal attempt")
return records
def write_outputs(rows: list[dict], out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
snapshot = {
"schema": "paper-formal-audit-v1",
"selection": "Only Jammers-simulator-full/test/**/*.jlog",
"statistics_source": "Official formal-statistics-queue.sqlite3; matched by SHA-256 of the public activation ticket",
"coverage": {str(p): sorted(r["formal_index"] for r in rows if r["problem_no"] == p)
for p in (3, 4)},
"measurement_scope": "Per-case official action counts, virtual duration and program duration",
"limitations": [
"Source totals, source types, solver commit and trajectories require additional evidence.",
"Queue state confirmed records a statistics submission acknowledgement.",
],
"records": rows,
}
(out / "formal-results.json").write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", "utf-8")
columns = ["problem_no", "formal_index", "case_code", *FIELDS, "package_sha256"]
with (out / "formal-results.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns + list(rows[0]["derived"]))
writer.writeheader()
for row in rows:
writer.writerow({**{key: row[key] for key in columns}, **row["derived"]})
for problem in (3, 4):
by_index = {r["formal_index"]: r for r in rows if r["problem_no"] == problem}
lines = ["% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding."]
for index in (1, 2, 3):
row = by_index.get(index)
if row is None:
lines.append(f"测试 {index} & 未提供记录 & -- & -- & -- " + r"\\")
else:
avg = Decimal(row["virtual_time_us"]) / 1_000_000 / row["cleared_jammer_count"]
lines.append(f"测试 {index} & \\texttt{{{row['case_code']}}} & {row['cleared_jammer_count']} & {fixed(avg)} & {row['derived']['program_run_duration_s']} " + r"\\")
(out / f"formal-q{problem}-rows.tex").write_text("\n".join(lines) + "\n", "utf-8")
cost_lines, constants = [], []
for row in rows:
label = f"Q{row['problem_no']}-{row['formal_index']}"
total = Decimal(row["virtual_time_us"]) / 1_000_000
move = Decimal(row["derived"]["inferred_movement_time_s"])
cost_lines.append(f"{label} & {row['measure_accepted_count']} & {row['channel_switch_count']} & {row['clear_failure_count']} & {fixed(total)} & {fixed(move)} & {fixed(100*move/total, 2)}\\% " + r"\\")
(out / "formal-cost-rows.tex").write_text("\n".join(cost_lines) + "\n", "utf-8")
for problem, word in ((3, "Three"), (4, "Four")):
group = [r for r in rows if r["problem_no"] == problem]
count = sum(r["cleared_jammer_count"] for r in group)
total = sum(Decimal(r["virtual_time_us"]) / 1_000_000 for r in group)
mean = sum(Decimal(r["virtual_time_us"]) / 1_000_000 / r["cleared_jammer_count"] for r in group) / len(group)
constants.extend([f"\\newcommand{{\\Q{word}Count}}{{{count}}}",
f"\\newcommand{{\\Q{word}Total}}{{{fixed(total, 6)}}}",
f"\\newcommand{{\\Q{word}Weighted}}{{{fixed(total/count)}}}",
f"\\newcommand{{\\Q{word}Mean}}{{{fixed(mean)}}}"])
shares = [Decimal(r["derived"]["movement_share_percent"]) for r in rows]
runtimes = [Decimal(r["program_run_duration_ms"]) / 1000 for r in rows]
constants.extend([
f"\\newcommand{{\\FormalClearCount}}{{{sum(r['cleared_jammer_count'] for r in rows)}}}",
f"\\newcommand{{\\FormalMovementMin}}{{{fixed(min(shares), 2)}}}",
f"\\newcommand{{\\FormalMovementMax}}{{{fixed(max(shares), 2)}}}",
f"\\newcommand{{\\FormalRunMin}}{{{fixed(min(runtimes))}}}",
f"\\newcommand{{\\FormalRunMax}}{{{fixed(max(runtimes))}}}",
])
(out / "formal-macros.tex").write_text("\n".join(constants) + "\n", "utf-8")
audit = ["# 正式测试数据核对", "", f"本次汇总 `Jammers-simulator-full/test` 中的 {len(rows)} 份正式导出包。",
"利用公开头部的案例信息、包哈希及票据哈希,与原始导出、结果侧文件和官方正式统计数据库建立对应关系。", "",
"| 案例 | 正式序号 | 原件一致 | 包 SHA-256 |", "| --- | --- | --- | --- |"]
for row in rows:
audit.append(f"| {row['case_code']} | Q{row['problem_no']}-{row['formal_index']} | 是 | `{row['package_sha256']}` |")
audit += ["", "虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。",
"移动时间由总时长减去检测、换频、成功及失败清除耗时计算。",
"`confirmed` 表示统计提交确认,`user_exit` 表示主动退出。",
"实验评价采用清除数量、逐案例平均定位清除时间、程序运行时间和动作耗时分解。",
"扩展评价所需的源总数与配对场景设计见论文未来工作。", ""]
(out / "audit.md").write_text("\n".join(audit), "utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sim-root", type=Path, default=ROOT / "Jammers-simulator-full")
parser.add_argument("--out", type=Path, default=ROOT / "docs/paper/data")
parser.add_argument("--snapshot", type=Path, help="Regenerate tables from the audited anonymous snapshot, without the simulator database")
args = parser.parse_args()
if args.snapshot:
rows = json.loads(args.snapshot.read_text("utf-8"))["records"]
for row in rows:
if derive(row) != row["derived"]:
raise ValueError("Snapshot arithmetic changed")
path = args.sim_root / row["file"]
if path.exists() and public_header(path)[1] != row["package_sha256"]:
raise ValueError("Snapshot and formal export disagree")
else:
rows = collect(args.sim_root)
write_outputs(rows, args.out)
for row in rows:
print(f"Q{row['problem_no']}-{row['formal_index']} {row['case_code']}: "
f"cleared={row['cleared_jammer_count']} average={row['derived']['average_clear_time_s']} s "
f"real={row['derived']['program_run_duration_s']} s")
if __name__ == "__main__":
main()
@@ -0,0 +1,393 @@
"""Build vector figure sources, comparison tables, source listings, and a support ZIP.
Formal figures use the audited snapshot only. Geometric figures use declared
algorithm parameters, not fabricated observations or hidden simulator data.
The paired optimal-versus-baseline tables are generated from project/results/*.json
so that no number in the comparison section is hand copied.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import zipfile
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAPER = ROOT / "docs/paper"
PROJECT_RESULTS = ROOT / "project/results"
# Display copies only: executable Python files remain byte-for-byte unchanged.
ESCAPE_OPEN = "(" + "*@"
ESCAPE_CLOSE = "@*" + ")"
LATEX_SYMBOLS = {
"\u03b1": r"\alpha", "\u03b4": r"\delta", "\u03b8": r"\theta",
"\u03bb": r"\lambda", "\u03c0": r"\pi", "\u03c1": r"\rho",
"\u03c6": r"\varphi", "\u2308": r"\lceil", "\u2309": r"\rceil",
"\u2200": r"\forall", "\u2203": r"\exists", "\u2205": r"\varnothing",
"\u2208": r"\in", "\u2227": r"\wedge", "\u2229": r"\cap",
"\u222a": r"\cup", "\u2248": r"\approx", "\u2264": r"\le",
"\u2265": r"\ge", "\u2286": r"\subseteq", "\u2287": r"\supseteq",
"\u27f9": r"\Longrightarrow",
}
def escaped_listing(source: str) -> str:
if ESCAPE_OPEN in source or ESCAPE_CLOSE in source:
raise ValueError("Source collides with the LaTeX escape delimiters")
return "".join(
ESCAPE_OPEN + r"\ensuremath{" + LATEX_SYMBOLS[c] + "}" + ESCAPE_CLOSE
if c in LATEX_SYMBOLS else c for c in source
)
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def figures(rows: list[dict]) -> None:
out = PAPER / "figures"
out.mkdir(parents=True, exist_ok=True)
plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8"))
lines = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]",
r"\fill[inkblue!5] (0,0) circle (1800);",
r"\draw[thick] (0,0) circle (1800);",
r"\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};",
r"\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};",
]
for x in (-2000, -1000, 1000, 2000):
lines += [fr"\draw ({x},-35)--({x},35);",
fr"\node[below,font=\scriptsize] at ({x},-50) {{{x}}};"]
for x, y in plan["points"]:
lines.append(fr"\fill[inkblue] ({x},{y}) circle (33);")
lines += [
r"\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};",
r"\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};",
r"\end{tikzpicture}",
r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}",
r"\label{fig:q4plan}\end{figure}",
]
(out / "q4-plan.tex").write_text("\n".join(lines) + "\n", "utf-8")
colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"]
bars = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]",
]
for j, row in enumerate(rows):
y = len(rows) - 1 - j
components = [Decimal(row["derived"]["inferred_movement_time_s"]),
Decimal(5 * row["measure_accepted_count"]),
Decimal(row["channel_switch_count"]),
Decimal(5 * row["cleared_jammer_count"] + 3 * row["clear_failure_count"])]
label = f"Q{row['problem_no']}-{row['formal_index']}"
bars.append(fr"\node[anchor=east] at (-100,{y + 0.3}) {{{label}}};")
current = Decimal(0)
for amount, color in zip(components, colors):
bars.append(fr"\fill[{color}] ({current},{y}) rectangle ({current + amount},{y + 0.6});")
current += amount
bars.append(fr"\node[anchor=west,font=\scriptsize] at ({current + 80},{y + 0.3}) {{{current:.1f}}};")
bars.append(r"\draw[->] (0,-0.25)--(10500,-0.25);")
for tick in range(0, 10001, 2000):
bars.append(fr"\draw ({tick},-0.25)--({tick},-0.35) node[below,font=\scriptsize] {{{tick}}};")
bars.append(r"\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};")
for x, color, label in zip((0, 2600, 5200, 7600), colors, ("移动", "检测", "换频", "清除")):
bars += [fr"\fill[{color}] ({x},-1.3) rectangle ({x + 350},-1.05);",
fr"\node[anchor=west] at ({x + 450},-1.175) {{{label}}};"]
bars += [
r"\end{tikzpicture}",
r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}",
r"\label{fig:costbars}\end{figure}",
]
(out / "formal-time-bars.tex").write_text("\n".join(bars) + "\n", "utf-8")
def fixed(value: float | None, digits: int = 3) -> str:
if value is None:
return "--"
return str(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
def percent(value: float | None, digits: int = 1) -> str:
if value is None:
return "--"
return fixed(100 * value, digits) + r"\%"
def flag(value: bool | None) -> str:
return "--" if value is None else ("是" if value else "否")
def project_tables() -> None:
"""Generate the optimal-versus-baseline comparison tables from project/results."""
out = PAPER / "data"
out.mkdir(parents=True, exist_ok=True)
data = {name: json.loads((PROJECT_RESULTS / f"{name}.json").read_text("utf-8"))
for name in ("q1", "q2", "q3", "q4")}
examples, random = data["q1"]["aggregate_examples"], data["q1"]["aggregate_random"]
two = data["q2"]["aggregate"]
three, paired3 = data["q3"]["aggregate"]["variants"], data["q3"]["aggregate"]["paired"]
four, paired4 = data["q4"]["aggregate"]["variants"], data["q4"]["aggregate"]["paired"]
head = "% Generated by scripts/build_paper_support.py from project/results/*.json."
macros = [
("ProjQOneExampleCases", str(examples["cases"])),
("ProjQOneRandomCases", str(random["cases"])),
("ProjQOneFixedStatusBase", percent(examples["status_accuracy_baseline"])),
("ProjQOneFixedMaeBase", fixed(examples["diameter_mae_baseline_m"])),
("ProjQOneFixedMaeMaxBase", fixed(examples["diameter_max_err_baseline_m"])),
("ProjQOneFixedCoverBase", percent(examples["coverage_accuracy_baseline"])),
("ProjQOneFixedMsOpt", fixed(examples["runtime_optimal_mean_ms"])),
("ProjQOneFixedMsBase", fixed(examples["runtime_baseline_mean_ms"])),
("ProjQOneRandStatusBase", percent(random["status_accuracy_baseline"])),
("ProjQOneRandMaeBase", fixed(random["diameter_mae_baseline_m"])),
("ProjQOneRandMaeMaxBase", fixed(random["diameter_max_err_baseline_m"])),
("ProjQOneRandCoverBase", percent(random["coverage_accuracy_baseline"])),
("ProjQOneRandMsOpt", fixed(random["runtime_optimal_mean_ms"])),
("ProjQOneRandMsBase", fixed(random["runtime_baseline_mean_ms"])),
("ProjQTwoCases", str(two["optimal"]["cases"])),
("ProjQTwoInitialRadius", fixed(two["_initial_radius_mean_m"])),
("ProjQTwoGuaranteeOpt", percent(two["optimal"]["guaranteed_rate"])),
("ProjQTwoGuaranteeMec", percent(two["baseline_mec"]["guaranteed_rate"])),
("ProjQTwoGuaranteePerp", percent(two["baseline_perp"]["guaranteed_rate"])),
("ProjQTwoWorstOpt", fixed(two["optimal"]["worst_posterior_mean_m"])),
("ProjQTwoWorstRef", fixed(two["reference"]["worst_posterior_mean_m"])),
("ProjQTwoRatioOpt", fixed(two["optimal"]["final_ratio_mean"])),
("ProjQTwoRatioMec", fixed(two["baseline_mec"]["final_ratio_mean"])),
("ProjQTwoWorstPerp", fixed(two["baseline_perp"]["worst_posterior_mean_m"])),
("ProjQTwoWorstMec", fixed(two["baseline_mec"]["worst_posterior_mean_m"])),
("ProjQThreeScenes", str(three["optimal"]["episodes"])),
("ProjQThreeMeanOpt", fixed(three["optimal"]["virtual_time_mean_s"])),
("ProjQThreeMeanSeven", fixed(three["seven"]["virtual_time_mean_s"])),
("ProjQThreeMeanBase", fixed(three["baseline"]["virtual_time_mean_s"])),
("ProjQThreeDropOpt", percent(-paired3["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQThreeDropMedianOpt",
percent(-paired3["optimal_vs_baseline"]["median_relative_change"])),
("ProjQThreeDropSeven", percent(-paired3["seven_vs_baseline"]["mean_relative_change"])),
("ProjQThreeFasterOpt",
f"{paired3['optimal_vs_baseline']['faster_scenes']}/{paired3['optimal_vs_baseline']['compared']}"),
("ProjQThreeMeasuresOpt", fixed(three["optimal"]["measures_mean"])),
("ProjQThreeMeasuresBase", fixed(three["baseline"]["measures_mean"])),
("ProjQThreeMoveOpt", fixed(three["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQThreeMoveBase", fixed(three["baseline"]["move_distance_mean_m"] / 1000)),
("ProjQThreeWallOpt", fixed(three["optimal"]["compute_wall_mean_s"])),
("ProjQFourScenes", str(four["optimal"]["episodes"])),
("ProjQFourMeanOpt", fixed(four["optimal"]["virtual_time_mean_s"])),
("ProjQFourMeanGrid", fixed(four["baseline_grid"]["virtual_time_mean_s"])),
("ProjQFourMeanBase", fixed(four["baseline"]["virtual_time_mean_s"])),
("ProjQFourClearOpt", percent(four["optimal"]["cleared_ratio_mean"])),
("ProjQFourClearGrid", percent(four["baseline_grid"]["cleared_ratio_mean"])),
("ProjQFourClearBase", percent(four["baseline"]["cleared_ratio_mean"])),
("ProjQFourAbsentBase", str(four["baseline"]["absent_misjudged_total"])),
("ProjQFourMeasuresOpt", fixed(four["optimal"]["measures_mean"])),
("ProjQFourMeasuresBase", fixed(four["baseline"]["measures_mean"])),
("ProjQFourFallbackOpt", fixed(four["optimal"]["fallback_clear_attempts_mean"])),
("ProjQFourValidOpt",
f"{four['optimal']['valid_episodes']}/{four['optimal']['episodes']}"),
("ProjQFourValidGrid",
f"{four['baseline_grid']['valid_episodes']}/{four['baseline_grid']['episodes']}"),
("ProjQFourRiseOpt", percent(paired4["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQFourMoveOpt", fixed(four["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQFourMoveBase", fixed(four["baseline"]["move_distance_mean_m"] / 1000)),
]
lines = [head] + [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
(out / "project-macros.tex").write_text("\n".join(lines) + "\n", "utf-8")
def write(stem: str, rows: list[str]) -> None:
(out / stem).write_text("\n".join([head, *rows]) + "\n", "utf-8")
write("project-overview-rows.tex", [
"问题 & 核心指标 & 最优实现 & 基线实现 " + r"\\",
f"1 & 直径平均绝对误差 / 覆盖判定准确率 & {fixed(random['diameter_mae_optimal_m'])} m / "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['diameter_mae_baseline_m'])} m / "
f"{percent(random['coverage_accuracy_baseline'])} " + r"\\",
f"2 & 保证接收率 / 最坏后验半径均值 & {percent(two['optimal']['guaranteed_rate'])} / "
f"{fixed(two['optimal']['worst_posterior_mean_m'])} m & {percent(two['baseline_mec']['guaranteed_rate'])} / "
f"{fixed(two['baseline_mec']['worst_posterior_mean_m'])} m " + r"\\",
f"3 & 全清率 / 平均虚拟耗时 & {percent(three['optimal']['cleared_ratio_mean'])} / "
f"{fixed(three['optimal']['virtual_time_mean_s'])} s & {percent(three['baseline']['cleared_ratio_mean'])} / "
f"{fixed(three['baseline']['virtual_time_mean_s'])} s " + r"\\",
f"4 & 全清率 / 缺席误判总数 & {percent(four['optimal']['cleared_ratio_mean'])} / "
f"{four['optimal']['absent_misjudged_total']} & {percent(four['baseline']['cleared_ratio_mean'])} / "
f"{four['baseline']['absent_misjudged_total']} " + r"\\",
])
write("project-q1-example-rows.tex", [
f"{row['name']} & {fixed(row['diameter_ref_m'])} & {fixed(row['diameter_opt_m'])} & "
f"{fixed(row['diameter_base_m'])} & {fixed(row['abs_err_base_m'])} & "
f"{flag(row['covers_ref'])} & {flag(row['covers_opt'])} & {flag(row['covers_base'])} " + r"\\"
for row in data["q1"]["examples"]
])
write("project-q1-summary-rows.tex", [
f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & "
f"{fixed(examples['diameter_mae_optimal_m'])} & {fixed(examples['diameter_max_err_optimal_m'])} & "
f"{percent(examples['coverage_accuracy_optimal'])} & {fixed(examples['runtime_optimal_mean_ms'])} " + r"\\",
f"最优(随机 {random['cases']} 例) & {percent(random['status_accuracy_optimal'])} & "
f"{fixed(random['diameter_mae_optimal_m'])} & {fixed(random['diameter_max_err_optimal_m'])} & "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['runtime_optimal_mean_ms'])} " + r"\\",
f"基线(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_baseline'])} & "
f"{fixed(examples['diameter_mae_baseline_m'])} & {fixed(examples['diameter_max_err_baseline_m'])} & "
f"{percent(examples['coverage_accuracy_baseline'])} & {fixed(examples['runtime_baseline_mean_ms'])} " + r"\\",
f"基线(随机 {random['cases']} 例) & {percent(random['status_accuracy_baseline'])} & "
f"{fixed(random['diameter_mae_baseline_m'])} & {fixed(random['diameter_max_err_baseline_m'])} & "
f"{percent(random['coverage_accuracy_baseline'])} & {fixed(random['runtime_baseline_mean_ms'])} " + r"\\",
])
write("project-q2-rows.tex", [
f"{label} & {percent(two[key]['guaranteed_rate'])} & {fixed(two[key]['worst_posterior_mean_m'])} & "
f"{fixed(two[key]['worst_posterior_median_m'])} & {fixed(two[key]['improvement_mean_m'])} & "
f"{fixed(two[key]['final_ratio_mean'])} & {fixed(two[key]['move_cost_mean_s'], 2)} " + r"\\"
for key, label in (("optimal", "算法2(默认选点)"), ("reference", "参考:盘内网格枚举"),
("baseline_perp", "基线:垂直 1000 m"), ("baseline_mec", "基线:候选圆心"))
])
def variant_rows(table: dict, mapping: list[tuple[str, str]]) -> list[str]:
return [
f"{label} & {table[key]['valid_episodes']}/{table[key]['episodes']} & "
f"{percent(table[key]['cleared_ratio_mean'])} & {table[key]['absent_misjudged_total']} & "
f"{fixed(table[key]['virtual_time_mean_s'])} & {fixed(table[key]['virtual_time_median_s'])} & "
f"{fixed(table[key]['virtual_time_max_s'])} & {fixed(table[key]['measures_mean'], 1)} & "
f"{fixed(table[key]['move_distance_mean_m'] / 1000)} & {fixed(table[key]['fallback_clear_attempts_mean'], 1)} "
+ r"\\"
for key, label in mapping
]
write("project-q3-rows.tex", variant_rows(three, [
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"), ("baseline", "grid(49 点网格)")]))
write("project-q4-rows.tex", variant_rows(four, [
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
("baseline", "bayes(问题三策略)")]))
def listings(rows: list[dict]) -> None:
data = PAPER / "data"
files = sorted((ROOT / "src/mcm26b").rglob("*.py"))
files += [ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_paper_support.py"]
text = [
r"\begingroup",
r"\linespread{1.0}\selectfont",
r"\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,"
r"aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none}",
]
for path in files:
rel = path.relative_to(ROOT).as_posix()
display = data / "listing-source" / (rel + ".tex")
display.parent.mkdir(parents=True, exist_ok=True)
display.write_text(escaped_listing(path.read_text("utf-8")), "utf-8")
display_rel = display.relative_to(PAPER).as_posix()
escape_option = "escapeinside={" + ESCAPE_OPEN + "}{" + ESCAPE_CLOSE + "}"
text += [fr"\subsection*{{\pathtext{{{rel}}}}}",
fr"\lstinputlisting[{escape_option}]{{{display_rel}}}"]
text.append(r"\endgroup")
(data / "source-listings.tex").write_text("\n".join(text) + "\n", "utf-8")
table = [
r"\begin{longtable}{p{1.15cm}p{13.3cm}}",
r"\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\",
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead",
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endhead",
]
for row in rows:
h = row["package_sha256"]
label = f"Q{row['problem_no']}-{row['formal_index']}"
table += [
fr"{label} & \pathtext{{{Path(row['file']).name}}}\\",
fr"& {{\scriptsize\texttt{{{h[:32]}}}\allowbreak\texttt{{{h[32:]}}}}}\\[4pt]",
]
table += [r"\bottomrule\end{longtable}"]
(data / "formal-file-list.tex").write_text("\n".join(table) + "\n", "utf-8")
SUPPORT_README = """# 论文支撑材料
汇总官方模拟器 test 中的六份正式导出及哈希匹配的官方统计。
问题三、四各包含三次正式测试。支撑材料保留原始文件名及文件校验值。
## 环境和运行
Python >=3.10;安装 requests,可选 numpy 加速。
在本目录使用 PowerShell:
$env:PYTHONPATH = (Resolve-Path src).Path
python -m mcm26b.cli --help
python -m mcm26b.cli run --problem 3 --strategy bayes --robot-id <模拟器登录队号>
python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号>
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
策略的自检命令另需可选 referee 包。
## 重算论文数据和图表
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py
上述命令在本地重算统计表、图表及源码附录。统计快照来自官方正式
统计队列,通过票据哈希匹配所选包。重新验证来源匹配需原电脑的统计队列。
连续覆盖与混合类型推断的扩展见论文未来工作。
## 编译
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回
同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。
support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。
"""
def package(rows: list[dict]) -> Path:
selected = set((ROOT / "src/mcm26b").rglob("*.py"))
selected.update((ROOT / "src/mcm26b/guarantee/plans").glob("*.json"))
selected.update((PAPER / "data").rglob("*"))
selected.update((PAPER / "figures").glob("*.tex"))
selected.update((PAPER / "sections").glob("*.tex"))
selected.update((ROOT / "docs/figures").glob("*.png"))
selected.update((ROOT / "docs/figures").glob("*.json"))
selected.add(ROOT / "docs/figures/README.md")
selected.update(path for path in (ROOT / "project").rglob("*")
if path.is_file() and "__pycache__" not in path.parts)
selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_paper_support.py"])
for row in rows:
path = ROOT / "Jammers-simulator-full" / row["file"]
if digest(path.read_bytes()) != row["package_sha256"]:
raise ValueError("Original formal package changed")
selected.add(path)
blobs = {p.relative_to(ROOT).as_posix(): p.read_bytes() for p in sorted(selected) if p.is_file()}
blobs["README.md"] = SUPPORT_README.encode("utf-8")
blobs["requirements.txt"] = b"requests>=2.31\nnumpy>=1.24\n"
manifest = [{"file": name, "bytes": len(value), "sha256": digest(value)} for name, value in sorted(blobs.items())]
blobs["support-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
out = PAPER / "support-materials.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, value in sorted(blobs.items()):
entry = zipfile.ZipInfo(name, (2026, 9, 13, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, value)
if out.stat().st_size > 20_000_000:
raise ValueError("Support archive exceeds 20 MB")
print(f"Support archive: {len(blobs)} files, {out.stat().st_size} bytes")
return out
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", action="store_true")
args = parser.parse_args()
rows = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
figures(rows)
project_tables()
listings(rows)
if args.package:
package(rows)
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
"""mcm26b:CUMCM 2026 B题"无线电干扰源的快速自动定位与清除"求解包。
模块分层(docs/B题建模分析与实施计划.md §7 的七模块划分):
- config 全项目参数中心(题面常量与工程参数,唯一字面量来源)
- protocol P0/P1 字段表、计时模型、状态机与串行幂等客户端
- geometry P2 角域交会定位、直径、最小包围圆与 P2 证书(已完工)
- sim 自建模拟器、故障注入与演练日志回放(测试专用)
- belief P3 逐频道候选集合(骨架)
- coverage P4 49 点覆盖记录(骨架+网格生成)
- planner P3 选点 / P4 全向闭环 / P5 定向扩展(骨架)
- evaluation 日志复算与统计
"""
__version__ = "0.2.0"
from .config import DEFAULT_CONFIG, ProblemConfig
from .geometry import Measurement, first_bearing_candidate_disk, solve, \
smallest_enclosing_circle
__all__ = ["DEFAULT_CONFIG", "Measurement", "ProblemConfig", "solve",
"first_bearing_candidate_disk", "smallest_enclosing_circle"]
@@ -0,0 +1,284 @@
"""P3 逐频道信念状态:候选集合 K_c 维护与频道处理状态。
核心不变式(验收标准):
- 保守性:真源永远属于维护中的候选集合。候选集用"圆盘外切多边形 (*@\ensuremath{\cap}@*)
测向角域半平面"的凸多边形表示——外切多边形严格包含圆盘,半平面裁剪
带正容差,因此每步更新只会放大而不会缩小真实候选集;
- direction 读数:K_c (*@\ensuremath{\subseteq}@*) Ω (*@\ensuremath{\cap}@*) W(s, (*@\ensuremath{\theta}@*)±(*@\ensuremath{\delta}@*)_eff) (*@\ensuremath{\cap}@*) B(s, 1500),后续读数再交
同样的约束;(*@\ensuremath{\delta}@*)_eff = 误差半角 + 示向度舍入裕量(服务器保留两位小数,
舍入本身引入 (*@\ensuremath{\le}@*)0.005° 偏差);
- no_signal 读数(P4,全向源):基线不据此删空间候选——对定向源它是
"位置×朝向"联合约束,单独维护空间候选会误删真源(P5 才启用);
"该频道不存在源"的证明由覆盖账本(coverage.CoverageLedger)给出;
- near 读数:源在 5m 内,候选集收缩为 B(s, 5),可直接产生清除证书;
- /clear 成功:状态转 CLEARED;失败只说明"该频道 20m 内无可清除源",
不得直接删除超出 20m 的候选,必须先补测再重试。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from enum import Enum
from ..config import ProblemConfig, Vec2
from ..geometry.localization import smallest_enclosing_circle
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
# 半平面裁剪的正容差(米)。坐标量级 ~2100,浮点误差远小于它;
# 容差只向"保留更多候选"的方向倾斜,不破坏保守性。
_CLIP_TOL_M = 1e-6
_DEDUPE_TOL_M = 1e-7
class InconsistentBelief(RuntimeError):
"""读数把候选集剪空等违背保守性的情形;须停机核查而非继续跑。"""
class ChannelStatus(Enum):
UNKNOWN = "unknown" # 尚无有效证据
DETECTED = "detected" # 收到过 direction/near,待定位
LOCATED = "located" # 候选集包围圆半径 <= 20,可清除
CLEARED = "cleared" # 清除成功
ABSENT = "absent" # 覆盖证明不存在(P4)
def circumscribed_polygon(center: Vec2, radius_m: float, sides: int) -> list[Vec2]:
"""圆的外切正多边形顶点(外接半径 r/cos((*@\ensuremath{\pi}@*)/n)),严格包含圆盘。
近似误差(外接圆超出圆盘的部分)为 r(1/cos((*@\ensuremath{\pi}@*)/n) - 1) (*@\ensuremath{\le}@*) r(1-cos((*@\ensuremath{\pi}@*)/n))·sec²,
调用方用 (1-cos((*@\ensuremath{\pi}@*)/n)) 口径记账即可(略保守偏大)。
"""
if sides < 8:
raise ValueError("外切多边形边数过少,近似误差不可控")
circumradius = radius_m / math.cos(math.pi / sides)
step = 2 * math.pi / sides
return [(center[0] + circumradius * math.cos(step * i + step / 2),
center[1] + circumradius * math.sin(step * i + step / 2))
for i in range(sides)]
def polygon_halfplanes(polygon: list[Vec2]) -> list[tuple[float, float, float]]:
"""CCW 多边形 -> 外法线半平面列表 (a, b, c),含义 a·x + b·y <= c。"""
planes = []
count = len(polygon)
for index in range(count):
p, q = polygon[index], polygon[(index + 1) % count]
dx, dy = q[0] - p[0], q[1] - p[1]
norm = math.hypot(dx, dy)
if norm <= 1e-12:
continue
a, b = dy / norm, -dx / norm # CCW 的右法线朝外
planes.append((a, b, a * p[0] + b * p[1]))
return planes
def clip_halfplane(polygon: list[Vec2], a: float, b: float, c: float,
tol: float = _CLIP_TOL_M) -> list[Vec2]:
"""Sutherland–Hodgman 凸多边形半平面裁剪,保留 a·x + b·y <= c + tol。
边界交点取在无容差直线上;进入判定带正容差,保证"贴边"的真点不丢。
"""
if not polygon:
return []
output: list[Vec2] = []
count = len(polygon)
for index in range(count):
p, q = polygon[index], polygon[(index + 1) % count]
fp = a * p[0] + b * p[1] - c
fq = a * q[0] + b * q[1] - c
p_in, q_in = fp <= tol, fq <= tol
if p_in:
output.append(p)
if p_in != q_in:
ratio = fp / (fp - fq)
output.append((p[0] + ratio * (q[0] - p[0]),
p[1] + ratio * (q[1] - p[1])))
return _dedupe(output)
def _dedupe(points: list[Vec2], tol: float = _DEDUPE_TOL_M) -> list[Vec2]:
unique: list[Vec2] = []
for point in points:
if not unique or math.dist(point, unique[-1]) > tol:
unique.append(point)
if len(unique) > 1 and math.dist(unique[0], unique[-1]) <= tol:
unique.pop()
return unique
class CandidateRegion:
"""某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。"""
def __init__(self, polygon: list[Vec2], slack_m: float = 0.0):
self.polygon = list(polygon)
self.slack_m = float(slack_m) # 外包近似引入的半径向误差记账
self._mec: tuple[Vec2, float] | None = None
# ------------------------------------------------------------ 构造
@classmethod
def disk(cls, center: Vec2, radius_m: float, sides: int,
slack_m: float | None = None) -> "CandidateRegion":
if slack_m is None:
slack_m = radius_m * (1 - math.cos(math.pi / sides))
return cls(circumscribed_polygon(center, radius_m, sides), slack_m)
@classmethod
def initial(cls, station: Vec2, config: ProblemConfig) -> "CandidateRegion":
"""首个 direction 后的基础集:Ω (*@\ensuremath{\cap}@*) B(station, r_max) 的保守外包。"""
field_cfg, sides = config.field, config.search.polygon_sides
slack = field_cfg.omega_radius_m * (1 - math.cos(math.pi / sides))
region = cls.disk((0.0, 0.0), field_cfg.omega_radius_m, sides, slack)
region.clip_disk(station, field_cfg.r_eff_max_m, sides)
return region
# ------------------------------------------------------------ 收缩
def _clip(self, a: float, b: float, c: float) -> None:
polygon = clip_halfplane(self.polygon, a, b, c)
if not polygon:
raise InconsistentBelief(
f"候选区域被半平面 ({a:.6f},{b:.6f},{c:.6f}) 剪空:读数与保守集矛盾")
self.polygon = polygon
self._mec = None
def clip_disk(self, center: Vec2, radius_m: float, sides: int) -> None:
"""与圆盘 B(center, radius) 的外切多边形求交(近似误差入账)。"""
for a, b, c in polygon_halfplanes(circumscribed_polygon(center, radius_m, sides)):
self._clip(a, b, c)
self.slack_m += radius_m * (1 - math.cos(math.pi / sides))
def clip_wedge(self, station: Vec2, bearing_deg: float,
half_angle_deg: float) -> None:
"""与测向角域 W(station, bearing±half_angle) 求交(半角须 <90°)。
两个边界半平面与 localization.halfplanes 同口径,0°/360° 跨界由
sin/cos 直接处理,无需分支。
"""
if not 0 < half_angle_deg < 90:
raise ValueError("角域半角须在 (0, 90) 度")
lower = math.radians(bearing_deg - half_angle_deg)
upper = math.radians(bearing_deg + half_angle_deg)
sx, sy = float(station[0]), float(station[1])
self._clip(math.sin(lower), -math.cos(lower),
math.sin(lower) * sx - math.cos(lower) * sy)
self._clip(-math.sin(upper), math.cos(upper),
-math.sin(upper) * sx + math.cos(upper) * sy)
# ------------------------------------------------------------ 查询
def mec(self) -> tuple[Vec2, float]:
"""最小包围圆(圆心, 半径)。半径是真实候选集 (*@\ensuremath{\rho}@*) 的上界。"""
if self._mec is None:
if not self.polygon:
raise InconsistentBelief("候选区域为空")
self._mec = smallest_enclosing_circle(self.polygon)
return self._mec
def witnesses(self, spacing_m: float) -> list[Vec2]:
"""顶点 + 边界按间距细分的采样点(最坏后验评估用)。"""
if len(self.polygon) < 3:
return list(self.polygon)
points = list(self.polygon)
count = len(self.polygon)
for index in range(count):
p, q = self.polygon[index], self.polygon[(index + 1) % count]
length = math.dist(p, q)
steps = int(length // spacing_m)
for k in range(1, steps + 1):
ratio = k / (steps + 1)
points.append((p[0] + ratio * (q[0] - p[0]),
p[1] + ratio * (q[1] - p[1])))
return _dedupe(points, tol=1e-9)
def contains(self, point: Vec2, tol_m: float = 0.05) -> bool:
"""点是否在候选集内(带容差;用于真源保留性检验,不做删点依据)。"""
if not self.polygon:
return False
if len(self.polygon) == 1:
return math.dist(point, self.polygon[0]) <= tol_m + self.slack_m
if len(self.polygon) == 2:
p, q = self.polygon
return _point_segment_distance(point, p, q) <= tol_m + self.slack_m
for index, p in enumerate(self.polygon):
q = self.polygon[(index + 1) % len(self.polygon)]
if (q[0] - p[0]) * (point[1] - p[1]) - (q[1] - p[1]) * (point[0] - p[0]) \
< -tol_m - self.slack_m:
return False
return True
def _point_segment_distance(point: Vec2, p: Vec2, q: Vec2) -> float:
vx, vy = q[0] - p[0], q[1] - p[1]
length_sq = vx * vx + vy * vy
if length_sq <= 1e-18:
return math.dist(point, p)
t = max(0.0, min(1.0, ((point[0] - p[0]) * vx + (point[1] - p[1]) * vy) / length_sq))
return math.dist(point, (p[0] + t * vx, p[1] + t * vy))
@dataclass
class ChannelBelief:
channel: int
status: ChannelStatus = ChannelStatus.UNKNOWN
region: CandidateRegion | None = None
measurements: list = field(default_factory=list) # 逐步观测记录(审计用)
class BeliefState:
"""全部 20 个频道的信念集合。策略端唯一的事实来源。"""
def __init__(self, config: ProblemConfig = ProblemConfig()):
self.config = config
self.channels = {channel: ChannelBelief(channel)
for channel in range(config.field.channel_min,
config.field.channel_max + 1)}
@property
def effective_half_angle_deg(self) -> float:
"""示向度误差半角 + 舍入裕量:服务器保留两位小数引入 (*@\ensuremath{\le}@*)0.005° 偏差。"""
return (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
def update_measure(self, channel: int, position: Vec2, result: str,
svd_deg: float | None) -> None:
"""按读数收敛候选集合;真源保留性由保守表示保证。"""
belief = self.channels[channel]
belief.measurements.append({"position": tuple(position), "result": result,
"svd_deg": svd_deg})
if result == DIRECTION:
if svd_deg is None:
raise ValueError("direction 读数必须携带 svd_deg")
sides = self.config.search.polygon_sides
if belief.region is None:
belief.region = CandidateRegion.initial(position, self.config)
else:
# 后续 direction 同样隐含距离 <= r_max [题面]
belief.region.clip_disk(position, self.config.field.r_eff_max_m, sides)
belief.region.clip_wedge(position, svd_deg, self.effective_half_angle_deg)
belief.status = ChannelStatus.DETECTED
elif result == NEAR:
sides = self.config.search.polygon_sides
if belief.region is None:
belief.region = CandidateRegion.disk(
tuple(position), self.config.field.near_radius_m, sides)
else:
belief.region.clip_disk(position, self.config.field.near_radius_m, sides)
belief.status = ChannelStatus.DETECTED
elif result == NO_SIGNAL:
# P3/P4 基线:不据此删空间候选(缺席证明由覆盖账本给出)。
pass
else:
raise ValueError(f"未知测向结果: {result!r}")
def candidate_set(self, channel: int) -> CandidateRegion | None:
"""该频道当前候选集合(保守外包形式);尚无 direction/near 时为 None。"""
return self.channels[channel].region
def mark_cleared(self, channel: int) -> None:
self.channels[channel].status = ChannelStatus.CLEARED
def mark_absent(self, channel: int) -> None:
self.channels[channel].status = ChannelStatus.ABSENT
def channels_by_status(self, *statuses: ChannelStatus) -> list[int]:
return sorted(channel for channel, belief in self.channels.items()
if belief.status in statuses)
@@ -0,0 +1,388 @@
"""位置×朝向联合可行集(第四问定向源)。
现状问题(docs/第三四问架构优化与运行提速方案.md §1.3 F3):第四问的失败**全部**
来自定向源(评测机两局:全向 17/17 清除,定向仅 2/6)。原因不是覆盖不足,而是
策略只知道"源可能在某个空间区域",不知道"它朝着哪边"——于是选点只能靠固定
45° 阶梯试探,一旦试探失败就退化到 (*@\ensuremath{\le}@*)400 格 20 m 盲清。
本模块把"看不看得见"变成可推理的量:为已在 Ω 上维护的网格幸存集
(:class:`~mcm26b.particles.ChannelParticles`)附加每格 24 档朝向可行性掩码,
得到 (位置 × 朝向) 联合可行集。观测语义严格对齐评测机
``referee.simulator._observe``:
- 距离 > r_eff → no_signal;定向且源→读点方向与朝向夹角 > 90° → no_signal;
- 距离 (*@\ensuremath{\le}@*) 5 m → near(**仍需可见**:3 m 处背向的定向源同样返回 no_signal);
- 其余 → direction(读数 = 源→读点方位 + 有界误差)。
更新规则(全部保守,只删"必然不可能"的格/档):
=============== ============================================ ==================
观测 位置 朝向
=============== ============================================ ==================
``direction``@s 距离 (*@\ensuremath{\le}@*) 1500、角向 |gap| (*@\ensuremath{\le}@*) (*@\ensuremath{\delta}@*)_eff + 格裕量 必须**可能**覆盖 s
``near``@s 距离 (*@\ensuremath{\le}@*) 5 必须**可能**覆盖 s
``no_signal``@s **不删**(可能是朝向遮挡或距离不足) 必须**确定不**覆盖 s
(仅当整格必在 r_eff_min 内才约束朝向)
=============== ============================================ ==================
阈值全部取保守侧,并计入**格内角裕量**
``asin(margin / max(dist − margin, 1))``(格内任一点相对格心方位偏差上界):
- "必须可能覆盖":``gap (*@\ensuremath{\le}@*) 90° + 半档 + 查表舍入 + 格内角裕量``(宁可保留);
- "必须确定不覆盖":先删去 ``gap (*@\ensuremath{\le}@*) 90° − 半档 − 查表舍入 − 格内角裕量`` 的档,
只保留确定看不见 s 的档(同样宁可保留)。
因此真源 (位置, 朝向) 永不被删——这是网格幸存集真源保留定理的二维推广。
(2026-09-12 修正:no_signal 分支原先误用"确定覆盖"掩码做交集,等价于把
规则反向;keep=True 分支也漏计了格内角裕量。两处都会删掉真源朝向档,
现由 ``tests/test_joint_belief.py`` 的随机观测序列保留性测试固化。)
用途:
1. ``prob_detect``:该点能看见该频道任一可行 (位置, 朝向) 的概率质量,
用于**可见性选点**(取代固定阶梯)——只影响选点顺序,不承担安全责任;
2. ``expected_posterior_radius``:一次测量后的期望后验 MEC 半径(D1 值函数);
3. ``is_absent``:联合集空 (*@\ensuremath{\Longrightarrow}@*) 该频道不存在源(严格,与覆盖证明同一条件);
4. 位置边际仍由凸证书负责 20 m 清除判定,本模块不改变任何证书口径。
"""
from __future__ import annotations
import math
from ..config import ProblemConfig, Vec2
from ..particles import ChannelParticles, _branch_radius_array
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
HEADING_BINS = 24 # 15° 一档
_BIN_HALF_DEG = 180.0 / HEADING_BINS # 7.5°
_FULL_MASK = (1 << HEADING_BINS) - 1
_GRID_DEG = 3.0 # 角度查表分辨率
_GRID_N = int(round(360.0 / _GRID_DEG))
try: # numpy 仅作可选加速后端(与 particles 一致)
import numpy as _np
from ..particles import _np as _particles_np
if _particles_np is None: # MCM26B_NO_NUMPY 或缺失:强制纯 Python
_np = None
except Exception: # pragma: no cover - 环境无 numpy
_np = None
def heading_gap(a_deg: float, b_deg: float) -> float:
"""两个朝向角的最小夹角(度)。"""
return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)
def _cover_mask(bearing_deg: float, half_deg: float) -> int:
"""与该方位夹角不超过 ``half_deg`` 的朝向档掩码。"""
mask = 0
for k in range(HEADING_BINS):
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) <= half_deg:
mask |= 1 << k
return mask
# 查表:以 bearing 为轴、半角 90° 的可见半平面。
# - COVER:**可能**覆盖该方位(含半档与查表舍入的保守侧);
# - SILENT:**确定**覆盖该方位(内缩半档与舍入)——离散对照表,供审计与
# 文档比对;实际裁剪走 _cover_mask / _far_mask,因为还必须逐格计入
# "格内角裕量"(格心到格内任一点的方位偏差上界),无法预先制表。
_COVER_TABLE = tuple(
_cover_mask(_GRID_DEG * i, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2)
for i in range(_GRID_N))
_SILENT_TABLE = tuple(
_cover_mask(_GRID_DEG * i, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2)
for i in range(_GRID_N))
def _far_mask(bearing_deg: float, exclude_half_deg: float) -> int:
"""与方位夹角**大于** ``exclude_half_deg`` 的朝向档掩码(no_signal 用)。
与 :func:`_cover_mask` 互补:no_signal 只允许保留"确定看不见"的朝向档。
"""
mask = 0
for k in range(HEADING_BINS):
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) > exclude_half_deg:
mask |= 1 << k
return mask
if _np is not None: # 12 位半字 popcount 查表(朝向掩码 24 位)与半平面查表数组
_POP16 = _np.array([bin(value).count("1") for value in range(1 << 12)],
dtype=_np.int64)
_COVER_ARR = _np.array(_COVER_TABLE, dtype=_np.int64)
else: # pragma: no cover - 无 numpy 分支
_POP16 = _COVER_ARR = None
def _popcount(mask: int) -> int:
return bin(mask).count("1")
def _table_index(bearing_deg: float) -> int:
return int(round((bearing_deg % 360.0) / _GRID_DEG)) % _GRID_N
class JointBelief:
"""单频道 (位置 × 朝向) 联合可行集。"""
def __init__(self, config: ProblemConfig = ProblemConfig()):
self.config = config
self.field = config.field
self.position = ChannelParticles(config)
self.masks: dict[int, int] = {} # 格索引 -> 朝向掩码(缺省为全档)
self._sample_cache: tuple[int, list[int]] | None = None
# ------------------------------------------------------------ 查询
def __len__(self) -> int:
return len(self.position)
def is_absent(self) -> bool:
"""联合集空 (*@\ensuremath{\Longrightarrow}@*) 该频道不存在源(真源保留定理的二维推广)。"""
return not self.position.alive
def _mask(self, index: int) -> int:
return self.masks.get(index, _FULL_MASK)
def sample(self, size: int = 256) -> list[int]:
"""均匀抽样幸存格(评分用;证书始终用完整集合)。"""
cached = self._sample_cache
if cached is not None and cached[0] == self.position.version:
return cached[1]
alive = sorted(self.position.alive)
if len(alive) > size:
step = len(alive) / size
alive = [alive[int(k * step) % len(alive)] for k in range(size)]
self._sample_cache = (self.position.version, alive)
return alive
def _mask_popcount(self, index: int) -> int:
return _popcount(self._mask(index))
def expected_posterior_radius(self, reader: Vec2, size: int | None = None) -> float:
"""一次测量后的期望后验 MEC 半径(米);D1 值函数。
与 :meth:`ChannelParticles.expected_posterior_radius` 同族,差别只在
no_signal 分支概率:定向源不仅会因距离 > r_eff 静默,还会因朝向遮挡
静默,因此
p_vis(p) = P(r_eff (*@\ensuremath{\ge}@*) |p−q|) × cover_ratio(p, q)
其中 ``cover_ratio`` 是该格朝向掩码中"可能覆盖 q 方位"的档位占比。
分支半径语义:
- direction(可见,非 near):读数为 q 处的方位 ±(*@\ensuremath{\delta}@*),后验收缩到该方位
楔形与当前可行集之交,半径取幸存子采样的质心最大距离;
- no_signal:定向源的静默**不删空间候选**,位置后验半径保持当前值;
- near:后验为 5m 盘。
返回值只用于**选点排序**,不参与任何安全判定;证书仍由凸/网格口径给出。
"""
pf = self.position
if not pf.alive:
return 0.0
if size is None:
size = self.config.bayes.score_subsample
indices = self.sample(max(2, size))
if not indices:
return 0.0
stride = max(len(indices) // max(self.config.bayes.mc_representatives, 1), 1)
representatives = indices[::stride]
if _np is not None:
return self._epr_vector(reader, indices, representatives)
return self._epr_python(reader, indices, representatives) # pragma: no cover
def _epr_vector(self, reader: Vec2, indices: list[int],
representatives: list[int]) -> float:
pf = self.position
points = _np.array([pf.cell_center(index) for index in indices], dtype=float)
masks = _np.array([self._mask(index) for index in indices], dtype=_np.int64)
reps = _np.array([pf.cell_center(index) for index in representatives],
dtype=float)
rep_masks = _np.array([self._mask(index) for index in representatives],
dtype=_np.int64)
dx, dy = points[:, 0] - reader[0], points[:, 1] - reader[1]
dist = _np.hypot(dx, dy)
bearing = _np.degrees(_np.arctan2(dy, dx))
rdx, rdy = reps[:, 0] - reader[0], reps[:, 1] - reader[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
cover = _COVER_ARR[_np.round(_np.mod(rbearing, 360.0) / _GRID_DEG)
.astype(_np.int64) % _GRID_N]
visible_mask = rep_masks & cover
bits = (_POP16[visible_mask & 0xFFF]
+ _POP16[(visible_mask >> 12) & 0xFFF])
cover_ratio = bits / float(HEADING_BINS)
p_silent_range = _np.clip((rdist - self.field.r_eff_min_m)
/ (self.field.r_eff_max_m
- self.field.r_eff_min_m), 0.0, 1.0)
p_vis = (1.0 - p_silent_range) * cover_ratio
p_ns = 1.0 - p_vis
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
in_wedge = ((gaps <= self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg + 1e-9)
& (dist[None, :] <= self.field.r_eff_max_m + pf.margin_m))
eps_radius = pf.spacing_m / 2.0
radii = []
for row in in_wedge:
member = points[row]
radii.append(_branch_radius_array(member)
if len(member) else eps_radius)
radii = _np.array(radii, dtype=float)
current = pf.mec()[1]
near = rdist <= self.field.near_radius_m + pf.margin_m
values = _np.where(near, self.field.near_radius_m + pf.margin_m,
p_ns * current + p_vis * radii)
return float(values.mean())
def _epr_python(self, reader: Vec2, indices: list[int],
representatives: list[int]) -> float: # pragma: no cover
"""纯 Python 回退(与向量版逐分支同语义,供无 numpy 环境与对拍)。"""
pf = self.position
points = [pf.cell_center(index) for index in indices]
masks = [self._mask(index) for index in indices]
eps_radius = pf.spacing_m / 2.0
current = pf.mec()[1]
half_angle = (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
total = 0.0
for rep_index in representatives:
rx, ry = pf.cell_center(rep_index)
distance = math.hypot(rx - reader[0], ry - reader[1])
if distance <= self.field.near_radius_m + pf.margin_m:
total += self.field.near_radius_m + pf.margin_m
continue
reading = math.degrees(math.atan2(ry - reader[1], rx - reader[0]))
cover = _COVER_TABLE[_table_index(reading)]
bits = _popcount(self._mask(rep_index) & cover)
ratio = bits / float(HEADING_BINS)
if distance <= self.field.r_eff_min_m:
p_range = 1.0
elif distance >= self.field.r_eff_max_m:
p_range = 0.0
else:
p_range = ((self.field.r_eff_max_m - distance)
/ (self.field.r_eff_max_m - self.field.r_eff_min_m))
p_vis = p_range * ratio
members = []
for point, mask in zip(points, masks):
if math.dist(point, reader) > self.field.r_eff_max_m + pf.margin_m:
continue
bearing = math.degrees(math.atan2(point[1] - reader[1],
point[0] - reader[0]))
if heading_gap(bearing, reading) <= half_angle + 1e-9:
members.append(point)
if members:
mx = sum(p[0] for p in members) / len(members)
my = sum(p[1] for p in members) / len(members)
radius = max(math.hypot(p[0] - mx, p[1] - my) for p in members)
else:
radius = eps_radius
total += (1.0 - p_vis) * current + p_vis * radius
return total / max(len(representatives), 1)
def prob_detect(self, reader: Vec2, size: int = 256) -> float:
"""在 ``reader`` 处能看见任一可行 (位置, 朝向) 的概率质量(启发式)。
r_eff ~ U[1000, 1500] 解析积分;朝向按可行档位占比计权。
只用于选点排序,不参与任何安全判定。
"""
cells = self.sample(size)
if not cells:
return 0.0
total = 0.0
span = self.field.r_eff_max_m - self.field.r_eff_min_m
for index in cells:
gx, gy = self.position.cell_center(index)
distance = math.hypot(reader[0] - gx, reader[1] - gy)
if distance > self.field.r_eff_max_m:
continue
if distance <= self.field.r_eff_min_m:
p_range = 1.0
else:
p_range = (self.field.r_eff_max_m - distance) / span
if p_range <= 0.0:
continue
bearing = math.degrees(math.atan2(reader[1] - gy, reader[0] - gx))
cover = _COVER_TABLE[_table_index(bearing)]
bits = bin(self._mask(index) & cover).count("1")
if bits:
total += p_range * bits / HEADING_BINS
return total / len(cells)
# ------------------------------------------------------------ 观测更新
def _apply_heading(self, station: Vec2, keep: bool,
within_m: float | None = None) -> None:
"""按可见性约束裁剪朝向档(保守:只删"必然不可能"的档)。
``keep=True``(direction / near)(*@\ensuremath{\Longrightarrow}@*) 朝向必须**可能**覆盖 station:
保留与方位夹角 (*@\ensuremath{\le}@*) 90° + 半档 + 查表舍入 + 格内角裕量的档。
``keep=False``(no_signal 且整格必在 r_eff_min 内)(*@\ensuremath{\Longrightarrow}@*) 朝向必须
**确定不覆盖** station:只保留与方位夹角 > 90° − 半档 − 查表舍入
− 格内角裕量的档。
``格内角裕量 = asin(margin / max(dist − margin, 1))``:格内任一点
相对格心的方位偏差上界。两个方向都必须计入它,否则会误删真源的
朝向档(keep=True 漏计)或保留必然可见的档(keep=False 漏计)。
"""
margin_m = self.position.margin_m
doomed: list[int] = []
for index in self.position.alive:
gx, gy = self.position.cell_center(index)
dx, dy = station[0] - gx, station[1] - gy
distance = math.hypot(dx, dy)
if within_m is not None and distance > within_m:
continue
bearing = math.degrees(math.atan2(dy, dx))
if distance <= margin_m + 1.0:
# 格贴测点:格内方位可指向任意方向,任何一侧都不可据此裁剪
continue
angular_margin = math.degrees(
math.asin(min(0.9999, margin_m / (distance - margin_m))))
if keep:
updated = self._mask(index) & _cover_mask(
bearing, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2 + angular_margin)
else:
updated = self._mask(index) & _far_mask(
bearing, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2 - angular_margin)
if updated == 0:
doomed.append(index)
self.masks.pop(index, None)
elif updated != _FULL_MASK:
self.masks[index] = updated
if doomed:
self.position.discard(doomed)
def apply(self, station: Vec2, result: str,
svd_deg: float | None = None) -> None:
"""按读数更新联合可行集(保守,真源 (位置, 朝向) 永不被删)。"""
if result == DIRECTION:
self.position.apply_measure(station, DIRECTION, svd_deg,
directional=True)
self._apply_heading(station, keep=True)
elif result == NEAR:
# near 同样要求可见(评测机先判朝向再判距离)
self.position.apply_measure(station, NEAR, directional=True)
self._apply_heading(station, keep=True)
elif result == NO_SIGNAL:
# 距离阈值必须取**保守侧**:只有"整格都必然落在 r_eff_min 圆盘内"
# 时,no_signal 才一定由朝向遮挡解释。格心到格内任一点 (*@\ensuremath{\le}@*) margin,
# 故阈值取 r_eff_min − margin;取 + margin 会把 1000~1010m 的模糊带
# 也当成"必须不覆盖",从而可能删掉真源的朝向档(破坏真源保留性)。
self._apply_heading(station, keep=False,
within_m=max(
0.0, self.field.r_eff_min_m
- self.position.margin_m))
else:
raise ValueError(f"未知测向结果: {result!r}")
# ------------------------------------------------------------ 诊断
def summary(self) -> dict:
"""联合集规模快照(写入策略 summary,供审计与论文复算)。"""
cells = self.sample(256)
bins = [bin(self._mask(index)).count("1") for index in cells]
return {"cells": len(self.position),
"sampled_mean_heading_bins": (round(sum(bins) / len(bins), 2)
if bins else 0.0),
"heading_bins_total": HEADING_BINS}
@@ -0,0 +1,374 @@
"""统一命令行入口:mcm26b <子命令>。
- q1 问题一角域交会求解(P2 已完工)
- sim-check 用评测机复现附件计时示例(P0 自检)
- run 闭环:发现—定位—清除—退出(--problem 3 全向 / 4 混合源;
--self-test 走独立评测机 referee)
解耦约定:运行时代码零导入评测机;referee/harness 仅在 --self-test 与
sim-check 分支内懒加载(测试侧 dev 依赖)。
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import threading
import time
from dataclasses import replace
from pathlib import Path
from .config import ProblemConfig
from .geometry.__main__ import main as q1_main
from .planner import DirectionalExtension, OmnidirectionalLoop
from .planner.bayes_loop import BayesLoop
from .planner.seven import SevenBatch
from .protocol.client import SimulatorClient
from .protocol.log import SessionLog
from .protocol.rules import EXIT_USER
Q3_STRATEGIES = {"grid": OmnidirectionalLoop, "seven": SevenBatch, "bayes": BayesLoop}
def _load_referee():
"""懒加载独立评测机与测试侧适配层(运行时/正式对接不触发)。"""
from .harness import (LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
from referee import LocalSimulator
return (LocalSimulator, LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
def _referee_missing():
print("未安装独立评测机包 referee(mcm26b-referee)。请先 `uv sync` 安装"
" dev 依赖,或 pip install mcm26b-referee;正式对接官方模拟器无需本包。",
file=sys.stderr)
return 2
# ---------------------------------------------------------------- 一键本地评测
def _port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _any_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _acquire_port(name: str, requested: int) -> int:
"""端口被占时积极询问:交互终端逐次询问(回车=自动改空闲端口),
非交互环境(脚本/CI)直接改用空闲端口并告知,绝不静默失败。"""
if _port_free(requested):
return requested
if sys.stdin.isatty():
while True:
auto = _any_free_port()
try:
answer = input(f"[一键评测] {name}端口 {requested} 已被占用:"
f"回车改用空闲端口 {auto},或输入其他端口:").strip()
except (EOFError, KeyboardInterrupt):
return auto
if not answer:
return auto
if answer.isdigit() and 1024 <= int(answer) <= 65535:
if _port_free(int(answer)):
return int(answer)
print(f"端口 {answer} 同样被占用,请再试。")
continue
print("请输入 1024..65535 的端口号,或直接回车让评测机自选。")
auto = _any_free_port()
print(f"[一键评测] {name}端口 {requested} 已被占用(非交互环境),"
f"自动改用空闲端口 {auto}。", file=sys.stderr)
return auto
def _start_local_judge(args):
"""启动独立评测机(机器狗接口 + WebUI 回放)并确认开始一局演练。
返回 (judge, session, action_port, ui_port);失败返回 None(已打印原因)。
"""
try:
from referee import Referee
from referee.server import ActionInterfaceServer
from referee.webui import WebUIServer
except ImportError:
_referee_missing()
return None
action_port = _acquire_port("机器狗接口", args.judge_port)
ui_port = None
judge = Referee(action_port=action_port, data_dir=Path(args.judge_data_dir))
ActionInterfaceServer(judge)
if not args.judge_no_ui:
ui_port = _acquire_port("WebUI", args.judge_ui_port)
ui = WebUIServer(judge, ui_port)
threading.Thread(target=ui.serve_forever, daemon=True,
name="mcm26b-local-judge-ui").start()
module = f"q{args.problem}_practice"
session = judge.start(module, args.robot_id, seed=args.seed,
countdown_s=args.judge_countdown_s)
deadline = time.monotonic() + session.countdown_s + 10
while session.phase == "countdown" and time.monotonic() < deadline:
time.sleep(0.05)
if session.phase != "open":
print("[一键评测] 评测机未能开放接口,已中止。", file=sys.stderr)
judge.abort()
return None
print(f"[一键评测] 案例编码 {session.case_code} · {session.module.title}"
f" · 接口 http://127.0.0.1:{action_port}")
if ui_port is not None:
print(f"[一键评测] WebUI http://127.0.0.1:{ui_port}"
f"(实时界面与\"回放\"Tab 逐步回看,数据目录 {args.judge_data_dir})")
return judge, session, action_port, ui_port
def _settle_local_judge(judge, timeout_s: float = 3.0) -> None:
"""等看门狗确认本局结束(/exit 后毫秒级);仅超时才兜底手工中止。"""
deadline = time.monotonic() + timeout_s
while judge.session.phase != "finished" and time.monotonic() < deadline:
time.sleep(0.05)
if judge.session.phase != "finished":
judge.abort()
def _finish_local_judge(judge, session, summary: dict) -> None:
"""等待评测机确认结束,把结束原因、真值统计与回放入口并入 summary。"""
_settle_local_judge(judge, timeout_s=10.0)
state = judge.snapshot()
truth_channels = sorted(s.channel for s in session.sources)
cleared = sorted(summary.get("cleared_channels", []))
summary["local_judge"] = {
"case_code": session.case_code,
"module": session.module.title,
"end_reason": state.get("end_reason"),
"end_reason_text": state.get("end_reason_text"),
"virtual_time_s": state.get("virtual_time_s"),
"request_count": state.get("request_count"),
"truth": {"source_channels": truth_channels,
"source_count": len(truth_channels),
"omni_count": sum(1 for s in session.sources if not s.directional),
"directional_count": sum(1 for s in session.sources if s.directional)},
"all_cleared": cleared == truth_channels,
"log": state.get("log"),
}
def _sim_check(args) -> int:
"""附件1表2/附件2第10节计时示例:期望 105/111/194/199 秒。"""
try:
local_simulator, local_sim_transport, *_ = _load_referee()
except ImportError:
return _referee_missing()
simulator = local_simulator([], robot_id=args.robot_id)
client = SimulatorClient(args.robot_id, transport=local_sim_transport(simulator))
client.enter()
steps = []
steps.append(client.measure((300, 400), 1).virtual_time_s) # 移动500m+检测5s
steps.append(client.measure((300, 400), 2).virtual_time_s) # 换频道1s+检测5s
steps.append(client.clear((300, 0), 3).virtual_time_s) # 移动400m+未发现3s
steps.append(client.measure((300, 0), 2).virtual_time_s) # 原地检测频道2
info = client.exit()
expected = [105, 111, 194, 199]
ok = [round(value, 6) for value in steps] == expected \
and round(info.virtual_time_s, 6) == 199 and info.reason == EXIT_USER
print(json.dumps({"expected": expected, "got": steps,
"final_virtual_time_s": info.virtual_time_s,
"exit_reason": info.reason, "pass": ok},
ensure_ascii=False))
return 0 if ok else 1
def _run(args) -> int:
"""问题3/4闭环。
三种对接方式:--local-judge 一键本地评测(自动起 referee 并对接);
--self-test 内存回环自检(不占端口);默认对接 --base-url 的模拟器
(官方或本地 referee)。真值只存在于测试端入口,策略端仅见响应。
实机预算:--self-test 时评测机按 --real-budget-s(默认 1800s)返回
remaining_real_duration_s;其余方式以服务器 /enter 返回为准(题面
程序运行上限 20 分钟,不可延长)。
"""
config = ProblemConfig()
session = replace(config.session,
base_url=args.base_url or config.session.base_url)
config = replace(config,
session=session,
search=replace(config.search,
bearing_rounding_margin_deg=args.bearing_margin_deg,
persistent_search=args.persistent))
if args.persistent:
# 历史教训:一次官方演练在 persistent 复扫下把 98.84% 的虚拟时间
# (346170 s)花在空复扫上。这里给出单轮上界,明确这是"扫到限时"模式。
stations = 49 if args.problem == 4 else 7
per_round = config.field.channel_max * stations * (
config.cost.measure_s + config.cost.switch_s)
print(f"[警告] --persistent:找全后仍复扫未清除频道直到限时。"
f"单轮上界约 {per_round:.0f}s 虚拟时间"
f"({config.field.channel_max} 频道 × {stations} 站点 × "
f"{config.cost.measure_s + config.cost.switch_s:.0f}s),"
f"且会持续多轮;成绩口径请保持默认(关闭)。", file=sys.stderr)
truth = None
transport = None
real_budget_s = None
judge = judge_session = None
if args.local_judge:
if args.self_test:
print("--local-judge 与 --self-test 不能同时使用。", file=sys.stderr)
return 2
if args.base_url:
print("--local-judge 忽略 --base-url(接口地址由评测机自动分配)。",
file=sys.stderr)
started = _start_local_judge(args)
if started is None:
return 2
judge, judge_session, action_port, _ui_port = started
config = replace(config, session=replace(
config.session, base_url=f"http://127.0.0.1:{action_port}"))
elif args.self_test:
try:
(local_simulator, local_sim_transport,
mixed_scene, random_omni_scene, referee_spec) = _load_referee()
except ImportError:
return _referee_missing()
real_budget_s = (args.real_budget_s if args.real_budget_s is not None
else config.session.self_test_real_budget_s)
config = replace(config,
field=replace(config.field,
max_real_duration_s=real_budget_s))
if args.problem == 4:
sources = mixed_scene(seed=args.seed, count=args.sources,
directional=args.directional, config=config)
else:
sources = random_omni_scene(seed=args.seed, count=args.sources,
config=config)
truth = sorted(source.channel for source in sources)
simulator = local_simulator(sources, robot_id=args.robot_id,
spec=referee_spec(config, max_real_duration_s=real_budget_s),
stress_mode=True)
transport = local_sim_transport(simulator)
elif args.real_budget_s is not None:
print("--real-budget-s 仅在 --self-test 下生效;正式测试以服务器 "
"/enter 返回的 remaining_real_duration_s 为准。", file=sys.stderr)
log = SessionLog(path=Path(args.log)) if args.log else SessionLog()
client = SimulatorClient(args.robot_id, config=config, transport=transport, log=log)
if args.problem == 4:
strategy = "directional"
loop_cls = DirectionalExtension
else:
strategy = args.strategy
loop_cls = Q3_STRATEGIES[strategy]
try:
summary = loop_cls(client, config).run()
finally:
if judge is not None:
_settle_local_judge(judge)
summary["robot_id"] = args.robot_id
return _finalize_run(args, summary, strategy, truth=truth, judge=judge,
judge_session=judge_session, real_budget_s=real_budget_s)
def _finalize_run(args, summary, strategy, *, truth, judge, judge_session,
real_budget_s) -> int:
"""补齐 summary 元信息、核对真值并输出;返回进程退出码。"""
summary["strategy"] = strategy
if real_budget_s is not None:
summary["self_test_real_budget_s"] = real_budget_s
if args.summary_steps is not None and len(summary.get("steps", [])) > args.summary_steps:
summary["steps"] = summary["steps"][:args.summary_steps]
summary["steps_truncated"] = True
all_cleared = None
if truth is not None:
# 仅测试端核对:清除集合是否与真值一致。
all_cleared = sorted(summary["cleared_channels"]) == truth
summary["truth"] = {"source_channels": truth,
"source_count": len(truth),
"all_cleared": all_cleared}
if judge is not None:
_finish_local_judge(judge, judge_session, summary)
all_cleared = summary["local_judge"]["all_cleared"]
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if summary["completed"] and (all_cleared is not False) else 1
def main(argv=None) -> int:
parser = argparse.ArgumentParser(prog="mcm26b", description=__doc__)
parser.add_argument("--config", default="",
help="预留:覆盖 ProblemConfig 的 JSON 文件(参数化入口)")
subparsers = parser.add_subparsers(dest="command", required=True)
q1 = subparsers.add_parser("q1", help="问题一角域交会求解")
q1.add_argument("input", type=str)
q1.add_argument("-o", "--output", type=str, default=None)
q1.add_argument("--rounding-margin-deg", type=float, default=0)
q1.set_defaults(func=lambda a: q1_main([a.input] + (["-o", a.output] if a.output else [])
+ (["--rounding-margin-deg", str(a.rounding_margin_deg)]
if a.rounding_margin_deg else [])) or 0)
check = subparsers.add_parser("sim-check", help="自建模拟器复现附件计时示例")
check.add_argument("--robot-id", default="2026001")
check.set_defaults(func=_sim_check)
run = subparsers.add_parser("run", help="闭环:发现—定位—清除—退出(问题3/4)")
run.add_argument("--robot-id", required=True, help="登录参赛队号,逐字节一致")
run.add_argument("--base-url", default=None,
help="模拟器接口地址(默认取配置 http://127.0.0.1:2026)")
run.add_argument("--log", default=None, help="会话日志 JSONL 输出路径")
run.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="问题3=全向源,问题4=混合定向源(默认 3)")
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES), default="bayes",
help="问题3策略:bayes=粒子滤波+信息增益+滚动调度(默认,"
"自建消融相对 seven 再降 19.5%%),seven=七点批量,"
"grid=49点网格基线;仅 --problem 3 生效")
run.add_argument("--bearing-margin-deg", type=float, default=0.005,
help="示向度舍入裕量(服务器保留两位小数;默认 0.005°)")
run.add_argument("--real-budget-s", type=float, default=None,
help="自检实机预算秒数(默认 1800=30 分钟;仅 --self-test 生效,"
"正式测试以服务器 /enter 返回为准)")
run.add_argument("--summary-steps", type=int, default=None,
help="summary 中保留的逐步计时条数上限(默认全部;完整明细在日志)")
run.add_argument("--persistent", action="store_true",
help="复扫保险模式:找全证明后继续复扫未清除频道直到限时"
"(默认关闭:虚拟时间即成绩口径,找全即 /exit)")
run.add_argument("--local-judge", action="store_true",
help="一键本地评测:自动启动独立评测机 referee(机器狗接口+"
"WebUI 实时/回放),确认开始一局演练并对接跑完整闭环;"
"端口被占时主动询问换端口(非交互环境自动改用空闲端口)")
run.add_argument("--judge-port", type=int, default=2026,
help="一键评测的机器狗接口端口(默认 2026;被占时询问/自选)")
run.add_argument("--judge-ui-port", type=int, default=8026,
help="一键评测的 WebUI 端口(默认 8026;被占时询问/自选)")
run.add_argument("--judge-no-ui", action="store_true",
help="一键评测不启动 WebUI(只保留机器狗接口)")
run.add_argument("--judge-countdown-s", type=float, default=5.0,
help="一键评测的确认开始倒计时秒数(题面默认 5 秒,可缩短加速)")
run.add_argument("--judge-data-dir", default="output/referee",
help="一键评测的会话日志目录(默认 output/referee,供 WebUI 回放)")
run.add_argument("--self-test", action="store_true",
help="走独立评测机 referee 与随机场景(测试端自检,不连官方)")
run.add_argument("--seed", type=int, default=20260910, help="自检场景种子")
run.add_argument("--sources", type=int, default=None,
help="自检源数量(默认按题面在 10~16 中随机)")
run.add_argument("--directional", type=int, default=3,
help="问题4自检场景中定向源数量(--problem 4 生效)")
run.set_defaults(func=_run)
args = parser.parse_args(argv)
try:
return args.func(args)
except NotImplementedError as exc:
print(f"未实现: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,213 @@
"""全项目参数中心:题面常量、协议限制、会话策略与搜索规划参数。
任何魔法数字只允许在这里出现一次。各模块通过 :class:`ProblemConfig` 读取;
命令行需要覆盖时用 ``dataclasses.replace`` 派生新配置,不散落字面量。
来源标注约定:
- ``[题面]`` 出自 issue/B题.pdf、附件1《模拟器使用说明》、附件2《通信接口说明》原文;
- ``[工程]`` 本队工程参数,可依演练实测调整,不代表官方规定。
"""
from __future__ import annotations
from dataclasses import dataclass
Vec2 = tuple[float, float]
@dataclass(frozen=True)
class FieldConfig:
"""题目物理世界与协议硬限制。[题面]"""
omega_radius_m: float = 1800.0 # 目标区域半径,圆心为原点
channel_min: int = 1 # 有效频道范围
channel_max: int = 20
source_count_min: int = 10 # 一局源总数范围(接口不直接告知)
source_count_max: int = 16
r_eff_min_m: float = 1000.0 # 有效接收半径下界:no_signal 只能排除该圆盘
r_eff_max_m: float = 1500.0 # 有效接收半径上界:direction 隐含距离上界
near_radius_m: float = 5.0 # "距离过近"阈值,不返回示向度
clear_radius_m: float = 20.0 # 清除半径;与定向朝向无关
bearing_error_half_deg: float = 1.0 # 示向度误差幅度(题设模型)
bearing_decimals: int = 2 # 返回示向度小数位
coord_abs_limit_m: float = 2_000_000.0 # 提交坐标的绝对值上限(超出返回 HTTP 400)
directional_half_angle_deg: float = 90.0 # 定向源朝向两侧各90度覆盖(含边界)
max_virtual_duration_s: float = 360_000.0 # 虚拟世界限时(100小时)
max_real_duration_s: float = 1200.0 # /enter 成功后程序运行上限(20分钟)
test_window_s: float = 1500.0 # 确认开始后的测试窗口(25分钟)
@dataclass(frozen=True)
class CostConfig:
"""虚拟世界动作耗时模型。[题面] 附件1表1、附件2第4节。"""
speed_mps: float = 5.0 # 移动速度;耗时=直线距离/speed
measure_s: float = 5.0 # 每次检测动作耗时(无论结果)
switch_s: float = 1.0 # 任意两频道间切换耗时,仅由合法 /measure 触发
clear_success_s: float = 5.0 # 清除成功耗时 = 光学定位3s + 激光清除2s
clear_fail_s: float = 3.0 # 未发现可清除目标 = 仅光学定位3s
@dataclass(frozen=True)
class SessionConfig:
"""会话与网络策略。arena/robot/request 规则为 [题面],其余为 [工程]。"""
arena_id: str = "default" # [题面] 固定值
base_url: str = "http://127.0.0.1:2026" # [题面] 模拟器默认接口地址
robot_id_max_bytes: int = 64 # [题面] UTF-8 长度 1..64
request_id_max_bytes: int = 128 # [题面] UTF-8 长度 1..128
request_body_max_bytes: int = 65_536 # [题面] 超出返回 HTTP 413
connect_timeout_s: float = 5.0 # [工程] 连接超时
read_timeout_s: float = 15.0 # [工程] 读超时;覆盖"服务器已处理但响应丢失"
max_attempts: int = 3 # [工程] 单动作最多尝试次数(含首次)
retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大
reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数
real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用
# [工程] 自检/演练实机预算:题面正式测试程序运行上限 1200s 不可延长;
# 自检经 --real-budget-s 覆盖 LocalSimulator 返回的 remaining_real_duration_s,
# 默认 1800s(30 分钟实机),用于验证"限时内持续运行直到找全"的行为。
self_test_real_budget_s: float = 1800.0
@dataclass(frozen=True)
class SearchConfig:
"""搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。"""
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)(*@\ensuremath{\approx}@*)989.95<=1000
grid_half_cells: int = 3 # i,j (*@\ensuremath{\in}@*) [-3,3],49 点覆盖 [-2100,2100]^2 (*@\ensuremath{\supseteq}@*) Ω
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
# 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];
# 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。
bearing_rounding_margin_deg: float = 0.005
# ---- P3/P4 在线定位与闭环参数 [工程] ----
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos((*@\ensuremath{\pi}@*)/192))(*@\ensuremath{\approx}@*)0.24m
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
candidate_rings: tuple = (0.0, 0.4, 0.75, 1.0) # 安全接收盘内候选点的半径比例
candidate_angles: int = 12 # 每环候选方向数
clear_margin_m: float = 0.5 # 清除证书的额外工程余量(多边形近似误差之外)
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
# ---- P5 定向扩展参数 [工程] ----
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
# (首次选点+对跖复核+2 次环探;达限转后备清除)
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
# ---- 覆盖计划(保证层)[工程,2026-09-12 架构优化 P0-2 / S2 硬化] ----
# 第四问发现点集:
# square49 旧的充分条件表(700m 方格,巡回 36.4km);
# q4_mincover_v1 75m/24 档贪心最小集(31 点,巡回 22.9km)——细网格复检
# 在 25m/48 档有 125 个 (p,h) 缺口,是采样过拟合解;
# q4_mincover_v2 **设计裕量**最小集(31 点,r_design=965m、前向角裕量 5°,
# 搜索分辨率 50m/48 档 + 证书分辨率精细剪枝),
# 15m/72 档(325.6 万 (p,h))缺口 0,最坏命中 990m,巡回 23.9km。
# 运行期按同一采样语义复核,未通过即拒绝启动。
q4_coverage_plan: str = "q4_mincover_v2"
verify_coverage_plan: bool = True # 运行期抽样校验(进程内缓存一次)
# ---- 第四问定向源联合信念 [工程,2026-09-12 架构优化 P0-3] ----
# 为已发现的定向频道维护 (位置 × 朝向) 联合可行集:用可见性概率选点,
# 取代固定 45° 阶梯探点;联合集空即给出缺席证明。安全性仍由凸/网格
# 清除证书与覆盖证明负责,本开关只影响选点顺序。
directional_joint_belief: bool = True
# ---- 第四问定向源探点值函数 [工程,2026-09-12 图论与 DP 方案 S3/D1] ----
# 旧评分 ``prob_detect/(移动+6)`` 只问"这点看不看得见源",不问"测完剩多少
# 不确定度",会反复选中同一个近处被遮挡点。D1 口径:先按可见概率过滤
# (只保留 prob_detect (*@\ensuremath{\ge}@*) ratio×最大值的候选),再最小化
# E[后验 MEC 半径](q) + (*@\ensuremath{\lambda}@*)·(当前位置→q→MEC 圆心 的移动秒数 + 检测)
# 9 场景 core 配对(修正联合信念朝向规则之后):
# (*@\ensuremath{\lambda}@*)=0.05:后备 6.8 次 / T 9740 s(值函数主导,为信息多绕路)
# (*@\ensuremath{\lambda}@*)=5 :后备 4.3 次 / T 8604 s(移动项主导,可见性过滤 + 就近抵近)
# 仅移动项(nearest_visible):后备 4.3 次 / T 8602 s,墙钟最低
# 相对旧 visibility 口径(后备 125.6 次、T 9225 s)三者都更好,且 (*@\ensuremath{\lambda}@*) 越大
# 越优说明本题"看得见 + 少走路"比"信息量最大"更重要,故默认取极限口径。
probe_score_mode: str = "nearest_visible" # visibility | expected_radius | nearest_visible
probe_lambda_move_s: float = 5.0 # expected_radius 口径下的移动折算
# 可见概率过滤:只保留 prob_detect (*@\ensuremath{\ge}@*) ratio×最大值的候选再比期望半径,
# 避免"期望上有点信息、实际必被朝向遮挡"的探点。
probe_visibility_ratio: float = 0.7
probe_exclude_blocked: bool = True # 已测过且被遮挡的探点不再重复选取
approach_fractions: tuple = () # D1 抵近点插值比例(0=圆心);默认关闭
# ---- 后备清除路径优化 [工程,2026-09-12 S4/D3] ----
# 实测反例:后备清除在命中真源时立即结束,"整条走格路径最短"并不是正确
# 目标——Or-opt 会把真源推到序列更后面。9 场景配对:最近邻 125.7 次失败
# vs Or-opt 161.4 次(T 8676 → 8940 s)。默认关闭,保留代码与开关。
fallback_optimize_tour: bool = False
# ---- 持续搜索 [工程,2026-09-11 用户决策修订] ----
# 虚拟时间即评测成绩口径(表1:平均定位清除时间):找全证明达成后
# 立即 /exit,不复扫。复扫保险模式(防缺席证明假设偏差)保留,经
# `mcm26b run --persistent` 显式开启:只复扫未清除频道,直到清除数
# 达 16、实机预算耗尽或虚拟安全余量不足。
persistent_search: bool = False
virtual_reserve_s: float = 10_000.0 # 虚拟时长安全余量:剩余不足时停止发起动作,
# 避免触及 100 小时上限被模拟器强制结束
@dataclass(frozen=True)
class BayesConfig:
"""P4b 贝叶斯网格滤波策略参数(问题3新策略 bayes)。[工程]
网格粒子滤波的严格性依赖 Voronoi 裕量:真实源所在格心与源的距离
<= sqrt(2)/2 * particle_spacing_m,所有 kill 判定都内缩该裕量,
因此幸存集永远包含真源(定理见 docs/algo-3-bayes.md §2)。
"""
particle_spacing_m: float = 14.65 # 网格间距;Voronoi 裕量 (*@\ensuremath{\approx}@*) 10.35m;
# 全域约 4.7 万格/频道,查询走粗格索引
# ---- 信息增益评分 [工程,Optuna TPE 调参回填 2026-09-11] ----
# 原调参记录见 output/q3/bayes-tuning/;当前代码的配对验收记录见
# output/q3/optimization-20260911/,随机分布与信息评分仅为工程假设。
score_mode: str = "entropy" # entropy=期望 log 幸存占比(信息增益)|
# radius=期望后验半径(直指证书)
score_subsample: int = 800 # 期望幸存熵估计的粒子子采样规模
mc_representatives: int = 7 # 假设真值代表点数(均匀抽取幸存格)
lambda_move_s: float = 0.05 # 选点评分中移动时间的折算系数(分值/秒);
# 2026-09-12 配对复跑:0.05 优于 0.0105(20 场均值 −2.8%),
# entropy 口径下信息项为 O(1) 对数值,(*@\ensuremath{\lambda}@*)须小型但不可过小
# ---- 路线感知探点 [工程,2026-09-12 P2] ----
# 旧评分只看 current->q + q->MEC,容易选到“信息增益略高但要多绕几百米”
# 的探点。开启后,在候选评分中加入 q 替换当前位置后对当前任务路线代价
# 的增量;A/B 以冻结基准集配对验收。
# 2026-09-12 冻结基准集复跑(18 场景):关闭 3193.1 s vs 开启 3210.3 s,
# 移动 12339.8 m vs 12446.8 m —— 权重 1e-3 的路线项未带来净收益,默认关闭。
route_aware_probe: bool = False
route_aware_insert_weight: float = 0.001 # 路线代价增量的折算系数(小权重,仅微调)
route_aware_clear_weight: float = 0.0 # 额外 q->MEC 清除接近项(基础移动项已含)
wide_rings: tuple = (1.0, 1.5, 2.0) # 无保证接收盘时的探索环(× PF MEC 半径)
# ---- 站点与覆盖 [工程] ----
station_ring_radius_m: float = 1159.7 # 七点环半径;构造时检验全向覆盖解析上界
# ---- 发现-定位融合巡回 [工程,2026-09-12 P0-1,实测无效,默认关闭] ----
# 设想:站点扫频顺带取第二/第三条方位线,取代专用探点。
# 实测(output/suite/q3-fuse-ab*,20 场景配对,2026-09-12):
# 仅追加站点方位线:3454.6s vs 3426.9s(+0.8%,检测 +10.8 次,位移仅 −188m);
# 追加 + 抑制专用探点:4989.0s vs 3426.9s(+45.6%,20/20 场景更慢)。
# 原因:七站环上只有 2–3 个站能对同一源取得有效方位线,且站间交会角
# 不足以保证 20m 证书,专用信息增益探点仍然必要;抑制探点只会白等巡回。
# 代码保留以便复现与后续研究,成绩口径必须保持 False。
fuse_localize_into_tour: bool = False
# ---- 任务排序 [工程] ----
two_opt_enabled: bool = True # 开放路径 2-opt 改进(最近邻打底)
two_opt_passes: int = 4 # 2-opt 最大改进轮数
# ---- 精确任务序(Held-Karp,含换频状态)[工程,2026-09-12 S5/T1] ----
# 任务表典型 6–12 项:|T| (*@\ensuremath{\le}@*) exact_order_limit 时用精确 DP(状态=已访问集合
# × 结束时测向频道),换频代价精确计入;否则回退 NN + 2-opt。0 表示禁用。
# 实测(18 场景):精确 DP 3203.2 s vs 2-opt 3193.1 s —— 逐位最优的"当前
# 任务表"在滚动时域下反而略差(贪心排序的次优性起到探索作用),默认 0;
# 代码与对拍测试保留,可随时开启复核。
exact_order_limit: int = 0
# ---- PF 清除证书 [工程] ----
grid_cert_enabled: bool = True # 允许用幸存集 MEC 颁发清除证书(凸证书之外)
opportunistic_measure: bool = True # 已停靠位置顺路交会;仍逐频道计费
# 预测后验半径至少减少 (1−ratio) 才补测;0.5 优于 0.65(18 场景 3205.4 vs 3210.3 s)
opportunistic_radius_ratio: float = 0.5
@dataclass(frozen=True)
class ProblemConfig:
"""聚合配置。各模块函数的第一个默认参数,便于单测替换。"""
field: FieldConfig = FieldConfig()
cost: CostConfig = CostConfig()
session: SessionConfig = SessionConfig()
search: SearchConfig = SearchConfig()
bayes: BayesConfig = BayesConfig()
DEFAULT_CONFIG = ProblemConfig()
@@ -0,0 +1,56 @@
"""P4 全域覆盖:49 点网格与逐频道覆盖记录。
覆盖基线(推导见 docs/B题建模分析与实施计划.md §4):
S = {(700i, 700j) : i, j (*@\ensuremath{\in}@*) [-3,3]},|S| = 49,覆盖 [-2100,2100]² (*@\ensuremath{\supseteq}@*) Ω,
且允许访问目标圆域外(边界朝外发射的定向源不漏)。
单元内任意点到 4 顶点距离 <= 700·√2 (*@\ensuremath{\approx}@*) 989.95 <= 1000 <= 最小接收半径。
验收标准:
- "路过某点"不算"测过某频道":必须按 (频道, 点) 二维记录;
- 已清除/已定位频道可跳过剩余点,但跳过原因必须留痕;
- 自适应跳点(P6)必须保留逐频道覆盖证据,否则不得启用。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..config import ProblemConfig, Vec2
def coverage_points(config: ProblemConfig = ProblemConfig()) -> list[Vec2]:
"""49 点覆盖网格生成(纯几何,可独立测试)。"""
step = config.search.grid_step_m
half = config.search.grid_half_cells
return [(step * i, step * j)
for j in range(half, -half - 1, -1)
for i in range(-half, half + 1)]
@dataclass
class CoverageLedger:
"""逐频道覆盖账本:(频道, 网格点) -> 是否已测。"""
config: ProblemConfig = field(default_factory=ProblemConfig)
points: list[Vec2] = field(default_factory=coverage_points)
measured: set = field(default_factory=set) # {(channel, point_index)}
skipped: dict = field(default_factory=dict) # channel -> 原因
def mark_measured(self, channel: int, point_index: int) -> None:
self.measured.add((channel, point_index))
def measured_at(self, channel: int, point_index: int) -> bool:
return (channel, point_index) in self.measured
def skip_channel(self, channel: int, reason: str) -> None:
"""记录整频道跳过原因(如 already_cleared / located_pending_clear)。"""
self.skipped[channel] = reason
def pending_points(self, channel: int) -> list[int]:
"""该频道尚未测量的网格点下标。扫描调度策略未实现(P4)。"""
return [index for index in range(len(self.points))
if not self.measured_at(channel, index)]
def coverage_complete(self, channel: int) -> bool:
"""该频道覆盖是否完成(含显式跳过)。"""
return channel in self.skipped or not self.pending_points(channel)
@@ -0,0 +1,85 @@
"""评估与统计:从自身动作日志离线复算虚拟时刻与统计量。
"演练统计"验收要求:总时间与分项耗时可独立复算 [题面/计划 §6]。
recompute_virtual_time 是回放测试与本模块共用的唯一复算实现,
客户端日志格式为 SessionLog 中 accepted 事件的 {path, request, response}。
"""
from __future__ import annotations
from ..config import CostConfig
from ..protocol.rules import CLEAR_SUCCESS
from ..protocol.timing import clear_cost, measure_cost
def recompute_virtual_time(events: list[dict],
cost: CostConfig = CostConfig()) -> dict:
"""从动作日志复算逐步虚拟时刻。
events 每项形如 {path, request, response}(accepted=true)。
返回逐步表与最终时刻;任何一步与服务器响应偏差超过容差记入 mismatches。
"""
position = (0.0, 0.0)
channel = 1
virtual = 0.0
virtual_us = 0
steps, mismatches = [], []
for index, event in enumerate(events):
path, request = event["path"], event["request"]
response = event["response"]
if response.get("accepted") is not True:
mismatches.append({"index": index, "issue": "响应未被接受"})
continue
if path == "/enter":
position, channel, virtual = (0.0, 0.0), 1, response["virtual_time_s"]
virtual_us = round(virtual * 1_000_000)
elif path == "/measure":
pos = (request["position"]["x"], request["position"]["y"])
predicted = measure_cost(position, pos, channel, request["channel"], cost)
position, channel = pos, request["channel"]
virtual_us += predicted.total_us
virtual = virtual_us / 1_000_000
elif path == "/clear":
pos = (request["position"]["x"], request["position"]["y"])
success = response.get("clear_result") == CLEAR_SUCCESS
predicted = clear_cost(position, pos, success, cost)
position = pos
virtual_us += predicted.total_us
virtual = virtual_us / 1_000_000
elif path == "/exit":
pass # /exit 不推进虚拟时钟 [题面]
else:
mismatches.append({"index": index, "issue": f"未知路径 {path}"})
continue
delta = response["virtual_time_s"] - virtual
if abs(delta) > 1e-6:
mismatches.append({"index": index, "path": path,
"recomputed_s": virtual,
"recorded_s": response["virtual_time_s"],
"gap_s": delta})
steps.append({"index": index, "path": path,
"predicted_s": round(virtual, 9),
"recorded_s": response["virtual_time_s"]})
return {"steps": steps, "final_virtual_time_s": virtual,
"mismatches": mismatches}
def summarize(events: list[dict]) -> dict:
"""动作计数摘要:检测/清除/移动距离等基础统计。"""
position = (0.0, 0.0)
measures, clears, cleared, distance_m = 0, 0, 0, 0.0
for event in events:
request = event["request"]
if event["path"] in ("/measure", "/clear"):
pos = (request["position"]["x"], request["position"]["y"])
distance_m += ((pos[0] - position[0]) ** 2
+ (pos[1] - position[1]) ** 2) ** 0.5
position = pos
if event["path"] == "/measure":
measures += 1
elif event["path"] == "/clear":
clears += 1
if event["response"].get("clear_result") == CLEAR_SUCCESS:
cleared += 1
return {"measures": measures, "clears": clears, "cleared": cleared,
"distance_m": distance_m}
@@ -0,0 +1,9 @@
"""P2 几何内核:B题问题一有界误差方位交会定位。仅用标准库,已完工。
问题二首次测向安全候选盘证书一并放在本包(planning 模块)。
"""
from .localization import Measurement, solve, smallest_enclosing_circle
from .planning import first_bearing_candidate_disk
__all__ = ["Measurement", "solve", "smallest_enclosing_circle", "first_bearing_candidate_disk"]
@@ -0,0 +1,40 @@
"""python -m mcm26b.geometry observations.json [-o result.json]"""
import argparse
import json
from dataclasses import replace
from math import isfinite
from pathlib import Path
from .localization import Measurement, solve
def main(argv=None):
parser = argparse.ArgumentParser(description="B题问题一:角域交、直径与包围圆")
parser.add_argument("input", type=Path, help="包含measurements数组的JSON文件")
parser.add_argument("-o", "--output", type=Path)
parser.add_argument("--rounding-margin-deg", type=float, default=0,
help="额外误差半角,默认0;可显式设0.005进行舍入敏感性分析")
args = parser.parse_args(argv)
try:
source = json.loads(args.input.read_text(encoding="utf-8-sig"))
measurements = [Measurement(**row) for row in source["measurements"]]
if not isfinite(args.rounding_margin_deg) or args.rounding_margin_deg < 0:
raise ValueError("额外误差半角必须为有限非负数")
measurements = [replace(m, half_angle_deg=m.half_angle_deg+args.rounding_margin_deg)
for m in measurements]
result = solve(measurements)
result["error_policy"] = {
"additional_half_angle_deg": args.rounding_margin_deg,
"effective_half_angles_deg": [m.half_angle_deg for m in measurements],
}
except (ValueError, TypeError, KeyError, OSError) as exc:
parser.error(str(exc))
text = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()
@@ -0,0 +1,289 @@
"""Intersect forward bearing wedges without an artificial bounding box.
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
from itertools import combinations
from math import cos, sin, radians, hypot, isfinite
from random import Random
Point = tuple[float, float]
EPS = 1e-9
PARALLEL_EPS = 1e-12
@dataclass(frozen=True)
class Measurement:
x: float
y: float
bearing_deg: float
half_angle_deg: float = 1.0
def __post_init__(self):
values = (self.x, self.y, self.bearing_deg, self.half_angle_deg)
if any(isinstance(v, bool) or not isinstance(v, (int, float))
or not isfinite(v) for v in values):
raise ValueError("坐标、示向度和误差半角必须是有限数值")
if not 0 <= self.half_angle_deg < 90:
raise ValueError("误差半角必须在 [0, 90) 度;题设为1度")
def cross(a: Point, b: Point) -> float:
return a[0] * b[1] - a[1] * b[0]
def distance(a: Point, b: Point) -> float:
return hypot(a[0] - b[0], a[1] - b[1])
def halfplanes(m: Measurement) -> list[tuple[float, float, float]]:
"""Return a*x+b*y<=c with unit normals, including the forward constraint."""
theta = m.bearing_deg % 360
lower, upper = radians(theta - m.half_angle_deg), radians(theta + m.half_angle_deg)
middle = radians(theta)
normals = [(sin(lower), -cos(lower)), (-sin(upper), cos(upper)),
(-cos(middle), -sin(middle))]
return [(a, b, a * m.x + b * m.y) for a, b in normals]
def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]:
"""Monotone chain, CCW, no repeated final vertex; retain segment endpoints."""
ordered = sorted(points)
unique = []
for p in ordered:
if not unique or distance(p, unique[-1]) > eps:
unique.append(p)
if len(unique) <= 2:
return unique
def chain(seq):
out = []
for p in seq:
while len(out) >= 2:
a = (out[-1][0] - out[-2][0], out[-1][1] - out[-2][1])
b = (p[0] - out[-1][0], p[1] - out[-1][1])
# Distance-to-line tolerance, not an unscaled area tolerance.
if cross(a, b) > eps * max(hypot(*a), hypot(*b), eps):
break
out.pop()
out.append(p)
return out
return chain(unique)[:-1] + chain(reversed(unique))[:-1]
def diameter(points: list[Point]):
if not points:
raise ValueError("空点集没有本程序定义下的直径")
a = b = points[0]
value = 0.0
for p, q in combinations(points, 2):
d = distance(p, q)
if d > value:
value, a, b = d, p, q
return value, (a, b)
def _circumcircle(a: Point, b: Point, c: Point):
u = (b[0] - a[0], b[1] - a[1])
v = (c[0] - a[0], c[1] - a[1])
det = 2 * cross(u, v)
if abs(det) <= PARALLEL_EPS * max(hypot(*u) * hypot(*v), 1e-300):
return None
uu, vv = u[0] ** 2 + u[1] ** 2, v[0] ** 2 + v[1] ** 2
center = (a[0] + (uu * v[1] - vv * u[1]) / det,
a[1] + (u[0] * vv - v[0] * uu) / det)
return center, distance(center, a)
def _enclosing_circle_reference(points: list[Point], eps: float = EPS):
"""Enumerate all 1/2/3-point supports; O(m^4), appropriate for small m.
Radius is recomputed against every vertex to preserve numerical containment.
Coordinates should be normalized when there are very large offsets.
"""
if not points:
raise ValueError("空集不计算最小包围圆")
xs, ys = [p[0] for p in points], [p[1] for p in points]
best_c = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
best_r = max(distance(best_c, p) for p in points)
def consider(center, radius):
nonlocal best_c, best_r
if radius > best_r + eps:
return
actual = max(distance(center, p) for p in points)
if actual <= radius + eps and actual < best_r:
best_c, best_r = center, actual
for p in points:
consider(p, 0.0)
for a, b in combinations(points, 2):
consider(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), distance(a, b) / 2)
for a, b, c in combinations(points, 3):
circle = _circumcircle(a, b, c)
if circle is not None:
consider(*circle)
return best_c, best_r
def _circle_covers(circle, point: Point, tolerance: float) -> bool:
"""Whether the circle already covers point; None means no circle yet."""
if circle is None:
return False
return distance(circle[0], point) <= circle[1] + tolerance
def smallest_enclosing_circle(points: list[Point], eps: float = EPS):
"""Welzl's randomized incremental algorithm, using an explicit call stack.
The fixed shuffle makes results reproducible. Expected linear work refers
to randomized input order, not a worst-case guarantee for this fixed seed.
Normalize internally so this public helper also handles translated inputs.
The exhaustive reference is retained for independent regression comparisons.
"""
if not points:
raise ValueError("空集不计算最小包围圆")
if not isfinite(eps) or eps <= 0:
raise ValueError("eps必须为有限正数")
if any(len(p) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float))
or not isfinite(x) for x in p) for p in points):
raise ValueError("点集必须由有限二维坐标组成")
origin = points[0]
scale = max(distance(origin, p) for p in points)
if scale == 0:
return tuple(origin), 0.0
local = [((p[0]-origin[0])/scale, (p[1]-origin[1])/scale) for p in points]
tolerance = min(eps/scale, EPS)
Random(20260910).shuffle(local)
# Frame = (remaining prefix length, forced boundary, returning from child).
stack = [(len(local), (), False)]
circle = None
while stack:
n, boundary, returning = stack.pop()
if n == 0 or len(boundary) == 3:
# Forced support points must lie ON the boundary. For three points
# use their circumcircle, even when their unconstrained MEC is smaller.
circle = _circumcircle(*boundary) if len(boundary) == 3 else None
if circle is None and boundary:
circle = _enclosing_circle_reference(list(boundary), tolerance)
elif not returning:
stack.append((n, boundary, True))
stack.append((n-1, boundary, False))
elif not _circle_covers(circle, local[n-1], tolerance):
stack.append((n-1, boundary + (local[n-1],), False))
assert circle is not None
center = (origin[0]+circle[0][0]*scale, origin[1]+circle[0][1]*scale)
# Recompute after transforming back: returned circle contains all input points
# to distance() precision, including any roundoff from the large translation.
return center, max(distance(center, p) for p in points)
def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
"""Solve Q1's pure angular intersection (no disk/range prior added).
Nonempty intersections of <180-degree forward wedges are pointed polyhedra,
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必须为有限正数")
if not measurements:
return {"status": "unbounded", "vertices": [], "diameter_m": None,
"diameter_infinite": True, "recession_direction": [1.0, 0.0],
"message": "没有观测,整个平面均可行"}
if len(measurements) > 100:
raise ValueError("参考实现最多接收100个检测点;更大规模应使用半平面交算法")
ox = sum(m.x for m in measurements) / len(measurements)
oy = sum(m.y for m in measurements) / len(measurements)
scale = max(1.0, max(hypot(m.x - ox, m.y - oy) for m in measurements))
local = [Measurement((m.x - ox) / scale, (m.y - oy) / scale,
m.bearing_deg, m.half_angle_deg) for m in measurements]
planes = [h for m in local for h in halfplanes(m)]
def inside(p):
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 det == 0.0:
continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)
p = ((c * e - b * f) / det, (a * f - c * d) / det)
if isfinite(p[0]) and isfinite(p[1]) and inside(p):
feasible_vertices.append(p)
if not feasible_vertices:
return {"status": "empty", "vertices": [], "diameter_m": None,
"diameter_infinite": False, "tolerance_m": eps * scale,
"message": "测向角域没有公共点;检查频道、数据及误差界"}
# A nontrivial 2D recession cone has a ray tangent to an input halfplane.
# Use a much tighter angular threshold than the positional feasibility epsilon.
ray = None
for a, b, _ in planes:
for direction in ((b, -a), (-b, a)):
if all(d * direction[0] + e * direction[1] <= 2e-14
for d, e, _ in planes):
ray = direction
break
if ray is not None:
break
hull = convex_hull(feasible_vertices, eps)
def world(p):
return [ox + p[0] * scale, oy + p[1] * scale]
result = {"status": "unbounded" if ray else
("point" if len(hull) == 1 else "segment" if len(hull) == 2 else "polygon"),
"vertices": [world(p) for p in hull], "tolerance_m": eps * scale,
"diameter_infinite": ray is not None}
if ray is not None:
result.update(diameter_m=None, recession_direction=list(ray),
message="存在非零衰退方向,区域无界,直径为无穷大")
return result
d, pair = diameter(hull)
midpoint = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2)
distances = [distance(midpoint, p) for p in hull]
witness_index = max(range(len(hull)), key=distances.__getitem__)
max_distance = distances[witness_index]
center, radius = smallest_enclosing_circle(hull, eps)
area = abs(sum(cross(hull[i], hull[(i + 1) % len(hull)])
for i in range(len(hull)))) / 2
result.update(diameter_m=d * scale, diameter_pair=[world(p) for p in pair],
area_m2=area * scale ** 2,
diameter_circle={"center": world(midpoint), "radius_m": d * scale / 2,
"max_vertex_distance_m": max_distance * scale,
"covers": max_distance <= d / 2 + eps * 4,
"coverage_tolerance_m": 4 * eps * scale,
"max_excess_m": (max_distance-d/2) * scale,
"outside_vertex_indices": [i for i, value in enumerate(distances)
if value > d/2 + 4*eps],
"witness_vertex": world(hull[witness_index])
if max_distance > d/2 + 4*eps else None},
minimum_enclosing_circle={"center": world(center), "radius_m": radius * scale},
guaranteed_clearable_at_mec_center=radius * scale <= 20 - eps * scale * 4,
clearance_certificate={"model": "angular_intersection_only",
"clear_radius_m": 20.0,
"numerical_margin_m": 4*eps*scale,
"radius_with_margin_m": (radius+4*eps)*scale,
"remaining_margin_m": 20-(radius+4*eps)*scale,
"center": world(center)})
return result
@@ -0,0 +1,32 @@
"""Distance certificates for Q2; does not change Q1's angular polygon model."""
from math import cos, sin, radians, isfinite
from .localization import Measurement
def first_bearing_candidate_disk(measurement: Measurement,
max_range_m: float = 1500,
guaranteed_range_m: float = 1000) -> dict:
"""Enclose the entire truncated first-bearing sector, then erode its disk.
For half-angle <=45 degrees the sector MEC has center distance and radius
a=R/(2*cos(delta)). Intersecting with Omega only shrinks the source set.
A missing sufficient disk is NOT proof that Q is empty. The certificate
guarantees distance only: directional transmitters may still be invisible.
"""
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v)
or v <= 0 for v in (max_range_m, guaranteed_range_m)):
raise ValueError("接收半径必须为有限正数")
if measurement.half_angle_deg > 45:
raise ValueError("此窄角域证书仅支持半角不超过45度;题设为1度")
delta, theta = radians(measurement.half_angle_deg), radians(measurement.bearing_deg % 360)
a = max_range_m/(2*cos(delta))
center = [measurement.x+a*cos(theta), measurement.y+a*sin(theta)]
available = guaranteed_range_m-a
return {"source_model": "first_bearing_sector_with_range_upper_bound",
"sector_enclosing_circle": {"center": center, "radius_m": a},
"sufficient_candidate_disk": {"center": center, "radius_m": available}
if available >= 0 else None,
"candidate_nonempty_certified": available >= 0,
"distance_only": True,
"max_range_m": max_range_m, "guaranteed_range_m": guaranteed_range_m}
@@ -0,0 +1,23 @@
"""保证层:覆盖计划(CoveragePlan)、证书与运行期校验。
对外只暴露覆盖计划与校验入口;`coverage` 包仍负责逐频道覆盖账本。
"""
from .cover import (CERTIFY_HEADINGS, CERTIFY_STEP_M, OMEGA_RADIUS_M,
R_EFF_MIN_M, CoverageCertificate,
CoveragePlan, PLAN_DIR, VERIFY_HEADINGS, VERIFY_STEP_M,
candidate_lattice, coverage_masks, forward_targets,
greedy_cover, load_plan, nn_tour_length, omega_positions,
plan_instance, plan_names, seven_plan, seven_points,
square_49_plan, verify_full_disk, verify_forward_half_disk,
verify_plan, write_plan)
__all__ = [
"CERTIFY_HEADINGS", "CERTIFY_STEP_M", "OMEGA_RADIUS_M", "R_EFF_MIN_M",
"CoverageCertificate", "CoveragePlan",
"PLAN_DIR", "VERIFY_HEADINGS", "VERIFY_STEP_M", "candidate_lattice",
"coverage_masks", "forward_targets", "greedy_cover", "load_plan",
"nn_tour_length", "omega_positions", "plan_instance", "plan_names",
"seven_plan", "seven_points", "square_49_plan", "verify_full_disk",
"verify_forward_half_disk", "verify_plan", "write_plan",
]
@@ -0,0 +1,428 @@
"""保证层:可离线搜索、可在运行期校验的覆盖计划(CoveragePlan)。
两类覆盖语义(题面约束不同,绝不可混用):
- ``full_disk``(第三问,全向源):在 s 处 no_signal 排除 B(s, r_eff_min),
因此"全点皆 no_signal (*@\ensuremath{\Longrightarrow}@*) 不存在源"要求
``(*@\ensuremath{\cup}@*) B(c, 1000) (*@\ensuremath{\supseteq}@*) Ω``。解析最优解是"原点 + 半径 r 的 6 等分环",
``r`` 的可行区间由 ``validate_seven_coverage`` 给出(本模块提供数值复核)。
- ``forward_half_disk``(第四问,定向源):源可能背向读点,no_signal 不再
排除整个圆盘,缺席证明要求
``(*@\ensuremath{\forall}@*)(p,h) (*@\ensuremath{\in}@*) Ω×S¹, (*@\ensuremath{\exists}@*)c (*@\ensuremath{\in}@*) S: |c−p| (*@\ensuremath{\le}@*) 1000 (*@\ensuremath{\wedge}@*) (c−p)·u(h) (*@\ensuremath{\ge}@*) 0``。
现役 700 m 方格 49 点是充分条件(单元对角 (*@\ensuremath{\le}@*) 1000),但远非必要;
本模块用贪心击中集把点数压到 31,并在同一采样空间上校核。
设计原则(docs/第三四问架构优化与运行提速方案.md §2.3):
**保证 = 离线搜索 + 运行期校验**。点表以 JSON 落盘并携带证书(采样分辨率、
缺口数、最坏命中距离、生成参数哈希),运行期用同一套采样语义重新校核,
未通过即拒绝启动。证书只能用于选点优化,不改变任何安全性证明。
"""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass, field, fields
from functools import lru_cache
from pathlib import Path
from ..config import Vec2
PLAN_DIR = Path(__file__).resolve().parent / "plans"
R_EFF_MIN_M = 1000.0 # [题面] 最小有效接收半径
OMEGA_RADIUS_M = 1800.0 # [题面] 目标圆域半径
SPEED_MPS = 5.0 # [题面] 用于把巡回长度折算成虚拟移动秒数
# 采样分辨率。**校核必须与证书同分辨率**:更粗的采样会放过"删掉一个关键点
# 仍然看似覆盖"的点集,使校核退化为装饰(实测 100 m/12 档即漏判)。
# 75 m / 24 档下一次全域校核约 1 s,且按计划名缓存,每进程只付一次。
CERTIFY_STEP_M = 75.0
CERTIFY_HEADINGS = 24
VERIFY_STEP_M = CERTIFY_STEP_M
VERIFY_HEADINGS = CERTIFY_HEADINGS
@dataclass(frozen=True)
class CoverageCertificate:
"""覆盖证书:采样语义 + 缺口 + 最坏命中距离。"""
kind: str # "full_disk" | "forward_half_disk"
target_step_m: float
headings: int
r_eff_min_m: float
targets: int
uncovered: int
worst_hit_distance_m: float
nn_tour_m: float
generator_sha256: str = ""
# 证书硬化:校核时使用的前向角裕量(度)。与 r_eff_min_m 的设计裕量
# 一起把"采样格覆盖"提升为"连续空间覆盖",见 verify_forward_half_disk。
verify_heading_margin_deg: float = 0.0
@property
def valid(self) -> bool:
return self.uncovered == 0 and self.targets > 0
def as_dict(self) -> dict:
return {"kind": self.kind, "target_step_m": self.target_step_m,
"headings": self.headings, "r_eff_min_m": self.r_eff_min_m,
"targets": self.targets, "uncovered": self.uncovered,
"worst_hit_distance_m": round(self.worst_hit_distance_m, 3),
"nn_tour_m": round(self.nn_tour_m, 1),
"nn_tour_virtual_s": round(self.nn_tour_m / SPEED_MPS, 1),
"generator_sha256": self.generator_sha256,
"verify_heading_margin_deg": self.verify_heading_margin_deg}
@dataclass(frozen=True)
class CoveragePlan:
"""一份覆盖计划:点集 + 语义 + 证书(可为解析证明)。"""
name: str
problem: int
points: tuple[Vec2, ...]
kind: str # "analytic" | "certified"
coverage: str # "full_disk" | "forward_half_disk"
note: str = ""
certificate: CoverageCertificate | None = None
generator: dict = field(default_factory=dict)
def __len__(self) -> int:
return len(self.points)
def as_dict(self) -> dict:
return {"name": self.name, "problem": self.problem, "kind": self.kind,
"coverage": self.coverage, "note": self.note,
"generator": self.generator,
"certificate": (self.certificate.as_dict()
if self.certificate is not None else None),
"points": [[p[0], p[1]] for p in self.points]}
# --------------------------------------------------------------------- 采样
def omega_positions(step_m: float) -> list[Vec2]:
"""Ω 内均匀方格位置采样。"""
span = int(OMEGA_RADIUS_M / step_m) + 1
return [(step_m * i, step_m * j)
for i in range(-span, span + 1) for j in range(-span, span + 1)
if (step_m * i) ** 2 + (step_m * j) ** 2 <= OMEGA_RADIUS_M ** 2]
def forward_targets(step_m: float, headings: int) -> list[tuple[float, float, float]]:
"""(p, h) 目标采样:位置在 Ω 内,朝向均分整圆。"""
return [(x, y, 2.0 * math.pi * k / headings)
for (x, y) in omega_positions(step_m) for k in range(headings)]
def candidate_lattice(spacing_m: float, radius_m: float) -> list[Vec2]:
"""半径 radius_m 圆盘内的方格候选点(离线搜索用)。"""
span = int(radius_m / spacing_m) + 1
return [(spacing_m * i, spacing_m * j)
for i in range(-span, span + 1) for j in range(-span, span + 1)
if (spacing_m * i) ** 2 + (spacing_m * j) ** 2 <= radius_m ** 2]
def _bucket(points: list[Vec2], cell: float) -> dict[tuple[int, int], list[int]]:
table: dict[tuple[int, int], list[int]] = {}
for index, (x, y) in enumerate(points):
table.setdefault((int(x // cell), int(y // cell)), []).append(index)
return table
def coverage_masks(targets, candidates: list[Vec2],
r_eff: float = R_EFF_MIN_M,
heading_margin_deg: float = 0.0) -> list[int]:
"""每个候选点覆盖的目标位掩码(Python 大整数,按位)。
只在与候选点空间桶相邻的格内做距离/前向判定,避免 |T|·|C| 笛卡尔积。
``r_eff`` / ``heading_margin_deg`` 是**设计裕量**:离线搜索时把"命中"
定义得比物理要求更严(距离 (*@\ensuremath{\le}@*) r_eff、前向夹角 (*@\ensuremath{\le}@*) 90°−margin),使采样格
上的覆盖自动蕴含相邻未采样 (p,h) 也被覆盖(S2 证书硬化)。
"""
table = _bucket(candidates, r_eff)
masks = [0] * len(candidates)
sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))
for t_index, (px, py, heading) in enumerate(targets):
ux, uy = math.cos(heading), math.sin(heading)
ci, cj = int(px // r_eff), int(py // r_eff)
bit = 1 << t_index
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = candidates[c_index]
dx, dy = cx - px, cy - py
distance_sq = dx * dx + dy * dy
if distance_sq > r_eff * r_eff:
continue
if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:
continue
masks[c_index] |= bit
return masks
def greedy_cover(masks: list[int], n_targets: int) -> list[int]:
"""贪心集合覆盖:每次取"新增覆盖目标数"最大的候选,返回索引表。"""
full = (1 << n_targets) - 1
covered = 0
chosen: list[int] = []
remaining = list(range(len(masks)))
while covered != full and remaining:
best_index, best_gain = -1, 0
for c_index in remaining:
gain = (masks[c_index] & ~covered).bit_count()
if gain > best_gain:
best_index, best_gain = c_index, gain
if best_index < 0:
break
chosen.append(best_index)
covered |= masks[best_index]
remaining.remove(best_index)
return chosen
def nn_tour_length(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:
"""最近邻开放路径长度(巡回长度上界)。"""
remaining = list(points)
position = start
total = 0.0
while remaining:
nxt = min(remaining, key=lambda q: (q[0] - position[0]) ** 2
+ (q[1] - position[1]) ** 2)
remaining.remove(nxt)
total += math.dist(position, nxt)
position = nxt
return total
# --------------------------------------------------------------------- 校验
def _hit_distance(points: list[Vec2], position: Vec2, heading: float,
r_eff: float) -> float | None:
"""最近的前向命中距离;没有任何前向命中点则返回 None。"""
ux, uy = math.cos(heading), math.sin(heading)
best = None
for (cx, cy) in points:
dx, dy = cx - position[0], cy - position[1]
if dx * dx + dy * dy > r_eff * r_eff:
continue
if dx * ux + dy * uy < -1e-9:
continue
distance = math.hypot(dx, dy)
if best is None or distance < best:
best = distance
return best
def _nearest_distance(points: list[Vec2], position: Vec2,
r_eff: float) -> float | None:
"""最近距离(不要求前向);超过 r_eff 视作未命中。"""
best = None
for (cx, cy) in points:
distance = math.hypot(cx - position[0], cy - position[1])
if distance <= r_eff and (best is None or distance < best):
best = distance
return best
def verify_forward_half_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,
headings: int = VERIFY_HEADINGS,
r_eff: float = R_EFF_MIN_M,
heading_margin_deg: float = 0.0) -> CoverageCertificate:
"""第四问语义:(*@\ensuremath{\forall}@*)(p,h) 存在前向半盘内的被访点。
``heading_margin_deg`` 是**证书硬化**用的前向角裕量:把"命中"收紧为
"前向夹角 (*@\ensuremath{\le}@*) 90° − margin"。与 ``r_eff`` 的设计裕量合用时,采样格上的
覆盖即可推出**连续空间**覆盖:真实 (p',h') 距采样格 (*@\ensuremath{\le}@*) step·√2/2、
方位差 (*@\ensuremath{\le}@*) 180/headings 度,只要
r_eff + step·√2/2 (*@\ensuremath{\le}@*) 1000 且 margin (*@\ensuremath{\ge}@*) 180/headings/2
就能保证 |c−p'| (*@\ensuremath{\le}@*) 1000 且 (c−p')·u(h') (*@\ensuremath{\ge}@*) 0。这正是 S2 取代"粗采样
假通过"的方式。默认 0 表示按物理条件校核。
"""
points = list(points)
targets = forward_targets(step_m, headings)
table = _bucket(points, r_eff)
missing, worst = 0, 0.0
sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))
for (px, py, heading) in targets:
ux, uy = math.cos(heading), math.sin(heading)
ci, cj = int(px // r_eff), int(py // r_eff)
best = None
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = points[c_index]
dx, dy = cx - px, cy - py
distance_sq = dx * dx + dy * dy
if distance_sq > r_eff * r_eff:
continue
if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:
continue
distance = math.sqrt(distance_sq)
if best is None or distance < best:
best = distance
if best is None:
missing += 1
else:
worst = max(worst, best)
return CoverageCertificate("forward_half_disk", step_m, headings, r_eff,
len(targets), missing, worst,
nn_tour_length(points))
def verify_full_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,
r_eff: float = R_EFF_MIN_M) -> CoverageCertificate:
"""第三问语义:Ω 内每点都在某个被访点的 r_eff 圆盘内。"""
points = list(points)
positions = omega_positions(step_m)
table = _bucket(points, r_eff)
missing, worst = 0, 0.0
for (px, py) in positions:
ci, cj = int(px // r_eff), int(py // r_eff)
best = None
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = points[c_index]
distance = math.hypot(cx - px, cy - py)
if distance <= r_eff and (best is None or distance < best):
best = distance
if best is None:
missing += 1
else:
worst = max(worst, best)
return CoverageCertificate("full_disk", step_m, 1, r_eff, len(positions),
missing, worst, nn_tour_length(points))
def verify_plan(plan: CoveragePlan, step_m: float | None = None,
headings: int | None = None) -> CoverageCertificate:
"""按计划声明的覆盖语义校核。
默认使用**证书自带的采样分辨率**(离线 75 m / 24 档):更粗的运行期采样
会放过"删掉一个关键点仍看似覆盖"的点集,使校核失去意义。校核按计划名
缓存(``load_plan`` 的 lru_cache),每进程只付一次成本。
"""
certificate = plan.certificate
if step_m is None:
step_m = certificate.target_step_m if certificate else VERIFY_STEP_M
if headings is None:
headings = (certificate.headings if certificate
else VERIFY_HEADINGS)
if plan.coverage == "forward_half_disk":
# 硬化证书:按证书自带的距离/角度设计裕量重新校核(旧证书默认
# r_eff=1000、margin=0,与历史口径逐位一致)。
return verify_forward_half_disk(
plan.points, step_m, headings,
r_eff=(certificate.r_eff_min_m if certificate else R_EFF_MIN_M),
heading_margin_deg=(certificate.verify_heading_margin_deg
if certificate else 0.0))
return verify_full_disk(plan.points, step_m, certificate.r_eff_min_m
if certificate else R_EFF_MIN_M)
# --------------------------------------------------------------------- 计划
def seven_points(ring_radius_m: float) -> list[Vec2]:
"""解析七站:原点 + 环上 6 等分点。"""
return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),
ring_radius_m * math.sin(k * math.pi / 3))
for k in range(6)]
def seven_plan(ring_radius_m: float) -> CoveragePlan:
"""第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。"""
return CoveragePlan(
name=f"seven-r{ring_radius_m:.1f}", problem=3, kind="analytic",
coverage="full_disk",
note=("解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;"
"r(*@\ensuremath{\in}@*)[1122.9, 1732] 时成立,巡检半径越小巡回越短"),
points=tuple(seven_points(ring_radius_m)))
def square_49_plan() -> CoveragePlan:
"""第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。"""
return CoveragePlan(
name="square49", problem=4, kind="certified",
coverage="forward_half_disk",
note="单元对角 700√2(*@\ensuremath{\approx}@*)989.95(*@\ensuremath{\le}@*)1000 的充分条件;点数为最小命中集的 1.6 倍",
# 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史
# 基线 grid49_immediate 的逐位可复现。
points=tuple((700.0 * i, 700.0 * j)
for j in range(3, -4, -1) for i in range(-3, 4)))
def _load_plan_file(path: Path) -> CoveragePlan:
payload = json.loads(path.read_text(encoding="utf-8"))
raw_cert = payload.get("certificate") or {}
if raw_cert:
# as_dict() 含 nn_tour_virtual_s 等派生列:只取数据类实际字段。
known = {field.name for field in fields(CoverageCertificate)}
certificate = CoverageCertificate(
**{key: value for key, value in raw_cert.items() if key in known})
else:
certificate = None
return CoveragePlan(
name=payload["name"], problem=int(payload["problem"]),
kind=payload.get("kind", "certified"),
coverage=payload.get("coverage", "forward_half_disk"),
note=payload.get("note", ""),
certificate=certificate,
generator=payload.get("generator", {}),
points=tuple((float(x), float(y)) for x, y in payload["points"]))
@lru_cache(maxsize=None)
def load_plan(name: str, verify: bool = True) -> CoveragePlan:
"""按名称加载覆盖计划;``verify`` 为真时做运行期抽样校验(进程内缓存)。"""
path = PLAN_DIR / f"{name}.json"
if not path.exists():
raise FileNotFoundError(f"未找到覆盖计划 {name!r}({path})")
plan = _load_plan_file(path)
if verify and plan.kind == "certified":
certificate = verify_plan(plan)
if not certificate.valid:
raise ValueError(
f"覆盖计划 {name!r} 运行期校验失败:"
f"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖")
plan = CoveragePlan(
name=plan.name, problem=plan.problem, kind=plan.kind,
coverage=plan.coverage, note=plan.note,
generator=plan.generator,
certificate=CoverageCertificate(
kind=certificate.kind, target_step_m=certificate.target_step_m,
headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,
targets=certificate.targets, uncovered=certificate.uncovered,
worst_hit_distance_m=certificate.worst_hit_distance_m,
nn_tour_m=certificate.nn_tour_m,
generator_sha256=(plan.certificate.generator_sha256
if plan.certificate else "")),
points=plan.points)
return plan
def plan_instance(name: str, verify: bool = True) -> CoveragePlan:
"""统一入口:支持内置计划名与磁盘上的证书计划。"""
if name == "square49":
return square_49_plan()
if name == "seven":
return seven_plan(1200.0)
return load_plan(name, verify)
def plan_names() -> list[str]:
"""全部可加载的计划名(含内置)。"""
builtin = ["square49", "seven"]
return builtin + sorted(p.stem for p in PLAN_DIR.glob("*.json"))
def write_plan(plan: CoveragePlan, path: Path) -> None:
"""落盘计划 + 证书(生成器用)。"""
path.parent.mkdir(parents=True, exist_ok=True)
payload = plan.as_dict()
payload["generator"] = dict(payload.get("generator") or {})
payload["sha256"] = hashlib.sha256(
json.dumps(payload["points"]).encode("utf-8")).hexdigest()
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
@@ -0,0 +1,19 @@
"""测试侧脚手架:把独立评测机(referee 包)接入本队传输与测试。
分层边界(与官方模拟器同构):
- 评测机本体在独立包 ``referee``(零 mcm26b 依赖),运行时代码绝不导入它;
- 本包只在测试/自检入口把 referee 的接口边界适配成 :class:`Transport`,
并提供故障注入与官方演练日志回放。
策略端只见 HTTP 响应;真值 Source 只存在于 referee 与测试端。
"""
from .playback import PlaybackTransport
from .scenes import mixed_scene, random_omni_scene, referee_spec
from .simulator import LocalSimulator
from .suites import SceneSpec, suite, suite_manifest
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "SceneSpec", "mixed_scene",
"random_omni_scene", "referee_spec", "suite", "suite_manifest"]
@@ -0,0 +1,55 @@
"""自检场景生成(测试端专用):委托 referee.scenes,映射本队配置。
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内
均匀分布、频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
均匀分布是测试假设,题面并未规定官方场景的概率分布。
"""
from __future__ import annotations
from referee import scenes as _referee_scenes
from ..config import ProblemConfig
def referee_spec(config: ProblemConfig = ProblemConfig(), *,
max_real_duration_s: float | None = None):
"""把本队 ProblemConfig 的题面常量映射为 referee 的 Spec。
max_real_duration_s 仅用于 --self-test 的实机预算覆盖(默认 1800s);
正式对接以服务器 /enter 返回为准,评测机默认保持题面 1200s。
"""
from referee import CostSpec, FieldSpec, Spec
field, cost = config.field, config.cost
return Spec(
field=FieldSpec(
omega_radius_m=field.omega_radius_m,
channel_min=field.channel_min, channel_max=field.channel_max,
source_count_min=field.source_count_min,
source_count_max=field.source_count_max,
r_eff_min_m=field.r_eff_min_m, r_eff_max_m=field.r_eff_max_m,
near_radius_m=field.near_radius_m, clear_radius_m=field.clear_radius_m,
bearing_error_half_deg=field.bearing_error_half_deg,
bearing_decimals=field.bearing_decimals,
coord_abs_limit_m=field.coord_abs_limit_m,
directional_half_angle_deg=field.directional_half_angle_deg,
max_virtual_duration_s=field.max_virtual_duration_s,
max_real_duration_s=(field.max_real_duration_s if max_real_duration_s is None
else max_real_duration_s),
test_window_s=field.test_window_s),
cost=CostSpec(speed_mps=cost.speed_mps, measure_s=cost.measure_s,
switch_s=cost.switch_s, clear_success_s=cost.clear_success_s,
clear_fail_s=cost.clear_fail_s))
def random_omni_scene(seed: int = 20260910, count: int | None = None,
config: ProblemConfig = ProblemConfig()):
"""全向源场景 [题面 问题3];签名沿用重构前 mcm26b.sim.scenes。"""
return _referee_scenes.random_omni_scene(seed, count, referee_spec(config))
def mixed_scene(seed: int = 20260910, count: int | None = None,
directional: int = 3, config: ProblemConfig = ProblemConfig()):
"""问题4混合场景:count 个源中 directional 个为定向源。"""
return _referee_scenes.mixed_scene(seed, count, directional, referee_spec(config))
@@ -0,0 +1,21 @@
"""兼容构造入口:本队 ProblemConfig -> referee.LocalSimulator。
评测机实现在独立包 referee(config= 换成了 spec=);本子类只做配置映射,
让既有测试与脚本继续以 ``config=ProblemConfig`` 构造,映射规则见
:func:`harness.scenes.referee_spec`。真值类型 Source 亦来自 referee。
"""
from __future__ import annotations
from referee import LocalSimulator as _RefereeLocalSimulator
from ..config import ProblemConfig
from .scenes import referee_spec
class LocalSimulator(_RefereeLocalSimulator):
def __init__(self, sources, *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(), **kwargs):
super().__init__(sources, robot_id=robot_id,
spec=referee_spec(config), **kwargs)
@@ -0,0 +1,132 @@
"""冻结基准集:第三/四问的固定场景清单与真值构造(测试端专用)。
存在意义(docs/第三四问架构优化与运行提速方案.md §1.4 B6 / §P1-3):
历史评估集是 20 个**单源全向**场景,使 `avg_locate_clear_time_s` 恒等于
`virtual_time_s`,也无法支撑任何第四问结论。基准集必须覆盖题面声明的
源数区间与定向源存在性,并且**版本化、可复现、不可随意增删**——
任何"更快"的声明都要在同一份清单上做配对比较。
真值只在本模块与评测机之间流动;策略端只通过 Transport 看到响应。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from referee import Source
from ..config import ProblemConfig
from .scenes import mixed_scene, random_omni_scene
SUITE_VERSION = 1
# 第三问:沿用历史验证集的种子与源数(seeds 100–105 × 10/13/16)
Q3_SEEDS = (100, 101, 102, 103, 104, 105)
Q3_COUNTS = (10, 13, 16)
# 第四问:三种"定向占比"组成(少量 / 约半数 / 多数)
Q4_SEEDS = (200, 201, 202)
Q4_CONFIGS = ((10, 3), (13, 6), (16, 8))
# 误差场种子偏移:与 benchmark_q3_bayes.py 同口径,保证逐点固定误差可复现
ERROR_SEED_BASE = 20260910
@dataclass(frozen=True)
class SceneSpec:
"""一个冻结场景:标识、题号、源数、定向数与种子。"""
scene_id: str
problem: int
kind: str # "random" | "boundary"
seed: int
error_seed: int
count: int
directional: int = 0
def as_dict(self) -> dict:
return {"scene_id": self.scene_id, "problem": self.problem,
"kind": self.kind, "seed": self.seed,
"error_seed": self.error_seed, "count": self.count,
"directional": self.directional}
def sources(self, config: ProblemConfig | None = None) -> list[Source]:
"""构造真值(仅测试端调用)。"""
config = config or ProblemConfig()
if self.kind == "random" and self.problem == 3:
return random_omni_scene(seed=self.seed, count=self.count,
config=config)
if self.kind == "random":
return mixed_scene(seed=self.seed, count=self.count,
directional=self.directional, config=config)
return _boundary_sources(self, config)
def _boundary_sources(spec: SceneSpec, config: ProblemConfig) -> list[Source]:
"""圆周边界压力场景:源全部位于 Ω 边界、r_eff 取下界。
第三问:全向,扇区接缝角度,检验七站覆盖的边界情形;
第四问:全部定向且朝向**背离圆心**(最不利于发现),检验
"朝外发射的边界源不漏"。
"""
field = config.field
radius = field.omega_radius_m
sources: list[Source] = []
for index in range(spec.count):
angle = math.pi / 6 + 2 * math.pi * index / spec.count
x, y = radius * math.cos(angle), radius * math.sin(angle)
directional = index < spec.directional
sources.append(Source(
channel=index + 1, x=x, y=y, r_eff_m=field.r_eff_min_m,
directional=directional,
heading_deg=(math.degrees(angle) % 360.0) if directional else 0.0))
return sources
def core_scenes(problem: int) -> list[SceneSpec]:
"""主体场景:覆盖题面源数区间与定向占比组成。"""
scenes: list[SceneSpec] = []
if problem == 3:
for seed in Q3_SEEDS:
for count in Q3_COUNTS:
scenes.append(SceneSpec(
scene_id=f"q3-core-s{seed}-n{count}", problem=3,
kind="random", seed=seed, error_seed=ERROR_SEED_BASE + seed,
count=count))
else:
for seed in Q4_SEEDS:
for count, directional in Q4_CONFIGS:
scenes.append(SceneSpec(
scene_id=f"q4-core-s{seed}-n{count}-d{directional}",
problem=4, kind="random", seed=seed,
error_seed=ERROR_SEED_BASE + seed, count=count,
directional=directional))
return scenes
def boundary_scenes(problem: int) -> list[SceneSpec]:
"""圆周边界压力场景(每问 2 场:10 源与 16 源)。"""
if problem == 3:
return [SceneSpec(f"q3-boundary-n{count}", 3, "boundary", 0,
ERROR_SEED_BASE, count)
for count in (10, 16)]
return [SceneSpec(f"q4-boundary-n{count}-d{directional}", 4, "boundary", 0,
ERROR_SEED_BASE, count, directional)
for count, directional in ((10, 5), (16, 8))]
def suite(name: str, problem: int) -> list[SceneSpec]:
"""按名称取场景清单:core / boundary / all。"""
if name == "core":
return core_scenes(problem)
if name == "boundary":
return boundary_scenes(problem)
if name == "all":
return core_scenes(problem) + boundary_scenes(problem)
raise ValueError(f"未知基准集 {name!r}(可选 core/boundary/all)")
def suite_manifest(name: str, problem: int) -> dict:
"""清单头:版本、问题、场景表(写入 manifest.json)。"""
scenes = suite(name, problem)
return {"suite_version": SUITE_VERSION, "suite": name, "problem": problem,
"scenes": [scene.as_dict() for scene in scenes]}
@@ -0,0 +1,62 @@
"""传输适配:评测机接口边界 <-> 本队 Transport 接口 + 故障注入脚本。
LocalSimTransport 把 referee 包的 handle_raw(与 HTTP 服务器共用的同一
边界实现)包成内存回环传输,不经过网络栈;故障注入语义与既有测试一致:
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
from urllib.parse import urlsplit
from referee.interface import handle_raw
from ..protocol.transport import Transport, TransportError, TransportResponse
class LocalSimTransport(Transport):
"""内存回环:把独立评测机接到 Transport 接口(与真实 HTTP 同一边界)。"""
def __init__(self, simulator):
# simulator: referee.LocalSimulator(类型注解走字符串,避免导入开销)
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
return self.request("POST", url, body, headers, timeout)
def request(self, method, url, body, headers, _timeout=None):
"""验证原始请求边界;内存回环不模拟 TCP、UI 或连接限流。"""
target = urlsplit(url)
raw_target = target.path + (f"?{target.query}" if target.query else "")
result = handle_raw(self.simulator, method, raw_target,
list(headers.items()), bytes(body),
on_request=lambda path, payload, _res:
self.received.append((path, payload)))
return TransportResponse(result.status, result.body)
class ScriptedFaultTransport(Transport):
"""按脚本注入故障后转发给内层传输。"""
def __init__(self, inner: Transport, script: list):
self.inner = inner
self.script = list(script)
self.sent_requests: list[bytes] = []
def post(self, url, body, headers, timeout):
self.sent_requests.append(body)
fault = self.script.pop(0) if self.script else None
if fault == "drop_before":
raise TransportError("injected: connection refused before send")
if fault == "timeout_after":
self.inner.post(url, body, headers, timeout) # 服务器已受理
raise TransportError("injected: read timeout after server applied")
if isinstance(fault, tuple) and fault and fault[0] == "status":
body = b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}'
return TransportResponse(int(fault[1]), body)
return self.inner.post(url, body, headers, timeout)
@@ -0,0 +1,458 @@
"""P4b 贝叶斯网格粒子滤波:逐频道幸存集维护与负观测更新。
与 belief.CandidateRegion(保守凸外包,无法表达非凸"挖洞")互补:
网格幸存集精确表达"角域 (*@\ensuremath{\cap}@*) 距离盘 (*@\ensuremath{\cap}@*) 全部 no_signal 排除盘"的非凸可行集,
从而支持三件凸表示做不到的事——
1. 缺席证明:幸存集为空 (*@\ensuremath{\Longrightarrow}@*) 可行集为空 (*@\ensuremath{\Longrightarrow}@*) 该频道无源(严格,无需覆盖账本);
2. 跳测规则:幸存格到测点的最小距离 > r_max + 裕量 (*@\ensuremath{\Longrightarrow}@*) 该次测量必 no_signal,
且不产生任何新排除,可直接跳过(省 5s 检测 + 1s 换频);
3. 负观测更新:no_signal 排除 B(s, 1000),凸表示不删点,网格直接删格。
严格性(真源保留定理,docs/algo-3-bayes.md §2):格距 h 的 Voronoi 裕量
m = sqrt(2)/2·h 是格心到格内任一点的最大距离。所有 kill 判定均内缩 m,
即"仅当整格与可行集不相交时才删格"。真源所在格与可行集之交含真源本身,
故该格永不被删——任意观测序列后幸存集恒包含真源,且:
- 清除证书:MEC(幸存).R + m <= 20 − 工程余量 (*@\ensuremath{\Longrightarrow}@*) 真源距清除点 <= 20;
- 缺席证明:幸存集为空 (*@\ensuremath{\Longrightarrow}@*) 可行集为空。
观测语义与独立评测机 referee.LocalSimulator 一致 [题面]:
- no_signal:距离 > r_eff >= 1000 (*@\ensuremath{\Longrightarrow}@*) 可行集 (*@\ensuremath{\cap}@*) B(s,1000) = (*@\ensuremath{\varnothing}@*);
- direction:距离 <= 1500,示向度误差 |读数−真值| <= (*@\ensuremath{\delta}@*)_eff(含舍入裕量);
- near:距离 <= 5。
性能设计:kill 与查询全部走 O(1) 集合运算;最近距离查询用粗粒度占用
索引(每格聚合 CF² 个细格)螺旋扫描 + 早退,服务跳测规则的高频调用。
"""
from __future__ import annotations
import math
import os
from .config import ProblemConfig, Vec2
from .geometry.localization import smallest_enclosing_circle
from .protocol.rules import DIRECTION, NEAR, NO_SIGNAL
_ANG_EPS_DEG = 1e-9 # 角向 kill 比较的浮点余量
_DIST_EPS_M = 1e-9
_COARSE_FACTOR = 4 # 粗格每边聚合的细格数
try: # numpy 仅作**可选**加速后端;缺失时全部回退纯 Python
import numpy as _np
except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支
_np = None
if os.environ.get("MCM26B_NO_NUMPY"): # 强制纯 Python:对拍与回退验证用
_np = None
def angular_gap(a_deg: float, b_deg: float) -> float:
"""两个方位角(度)的最小夹角。"""
return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)
def as_sample_array(samples):
"""把 [(x,y), ...] 采样转成 (n,2) float64 数组;numpy 缺失时原样返回。
评分为 O(代表点 × 子采样) 的向量运算,是单局 CPU 的主要开销
(实测占 72%)。调用方在每次选点前转换一次,评分函数按类型分派,
两条实现(numpy / 纯 Python)语义逐位对齐,见 tests/test_particles.py。
"""
if _np is None or isinstance(samples, _np.ndarray) or len(samples) == 0:
return samples
return _np.asarray(samples, dtype=float)
def _branch_radius_array(members) -> float:
"""分支后验半径的数组版:到质心的最大距离。"""
centroid = members.mean(axis=0)
return float(_np.max(_np.hypot(members[:, 0] - centroid[0],
members[:, 1] - centroid[1])))
class ChannelParticles:
"""单个频道的网格幸存集。
网格为覆盖 Ω 外扩一格的轴对齐方格(边长 h),索引 ``i*W + j``;
``alive`` 是幸存格索引集合。初始集 = 格心距原点 <= Ω 半径 + 裕量的
全部格子,因此真源(|p| <= 1800 [题面])所在格必在初始集内。
``version`` 随每次删除自增,调用方可据此缓存派生量(MEC、跳测判定)。
"""
def __init__(self, config: ProblemConfig = ProblemConfig(),
spacing_m: float | None = None):
field = config.field
self.config = config
self.spacing_m = float(spacing_m if spacing_m is not None
else config.bayes.particle_spacing_m)
if not 1.0 <= self.spacing_m <= 200.0:
raise ValueError("网格间距须在 [1, 200] 米")
self.margin_m = self.spacing_m * math.sqrt(2) / 2 # Voronoi 裕量
self.r_eff_min_m = field.r_eff_min_m
self.r_eff_max_m = field.r_eff_max_m
self.near_radius_m = field.near_radius_m
self.half_angle_deg = (field.bearing_error_half_deg
+ config.search.bearing_rounding_margin_deg)
# 覆盖 [-R-m, R+m]² 的格点阵列;格 (i,j) 的中心在
# (x0 + (i+0.5)h, x0 + (j+0.5)h),中心到格内任一点 <= h·√2/2 = 裕量
span = field.omega_radius_m + self.margin_m
self.x0 = -span
self.width = int(math.ceil(2 * span / self.spacing_m)) + 1
limit_sq = (span + 1e-9) ** 2
self.alive: set[int] = set()
self._coarse: dict[int, int] = {} # 粗格索引 -> 幸存细格数
coarse_w = self._coarse_width()
for i in range(self.width):
x = self.x0 + (i + 0.5) * self.spacing_m
for j in range(self.width):
if x * x + (self.x0 + (j + 0.5) * self.spacing_m) ** 2 <= limit_sq:
index = i * self.width + j
self.alive.add(index)
key = (i // _COARSE_FACTOR) * coarse_w + j // _COARSE_FACTOR
self._coarse[key] = self._coarse.get(key, 0) + 1
self.version = 0
self._mec: tuple[Vec2, float] | None = None
# ------------------------------------------------------------ 索引与坐标
def _coarse_width(self) -> int:
return (self.width + _COARSE_FACTOR - 1) // _COARSE_FACTOR
def cell_center(self, index: int) -> Vec2:
i, j = divmod(index, self.width)
return (self.x0 + (i + 0.5) * self.spacing_m,
self.x0 + (j + 0.5) * self.spacing_m)
def discard(self, indices: list[int]) -> None:
"""公开删除入口:联合信念把"朝向不可能"的格一并删除时使用。"""
self._discard(indices)
def _discard(self, indices: list[int]) -> None:
if not indices:
return
coarse_w = self._coarse_width()
for index in indices:
if index not in self.alive:
continue
self.alive.discard(index)
key = (index // self.width // _COARSE_FACTOR) * coarse_w \
+ (index % self.width) // _COARSE_FACTOR
count = self._coarse.get(key, 0) - 1
if count <= 0:
self._coarse.pop(key, None)
else:
self._coarse[key] = count
self.version += 1
self._mec = None
# ------------------------------------------------------------ 观测更新
def _kill_disk(self, center: Vec2, radius_m: float) -> None:
"""删除格心距 center <= radius_m 的全部幸存格(整格判据见模块注释)。"""
if not self.alive:
return
reach = radius_m + _DIST_EPS_M
ilo = max(0, int(math.floor((center[0] - reach - self.x0) / self.spacing_m)))
ihi = min(self.width - 1,
int(math.ceil((center[0] + reach - self.x0) / self.spacing_m)))
jlo = max(0, int(math.floor((center[1] - reach - self.x0) / self.spacing_m)))
jhi = min(self.width - 1,
int(math.ceil((center[1] + reach - self.x0) / self.spacing_m)))
limit_sq = reach * reach
doomed = []
for i in range(ilo, ihi + 1):
dx = self.x0 + (i + 0.5) * self.spacing_m - center[0]
base = i * self.width
for j in range(jlo, jhi + 1):
index = base + j
if index not in self.alive:
continue
dy = self.x0 + (j + 0.5) * self.spacing_m - center[1]
if dx * dx + dy * dy <= limit_sq:
doomed.append(index)
self._discard(doomed)
def _kill_outside_disk(self, center: Vec2, radius_m: float) -> None:
"""删除格心距 center > radius_m 的全部幸存格(保留盘内)。"""
if not self.alive:
return
reach = radius_m + _DIST_EPS_M
doomed = [index for index in self.alive
if math.dist(self.cell_center(index), center) > reach]
self._discard(doomed)
def _kill_outside_wedge(self, station: Vec2, reading_deg: float) -> None:
"""删除角向偏离读数超出 (*@\ensuremath{\delta}@*)_eff + 角向格裕量的幸存格。
角向格裕量 = asin(m / max(dist−m, 1)):格内任一点相对格心的方位
偏差不超过它(点距测点还可再近 m,故分母再内缩 m)。
"""
if not self.alive:
return
sx, sy = float(station[0]), float(station[1])
doomed = []
for index in self.alive:
gx, gy = self.cell_center(index)
dist = math.hypot(gx - sx, gy - sy)
if dist <= self.margin_m + 1.0:
continue # 格贴测点:格内方位可指向任意方向,不可删
angular_margin = math.degrees(
math.asin(min(0.9999, self.margin_m / (dist - self.margin_m))))
gap = angular_gap(math.degrees(math.atan2(gy - sy, gx - sx)), reading_deg)
if gap > self.half_angle_deg + angular_margin + _ANG_EPS_DEG:
doomed.append(index)
self._discard(doomed)
def apply_measure(self, station: Vec2, result: str,
svd_deg: float | None = None, *,
directional: bool = False) -> None:
"""按读数更新幸存集。真源保留性由 Voronoi 裕量保证(模块注释)。
``directional=True``(第四问):no_signal **不删空间候选**——定向源
的静默还可能来自朝向遮挡,单独删空间候选会误删真源;朝向维度由
:class:`~mcm26b.belief.joint.JointBelief` 维护。
"""
if result == NO_SIGNAL:
if directional:
return
# no_signal (*@\ensuremath{\Longrightarrow}@*) dist > r_eff >= 1000:整格落入 B(s, 1000−m) 的删。
self._kill_disk(station, self.r_eff_min_m - self.margin_m)
elif result == NEAR:
self._kill_outside_disk(station, self.near_radius_m + self.margin_m)
elif result == DIRECTION:
if svd_deg is None:
raise ValueError("direction 读数必须携带 svd_deg")
# direction 隐含 dist <= r_max [题面]
self._kill_outside_disk(station, self.r_eff_max_m + self.margin_m)
self._kill_outside_wedge(station, svd_deg)
else:
raise ValueError(f"未知测向结果: {result!r}")
# ------------------------------------------------------------ 查询
def __len__(self) -> int:
return len(self.alive)
def alive_points(self, limit: int | None = None) -> list[Vec2]:
sorted_alive = sorted(self.alive)
if limit is not None and len(sorted_alive) > limit:
step = len(sorted_alive) / limit
indices = {sorted_alive[int(k * step) % len(sorted_alive)]
for k in range(limit)}
indices = sorted(indices)
else:
indices = sorted_alive
return [self.cell_center(index) for index in indices]
def nearest_alive_distance(self, point: Vec2, limit_m: float) -> float | None:
"""最近幸存格心距离;> limit_m 时返回 None(粗格环扫 + 下界剪枝)。
返回值语义:恰为最近幸存格心距离(若 <= limit_m);None 表示所有
幸存格心都超出 limit_m。粗格聚合半径已计入扫描范围,不漏判。
"""
if not self.alive:
return None
coarse_cell_m = self.spacing_m * _COARSE_FACTOR
coarse_reach = coarse_cell_m * math.sqrt(2) / 2
cw = self._coarse_width()
ci_base = int(math.floor((point[0] - self.x0) / coarse_cell_m))
cj_base = int(math.floor((point[1] - self.x0) / coarse_cell_m))
ring_max = int(math.ceil((limit_m + coarse_reach) / coarse_cell_m)) + 1
best: float | None = None
for ring in range(ring_max + 1):
lower_bound = max(0.0, ring - 1) * coarse_cell_m - coarse_reach
if best is not None and lower_bound >= best:
break # 更外环的所有格心都 >= 下界 >= 当前最优
for ci in range(max(0, ci_base - ring), min(cw - 1, ci_base + ring) + 1):
for cj in range(max(0, cj_base - ring), min(cw - 1, cj_base + ring) + 1):
if max(abs(ci - ci_base), abs(cj - cj_base)) != ring:
continue # 只扫当前环(Chebyshev 环)
if (ci * cw + cj) not in self._coarse:
continue
ccx = self.x0 + (ci + 0.5) * coarse_cell_m
ccy = self.x0 + (cj + 0.5) * coarse_cell_m
if math.hypot(ccx - point[0], ccy - point[1]) \
> limit_m + coarse_reach:
continue
i0, j0 = ci * _COARSE_FACTOR, cj * _COARSE_FACTOR
for i in range(i0, min(i0 + _COARSE_FACTOR, self.width)):
base = i * self.width
for j in range(j0, min(j0 + _COARSE_FACTOR, self.width)):
index = base + j
if index not in self.alive:
continue
dist = math.dist(self.cell_center(index), point)
if dist <= limit_m and (best is None or dist < best):
best = dist
return best
def guaranteed_no_signal(self, station: Vec2) -> bool:
"""真源与该测点距离必 > r_max(>= r_eff)(*@\ensuremath{\Longrightarrow}@*) 必 no_signal,测了也无信息。"""
return self.nearest_alive_distance(
station, self.r_eff_max_m + self.margin_m) is None
def mec(self) -> tuple[Vec2, float]:
"""幸存集最小包围圆(圆心, 半径);按 version 缓存。"""
if self._mec is None:
if not self.alive:
raise ValueError("幸存集为空(该频道已可证明缺席)")
points = [self.cell_center(index) for index in self.alive]
self._mec = smallest_enclosing_circle(points)
return self._mec
# ------------------------------------------------------------ 信息增益
def subsample(self, size: int) -> list[Vec2]:
"""按索引序均匀抽取的子采样(索引序(*@\ensuremath{\approx}@*)空间序,代表点空间铺开)。"""
return self.alive_points(limit=size)
# ---------------------------------------------------- 向量化评分(可选)
def _expected_survival_vector(self, point: Vec2, sub, representatives) -> float:
"""``expected_survival`` 的向量化实现(与纯 Python 版逐分支同语义)。"""
count = len(sub)
eps = 1.0 / count
dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]
dist = _np.hypot(dx, dy)
within = int(_np.count_nonzero(
dist <= self.r_eff_min_m - self.margin_m + _DIST_EPS_M))
log_keep_ns = math.log(max(1.0 - within / count, eps))
bearing = _np.degrees(_np.arctan2(dy, dx))
in_range = dist <= self.r_eff_max_m + self.margin_m
rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
survivors = _np.count_nonzero(
(gaps <= self.half_angle_deg + _ANG_EPS_DEG) & in_range[None, :], axis=1)
p_ns = _np.clip((rdist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)
keep_dir = _np.maximum(survivors / count, eps)
values = p_ns * log_keep_ns + (1.0 - p_ns) * _np.log(keep_dir)
near = rdist <= self.near_radius_m + self.margin_m
values = _np.where(near, math.log(eps), values)
return float(values.mean())
def _expected_posterior_radius_vector(self, point: Vec2, sub,
representatives) -> float:
"""``expected_posterior_radius`` 的向量化实现。"""
eps_radius = self.spacing_m / 2.0
dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]
dist = _np.hypot(dx, dy)
bearing = _np.degrees(_np.arctan2(dy, dx))
keep_ns = dist > self.r_eff_min_m - self.margin_m
r_ns = (_branch_radius_array(sub[keep_ns]) if keep_ns.any()
else eps_radius)
rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
dir_mask = ((gaps <= self.half_angle_deg + _ANG_EPS_DEG)
& (dist[None, :] <= self.r_eff_max_m + self.margin_m))
r_dir = _np.array([_branch_radius_array(sub[row]) if row.any()
else eps_radius for row in dir_mask])
p_ns = _np.clip((rdist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)
near = rdist <= self.near_radius_m + self.margin_m
values = _np.where(near, self.near_radius_m + self.margin_m,
p_ns * r_ns + (1.0 - p_ns) * r_dir)
return float(values.mean())
def expected_survival(self, point: Vec2, subsample,
representatives) -> float:
"""E[log 幸存占比]:越小该测点的期望信息增益越大。
r_eff 不确定性解析积分:dist(*@\ensuremath{\in}@*)(1000,1500] 时按 r_eff~U(1000,1500)
取 p_ns=(dist−1000)/500 为 no_signal 分支概率。
- no_signal 分支:删 B(point, 1000−m),幸存 = 子采样中盘外比例;
- direction 分支:以代表点方位为期望读数,角域 (*@\ensuremath{\cap}@*) B(point,1500+m) 内幸存;
- near 分支(dist <= 5+m):可行集收缩到 5m 盘,幸存占比趋近 0。
"""
if not self.alive or len(subsample) == 0 or len(representatives) == 0:
return 0.0
if _np is not None and isinstance(subsample, _np.ndarray) \
and isinstance(representatives, _np.ndarray):
return self._expected_survival_vector(point, subsample, representatives)
eps = 1.0 / len(subsample)
keep_ns = max(1.0 - self.fraction_within(subsample, point,
self.r_eff_min_m - self.margin_m),
eps)
log_keep_ns = math.log(keep_ns)
total = 0.0
for rep in representatives:
dist = math.hypot(rep[0] - point[0], rep[1] - point[1])
if dist <= self.near_radius_m + self.margin_m:
total += math.log(eps)
continue
p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m)))
reading = math.atan2(rep[1] - point[1], rep[0] - point[0])
survivors = 0
for p in subsample:
if math.dist(p, point) > self.r_eff_max_m + self.margin_m:
continue
gap = angular_gap(math.degrees(
math.atan2(p[1] - point[1], p[0] - point[0])),
math.degrees(reading))
if gap <= self.half_angle_deg + _ANG_EPS_DEG:
survivors += 1
keep_dir = max(survivors / len(subsample), eps)
total += p_ns * log_keep_ns + (1.0 - p_ns) * math.log(keep_dir)
return total / max(len(representatives), 1)
def fraction_within(self, samples, point: Vec2, radius_m: float) -> float:
if len(samples) == 0:
return 0.0
reach = radius_m + _DIST_EPS_M
if _np is not None and isinstance(samples, _np.ndarray):
distance = _np.hypot(samples[:, 0] - point[0], samples[:, 1] - point[1])
return float(_np.count_nonzero(distance <= reach)) / len(samples)
count = sum(1 for p in samples if math.dist(p, point) <= reach)
return count / len(samples)
def _branch_radius(self, members) -> float:
"""分支后验的半径代理:幸存子采样到其质心的最大距离(O(n),免 Welzl)。"""
if len(members) == 0:
return 0.0
if _np is not None and isinstance(members, _np.ndarray):
return _branch_radius_array(members)
mx = sum(p[0] for p in members) / len(members)
my = sum(p[1] for p in members) / len(members)
return max(math.hypot(p[0] - mx, p[1] - my) for p in members)
def expected_posterior_radius(self, point: Vec2, subsample,
representatives) -> float:
"""一次测量后期望后验半径(米):选点主指标,直指清除证书阈值。
对每个代表假设(真值候选)按 r_eff~U(1000,1500) 解析积分
no_signal/direction 分支,near 分支(dist <= 5+m)后验为 5m 盘;
各分支半径用幸存子采样的质心最大距离近似(偏差 << 格距,排序用)。
与几何版 worst-posterior 同族,但作用于含负观测的非凸幸存集,
且以期望代替最坏(代表点覆盖幸存集主轴)。
"""
if not self.alive or len(subsample) == 0 or len(representatives) == 0:
return 0.0
if _np is not None and isinstance(subsample, _np.ndarray) \
and isinstance(representatives, _np.ndarray):
return self._expected_posterior_radius_vector(
point, subsample, representatives)
eps_radius = self.spacing_m / 2.0
total = 0.0
for rep in representatives:
dist = math.hypot(rep[0] - point[0], rep[1] - point[1])
if dist <= self.near_radius_m + self.margin_m:
total += self.near_radius_m + self.margin_m
continue
p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m)))
members_ns = [p for p in subsample
if math.dist(p, point) > self.r_eff_min_m - self.margin_m]
reading = math.atan2(rep[1] - point[1], rep[0] - point[0])
members_dir = []
for p in subsample:
if math.dist(p, point) > self.r_eff_max_m + self.margin_m:
continue
gap = angular_gap(math.degrees(
math.atan2(p[1] - point[1], p[0] - point[0])),
math.degrees(reading))
if gap <= self.half_angle_deg + _ANG_EPS_DEG:
members_dir.append(p)
r_ns = self._branch_radius(members_ns) if members_ns else eps_radius
r_dir = self._branch_radius(members_dir) if members_dir else eps_radius
total += p_ns * r_ns + (1.0 - p_ns) * r_dir
return total / max(len(representatives), 1)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,816 @@
"""P4b 贝叶斯网格滤波闭环(问题3新策略 ``bayes``)。
在 :class:`OmnidirectionalLoop` 骨架(run/异常收尾/summary/预算/异常留痕)
上的三层重构,对应 docs/algo-3-bayes.md 的架构:
1. 感知层:每频道一个网格幸存集(particles.ChannelParticles),全部读数
(含 no_signal 负观测)即时更新;幸存集为空即缺席证明,无需等覆盖账本;
2. 决策层:统一任务循环——站点扫频、定位测量、清除三类任务合成任务表,
开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP);
3. 选点层:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)
内按"期望 log 幸存占比 + (*@\ensuremath{\lambda}@*)·移动时间"评分(信息增益贪心)。
与基线 SevenBatch 的差异:负观测跳站/跳测(必 no_signal 的 (站,频道) 对
不消耗检测与换频)、PF 缺席证明提前证毕、全局任务排序替代"站内最近邻"。
清除证书仍以凸证书为主(PF 网格证书仅在负观测挖洞使幸存集更紧时提前
触发),两者均满足 Voronoi/保守裕量,不牺牲全清率。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from ..belief import CandidateRegion, ChannelStatus
from ..config import ProblemConfig, Vec2
from ..coverage import CoverageLedger
from ..particles import ChannelParticles, as_sample_array
from ..protocol.client import SimulatorClient
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
from ..protocol.timing import move_time
from . import (ClearCertificate, OmnidirectionalLoop, clear_certificate,
candidate_points, safe_disk_radius, select_measurement_point)
from .seven import validate_seven_coverage
from ..guarantee import seven_plan
@dataclass(frozen=True)
class Task:
"""统一任务表的一项。service_s 为不含移动的固有耗时(检测+换频/清除)。"""
kind: str # "station" | "locate" | "clear"
position: Vec2
service_s: float
channel: int | None = None
station_index: int | None = None
station_channels: tuple[int, ...] = ()
def _point_key(point: Vec2) -> tuple[int, int]:
"""候选点去重键:0.5m 网格取整(同一物理点不重复测量)。"""
return (int(round(point[0] * 2)), int(round(point[1] * 2)))
class BayesLoop(OmnidirectionalLoop):
"""问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。"""
def __init__(self, client: SimulatorClient,
config: ProblemConfig = ProblemConfig()):
super().__init__(client, config)
validate_seven_coverage(config.bayes.station_ring_radius_m, config)
self.coverage_plan = seven_plan(config.bayes.station_ring_radius_m)
self.ledger = CoverageLedger(
config, points=list(self.coverage_plan.points))
self.particles = {channel: ChannelParticles(config)
for channel in self.belief.channels}
self._measured_points: dict[int, set[tuple[int, int]]] = {}
self._clear_failed: set[int] = set() # 证书清除失败后须先补测再重试
self._stall: dict[int, int] = {} # 连续无收缩计数(异常留痕用)
self._skip_cache: dict[tuple[int, int], tuple[int, bool]] = {}
self.skipped_measures = 0 # 跳测规则省下的检测次数
self.no_shrink_measures = 0 # 未收缩幸存集的定位检测(诊断用)
self.pf_absent_channels: list[int] = [] # 由幸存集为空证明缺席的频道
self.opportunistic_measures = 0
# ------------------------------------------------------------ 主循环
def _coverage_phase(self) -> None:
"""统一任务循环:发现 + 定位 + 清除滚动调度,直到任务表为空。"""
while self.stop_reason is None:
if self._count_limit_reached() or self._virtual_cap_reached():
return
if not self._step_once():
return
def _step_once(self) -> bool:
"""构建任务表、排序并执行首任务;无可执行任务返回 False。"""
if self.config.bayes.opportunistic_measure:
self._measure_at_stop()
tasks = self._build_tasks()
if not tasks:
return False
tasks = self._refine_locate_tasks(tasks)
order = self._order_tasks(tasks)
self._execute_task(order[0])
return True
def _measure_at_stop(self) -> None:
"""利用已停靠位置补交会方位;评分只作选测,证书仍用完整角域。
只测尚未在此处测过、有望显著收缩且保证距离可接收的已发现频道。
逐频道调用measure,换频与5秒检测照常计费;不在移动中检测。
此距离保证仅适用于第三问,不能用于未知定向源。
"""
point = self.client.state.position
channels = self.belief.channels_by_status(ChannelStatus.DETECTED)
channels.sort(key=lambda c: (c != self.client.state.channel, c))
for channel in channels:
if self._virtual_cap_reached():
return
if _point_key(point) in self._measured_points.get(channel, set()):
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
continue
region = self.belief.candidate_set(channel)
farthest = max(math.dist(point, v) for v in region.polygon)
if farthest > self.config.field.r_eff_min_m - 1e-3:
continue
radius = region.mec()[1]
vertices = region.polygon
stride = max(1, len(vertices) // self.config.bayes.mc_representatives)
radii = []
delta = (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
for target in vertices[::stride]:
theta = math.degrees(math.atan2(target[1]-point[1], target[0]-point[0]))
probe = CandidateRegion(list(vertices), region.slack_m)
probe.clip_wedge(point, theta, delta)
radii.append(probe.mec()[1] if probe.polygon else radius)
if sum(radii) / len(radii) > radius * self.config.bayes.opportunistic_radius_ratio:
continue
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
self.opportunistic_measures += 1
# ------------------------------------------------------------ 任务构建
def _station_pending(self, index: int) -> list[int]:
"""站点待测频道:UNKNOWN、未测过、非"必 no_signal"且未证缺席。"""
point = self.ledger.points[index]
pending = []
for channel, belief in self.belief.channels.items():
if belief.status is not ChannelStatus.UNKNOWN:
continue
if self.ledger.measured_at(channel, index):
continue
if self._skip_station_channel(channel, index, point):
continue
pending.append(channel)
return pending
def _fused_channels_at(self, index: int) -> list[int]:
"""本站要测的频道 = 未发现(UNKNOWN) + 已发现但尚未定证(DETECTED)。
P0-1:把定位并入覆盖巡回。站点三角化可行性(方案 §1.3 F11):
99.28% 的源位置有 (*@\ensuremath{\ge}@*)2 个站在 r_eff_max=1500m 内,最佳交会角中位
122.9°、5 分位 58.5°,因此"顺路取第二条方位线"对绝大多数源成立。
已有证书、已在同一 0.5m 测点测过、或该点必 no_signal 的频道跳过。
实测结论(2026-09-12,`output/suite/q3-fuse-ab*`):本机制**不划算**。
七站环上通常只有 2–3 个站能对该源取得有效方位线,交会角也不足以
直接换到 20m 证书,专用信息增益探点仍然必要;仅在站点顺带测量等于
平白多花约 11 次检测(+0.8%),若同时抑制专用探点则 +45.6%(20/20
场景更慢)。默认关闭,保留代码以便复现与后续研究。
"""
point = self.ledger.points[index]
channels = self._station_pending(index)
selected = set(channels)
if not self.config.bayes.fuse_localize_into_tour:
return channels
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
if channel in selected or self.ledger.measured_at(channel, index):
continue
if _point_key(point) in self._measured_points.get(channel, set()):
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
continue # 已有证书:交给清除任务,不再多测
if self._skip_station_channel(channel, index, point):
continue # 幸存集都在 1500m 外:该点必 no_signal
channels.append(channel)
selected.add(channel)
return channels
def _skip_station_channel(self, channel: int, index: int, point: Vec2) -> bool:
"""跳测规则(按 pf.version 缓存;幸存集单调收缩,判定单调)。"""
pf = self.particles[channel]
if not pf:
return True
cached = self._skip_cache.get((channel, index))
if cached is not None and cached[0] == pf.version:
return cached[1]
value = pf.guaranteed_no_signal(point)
self._skip_cache[(channel, index)] = (pf.version, value)
return value
def _build_tasks(self) -> list[Task]:
tasks: list[Task] = []
for index, point in enumerate(self.ledger.points):
pending = self._fused_channels_at(index)
if not pending:
continue
switches = len(pending) - (1 if self.client.state.channel in pending else 0)
tasks.append(Task("station", tuple(point),
self.config.cost.measure_s * len(pending)
+ self.config.cost.switch_s * max(switches, 0),
station_index=index, station_channels=tuple(pending)))
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
cert = self._best_certificate(channel)
if cert is not None and cert.certified \
and channel not in self._clear_failed:
tasks.append(Task("clear", cert.center,
self.config.cost.clear_success_s,
channel=channel))
continue
if self._defer_to_station(channel):
continue # 下一条方位线可由"本就要去的站点"顺路提供
point = self._select_point(channel)
if point is None:
continue
tasks.append(Task("locate", point,
self.config.cost.measure_s + self.config.cost.switch_s,
channel=channel))
return tasks
def _defer_to_station(self, channel: int) -> bool:
"""该频道能否由"本就要去的站点"提供下一条方位线。
P0-1 的关键:仅仅在站点顺带测量是不够的(那只是多做一次检测),
必须同时**抑制专用探点任务**,否则滚动排序仍会优先飞向更近的探点。
判据要求该站本来就有 UNKNOWN 频道待测(覆盖巡回必须去),因此不会
为了等一条方位线而制造新的绕行,也不会出现"无任务可做"的死锁:
站点扫完后 ``_station_pending`` 为空,探点任务自然恢复。
"""
if not self.config.bayes.fuse_localize_into_tour:
return False
for index, point in enumerate(self.ledger.points):
if not self._station_pending(index):
continue
if self.ledger.measured_at(channel, index):
continue
if _point_key(point) in self._measured_points.get(channel, set()):
continue
if self._skip_station_channel(channel, index, point):
continue
return True
return False
# ------------------------------------------------------------ 任务排序
def _order_tasks(self, tasks: list[Task]) -> list[Task]:
"""开放路径排序:小任务表用精确 DP,否则最近邻 + 2-opt。
Held-Karp(S5/T1)把"已访问集合 × 结束时测向频道"并入状态,因此换频
代价是精确的,而不是 :meth:`_path_cost` 里"按频道排序"的贪心近似。
"""
if len(tasks) <= 2:
ordered = sorted(tasks, key=lambda t: (
move_time(self.client.state.position, t.position, self.config.cost),
t.kind, t.channel or 0))
return min((ordered, ordered[::-1]), key=self._path_cost)
limit = self.config.bayes.exact_order_limit
if 0 < limit and len(tasks) <= limit:
exact = self._exact_order(tasks)
if exact is not None:
return exact
order = self._nearest_order(tasks)
if not self.config.bayes.two_opt_enabled:
return order
return self._two_opt(order)
def _service_and_exit(self, task: Task,
channel: int) -> tuple[float, int]:
"""任务在进入频道 ``channel`` 时的固有耗时与离开后的测向频道。"""
cost = self.config.cost
if task.kind == "locate":
return (cost.measure_s + (cost.switch_s if channel != task.channel else 0.0),
task.channel)
if task.kind == "station" and task.station_channels:
pending = sorted(task.station_channels,
key=lambda c: (c != channel, c))
switches = len(pending) - (1 if channel in pending else 0)
return (cost.measure_s * len(pending) + cost.switch_s * switches,
pending[-1])
return task.service_s, channel
def _exact_order(self, tasks: list[Task]) -> list[Task] | None:
"""Held-Karp 精确开放路径(起点固定、终点自由,含换频状态)。
状态 ``(mask, c)`` = 已访问任务集 mask、离开最后一个任务后测向频道为 c;
频道取值域只需 {当前频道} (*@\ensuremath{\cup}@*) {locate 频道} (*@\ensuremath{\cup}@*) {station 离开频道}。
规模 ``2^n·n·|C|``;n 超过 :attr:`BayesConfig.exact_order_limit` 时由
调用方回退启发式。返回 ``None`` 表示状态过大(防御性)。
"""
n = len(tasks)
if n <= 1:
return list(tasks)
start = self.client.state.position
init = self.client.state.channel
channels = {init}
for task in tasks:
if task.kind == "locate":
channels.add(task.channel)
elif task.kind == "station" and task.station_channels:
# 离开频道 = 按"当前频道优先"排序后的最后一个:通常是最大值;
# 进入频道恰为最大值时退化为次大值,故两者都要进状态空间。
ordered = sorted(task.station_channels)
channels.add(ordered[-1])
if len(ordered) > 1:
channels.add(ordered[-2])
channels = sorted(channels)
width = len(channels)
if width * (1 << n) * n > 4_000_000:
return None
index_of = {channel: i for i, channel in enumerate(channels)}
c_init = index_of[init]
service: list[list[float]] = []
exits: list[list[int]] = []
for task in tasks:
row_service, row_exit = [], []
for channel in channels:
value, exit_channel = self._service_and_exit(task, channel)
row_service.append(value)
row_exit.append(index_of[exit_channel])
service.append(row_service)
exits.append(row_exit)
move = [[move_time(tasks[i].position, tasks[j].position, self.config.cost)
for j in range(n)] for i in range(n)]
from_start = [move_time(start, task.position, self.config.cost)
for task in tasks]
inf = float("inf")
# dp[(mask, ci)] = 最小代价;parent[(mask, ci)] = (上一 mask, 上一 ci)
dp: dict[tuple[int, int], float] = {}
last_task: dict[tuple[int, int], int] = {}
parent: dict[tuple[int, int], tuple[int, int] | None] = {}
for k in range(n):
key = (1 << k, exits[k][c_init])
value = from_start[k] + service[k][c_init]
if value < dp.get(key, inf):
dp[key] = value
last_task[key] = k
parent[key] = None
for mask in range(1, 1 << n):
for ci in range(width):
state = (mask, ci)
cost = dp.get(state)
if cost is None:
continue
for k in range(n):
if (mask >> k) & 1:
continue
nxt = (mask | (1 << k), exits[k][ci])
value = cost + move[last_task[state]][k] + service[k][ci]
if value < dp.get(nxt, inf) - 1e-12:
dp[nxt] = value
last_task[nxt] = k
parent[nxt] = state
full = (1 << n) - 1
best_key, best_cost = None, inf
for ci in range(width):
state = (full, ci)
if state in dp and dp[state] < best_cost:
best_key, best_cost = state, dp[state]
if best_key is None:
return None
order: list[Task] = []
key: tuple[int, int] | None = best_key
while key is not None:
order.append(tasks[last_task[key]])
key = parent[key]
order.reverse()
return order
def _nearest_order(self, tasks: list[Task]) -> list[Task]:
remaining = list(tasks)
order = []
position = self.client.state.position
while remaining:
best = min(remaining, key=lambda t: (
move_time(position, t.position, self.config.cost),
t.kind, t.channel or 0))
remaining.remove(best)
order.append(best)
position = best.position
return order
def _path_cost(self, order: list[Task]) -> float:
total = 0.0
position = self.client.state.position
channel = self.client.state.channel
for task in order:
total += move_time(position, task.position, self.config.cost)
if task.kind == "locate":
total += self.config.cost.measure_s
total += self.config.cost.switch_s * (channel != task.channel)
channel = task.channel
elif task.kind == "station" and task.station_channels:
pending = sorted(
task.station_channels,
key=lambda c, channel=channel: (c != channel, c))
total += self.config.cost.measure_s * len(pending)
total += self.config.cost.switch_s * (len(pending) - (channel in pending))
channel = pending[-1]
else:
total += task.service_s # 清除不改变测向频道
position = task.position
return total
def _two_opt(self, order: list[Task]) -> list[Task]:
best = list(order)
best_cost = self._path_cost(best)
for _ in range(self.config.bayes.two_opt_passes):
improved = False
for i in range(len(best) - 1):
for j in range(i + 1, len(best)):
candidate = best[:i] + best[i:j + 1][::-1] + best[j + 1:]
cost = self._path_cost(candidate)
if cost < best_cost - 1e-6:
best, best_cost, improved = candidate, cost, True
if not improved:
break
return best
# ------------------------------------------------------------ 任务执行
def _execute_task(self, task: Task) -> None:
if task.kind == "station":
self._execute_station(task.station_index)
elif task.kind == "clear":
cert = self._best_certificate(task.channel)
if cert is not None and cert.certified:
if not self._attempt_clear(task.channel, cert.center, cert):
self._clear_failed.add(task.channel)
self._may_clear.discard(task.channel)
else:
self._clear_failed.discard(task.channel)
elif task.kind == "locate":
self._execute_locate(task.channel, task.position)
def _execute_station(self, index: int) -> None:
"""单站批量扫频:只测待测频道(跳测规则已过滤),扫完即返回重排。
P0-1:本站同时测量"已发现未定证"频道,使第二/第三条方位线在巡回
途中顺路取得;证书一旦成立即就地清除,不额外绕行。
"""
point = self.ledger.points[index]
pending = self._fused_channels_at(index)
unknown = [c for c, b in self.belief.channels.items()
if b.status is ChannelStatus.UNKNOWN]
self.skipped_measures += len(unknown) - len(
[c for c in pending if self.belief.channels[c].status
is ChannelStatus.UNKNOWN])
# 当前频道优先,减少换频;同站 near 直接清除不打断扫频
pending.sort(key=lambda c: (c != self.client.state.channel, c))
for channel in pending:
if self.stop_reason is not None or self._count_limit_reached() \
or self._virtual_cap_reached():
return
detected_before = (self.belief.channels[channel].status
is ChannelStatus.DETECTED)
outcome = self.client.measure(point, channel)
self.ledger.mark_measured(channel, index)
self._record_measure(channel, point, outcome)
if outcome.result in (DIRECTION, NEAR):
self._may_clear.add(channel)
if outcome.result == NEAR:
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config)
if cert.certified:
if self._attempt_clear(channel, point, cert):
self._clear_failed.discard(channel)
else:
self._clear_failed.add(channel)
self._may_clear.discard(channel)
else:
self._anomaly("near_without_certificate", channel, position=point)
continue
if not detected_before:
continue
if outcome.result == NO_SIGNAL:
# 该频道已被发现:保证接收盘内不可能 no_signal,属守恒性异常
region = self.belief.candidate_set(channel)
safe = (safe_disk_radius(region, self.config)
if region is not None else -1.0)
if safe >= 0 and math.dist(point, region.mec()[0]) <= safe:
self._anomaly("no_signal_in_safe_disk", channel,
position=point)
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
else:
self._clear_failed.add(channel)
self._may_clear.discard(channel)
def _execute_locate(self, channel: int, point: Vec2) -> None:
"""单步定位测量:执行后立即重排(滚动时域),收敛则顺路清除。"""
pf = self.particles[channel]
before = len(pf)
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
after = len(pf)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
self._stall.pop(channel, None)
return
self._clear_failed.add(channel)
self._may_clear.discard(channel)
if after == before:
self._stall[channel] = self._stall.get(channel, 0) + 1
self.no_shrink_measures += 1 # 只是浪费不破坏守恒性,不进异常表
else:
self._stall[channel] = 0
# ------------------------------------------------------------ 感知与证书
def _record_measure(self, channel: int, position: Vec2, outcome) -> None:
"""belief(凸证书)与 PF(幸存集)同步更新;PF 空即证缺席。"""
self.belief.update_measure(channel, position, outcome.result,
outcome.svd_deg)
# 同一 0.5m 测点 × 同一频道的重复检测:历史上一次官方演练有 98.84%
# 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。
key = _point_key(position)
seen = self._measured_points.setdefault(channel, set())
if key in seen:
self.redundant_rescan_measures += 1
seen.add(key)
self._clear_failed.discard(channel) # 新证据落地:允许再次尝试清除
belief = self.belief.channels[channel]
if belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):
return # 已证毕:清除/缺席后的 no_signal 与距离无关,不进 PF
pf = self.particles[channel]
pf.apply_measure(position, outcome.result, outcome.svd_deg)
if not pf:
if belief.status is ChannelStatus.UNKNOWN:
self.belief.mark_absent(channel)
self.absence_evidence[channel] = "pf_empty"
if channel not in self.pf_absent_channels:
self.pf_absent_channels.append(channel)
else:
self._anomaly("pf_empty_while_detected", channel, position=position)
self._apply_count_bound()
def _apply_count_bound(self) -> None:
"""存在证据达到题面源数上界时,其余 UNKNOWN 可由计数直接判缺席。
依据 [题面]:每频道至多一个源、全局限定 10..16 个源。一旦
DETECTED/CLEARED 的不同频道数达到 16,任何尚未出现有效信号的
频道都不可能是源,无须等 7 站覆盖账本跑完。该判定与位置无关,
是严格的计数证明;evidence 与 coverage/pf_empty 分开留痕。
"""
present = [channel for channel, belief in self.belief.channels.items()
if belief.status in (ChannelStatus.DETECTED,
ChannelStatus.CLEARED)]
if len(present) < self.config.field.source_count_max:
return
for channel, belief in self.belief.channels.items():
if belief.status is ChannelStatus.UNKNOWN:
belief.status = ChannelStatus.ABSENT
self.absence_evidence[channel] = "count_bound"
def _best_certificate(self, channel: int) -> ClearCertificate | None:
"""凸证书优先;负观测挖洞使 PF 幸存集更紧时用网格证书补位。"""
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config) \
if region is not None else None
if cert is not None and cert.certified:
return self._near_clear_target(cert)
if self.config.bayes.grid_cert_enabled:
pf = self.particles[channel]
if len(pf):
center, radius = pf.mec()
limit = (self.config.field.clear_radius_m - pf.margin_m
- self.config.search.clear_margin_m)
if radius <= limit:
return self._near_clear_target(
ClearCertificate(channel, center, radius, limit, True))
return cert
def _near_clear_target(self, cert: ClearCertificate) -> ClearCertificate:
"""沿当前来向利用20m清除容差;三角不等式保留整集覆盖证明。"""
position = self.client.state.position
distance = math.dist(position, cert.center)
shift = min(distance, max(0.0, cert.limit_m - cert.radius_m - 1e-6))
if distance <= 1e-9 or shift <= 0:
return cert
target = tuple(c + (p-c)*shift/distance for p, c in zip(position, cert.center))
return ClearCertificate(cert.channel, target, cert.radius_m+shift, cert.limit_m, True)
# ------------------------------------------------------------ 信息增益选点
def _probe_context(self, channel: int):
"""构造某频道下一次定位测量的候选集与信息增益评分函数。
返回 ``(candidates, info_score, mec_center, current_position)``;
候选为空返回 ``None``。``_select_point`` 与路线感知精修共用此逻辑,
保证两者只差“移动代价如何折算”。
"""
bayes, search = self.config.bayes, self.config.search
pf = self.particles[channel]
if not pf:
return None
region = self.belief.candidate_set(channel)
center, radius = pf.mec()
measured = self._measured_points.get(channel, set())
position = self.client.state.position
candidates: list[Vec2] = []
safe = safe_disk_radius(region, self.config) if region is not None else -1.0
if region is not None and safe >= 0:
# 保证接收盘(凸证书口径):任意候选必产生 direction/near
candidates = candidate_points(tuple(region.mec()[0]), safe,
search.candidate_rings,
search.candidate_angles)
# 以实际来向生成对称交会候选,避免固定全局角度网格的旋转偏差。
geometric_center = region.mec()[0]
base_angle = math.atan2(position[1] - geometric_center[1],
position[0] - geometric_center[0])
for fraction in search.candidate_rings:
if fraction <= 0:
continue
for offset in (-math.pi/3, -math.pi/6, 0, math.pi/6, math.pi/3):
angle = base_angle + offset
candidates.append((geometric_center[0] + safe * fraction * math.cos(angle),
geometric_center[1] + safe * fraction * math.sin(angle)))
# 已停靠位置若本身在安全接收盘内,是零移动的合法测点;显式加入
# 可避免为了固定角度环而白白横移。
if math.dist(position, geometric_center) <= safe + 1e-9:
candidates.append(tuple(position))
else:
# 宽候选集:PF MEC 探索环(负观测挖洞后幸存集的包围圆)
for fraction in bayes.wide_rings:
ring = max(radius * fraction, 2.0 * pf.margin_m)
candidates.extend(candidate_points(center, ring, (1.0,),
search.candidate_angles))
candidates.append(tuple(center))
candidates.append(tuple(position))
candidates = [tuple(c) for c in candidates
if _point_key(c) not in measured]
if not candidates:
return None
# 采样一次转成数组(numpy 可用时),使候选评分走向量路径;
# numpy 缺失时 as_sample_array 原样返回列表,语义不变。
subsample = as_sample_array(pf.subsample(bayes.score_subsample))
stride = max(len(subsample) // bayes.mc_representatives, 1)
representatives = subsample[::stride]
def info_score(q: Vec2) -> float:
if bayes.score_mode == "entropy":
return pf.expected_survival(q, subsample, representatives)
return pf.expected_posterior_radius(q, subsample, representatives)
return candidates, info_score, center, position
def _select_point(self, channel: int) -> Vec2 | None:
"""定位测量点:期望 log 幸存占比 + (*@\ensuremath{\lambda}@*)·移动时间 最小的候选。"""
context = self._probe_context(channel)
if context is None:
return None
candidates, info_score, center, position = context
bayes = self.config.bayes
def score(q: Vec2) -> float:
# 一次定位后仍需接近源才能清除;仅计到检测点的移动会偏好绕行。
move = bayes.lambda_move_s * (move_time(position, q, self.config.cost)
+ move_time(q, center, self.config.cost))
return info_score(q) + move
return min(candidates, key=lambda q: (score(q), _point_key(q)))
def _refine_locate_tasks(self, tasks: list[Task]) -> list[Task]:
"""路线感知精修定位任务点(P2,2026-09-12)。
旧评分只比较 ``current->q + q->MEC``,是局部代理;滚动 2-opt 的真实
代价还包含 ``q->next_task``。这里对每个 locate 任务在候选点上计算
“替换该任务后整条当前路线代价的增量”,再与信息增益、MEC 接近项合并
选点。只改变选点启发式,不改变覆盖/清除证明。
"""
bayes = self.config.bayes
if not bayes.route_aware_probe:
return tasks
locate_indices = [index for index, task in enumerate(tasks)
if task.kind == "locate"]
if not locate_indices:
return tasks
base_order = self._order_tasks(tasks)
base_cost = self._path_cost(base_order)
refined = list(tasks)
for task_index in locate_indices:
task = tasks[task_index]
context = self._probe_context(task.channel)
if context is None:
continue
candidates, info_score, center, position = context
try:
order_index = next(index for index, item in enumerate(base_order)
if item is task)
except StopIteration: # pragma: no cover - 同对象应始终存在
order_index = next(index for index, item in enumerate(base_order)
if item.kind == "locate"
and item.channel == task.channel)
best_score: float | None = None
best_point: Vec2 | None = None
for point in candidates:
trial_order = list(base_order)
trial_order[order_index] = Task(
"locate", tuple(point), task.service_s, channel=task.channel)
route_delta = self._path_cost(trial_order) - base_cost
move_proxy = bayes.lambda_move_s * (
move_time(position, point, self.config.cost)
+ move_time(point, center, self.config.cost))
clear_near = move_time(point, center, self.config.cost)
value = (info_score(point)
+ move_proxy
+ bayes.route_aware_insert_weight * route_delta
+ bayes.route_aware_clear_weight * clear_near)
if best_score is None or value < best_score:
best_score, best_point = value, tuple(point)
if best_point is not None:
refined[task_index] = Task(
"locate", best_point, task.service_s, channel=task.channel)
return refined
# ------------------------------------------------------------ 安全网与复扫
def _localize_and_clear(self, channel: int) -> bool:
"""阻塞式定位子循环(resolve 阶段安全网):PF 选点,失败退凸选点。"""
search = self.config.search
self._may_clear.add(channel)
for _ in range(search.max_localize_steps):
if self._virtual_cap_reached():
return False
cert = self._best_certificate(channel)
if cert is not None and cert.certified and channel in self._may_clear:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
continue
point = self._select_point(channel)
if point is None:
region = self.belief.candidate_set(channel)
if region is None:
return False
point = select_measurement_point(
region, self.client.state.position, self.config).position
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
return self.belief.channels[channel].status is ChannelStatus.CLEARED
def _persistent_round(self) -> None:
"""一轮复扫:与基线一致的全站复测,但读数同步进 PF(防假设偏差)。"""
measured: set[tuple[int, int]] = set()
while self.stop_reason is None:
index = self._next_resweep_point(measured)
if index is None:
return
point = self.ledger.points[index]
for channel in self._resweep_channels_at(index, measured):
if self._virtual_cap_reached():
return
measured.add((channel, index))
was_absent = (self.belief.channels[channel].status
is ChannelStatus.ABSENT)
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
if was_absent and outcome.result in (DIRECTION, NEAR):
# 复测推翻了缺席证明:守恒性留痕,按新证据定位清除。
self._anomaly("absent_detected_on_resweep", channel,
position=point, result=outcome.result)
if outcome.result == DIRECTION:
self._localize_and_clear(channel)
elif outcome.result == NEAR:
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if not self._attempt_clear(channel, cert.center, cert):
self._clear_failed.add(channel)
else:
self._anomaly("near_without_certificate", channel,
position=point)
if self.stop_reason is not None:
return
# ------------------------------------------------------------ 收尾
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "bayes"
report["opportunistic_measures"] = self.opportunistic_measures
report["skipped_measures"] = self.skipped_measures
report["no_shrink_measures"] = self.no_shrink_measures
report["pf_absent_channels"] = sorted(self.pf_absent_channels)
report["pf_alive_final"] = {
str(channel): len(self.particles[channel])
for channel in sorted(self.particles)
if self.belief.channels[channel].status is ChannelStatus.DETECTED}
return report
@@ -0,0 +1,113 @@
"""P4 七点覆盖策略族:解析证明的六边形发现站 + 同站批量扫频。
从 scripts/benchmark_q3_search.py 提升为库代码(A2 基线,官方演练
12/12 全清、虚拟 5232s)。覆盖几何:原点 + 半径 RING_RADIUS_M 环上
6 等分点;域半径R、环半径r时,最近站距离的解析
上界 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000m [题面
r_eff 下界],故任意合法源必被至少一站检出——缺席证明只需该频道
7 站全 no_signal(CoverageLedger.coverage_complete)。
调度变体:
- SevenImmediate:站内即时进入定位子循环(A1);
- SevenBatch:站内只扫频,扫完按"距候选集 MEC 圆心最近"服务队列(A2);
- SevenSweep:全域扫完再统一服务(A3)。
"""
from __future__ import annotations
import math
from ..belief import ChannelStatus
from ..config import ProblemConfig
from ..coverage import CoverageLedger
from ..guarantee import seven_plan
from ..protocol.rules import NEAR
from . import OmnidirectionalLoop, clear_certificate
RING_RADIUS_M = 1200.0 # [工程] 解析证明见 docs/algo-3.md §5 与 geometry_check
def seven_points(ring_radius_m: float = RING_RADIUS_M) -> list[tuple[float, float]]:
"""原点 + 环上 6 等分点的七站覆盖集。"""
return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),
ring_radius_m * math.sin(k * math.pi / 3)) for k in range(6)]
def validate_seven_coverage(ring_radius_m: float, config: ProblemConfig) -> float:
"""六个60°扇区内的最近站最远距离上界;仅证明全向源覆盖。
原点/环站Voronoi交点给出r/√3,目标圆边界的扇区接缝给出另一项。
环半径满足0<r<=√3R时这两项覆盖全部极值。
"""
radius = config.field.omega_radius_m
if not 0 < ring_radius_m <= math.sqrt(3)*radius:
raise ValueError("七站环半径不满足全域覆盖证明的前提")
bound = max(ring_radius_m / math.sqrt(3), math.sqrt(
radius**2 + ring_radius_m**2 - math.sqrt(3)*radius*ring_radius_m))
if bound > config.field.r_eff_min_m - 1e-6:
raise ValueError("七站未覆盖整个目标域,不能据此证明频道缺席")
return bound
class SevenImmediate(OmnidirectionalLoop):
"""七点覆盖 + 站内即时定位(基类覆盖阶段逻辑不变,仅换站集)。"""
def __init__(self, client, config: ProblemConfig = ProblemConfig(),
ring_radius_m: float = RING_RADIUS_M):
super().__init__(client, config)
validate_seven_coverage(ring_radius_m, config)
self.coverage_plan = seven_plan(ring_radius_m)
self.ledger = CoverageLedger(config=config,
points=list(self.coverage_plan.points))
class SevenBatch(SevenImmediate):
"""站内只扫频,扫完按最近候选集圆心顺序服务检测队列(A2 主策略)。"""
defer_all = False
def _serve_queue(self):
# Each detected channel gets at most one localization call in this pass.
queue = self.belief.channels_by_status(ChannelStatus.DETECTED)
while queue:
c = min(queue, key=lambda c: (math.dist(self.client.state.position,
self.belief.candidate_set(c).mec()[0]), c))
queue.remove(c)
self._localize_and_clear(c)
if self._count_limit_reached():
break
def _coverage_phase(self):
while not self._count_limit_reached():
index = self._next_point_index()
if index is None:
return
point = self.ledger.points[index]
pending = self._pending_channels_at(index)
# Keeping the current measuring channel first saves a switch when possible.
pending.sort(key=lambda c: (c != self.client.state.channel, c))
for c in pending:
outcome = self.client.measure(point, c)
self.ledger.mark_measured(c, index)
self.belief.update_measure(c, point, outcome.result, outcome.svd_deg)
if outcome.result == NEAR:
cert = clear_certificate(self.belief.candidate_set(c), c, self.config)
# Same-station near clear; do not displace the station scan.
if cert.certified:
self._attempt_clear(c, point, cert)
if self._count_limit_reached():
return
if not self.defer_all:
self._serve_queue()
def _resolve_detected(self):
self._serve_queue()
unresolved = [c for c, b in self.belief.channels.items()
if b.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)]
return "all_resolved" if not unresolved else "unresolved_channels"
class SevenSweep(SevenBatch):
"""Finish discovery at all necessary stations before servicing the queue."""
defer_all = True
@@ -0,0 +1,15 @@
"""P0/P1 协议层:字段表、规则表、计时模型、状态机与客户端。"""
from .client import (ActionRejected, ClearOutcome, DeadlineExceeded,
ExitInfo, MeasureOutcome, ProtocolError,
ProtocolViolation, SimulatorClient)
from .log import SessionLog
from .state import RobotState, StateError
from .timing import CostBreakdown, clear_cost, measure_cost, move_time
from .transport import RequestsTransport, Transport, TransportError, TransportResponse
__all__ = ["ActionRejected", "ClearOutcome", "CostBreakdown", "DeadlineExceeded",
"ExitInfo", "MeasureOutcome", "ProtocolError", "ProtocolViolation",
"RequestsTransport", "RobotState", "SessionLog", "SimulatorClient",
"StateError", "Transport", "TransportError", "TransportResponse",
"clear_cost", "measure_cost", "move_time"]
@@ -0,0 +1,333 @@
"""P1 协议基线:串行、幂等、带现实预算的模拟器客户端。
协议硬规则 [题面] 与实现对应:
- 每个新动作使用新的 request_id;仅在网络失败后重试"完全相同的动作"时,
复用原 request_id 与原请求体 —— 本类通过一次性序列化出 bytes 并原样重发保证;
- 必须同时检查 HTTP 状态与 accepted;accepted=false 不更新状态,
其 virtual_time_s=0 不当作当前虚拟时刻;
- 逐次等待响应、不得并发;本类完全同步,一个动作未决不发起下一个;
- 现实截止用 /enter 响应的 remaining_real_duration_s 建立本地单调时钟,
预留退出余量;/exit 不做预算拦截。
"""
from __future__ import annotations
import json
import math
import uuid
from dataclasses import dataclass
from time import monotonic, sleep
from ..config import ProblemConfig, Vec2
from .log import SessionLog
from .rules import (CLEAR_RESULTS, DIRECTION, EXIT_USER, MEASURE_RESULTS,
POSITION_FIELDS, STATUS_OK, STATUS_RETRYABLE)
from .state import RobotState
from .timing import CostBreakdown
from .transport import RequestsTransport, Transport, TransportError
class ProtocolError(RuntimeError):
"""协议层失败,含 HTTP 状态与响应体;默认不重试(确定性错误)。"""
def __init__(self, message: str, status: int | None = None, body=None):
super().__init__(message)
self.status = status
self.body = body
class ActionRejected(ProtocolError):
"""HTTP 200 但 accepted=false:动作未生效,状态不得更新。"""
def __init__(self, message: str, body=None):
super().__init__(message, status=STATUS_OK, body=body)
class ProtocolViolation(ProtocolError):
"""响应与协议约定不符(字段缺失、值非法、虚拟时刻回退等)。"""
class DeadlineExceeded(RuntimeError):
"""现实预算不足:预留退出余量后剩余时间不够执行新动作。"""
@dataclass(frozen=True)
class MeasureOutcome:
result: str
svd_deg: float | None
virtual_time_s: float
predicted: CostBreakdown
raw: dict
@dataclass(frozen=True)
class ClearOutcome:
result: str
virtual_time_s: float
predicted: CostBreakdown
raw: dict
@dataclass(frozen=True)
class ExitInfo:
reason: str
virtual_time_s: float
raw: dict
def _validate_identifier(name: str, value: str, max_bytes: int) -> None:
if not isinstance(value, str) or not value:
raise ValueError(f"{name} 必须是非空字符串")
encoded = value.encode("utf-8")
if not 1 <= len(encoded) <= max_bytes:
raise ValueError(f"{name} 的 UTF-8 长度须在 1..{max_bytes} 字节")
if any(code < 0x20 or code == 0x7F for code in encoded):
raise ValueError(f"{name} 不能包含控制字符")
def _validate_position(position: dict, limit: float) -> None:
if set(position) != set(POSITION_FIELDS):
raise ValueError("position 字段必须是且只能是 x、y")
for key in ("x", "y"):
value = position[key]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"position.{key} 必须是数值")
if not math.isfinite(value):
raise ValueError(f"position.{key} 必须是有限数值")
if abs(value) > limit:
raise ValueError(f"position.{key} 绝对值不得超过 {limit} 米")
def _validate_channel(channel) -> int:
# 写成 JSON number 1.0 且恰为整数也接受 [题面];1.5 拒绝。
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
raise ValueError("channel 必须是数值")
if isinstance(channel, float) and not channel.is_integer():
raise ValueError("channel 必须是整数")
channel = int(channel)
if not 1 <= channel <= 20:
raise ValueError("channel 必须在 1..20 范围内")
return channel
class SimulatorClient:
"""四条指令的同步客户端。状态只在 accepted=true 后推进。"""
def __init__(self, robot_id: str, *,
config: ProblemConfig = ProblemConfig(),
transport: Transport | None = None,
log: SessionLog | None = None,
clock=monotonic):
_validate_identifier("robot_id", robot_id, config.session.robot_id_max_bytes)
self.robot_id = robot_id
self.config = config
self.session = config.session
self.transport = transport if transport is not None else RequestsTransport()
self.log = log if log is not None else SessionLog()
self.clock = clock
self.state = RobotState()
self._prefix = uuid.uuid4().hex[:12]
self._seq = 0
self._deadline: float | None = None # 本地单调时钟截止
self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对
self._enter_monotonic: float | None = None # 实机计时起点(/enter 成功)
# ------------------------------------------------------------ 公共动作 API
def enter(self) -> dict:
started = self.clock()
payload = self._payload("/enter")
response = self._call("/enter", payload)
self.state.apply_enter(response)
remaining = response["remaining_real_duration_s"]
if not isinstance(remaining, (int, float)) or remaining < 0:
raise ProtocolViolation("remaining_real_duration_s 缺失或非法", body=response)
self._deadline = self.clock() + remaining
self._server_deadline_ms = (response["real_timestamp_ms"]
+ remaining * 1000.0)
self._enter_monotonic = self.clock()
self._log_step("/enter", payload, response, started,
predicted=CostBreakdown(0.0, 0.0, 0.0))
return response
def measure(self, position: Vec2, channel: int) -> MeasureOutcome:
started = self.clock()
payload = self._payload("/measure", position=position, channel=channel)
response = self._call("/measure", payload)
predicted = self.state.apply_measure(payload, response,
cost=self.config.cost)
result = response.get("measure_result")
if result not in MEASURE_RESULTS:
raise ProtocolViolation(f"measure_result 非法: {result!r}", body=response)
svd = response.get("svd_deg")
if result == DIRECTION:
if not isinstance(svd, (int, float)) or isinstance(svd, bool):
raise ProtocolViolation("direction 响应缺少数值 svd_deg", body=response)
self._log_step("/measure", payload, response, started, result=result,
predicted=predicted, channel=payload["channel"])
return MeasureOutcome(result, float(svd) if svd is not None else None,
response["virtual_time_s"], predicted, response)
def clear(self, position: Vec2, channel: int) -> ClearOutcome:
started = self.clock()
payload = self._payload("/clear", position=position, channel=channel)
response = self._call("/clear", payload)
predicted = self.state.apply_clear(payload, response,
cost=self.config.cost)
result = response.get("clear_result")
if result not in CLEAR_RESULTS:
raise ProtocolViolation(f"clear_result 非法: {result!r}", body=response)
self._log_step("/clear", payload, response, started, result=result,
predicted=predicted, channel=payload["channel"])
return ClearOutcome(result, response["virtual_time_s"], predicted, response)
def exit(self) -> ExitInfo:
started = self.clock()
payload = self._payload("/exit")
response = self._call("/exit", payload)
self.state.apply_exit(response)
reason = response.get("exit_reason")
if reason != EXIT_USER:
raise ProtocolViolation(f"exit_reason 非法: {reason!r}", body=response)
self._log_step("/exit", payload, response, started,
predicted=CostBreakdown(0.0, 0.0, 0.0))
return ExitInfo(reason, response["virtual_time_s"], response)
# ---------------------------------------------------------------- 预算控制
@property
def real_remaining_s(self) -> float | None:
"""按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。"""
return None if self._deadline is None else self._deadline - self.clock()
def _log_step(self, path: str, payload: dict, response: dict,
started: float, *, result: str | None = None,
predicted: CostBreakdown, channel: int | None = None) -> None:
"""每步计时留档 [用户决策 2026-09-11]:实机墙钟耗时 + 比赛虚拟耗时分项
(移动=直线距离/5、切频道 1s、检测 5s、清除成功 5s/未发现 3s)。
step 事件不含 response 字段,不会混入 action_events / 虚拟时刻复算。
"""
now = self.clock()
entry: dict = {"event": "step", "path": path,
"request_id": payload["request_id"],
"real_elapsed_s": round(now - started, 6),
"virtual_time_s": response["virtual_time_s"],
"virtual_cost": {"move_s": round(predicted.move_s, 6),
"switch_s": round(predicted.switch_s, 6),
"action_s": round(predicted.action_s, 6),
"total_s": round(predicted.total_s, 6)}}
if self._enter_monotonic is not None:
entry["real_since_enter_s"] = round(now - self._enter_monotonic, 6)
if "position" in payload:
entry["position"] = {"x": payload["position"]["x"],
"y": payload["position"]["y"]}
if channel is not None:
entry["channel"] = channel
if result is not None:
entry["result"] = result
self.log.append(entry)
def _check_budget(self, path: str) -> None:
if path == "/exit" or self._deadline is None:
return
margin = self.session.reserve_exit_s
if self.real_remaining_s <= margin:
raise DeadlineExceeded(
f"剩余现实时间 {self.real_remaining_s:.1f}s 不足以预留 {margin:.1f}s 退出余量")
# ---------------------------------------------------------------- 内部机制
def _payload(self, _path: str, *, position: Vec2 | None = None,
channel: int | None = None) -> dict:
payload = {"arena_id": self.session.arena_id,
"robot_id": self.robot_id,
"request_id": self._next_request_id()}
if position is not None:
x, y = float(position[0]), float(position[1])
_validate_position({"x": x, "y": y}, self.config.field.coord_abs_limit_m)
payload["position"] = {"x": x, "y": y}
if channel is not None:
payload["channel"] = _validate_channel(channel)
return payload
def _next_request_id(self) -> str:
self._seq += 1
request_id = f"{self._prefix}-{self._seq:04d}"
_validate_identifier("request_id", request_id,
self.session.request_id_max_bytes)
return request_id
def _call(self, path: str, payload: dict) -> dict:
"""串行执行一个动作;网络失败原样重发,HTTP 确定性错误立即抛出。"""
self._check_budget(path)
body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
if len(body) > self.session.request_body_max_bytes:
raise ValueError("请求体超过 65536 字节")
headers = {"Content-Type": "application/json"}
url = self.session.base_url + path
attempts, last_error = 0, None
while attempts < self.session.max_attempts:
attempts += 1
started = self.clock()
self.log.append({"event": "attempt", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "request": payload})
try:
result = self.transport.post(
url, body, headers,
timeout=(self.session.connect_timeout_s,
self.session.read_timeout_s))
except TransportError as exc:
last_error = exc
self.log.append({"event": "transport_error", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "error": str(exc),
"elapsed_s": self.clock() - started})
if attempts < self.session.max_attempts:
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
continue
if result.status in STATUS_RETRYABLE:
self.log.append({"event": "retryable_status", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "status": result.status})
if attempts < self.session.max_attempts:
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
continue
if result.status != STATUS_OK:
raise ProtocolError(
f"{path} 返回 HTTP {result.status}", status=result.status,
body=self._safe_json(result.body))
response = self._safe_json(result.body)
if response is None or response.get("accepted") is not True:
raise ActionRejected(
f"{path} 未被接受: {response}", body=response)
self._verify_clock(response)
self.log.append({"event": "accepted", "path": path,
"request_id": payload["request_id"],
"request": payload, "response": response,
"attempts": attempts,
"elapsed_s": self.clock() - started})
return response
raise TransportError(
f"{path} 重试 {attempts} 次仍失败: {last_error}") from last_error
def _verify_clock(self, response: dict) -> None:
"""accepted=true 的虚拟时刻必须单调不减;现实时间戳用于漂移核对。"""
virtual = response.get("virtual_time_s")
if not isinstance(virtual, (int, float)) or isinstance(virtual, bool):
raise ProtocolViolation("accepted 响应缺少 virtual_time_s", body=response)
if virtual < self.state.virtual_time_s - 1e-9:
raise ProtocolViolation(
f"虚拟时刻回退: {self.state.virtual_time_s} -> {virtual}",
body=response)
if self._server_deadline_ms is not None:
drift = (self._server_deadline_ms - response["real_timestamp_ms"]) / 1000.0
if drift < self.session.reserve_exit_s / 2:
# 服务器视角余量告急(本地时钟漂移或请求排队);仅记录,交由上层决策。
self.log.append({"event": "server_time_warning",
"server_remaining_s": drift})
@staticmethod
def _safe_json(body: bytes):
try:
return json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
@@ -0,0 +1,38 @@
"""会话日志:机器狗程序必须自行记录指令序列与响应 [题面] 附件2 12节。
事件逐条追加写入 JSONL,进程崩溃时已完成的动作不丢;
内存中的 events 同时供回放测试与 evaluation 复算使用。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from time import time
@dataclass
class SessionLog:
path: Path | None = None
events: list = field(default_factory=list)
def __post_init__(self):
if self.path is not None:
self.path.parent.mkdir(parents=True, exist_ok=True)
def append(self, event: dict) -> None:
event = {**event, "logged_at_unix_s": time()}
self.events.append(event)
if self.path is not None:
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False, allow_nan=False) + "\n")
def action_events(self) -> list[dict]:
"""仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。"""
return [event for event in self.events if event.get("response") is not None
and event["response"].get("accepted") is True]
def step_events(self) -> list[dict]:
"""每步计时事件(客户端 _log_step 写入,按发生顺序)。"""
return [event for event in self.events if event.get("event") == "step"]
@@ -0,0 +1,94 @@
"""P0 规则固定:四条指令的字段表、结果码与错误语义,全部落成可执行数据。
依据 issue/附件/附件2.docx《模拟器通信接口说明及编程指南》第 5-9 节。
客户端用它构造与预校验请求,自建模拟器用它校验并拒绝未知字段;
表格本身即交付物"字段表/规则表",人类可读版本见 docs/协议规则与状态机.md。
"""
from __future__ import annotations
from dataclasses import dataclass
# ---- 结果码 [题面] ----------------------------------------------------------
NO_SIGNAL = "no_signal" # 收不到信号:无未清除源 / 超接收半径 / 定向覆盖角外
NEAR = "near" # 距离 <=5m 且在覆盖角内;不返回 svd_deg
DIRECTION = "direction" # 有信号;附 svd_deg(含 ±1° 误差,保留两位小数)
MEASURE_RESULTS = (NO_SIGNAL, NEAR, DIRECTION)
CLEAR_SUCCESS = "success" # 20m 内发现并清除;耗时 5s
CLEAR_NO_TARGET = "no_target_in_range" # 未发现(无源/已清除/超20m);耗时 3s
CLEAR_RESULTS = (CLEAR_SUCCESS, CLEAR_NO_TARGET)
EXIT_USER = "user_exit" # /exit 成功响应中的固定退出原因
# accepted=false 的响应只含这 3 个字段;virtual_time_s 恒为 0,不是当前时刻 [题面]
BASE_RESPONSE_FIELDS = ("accepted", "real_timestamp_ms", "virtual_time_s")
# ---- 请求字段表 --------------------------------------------------------------
@dataclass(frozen=True)
class FieldSpec:
"""单个请求字段的约束。[题面] 附件2 5.1 节与各指令小节。"""
kind: type # str / dict;数值字段由 sim 按整型/浮点细则单独校验
required: bool = True
max_bytes: int | None = None # 字符串字段的 UTF-8 长度上限
COMMON_FIELDS: dict[str, FieldSpec] = {
"arena_id": FieldSpec(str), # 必须为 "default"
"robot_id": FieldSpec(str, max_bytes=64), # 逐字节等于登录参赛队号
"request_id": FieldSpec(str, max_bytes=128), # 会话内幂等键,新动作用新 ID
}
POSITION_FIELDS: dict[str, FieldSpec] = {
"x": FieldSpec(float), # 有限 JSON number,|x| <= 2_000_000
"y": FieldSpec(float),
}
# 路径 -> 除公共字段外的动作字段;/enter 与 /exit 无附加字段
ACTION_FIELDS: dict[str, dict[str, FieldSpec]] = {
"/enter": {},
"/measure": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
"/clear": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
"/exit": {},
}
ACTION_PATHS = tuple(ACTION_FIELDS)
# channel 写成 JSON number 1.0 且恰为整数也接受;1.5 返回 HTTP 400 [题面]
# ---- 响应字段表(accepted=true 时的附加字段) --------------------------------
RESPONSE_FIELDS: dict[str, tuple[str, ...]] = {
"/enter": ("max_virtual_duration_s", "max_real_duration_s", "remaining_real_duration_s"),
"/measure": ("measure_result",), # direction 时另有 svd_deg
"/clear": ("clear_result",),
"/exit": ("exit_reason",),
}
# ---- 错误语义 [题面] 附件2 5.3 节 --------------------------------------------
STATUS_OK = 200
STATUS_BAD_REQUEST = 400 # JSON 语法/重复键/缺字段/类型/频道或坐标范围
STATUS_CONFLICT = 409 # 同 request_id 对应不同动作,或并发不同动作
STATUS_RETRYABLE = (429, 500) # 流量保护/幂等记录上限、服务器内部错误:可原样重试
def http_status_meaning(status: int) -> str:
table = {
200: "动作已执行,或 JSON 合法但被业务拒绝(看 accepted)",
400: "请求结构错误;不占用 request_id,修正后可复用该 ID",
404: "路径未知或不精确(不接受尾随斜线与查询参数)",
405: "已知路径使用了非 POST 方法",
409: "request_id 幂等冲突或并发发送不同动作",
413: "请求体超过 65536 字节",
415: "Content-Type 或 Content-Encoding 不受支持",
429: "连接/流量保护触发,或本局幂等记录达到上限",
500: "模拟器内部错误",
}
return table.get(status, "未知状态码")
@@ -0,0 +1,97 @@
"""P0/P1 机器狗本地状态机:位置、测向频道、虚拟时刻与进出状态。
更新规则 [题面]:
- 只有 accepted=true 的响应才允许调用 apply_*;accepted=false 一律不更新,
其 virtual_time_s=0 不是当前虚拟时刻;
- 虚拟时刻以最近一次 accepted=true 的响应为准,本地预测值仅作一致性核对;
- /clear 不改变测向机当前频道,也不产生切换耗时;
- 只有合法 /measure 会把测向机频道更新为本次检测频道。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..config import CostConfig, Vec2
from .rules import CLEAR_SUCCESS
from .timing import CostBreakdown, clear_cost, measure_cost
class StateError(RuntimeError):
"""状态机被以非法顺序驱动(例如未 enter 就 apply_measure)。"""
@dataclass
class RobotState:
position: Vec2 = (0.0, 0.0) # /enter 成功后的初始位置 [题面]
channel: int = 1 # /enter 成功后的初始频道 [题面]
virtual_time_s: float = 0.0
entered: bool = False
exited: bool = False
action_count: int = 0
# 预测与实测的偏差记录;strict 模式下出现偏差会直接抛错。
mismatches: list = field(default_factory=list)
# -------------------------------------------------------------- enter/exit
def apply_enter(self, response: dict, *, strict: bool = False) -> None:
self._require_accepted(response)
if strict and (self.entered or self.exited):
raise StateError("重复 /enter 或已退出后再 enter")
self.entered = True
self.virtual_time_s = response["virtual_time_s"]
def apply_exit(self, response: dict) -> None:
self._require_accepted(response)
self.exited = True
self.virtual_time_s = response["virtual_time_s"]
# ----------------------------------------------------------------- measure
def apply_measure(self, request: dict, response: dict, *,
cost: CostConfig = CostConfig(),
strict: bool = False) -> CostBreakdown:
self._require_accepted(response)
if strict and (not self.entered or self.exited):
raise StateError("/measure 要求已 enter 且未退出")
pos = (request["position"]["x"], request["position"]["y"])
channel = request["channel"]
predicted = measure_cost(self.position, pos, self.channel, channel, cost)
predicted = self._advance(pos, channel, response, predicted, strict)
return predicted
# ------------------------------------------------------------------- clear
def apply_clear(self, request: dict, response: dict, *,
cost: CostConfig = CostConfig(),
strict: bool = False) -> CostBreakdown:
self._require_accepted(response)
if strict and (not self.entered or self.exited):
raise StateError("/clear 要求已 enter 且未退出")
pos = (request["position"]["x"], request["position"]["y"])
success = response["clear_result"] == CLEAR_SUCCESS
predicted = clear_cost(self.position, pos, success, cost)
# 频道保持不变:/clear 不切换测向机频道 [题面]
return self._advance(pos, self.channel, response, predicted, strict)
# ------------------------------------------------------------------ 共用
def _advance(self, pos: Vec2, channel: int, response: dict,
predicted: CostBreakdown, strict: bool) -> CostBreakdown:
# 先完成全部校验再落子:strict 模式下失败时不留半更新状态。
actual_delta = response["virtual_time_s"] - self.virtual_time_s
if abs(actual_delta - predicted.total_s) > 1e-6:
message = (f"耗时预测不一致: 预测 {predicted.total_s:.6f}s, "
f"实测 {actual_delta:.6f}s ({predicted})")
if strict:
raise StateError(message)
# 非严格模式:状态仍以响应为准,偏差记入日志供复盘。
self.mismatches.append(
{"action": self.action_count + 1, "predicted_s": predicted.total_s,
"actual_s": actual_delta, "message": message})
self.position = pos
self.channel = channel
self.virtual_time_s = response["virtual_time_s"]
self.action_count += 1
return predicted
@staticmethod
def _require_accepted(response: dict) -> None:
if response.get("accepted") is not True:
raise StateError("accepted=false 的响应不允许更新状态")
@@ -0,0 +1,61 @@
"""P0 纯计时模型:/measure 与 /clear 的耗时分解,不涉及网络与状态。
附件1表2 与附件2第10节的计时示例是唯一权威锚点:
逐步得到 105、111、194、199 秒(tests/test_protocol_timing.py 固化)。
客户端用本模块预测动作代价,模拟器用同一函数推进虚拟时钟,
保证"预测-执行-复算"三方一致。
"""
from __future__ import annotations
from dataclasses import dataclass
from math import dist
from ..config import CostConfig, Vec2
@dataclass(frozen=True)
class CostBreakdown:
"""一次合法动作的三段耗时 [题面]:移动 + 换频道(仅检测)+ 动作本身。"""
move_s: float
switch_s: float
action_s: float
@property
def total_s(self) -> float:
return self.total_us / 1_000_000
@property
def total_us(self) -> int:
"""逐段量化后按整数微秒累计,与官方虚拟时钟一致。"""
return sum(round(value * 1_000_000)
for value in (self.move_s, self.switch_s, self.action_s))
def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,
cost: CostConfig = CostConfig()) -> CostBreakdown:
"""合法 /measure 的耗时。移动按直线距离;换频道只看前后频道是否不同。"""
return CostBreakdown(
move_s=dist(prev_pos, pos) / cost.speed_mps,
switch_s=0.0 if channel == prev_channel else cost.switch_s,
action_s=cost.measure_s,
)
def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool,
cost: CostConfig = CostConfig()) -> CostBreakdown:
"""合法 /clear 的耗时。
/clear 的 channel 是目标干扰源频道,不切换测向机频道,也不产生换频道耗时 [题面]。
"""
return CostBreakdown(
move_s=dist(prev_pos, pos) / cost.speed_mps,
switch_s=0.0,
action_s=cost.clear_success_s if success else cost.clear_fail_s,
)
def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float:
"""任意两点间的移动耗时(规划用)。"""
return dist(a, b) / cost.speed_mps
@@ -0,0 +1,44 @@
"""传输抽象:HTTP 细节与协议客户端解耦,便于故障注入与本地模拟。
真实运行用 :class:`RequestsTransport`;自建模拟器经 :class:`LocalSimTransport`
(sim 模块)挂载;回放用 PlaybackTransport。连接失败与读超时统一抛
:class:`TransportError` —— 此类失败后重试"同一动作"必须复用原 request_id
与原请求体 [题面],由 client 层保证。
"""
from __future__ import annotations
from dataclasses import dataclass
import requests
class TransportError(Exception):
"""网络层失败:连接不上、读写超时。动作可能未送达,也可能结果未知。"""
@dataclass(frozen=True)
class TransportResponse:
status: int
body: bytes
class Transport:
"""最小 POST 接口。同步阻塞,一次一个请求(协议要求串行)。"""
def post(self, url: str, body: bytes, headers: dict[str, str],
timeout: tuple[float, float]) -> TransportResponse:
raise NotImplementedError
class RequestsTransport(Transport):
"""基于 requests 的默认实现。timeout=(连接, 读取)。"""
def post(self, url, body, headers, timeout):
try:
response = requests.post(url, data=body, headers=headers, timeout=timeout)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
raise TransportError(str(exc)) from exc
except requests.exceptions.RequestException as exc: # 其它请求级异常同样按网络失败处理
raise TransportError(str(exc)) from exc
return TransportResponse(response.status_code, response.content)
@@ -0,0 +1,254 @@
"""图论/动态规划工具箱:开放路径排序、下界与路由度量(S1/S5)。
设计原则(docs/第三四问图论与动态规划优化方案.md §1、§3.2、§3.4):
- **开放路径**(起点固定为原点、终点自由)是本题的真实图问题:一次作业回合
= 依次停靠若干作业点,代价 = Σ 移动时间 + Σ 动作耗时;
- 小规模(n (*@\ensuremath{\le}@*) ``EXACT_LIMIT``)直接用 Held-Karp 精确 DP,规模更大时用
最近邻打底 + 2-opt / Or-opt 改进,并始终给出 **MST 下界**用于在线后悔值审计;
- 所有函数只做"排序与度量",不承担任何安全责任,也不改变覆盖/清除语义。
约定:边权为**欧氏距离**(米);时间折算由调用方按 ``speed_mps`` 处理。
2-opt / Or-opt 用 O(1) 增量代价评估,使 n (*@\ensuremath{\approx}@*) 10² 量级仍可在毫秒内完成。
"""
from __future__ import annotations
import math
from .config import Vec2
EXACT_LIMIT = 13 # Held-Karp 状态数 2^n·n:n=13 → 106 496,纯 Python 可接受
OR_OPT_LIMIT = 60 # 作业点数超过此值时跳过高阶 Or-opt(见 improve_open_path)
def path_length(order: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:
"""开放路径长度(起点 start,终点自由)。"""
total = 0.0
position = start
for point in order:
total += math.dist(position, point)
position = point
return total
def nearest_neighbor_order(points: list[Vec2],
start: Vec2 = (0.0, 0.0)) -> list[Vec2]:
"""最近邻开放路径(O(n²),启发式上界)。"""
remaining = list(points)
order: list[Vec2] = []
position = start
while remaining:
nxt = min(remaining, key=lambda q: math.dist(position, q))
remaining.remove(nxt)
order.append(nxt)
position = nxt
return order
def two_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 8) -> list[Vec2]:
"""开放路径 2-opt(终点自由:只反转内部片段,不做首尾相接)。
增量代价 O(1):反转 [i..j] 只影响 (prev, i) 与 (j, next) 两条边。
"""
best = list(order)
n = len(best)
if n < 3:
return best
for _ in range(max_passes):
improved = False
for i in range(n - 1):
prev = best[i - 1] if i > 0 else start
pi = best[i]
d_prev_i = math.dist(prev, pi)
for j in range(i + 1, n):
pj = best[j]
nxt = best[j + 1] if j + 1 < n else None
d_prev_j = math.dist(prev, pj)
removed = d_prev_i
added = d_prev_j
if nxt is not None:
removed += math.dist(pj, nxt)
added += math.dist(pi, nxt)
if added < removed - 1e-9:
best[i:j + 1] = best[i:j + 1][::-1]
improved = True
pi = best[i]
d_prev_i = math.dist(prev, pi)
if not improved:
break
return best
def or_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 4, max_segment: int = 3) -> list[Vec2]:
"""Or-opt:把长度 (*@\ensuremath{\le}@*) max_segment 的片段搬家(开放路径,增量代价 O(1))。"""
best = list(order)
n = len(best)
if n < 4:
return best
for _ in range(max_passes):
improved = False
for length in range(1, min(max_segment, n - 1) + 1):
for i in range(0, n - length + 1):
segment = best[i:i + length]
rest = best[:i] + best[i + length:]
for j in range(len(rest) + 1):
if j == i:
continue
candidate = rest[:j] + segment + rest[j:]
if _segment_move_gain(best, i, length, j, start) > 1e-9:
best = candidate
improved = True
break
if improved:
break
if improved:
break
if not improved:
break
return best
def _segment_move_gain(order: list[Vec2], i: int, length: int, j: int,
start: Vec2) -> float:
"""把 order[i:i+length] 插到去掉该段后的第 j 位所省下的长度(正=更省)。"""
rest = order[:i] + order[i + length:]
segment = order[i:i + length]
def length_of(seq: list[Vec2]) -> float:
return path_length(seq, start)
return length_of(order) - length_of(rest[:j] + segment + rest[j:])
def improve_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 8) -> list[Vec2]:
"""最近邻 + 2-opt + Or-opt 的开放路径改进(大规模兜底)。
Or-opt 的搬移评估是 O(n³),作业点数超过 :data:`OR_OPT_LIMIT` 时只做
O(n²) 的 2-opt(度量口径下 Or-opt 的相对收益远小于其墙钟代价)。
"""
order = nearest_neighbor_order(points, start)
order = two_opt_open(order, start, max_passes)
if len(order) > OR_OPT_LIMIT:
return order
return or_opt_open(order, start, max_passes)
def held_karp_open(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> list[Vec2]:
"""Held-Karp 精确开放路径(起点固定、终点自由)。
状态 ``dp[mask][j]`` = 访问集合 mask、当前停在 j 的最小长度;
规模 2^n·n,仅用于 n (*@\ensuremath{\le}@*) :data:`EXACT_LIMIT`。返回最优访问顺序。
"""
n = len(points)
if n == 0:
return []
if n == 1:
return list(points)
dist = [[math.dist(points[i], points[j]) for j in range(n)] for i in range(n)]
from_start = [math.dist(start, points[j]) for j in range(n)]
full = (1 << n) - 1
inf = float("inf")
dp: list[list[float]] = [[inf] * n for _ in range(1 << n)]
parent: list[list[int]] = [[-1] * n for _ in range(1 << n)]
for j in range(n):
dp[1 << j][j] = from_start[j]
for mask in range(1 << n):
row = dp[mask]
for j in range(n):
current = row[j]
if current == inf or not (mask >> j) & 1:
continue
rest = full ^ mask
while rest:
bit = rest & -rest
k = bit.bit_length() - 1
rest ^= bit
candidate = current + dist[j][k]
nxt = mask | bit
if candidate < dp[nxt][k]:
dp[nxt][k] = candidate
parent[nxt][k] = j
best_end = min(range(n), key=lambda j: dp[full][j])
order: list[Vec2] = []
mask, j = full, best_end
while j != -1:
order.append(points[j])
previous = parent[mask][j]
mask ^= 1 << j
j = previous
order.reverse()
return order
def optimal_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),
exact_limit: int = EXACT_LIMIT) -> list[Vec2]:
"""离线最优(或近优)开放路径:小规模精确,大规模 2-opt + Or-opt。"""
unique = _dedupe(points)
if len(unique) <= exact_limit:
return held_karp_open(unique, start)
return improve_open_path(unique, start)
def mst_weight(points: list[Vec2], start: Vec2 | None = (0.0, 0.0)) -> float:
"""最小生成树权重(Prim,O(n²));含 start 时给出开放路径最优值的下界。
开放路径删除其最后一条边即得一条生成树,故 MST 权重 (*@\ensuremath{\le}@*) 最优路径长度。
"""
nodes = ([start] if start is not None else []) + list(points)
n = len(nodes)
if n <= 1:
return 0.0
in_tree = [False] * n
best = [float("inf")] * n
best[0] = 0.0
total = 0.0
for _ in range(n):
u, u_cost = -1, float("inf")
for i in range(n):
if not in_tree[i] and best[i] < u_cost:
u, u_cost = i, best[i]
if u < 0:
break
in_tree[u] = True
total += u_cost
ux, uy = nodes[u]
for v in range(n):
if in_tree[v]:
continue
distance = math.hypot(nodes[v][0] - ux, nodes[v][1] - uy)
if distance < best[v]:
best[v] = distance
return total
def _dedupe(points, cell_m: float = 0.5) -> list[Vec2]:
"""按 ``cell_m`` 网格去重(同一物理点只算一次作业)。"""
seen: set[tuple[int, int]] = set()
unique: list[Vec2] = []
for point in points:
key = (int(round(point[0] / cell_m)), int(round(point[1] / cell_m)))
if key in seen:
continue
seen.add(key)
unique.append((float(point[0]), float(point[1])))
return unique
def route_metrics(service_points, actual_move_m: float,
speed_mps: float = 5.0) -> dict:
"""路由度量:离线最优开放回路、MST 下界与相对 gap(S1 必报列)。"""
unique = _dedupe(service_points)
offline = path_length(optimal_open_path(unique)) if unique else 0.0
lower = mst_weight(unique)
gap = ((actual_move_m - offline) / actual_move_m) if actual_move_m > 0 else 0.0
return {
"distinct_service_points": len(unique),
"offline_opt_open_path_m": round(offline, 1),
"offline_opt_open_path_s": round(offline / speed_mps, 1),
"route_lower_bound_s": round(lower / speed_mps, 1),
"route_gap_vs_offline": round(gap, 4),
}
+55
View File
@@ -0,0 +1,55 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
\newcommand{\ProjQOneExampleCases}{5}
\newcommand{\ProjQOneRandomCases}{40}
\newcommand{\ProjQOneFixedStatusBase}{80.0\%}
\newcommand{\ProjQOneFixedMaeBase}{7.886}
\newcommand{\ProjQOneFixedMaeMaxBase}{10.000}
\newcommand{\ProjQOneFixedCoverBase}{33.3\%}
\newcommand{\ProjQOneFixedMsOpt}{0.064}
\newcommand{\ProjQOneFixedMsBase}{14.202}
\newcommand{\ProjQOneRandStatusBase}{100.0\%}
\newcommand{\ProjQOneRandMaeBase}{4.583}
\newcommand{\ProjQOneRandMaeMaxBase}{14.599}
\newcommand{\ProjQOneRandCoverBase}{52.5\%}
\newcommand{\ProjQOneRandMsOpt}{0.086}
\newcommand{\ProjQOneRandMsBase}{15.933}
\newcommand{\ProjQTwoCases}{30}
\newcommand{\ProjQTwoInitialRadius}{681.467}
\newcommand{\ProjQTwoGuaranteeOpt}{100.0\%}
\newcommand{\ProjQTwoGuaranteeMec}{100.0\%}
\newcommand{\ProjQTwoGuaranteePerp}{0.0\%}
\newcommand{\ProjQTwoWorstOpt}{73.890}
\newcommand{\ProjQTwoWorstRef}{75.627}
\newcommand{\ProjQTwoRatioOpt}{0.104}
\newcommand{\ProjQTwoRatioMec}{0.500}
\newcommand{\ProjQTwoWorstPerp}{80.111}
\newcommand{\ProjQTwoWorstMec}{340.747}
\newcommand{\ProjQThreeScenes}{18}
\newcommand{\ProjQThreeMeanOpt}{3188.734}
\newcommand{\ProjQThreeMeanSeven}{5361.707}
\newcommand{\ProjQThreeMeanBase}{14935.659}
\newcommand{\ProjQThreeDropOpt}{78.4\%}
\newcommand{\ProjQThreeDropMedianOpt}{80.0\%}
\newcommand{\ProjQThreeDropSeven}{63.7\%}
\newcommand{\ProjQThreeFasterOpt}{18/18}
\newcommand{\ProjQThreeMeasuresOpt}{111.111}
\newcommand{\ProjQThreeMeasuresBase}{430.556}
\newcommand{\ProjQThreeMoveOpt}{12.338}
\newcommand{\ProjQThreeMoveBase}{61.550}
\newcommand{\ProjQThreeWallOpt}{0.767}
\newcommand{\ProjQFourScenes}{11}
\newcommand{\ProjQFourMeanOpt}{9069.871}
\newcommand{\ProjQFourMeanGrid}{18179.173}
\newcommand{\ProjQFourMeanBase}{3890.801}
\newcommand{\ProjQFourClearOpt}{100.0\%}
\newcommand{\ProjQFourClearGrid}{66.6\%}
\newcommand{\ProjQFourClearBase}{77.0\%}
\newcommand{\ProjQFourAbsentBase}{24}
\newcommand{\ProjQFourMeasuresOpt}{355.636}
\newcommand{\ProjQFourMeasuresBase}{132.000}
\newcommand{\ProjQFourFallbackOpt}{24.818}
\newcommand{\ProjQFourValidOpt}{11/11}
\newcommand{\ProjQFourValidGrid}{0/11}
\newcommand{\ProjQFourRiseOpt}{142.9\%}
\newcommand{\ProjQFourMoveOpt}{34.268}
\newcommand{\ProjQFourMoveBase}{15.319}
@@ -0,0 +1,6 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
问题 & 核心指标 & 最优实现 & 基线实现 \\
1 & 直径平均绝对误差 / 覆盖判定准确率 & 0.000 m / 100.0\% & 4.583 m / 52.5\% \\
2 & 保证接收率 / 最坏后验半径均值 & 100.0\% / 73.890 m & 100.0\% / 340.747 m \\
3 & 全清率 / 平均虚拟耗时 & 100.0\% / 3188.734 s & 100.0\% / 14935.659 s \\
4 & 全清率 / 缺席误判总数 & 100.0\% / 0 & 77.0\% / 24 \\
@@ -0,0 +1,6 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
三点交会 & 26.986 & 26.986 & 21.213 & 5.773 & 否 & 否 & 是 \\
直径圆反例 & 40.000 & 40.000 & 50.000 & 10.000 & 否 & 否 & 是 \\
近共线检测点 & -- & -- & -- & -- & -- & -- & -- \\
零误差退化线段 & 100.000 & 100.000 & 0.000 & 100.000 & 是 & 是 & 是 \\
不相容观测 & -- & -- & -- & -- & -- & -- & -- \\
@@ -0,0 +1,5 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
最优(固定 5 例) & 100.0\% & 0.000 & 0.000 & 100.0\% & 0.064 \\
最优(随机 40 例) & 100.0\% & 0.000 & 0.000 & 100.0\% & 0.086 \\
基线(固定 5 例) & 80.0\% & 7.886 & 10.000 & 33.3\% & 14.202 \\
基线(随机 40 例) & 100.0\% & 4.583 & 14.599 & 52.5\% & 15.933 \\
+5
View File
@@ -0,0 +1,5 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
算法2(默认选点) & 100.0\% & 73.890 & 90.899 & 607.577 & 0.104 & 170.44 \\
参考:盘内网格枚举 & 100.0\% & 75.627 & 91.349 & 605.841 & 0.106 & 170.28 \\
基线:垂直 1000 m & 0.0\% & 80.111 & 90.775 & 601.356 & 0.116 & 200.00 \\
基线:候选圆心 & 100.0\% & 340.747 & 375.088 & 340.721 & 0.500 & 136.29 \\
+4
View File
@@ -0,0 +1,4 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
bayes(默认策略) & 18/18 & 100.0\% & 0 & 3188.734 & 3111.256 & 3663.707 & 111.1 & 12.338 & 0.0 \\
seven(七点批量) & 18/18 & 100.0\% & 0 & 5361.707 & 5367.455 & 6462.351 & 100.8 & 23.508 & 0.0 \\
grid(49 点网格) & 18/18 & 100.0\% & 0 & 14935.659 & 15275.057 & 16691.406 & 430.6 & 61.550 & 0.0 \\
+4
View File
@@ -0,0 +1,4 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
directional(默认策略) & 11/11 & 100.0\% & 0 & 9069.871 & 8803.261 & 12081.148 & 355.6 & 34.268 & 24.8 \\
grid(问题三网格) & 0/11 & 66.6\% & 0 & 18179.173 & 18345.164 & 20457.858 & 573.7 & 73.876 & 0.0 \\
bayes(问题三策略) & 0/11 & 77.0\% & 24 & 3890.801 & 3646.178 & 5054.382 & 132.0 & 15.319 & 0.0 \\
+70
View File
@@ -0,0 +1,70 @@
\begingroup
\linespread{1.0}\selectfont
\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none}
\subsection*{\pathtext{src/mcm26b/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/belief/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/belief/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/belief/joint.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/belief/joint.py.tex}
\subsection*{\pathtext{src/mcm26b/cli.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/cli.py.tex}
\subsection*{\pathtext{src/mcm26b/config.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/config.py.tex}
\subsection*{\pathtext{src/mcm26b/coverage/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/coverage/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/evaluation/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/evaluation/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/geometry/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/geometry/__main__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/__main__.py.tex}
\subsection*{\pathtext{src/mcm26b/geometry/localization.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/localization.py.tex}
\subsection*{\pathtext{src/mcm26b/geometry/planning.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/planning.py.tex}
\subsection*{\pathtext{src/mcm26b/guarantee/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/guarantee/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/guarantee/cover.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/guarantee/cover.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/playback.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/playback.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/scenes.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/scenes.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/simulator.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/simulator.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/suites.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/suites.py.tex}
\subsection*{\pathtext{src/mcm26b/harness/transport.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/transport.py.tex}
\subsection*{\pathtext{src/mcm26b/particles.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/particles.py.tex}
\subsection*{\pathtext{src/mcm26b/planner/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/planner/bayes_loop.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/bayes_loop.py.tex}
\subsection*{\pathtext{src/mcm26b/planner/seven.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/seven.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/__init__.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/__init__.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/client.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/client.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/log.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/log.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/rules.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/rules.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/state.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/state.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/timing.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/timing.py.tex}
\subsection*{\pathtext{src/mcm26b/protocol/transport.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/transport.py.tex}
\subsection*{\pathtext{src/mcm26b/routing.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/routing.py.tex}
\subsection*{\pathtext{scripts/audit_paper_formal.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/scripts/audit_paper_formal.py.tex}
\subsection*{\pathtext{scripts/build_paper_support.py}}
\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/scripts/build_paper_support.py.tex}
\endgroup
+57
View File
@@ -0,0 +1,57 @@
\begin{figure}[htbp]\centering
\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]
\node[anchor=east] at (-100,5.3) {Q3-1};
\fill[inkblue] (0,5) rectangle (2408.978068,5.6);
\fill[inkblue!50] (2408.978068,5) rectangle (2933.978068,5.6);
\fill[warmgray!70] (2933.978068,5) rectangle (3029.978068,5.6);
\fill[black!18] (3029.978068,5) rectangle (3094.978068,5.6);
\node[anchor=west,font=\scriptsize] at (3174.978068,5.3) {3095.0};
\node[anchor=east] at (-100,4.3) {Q3-2};
\fill[inkblue] (0,4) rectangle (2725.33514,4.6);
\fill[inkblue!50] (2725.33514,4) rectangle (3310.33514,4.6);
\fill[warmgray!70] (3310.33514,4) rectangle (3418.33514,4.6);
\fill[black!18] (3418.33514,4) rectangle (3493.33514,4.6);
\node[anchor=west,font=\scriptsize] at (3573.33514,4.3) {3493.3};
\node[anchor=east] at (-100,3.3) {Q3-3};
\fill[inkblue] (0,3) rectangle (2164.862876,3.6);
\fill[inkblue!50] (2164.862876,3) rectangle (2749.862876,3.6);
\fill[warmgray!70] (2749.862876,3) rectangle (2856.862876,3.6);
\fill[black!18] (2856.862876,3) rectangle (2906.862876,3.6);
\node[anchor=west,font=\scriptsize] at (2986.862876,3.3) {2906.9};
\node[anchor=east] at (-100,2.3) {Q4-1};
\fill[inkblue] (0,2) rectangle (7277.225736,2.6);
\fill[inkblue!50] (7277.225736,2) rectangle (9022.225736,2.6);
\fill[warmgray!70] (9022.225736,2) rectangle (9297.225736,2.6);
\fill[black!18] (9297.225736,2) rectangle (9362.225736,2.6);
\node[anchor=west,font=\scriptsize] at (9442.225736,2.3) {9362.2};
\node[anchor=east] at (-100,1.3) {Q4-2};
\fill[inkblue] (0,1) rectangle (7101.30208,1.6);
\fill[inkblue!50] (7101.30208,1) rectangle (8906.30208,1.6);
\fill[warmgray!70] (8906.30208,1) rectangle (9221.30208,1.6);
\fill[black!18] (9221.30208,1) rectangle (9271.30208,1.6);
\node[anchor=west,font=\scriptsize] at (9351.30208,1.3) {9271.3};
\node[anchor=east] at (-100,0.3) {Q4-3};
\fill[inkblue] (0,0) rectangle (7005.382102,0.6);
\fill[inkblue!50] (7005.382102,0) rectangle (8325.382102,0.6);
\fill[warmgray!70] (8325.382102,0) rectangle (8538.382102,0.6);
\fill[black!18] (8538.382102,0) rectangle (8608.382102,0.6);
\node[anchor=west,font=\scriptsize] at (8688.382102,0.3) {8608.4};
\draw[->] (0,-0.25)--(10500,-0.25);
\draw (0,-0.25)--(0,-0.35) node[below,font=\scriptsize] {0};
\draw (2000,-0.25)--(2000,-0.35) node[below,font=\scriptsize] {2000};
\draw (4000,-0.25)--(4000,-0.35) node[below,font=\scriptsize] {4000};
\draw (6000,-0.25)--(6000,-0.35) node[below,font=\scriptsize] {6000};
\draw (8000,-0.25)--(8000,-0.35) node[below,font=\scriptsize] {8000};
\draw (10000,-0.25)--(10000,-0.35) node[below,font=\scriptsize] {10000};
\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};
\fill[inkblue] (0,-1.3) rectangle (350,-1.05);
\node[anchor=west] at (450,-1.175) {移动};
\fill[inkblue!50] (2600,-1.3) rectangle (2950,-1.05);
\node[anchor=west] at (3050,-1.175) {检测};
\fill[warmgray!70] (5200,-1.3) rectangle (5550,-1.05);
\node[anchor=west] at (5650,-1.175) {换频};
\fill[black!18] (7600,-1.3) rectangle (7950,-1.05);
\node[anchor=west] at (8050,-1.175) {清除};
\end{tikzpicture}
\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}
\label{fig:costbars}\end{figure}
+50
View File
@@ -0,0 +1,50 @@
\begin{figure}[htbp]\centering
\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]
\fill[inkblue!5] (0,0) circle (1800);
\draw[thick] (0,0) circle (1800);
\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};
\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};
\draw (-2000,-35)--(-2000,35);
\node[below,font=\scriptsize] at (-2000,-50) {-2000};
\draw (-1000,-35)--(-1000,35);
\node[below,font=\scriptsize] at (-1000,-50) {-1000};
\draw (1000,-35)--(1000,35);
\node[below,font=\scriptsize] at (1000,-50) {1000};
\draw (2000,-35)--(2000,35);
\node[below,font=\scriptsize] at (2000,-50) {2000};
\fill[inkblue] (-800.0,-200.0) circle (33);
\fill[inkblue] (800.0,200.0) circle (33);
\fill[inkblue] (-200.0,900.0) circle (33);
\fill[inkblue] (200.0,-900.0) circle (33);
\fill[inkblue] (-1200.0,700.0) circle (33);
\fill[inkblue] (1200.0,-700.0) circle (33);
\fill[inkblue] (800.0,1200.0) circle (33);
\fill[inkblue] (-1800.0,-300.0) circle (33);
\fill[inkblue] (1800.0,300.0) circle (33);
\fill[inkblue] (-400.0,1800.0) circle (33);
\fill[inkblue] (0.0,0.0) circle (33);
\fill[inkblue] (1400.0,-1400.0) circle (33);
\fill[inkblue] (-1500.0,-1300.0) circle (33);
\fill[inkblue] (1500.0,1300.0) circle (33);
\fill[inkblue] (-400.0,-1800.0) circle (33);
\fill[inkblue] (400.0,1800.0) circle (33);
\fill[inkblue] (-1800.0,400.0) circle (33);
\fill[inkblue] (1800.0,-400.0) circle (33);
\fill[inkblue] (-500.0,700.0) circle (33);
\fill[inkblue] (-700.0,-700.0) circle (33);
\fill[inkblue] (700.0,700.0) circle (33);
\fill[inkblue] (1300.0,1100.0) circle (33);
\fill[inkblue] (-1300.0,1500.0) circle (33);
\fill[inkblue] (500.0,-1800.0) circle (33);
\fill[inkblue] (-900.0,-2100.0) circle (33);
\fill[inkblue] (600.0,1900.0) circle (33);
\fill[inkblue] (-2500.0,500.0) circle (33);
\fill[inkblue] (1900.0,-900.0) circle (33);
\fill[inkblue] (-1700.0,-500.0) circle (33);
\fill[inkblue] (-700.0,-1200.0) circle (33);
\fill[inkblue] (900.0,-700.0) circle (33);
\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};
\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};
\end{tikzpicture}
\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}
\label{fig:q4plan}\end{figure}
Binary file not shown.
+232 -884
View File
File diff suppressed because it is too large Load Diff
View File
+74
View File
@@ -0,0 +1,74 @@
\section{正式数据清单与复算说明}\label{app:data}
六份正式导出文件及其 SHA-256 校验值列于表~\ref{tab:packages}。
论文采用原项目模板,按2026年格式规范设置 A4 纸张、四边距 $2.5$ cm,
电子版从摘要页开始连续编号\cite{format}。
所给往届论文用于章节组织与版式参考。
\input{data/formal-file-list}
复算脚本 \pathtext{scripts/audit_paper_formal.py} 将正式导出、结果侧文件与
官方统计记录匹配,生成结构化数据、CSV 和 LaTeX 表格。
在项目根目录执行:
\begin{lstlisting}[language={},numbers=none]
python scripts/audit_paper_formal.py
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
\end{lstlisting}
第一条命令完成来源匹配与统计计算;
第二条根据已有快照重新计算指标,并核对现有正式包的校验值。
耗时图与汇总量由同一统计快照生成。
第~\ref{sec:comparison} 节的配对对比表由 \pathtext{scripts/build_paper_support.py}
从 \pathtext{project/results/*.json} 生成,复跑对比框架本身执行
\texttt{uv run python -m project.run}。
正文插图由 \pathtext{scripts/build_figure_data.py} 准备数据、
\pathtext{scripts/matlab/run_all_figures.m} 出图,逐图来源与生成命令见
\pathtext{docs/figures/manifest.json}。
其中回放画布与实测计时两张插图取自评测机演练留档,
只用于说明机制与时间构成,不参与正式统计。
\section{支撑材料与运行方法}
\begin{table}[H]
\centering\small
\caption{支撑材料目录及用途}
\begin{tabularx}{\linewidth}{p{5.8cm}X}
\toprule
文件或目录 & 用途\\
\midrule
<<<<<<< HEAD
\pathtext{src/mcm26b/} & 几何计算、集合估计、覆盖优化、任务排序与通信实现\\
\pathtext{src/mcm26b/guarantee/plans/} & 第四问站点方案与离散验证参数\\
\pathtext{scripts/audit_paper_formal.py} & 正式文件核对、统计计算与表格生成\\
\pathtext{scripts/build_paper_support.py} & 矢量图、源码附录与支撑包生成\\
\pathtext{docs/paper/data/} & 六个正式案例的统计快照、表格及校验值\\
\pathtext{docs/paper/sections/} & 分章论文源文件\\
\pathtext{docs/paper/figures/} & 几何示意图与正式耗时图的矢量源文件\\
\pathtext{Jammers-simulator-full/test/} & 六份原名官方正式日志\\
=======
\pathtext{src/mcm26b/} & 四问几何、信念、覆盖、调度与串行通信实现\\
\pathtext{src/mcm26b/guarantee/plans/} & 第四问已实现的覆盖计划及验证参数\\
\pathtext{project/} & 最优与基线配对对比框架及其冻结基准集结果\\
\pathtext{scripts/audit_paper_formal.py} & 正式文件对应核对、匿名快照及表格复算\\
\pathtext{scripts/build_paper_support.py} & 对比表、矢量图、源码附录与支撑包生成\\
\pathtext{scripts/build_figure_data.py}、\pathtext{scripts/matlab/} & 插图数据准备与 MATLAB 出图脚本\\
\pathtext{docs/paper/data/} & 仅四个所选正式案例的可读统计、表格及哈希\\
\pathtext{docs/paper/sections/} & 分章论文源文件\\
\pathtext{docs/paper/figures/} & 正式累计耗时图的矢量源文件\\
\pathtext{docs/figures/} & 正文插图(PNG)及其数据清单与生成命令\\
\pathtext{Jammers-simulator-full/test/} & 四份原名官方加密正式日志\\
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
\pathtext{support-manifest.json} & 支撑包文件清单及逐文件 SHA-256\\
\bottomrule
\end{tabularx}
\end{table}
Python 运行时需要 \texttt{requests},\texttt{numpy} 提供可选加速。
将 \texttt{src} 加入 Python 模块路径后,可调用
\texttt{python -m mcm26b.geometry} 进行问题一几何分析,
或通过 \texttt{mcm26b.cli} 运行在线策略。
在线测试由模拟器界面启动,接口就绪后执行策略入口。
具体运行指令和编译步骤见支撑包的 \texttt{README.md}。
\section{完整程序源码}
以下列出当前策略、配套模块及论文数据处理程序的完整源码。
附录通过生成的显示副本将数学符号转换为 LaTeX 命令,
支撑包保留对应的可执行 Python 文件。
实验结果见正文正式测试表及附录A的数据清单。
\input{data/source-listings}
+173
View File
@@ -0,0 +1,173 @@
\section{算法对比实验:最优实现与基线}\label{sec:comparison}
\subsection{对比口径与有效性判定}
本节数据来自仓库内的配对对比框架 \pathtext{project/},
用于回答上述几何判据与闭环策略相对朴素做法改进在哪里;
它与第~\ref{sec:formal} 节的四份官方正式导出完全分开,两类数字不合并、不相加。
框架设三种角色:\textbf{最优实现}直接复用 \pathtext{src/mcm26b} 中已由测试固化的生产代码,
只增加输入输出适配;\textbf{基线实现}是同题下更朴素但能独立跑通的对照,
不共享最优实现的关键步骤;\textbf{独立参考}与分析算法正交,用于判定谁对谁错。
问题三、四在内存回环评测机上运行,与官方接口使用同一请求与响应边界:
源真值只在评测机一侧,策略端只读响应,因此全清率与缺席误判可以核查。
一局有效必须同时满足全清、无缺席误判、正常 \texttt{/exit}、零异常与逐微秒计时复算一致,
否则记为无效,失败样本不静默丢弃;所有对比按场景配对,并给出均值、中位与最大值。
源位置均匀、误差场逐点固定等分布属于自建测试假设,题面并未规定,
本节结论只描述各方法的相对行为。
\begin{table}[htbp]
\centering\small
\caption{四问最优实现与基线的核心指标总览(自建基准集)}
\begin{tabularx}{\linewidth}{c X l l}
\toprule
问 & 核心指标 & 最优实现 & 基线实现\\
\midrule
\csname @@input\endcsname data/project-overview-rows.tex
\bottomrule
\end{tabularx}
\label{tab:projoverview}
\end{table}
表~\ref{tab:projoverview} 中问题二、四的基线分别取“候选圆心”与
“问题三策略直接搬用”,它们是各自问中最需要区分的对照,
其余各表的配对数字同样如此选择。
本节全部数字由 \pathtext{scripts/build_paper_support.py} 从
\pathtext{project/results/*.json} 生成,正文与表格均不手抄。
\subsection{问题一:直径与覆盖判定}
\begin{table}[htbp]
\centering\small
\caption{问题一固定示例的直径与覆盖判定(单位:m,$\Delta D$ 为相对参考解的误差)}
\setlength{\tabcolsep}{4.5pt}
\begin{tabular}{lrrrrccc}
\toprule
算例 & $D$参考 & $D$最优 & $D$基线 & $|\Delta D|$基线 & 覆盖参考 & 覆盖最优 & 覆盖基线\\
\midrule
\csname @@input\endcsname data/project-q1-example-rows.tex
\bottomrule
\end{tabular}
\label{tab:projq1case}
\end{table}
\begin{table}[htbp]
\centering\small
\caption{问题一的汇总指标(直径误差单位:m,耗时单位:ms)}
\setlength{\tabcolsep}{5pt}
\begin{tabular}{lrrrrr}
\toprule
数据子集/方法 & 状态判定 & 直径平均误差 & 直径最大误差 & 覆盖判定 & 平均耗时\\
\midrule
\csname @@input\endcsname data/project-q1-summary-rows.tex
\bottomrule
\end{tabular}
\label{tab:projq1sum}
\end{table}
表~\ref{tab:projq1case} 显示基线误差的来源是代理方式而非数值精度:
它以轴对齐包围盒的对角线代替区域直径,把边长 $40$ m 的等边三角形判成 $50$ m,
把零误差退化线段判成 $0$ m;覆盖判定以“包围盒中心加代理直径圆”代替逐顶点检查,
于是对任意包围盒几乎恒判覆盖。
在 $\ProjQOneExampleCases$ 个固定示例上,基线的状态判定准确率为 $\ProjQOneFixedStatusBase$,
平均直径误差 $\ProjQOneFixedMaeBase$ m,最大误差 $\ProjQOneFixedMaeMaxBase$ m,
覆盖判定准确率仅 $\ProjQOneFixedCoverBase$;
在 $\ProjQOneRandomCases$ 个随机交会算例上状态判定回到 $\ProjQOneRandStatusBase$,
但平均直径误差仍为 $\ProjQOneRandMaeBase$ m,最大误差 $\ProjQOneRandMaeMaxBase$ m,
覆盖判定准确率 $\ProjQOneRandCoverBase$。
最优实现与独立参考解在全部算例上的状态、直径与覆盖判定一致;
平均耗时为 $\ProjQOneRandMsOpt$ 与 $\ProjQOneRandMsBase$ ms(随机算例),
说明精确几何并未以计算量为代价。
\subsection{问题二:第二检测点选择}
\begin{table}[htbp]
\centering\small
\caption{问题二各方法在 $\ProjQTwoCases$ 个随机首次示向度算例上的汇总
(半径与收缩单位:m,移动时间单位:s)}
\setlength{\tabcolsep}{5pt}
\begin{tabular}{lrrrrrr}
\toprule
方法 & 保证接收率 & 最坏后验均值 & 中位 & 平均收缩 & 终值比 & 移动均值\\
\midrule
\csname @@input\endcsname data/project-q2-rows.tex
\bottomrule
\end{tabular}
\label{tab:projq2}
\end{table}
表~\ref{tab:projq2} 的算例由随机首次示向度生成,
首次候选集的平均包围半径为 $\ProjQTwoInitialRadius$ m。
垂直 $1000$ m 的基线交会角最大,却忽略接收保证,保证接收率为 $\ProjQTwoGuaranteePerp$:
真源位于角域远端时第二点必然无信号,其最坏后验均值 $\ProjQTwoWorstPerp$ m
是在丢失样本上算出的,不能与其它方法直接比较。
候选圆心基线保证接收($\ProjQTwoGuaranteeMec$),但不增加横向基线,
最坏后验半径均值仍为 $\ProjQTwoWorstMec$ m,终值比 $\ProjQTwoRatioMec$,只收缩一半。
默认选点器在保证接收下取得最坏后验均值 $\ProjQTwoWorstOpt$ m、终值比 $\ProjQTwoRatioOpt$,
与保证接收盘内 $9\times9$ 网格枚举的参考解($\ProjQTwoWorstRef$ m)相差约 $2.3\%$,
说明 $37$ 点环形候选已接近该目标函数下的网格最优。
这里的最坏后验是对候选外包多边形的采样评估,不是连续极小极大的全局上界;
“保证接收”一项由安全盘独立成立。
\subsection{问题三:全向闭环}
\begin{table}[htbp]
\centering\footnotesize
\caption{问题三三种方法在 $\ProjQThreeScenes$ 个冻结场景上的汇总
(时间单位:s,位移单位:km)}
\setlength{\tabcolsep}{3.5pt}
\begin{tabular*}{\linewidth}{@{\extracolsep{\fill}}lrrrrrrrrr}
\toprule
方法 & 有效局 & 全清率 & 缺席误判 & $T$均值/s & $T$中位/s & $T$最大/s & 检测 & 位移/km & 后备清除\\
\midrule
\csname @@input\endcsname data/project-q3-rows.tex
\bottomrule
\end{tabular*}
\label{tab:projq3}
\end{table}
三种方法在 $\ProjQThreeScenes$ 个冻结场景(源数 $10$、$13$、$16$,种子与源数版本化)
上全部完成全清,缺席误判均为零,差别只在时间:
默认 \texttt{bayes} 平均 $\ProjQThreeMeanOpt$ s,
七点批量 \texttt{seven} $\ProjQThreeMeanSeven$ s,$49$ 点网格 \texttt{grid} $\ProjQThreeMeanBase$ s。
按场景配对,\texttt{bayes} 相对 \texttt{grid} 的平均虚拟时间下降 $\ProjQThreeDropOpt$、
中位下降 $\ProjQThreeDropMedianOpt$,在 $\ProjQThreeFasterOpt$ 场景上更快,
\texttt{seven} 相对 \texttt{grid} 下降 $\ProjQThreeDropSeven$。
分项上,默认策略检测 $\ProjQThreeMeasuresOpt$ 次、位移 $\ProjQThreeMoveOpt$ km,
网格基线需要 $\ProjQThreeMeasuresBase$ 次检测、$\ProjQThreeMoveBase$ km 位移;
单局在线计算均在 $1.6$ s 以内(默认策略 $\ProjQThreeWallOpt$ s),不构成瓶颈。
七点方案用解析覆盖保证发现,站点最少,但按站点批量扫频需要绕行,位移约为默认策略的两倍。
可见三者的差别不是“能否完成”,而是覆盖证明与信息增益选点各自省下了多少移动与检测。
\subsection{问题四:混合源闭环}
\begin{table}[htbp]
\centering\footnotesize
\caption{问题四三种方法在 $\ProjQFourScenes$ 个场景上的汇总
(时间单位:s,位移单位:km)}
\setlength{\tabcolsep}{3.5pt}
\begin{tabular*}{\linewidth}{@{\extracolsep{\fill}}lrrrrrrrrr}
\toprule
方法 & 有效局 & 全清率 & 缺席误判 & $T$均值/s & $T$中位/s & $T$最大/s & 检测 & 位移/km & 后备清除\\
\midrule
\csname @@input\endcsname data/project-q4-rows.tex
\bottomrule
\end{tabular*}
\label{tab:projq4}
\end{table}
$\ProjQFourScenes$ 个场景取自 core 与 boundary 两组,
boundary 全部是背离圆心发射的边界定向源,属最不利工况。
\texttt{directional} 在 $\ProjQFourValidOpt$ 局上全部有效,全清率 $\ProjQFourClearOpt$,
平均后备清除 $\ProjQFourFallbackOpt$ 次,对应遮挡导致的定位停滞。
把问题三的 \texttt{bayes} 直接搬用时全清率降到 $\ProjQFourClearBase$,
并出现 $\ProjQFourAbsentBase$ 次缺席误判:七站全向缺席证明把朝外发射的定向源判为不存在,
因此它用时最短($\ProjQFourMeanBase$ s),但结论是错的。
\texttt{grid} 的 $49$ 点覆盖对定向源发现完备,全清率 $\ProjQFourClearGrid$、
平均虚拟时间 $\ProjQFourMeanGrid$ s,却没有朝向推理与后备清除,
定位停滞时只能停在“已发现”状态,$\ProjQFourValidGrid$ 局通过有效性判定。
\texttt{directional} 平均 $\ProjQFourMeanOpt$ s,比 \texttt{bayes} 高 $\ProjQFourRiseOpt$,
位移 $\ProjQFourMoveOpt$ km 对 $\ProjQFourMoveBase$ km;由于基线 $11$ 局全部无效,
这一比值不构成“默认策略更慢”的证据,在定向源上应先保证判对、再比较快慢。
\subsection{对比实验的结论与边界}
四组配对比较支持的结论是:问题一的误差来自代理近似,改用精确顶点枚举与最小包围圆后消失;
问题二要求同时满足接收保证与横向基线,只取其一都会被表~\ref{tab:projq2} 的某一列暴露;
问题三的三种方法都能全清,默认策略的收益来自信息增益选点与滚动排序而非覆盖证明本身;
问题四的差别在正确性,搬用全向缺席证明会“快而错”,
只扩大覆盖而不补朝向推理与后备清除则“稳而不全”。
边界有四条。随机分布是自建测试假设,场景数、源数与种子见
\pathtext{project/results/*.json},不能据此推断官方测试的分布。
基线不是稻草人:候选圆心、七点批量与网格覆盖都能独立跑通并在部分场景有效,
它们暴露的是各自缺少的那一步。
问题二的最坏后验与问题四的可见性评分都是启发式评估量,不是全局最优值或校准概率。
本节指标与第~\ref{sec:formal} 节的官方统计口径不同,两组数字不可互相换算。
+85
View File
@@ -0,0 +1,85 @@
\section{模型评价与推广}
\subsection{模型的有效性}
本文采用集合估计处理有界测向误差,将每次观测转化为真实位置的可行约束。
问题一通过凸包顶点计算直径,以最小包围圆半径给出清除充分条件;
问题二将接收距离要求表示为闭圆盘的交集,并由三角不等式构造显式可行子集。
几何定位、接收约束与清除距离由统一的集合运算联系起来。
多源搜索采用覆盖点集与检测状态矩阵维护各频道的搜索进度。
网格表示可处理负观测产生的非凸可行区域,
信息增益用于选择有利于收缩候选集的测点。
开放路径排序将移动、检测、换频和清除纳入统一目标,
并在新观测到来后重新求解任务次序。
对于定向源,位置与朝向的联合约束提供可见性信息,
凸组合性质给出方格点集的连续覆盖证明,
网格遍历清除则利用清除半径与方向无关的物理条件。
六次正式测试提供了算法在全向及混合源案例中的清除数量和时间成本。
\subsection{未来工作}\label{sec:future}
\textbf{提高离散选点的近似精度。}
算法2以边界采样和读数端点枚举近似最坏后验半径,
信息增益评分采用离散先验,任务排序采用最近邻初始化与 2-opt 局部搜索。
这些方法尚未给出整个动态任务的全局最优性保证。
后续可用区间分支定界控制后验半径的近似误差,
结合动态规划或滚动时域优化,研究测点选择与路径规划的联合求解。
\textbf{建立31点方案的连续覆盖误差界。}
现有方案在 $15$ m 网格和 $72$ 个朝向上完成离散验证。
若采用位置误差界 $15/\sqrt2$,则
$990.051+15/\sqrt2\approx1000.658$ m,超过最小接收半径;
最终验证的前向角余量为 $0^\circ$,也不足以控制全部朝向扰动。
因此当前离散结果尚不能推出连续覆盖。
后续可按式~\eqref{eq:continuous} 同时约束位置与朝向误差,
或采用已证明的 $49$ 点方案,在相同场景中比较站点数量与耗时。
\textbf{扩展混合类型的集合估计。}
当前实现主要维护定向假设,并以 \texttt{joint\_empty} 处理部分候选集为空的情形,
尚未单独维护全向分支。全向源可从多个方向被接收,
定向候选集为空不足以判定该频道无源。
后续应将类型加入状态,维护
\begin{equation}
\mathcal H_c=\mathcal H_c^{\mathrm{omni}}\cup\mathcal H_c^{\mathrm{dir}},
\label{eq:typeunion}
\end{equation}
并分别更新两类可行集合。例如源周围三个相隔 $120^\circ$ 的测点可以同时接收全向信号,
却不能同时属于一个固定前向半平面。
类型并集模型可保留这一解释,使源存在性判断与观测条件一致。
\textbf{研究资源约束下的终止性。}
连续覆盖与单次清除充分条件尚不足以保证所有案例均在有限预算内完成。
持续遮挡、几何退化及较大的候选区域会增加定位步数,
当前 $400$ 格上限也会限制遍历清除。
后续可设计自适应网格细化与计算预算分配,并研究最坏情况下的动作次数上界。
\textbf{扩展统计评价。}
本文的六次正式测试完成了两问各三次案例的结果汇总,
但样本量仍不足以代表所有位置与朝向分布。
官方统计中的源总数及类型组成尚未公开,因此全清率与分类型指标有待补充。
未来可在可重复场景中开展配对试验,
同时保存策略版本、源数量和轨迹等信息,以估计置信区间并比较各模块的贡献。
\textbf{第六,配对对比依赖自建基准集。}
第~\ref{sec:comparison} 节的最优与基线对比运行在内存回环评测机上,
源位置均匀、误差场逐点固定等分布是测试假设,题面并未规定;
场景数有限,基线在部分场景同样有效。
该节结论说明的是各方法缺少哪一步,而不是普适的性能排序,
也不能与官方正式统计互相换算。
\subsection{参数影响的解析讨论}
主要参数的影响可由模型公式分析。
由 $a(\delta)=1500/(2\cos\delta)$ 得
$a'(\delta)=750\sec\delta\tan\delta$,其中 $\delta$ 以弧度计。
误差半角增大会扩大源位置的包围圆,并缩小保证接收圆盘。
表~\ref{tab:safeanalytic} 给出了两位小数舍入余量对首次可行区域的影响。
由式~\eqref{eq:coveragebound},环半径影响覆盖路径长度和最大覆盖距离。
默认半径保留约 $15.461$ m 的距离裕量。
减小网格边长可降低 $\epsilon_g$ 并收紧位置误差上界,
而二维网格数量大致与 $\ell^{-2}$ 成正比,因此应在精度、内存和计算时间间取得平衡。
\subsection{模型推广}
该模型适用于有界方向误差下的静态信号源排查、室内设备巡检和移动传感器主动定位。
推广时,根据新的设备参数校准接收半径、测向误差和动作时间;
对于移动目标或额外遮挡,可扩展状态转移与观测约束,
继续采用集合估计、覆盖优化和路径排序进行联合决策。
+194
View File
@@ -0,0 +1,194 @@
\section{问题一:有界误差交会定位与覆盖圆}
\subsection{前向角域的半平面模型}
给定检测点 $s_i=(s_{ix},s_{iy})$ 与读数 $\theta_i$,
误差约束意味着真实方位位于
$[\theta_i-\delta,\theta_i+\delta]$ 内。因此可行位置属于前向角域
\begin{equation}
W_i=\left\{g:
\begin{aligned}\relax
[u(\theta_i-\delta),g-s_i]&\ge0,\\
[u(\theta_i+\delta),g-s_i]&\le0,\\
u(\theta_i)\cdot(g-s_i)&\ge0
\end{aligned}\right\}.
\label{eq:wedge}
\end{equation}
三条不等式均可写为 $a x+b y\le c$,其法向依次为
\begin{equation}
\begin{aligned}
&(\sin(\theta_i-\delta),-\cos(\theta_i-\delta)),\\
&(-\sin(\theta_i+\delta),\cos(\theta_i+\delta)),\\
&(-\cos\theta_i,-\sin\theta_i),
\end{aligned}
\end{equation}
右端为相应法向与 $s_i$ 的内积。在 $0<\delta<90^\circ$ 时,
前两条已限定前向窄角域;第三条显式保留方向性,
并使零误差退化情形对应射线而非整条直线。
采用正弦、余弦表示可自动处理 $0^\circ/360^\circ$ 跨界。
问题一的多边形定位区域定义为
\begin{equation}
P=\bigcap_{i=1}^{n}W_i=\{g:Ag\le b\}.
\label{eq:q1set}
\end{equation}
式~\eqref{eq:q1set} 根据各次测向角域确定问题一的定位区域。
后续问题将在此基础上加入 $\Omega$ 与接收距离约束。
半平面交是闭凸集,可能为空、单点、线段、有界多边形或无界集。
\subsection{区域构造、空集及无界情形}
令约束数 $k=3n$。对任意两条不平行边界
$a x+b y=c$、$d x+e y=f$,候选交点为
\begin{equation}
x=\frac{ce-bf}{ae-bd},\qquad y=\frac{af-cd}{ae-bd}.
\end{equation}
仅保留满足全部不等式的交点,再经去重和单调链凸包构造得到顶点集合。
实现先平移与缩放坐标,以降低千米尺度坐标造成的数值误差;
近平行边界仍参与求交,因为其远端交点可能决定细长区域的直径。
对非空集合定义衰退锥
\begin{equation}
\mathcal R=\{d:Ad\le0\}.
\end{equation}
若 $\mathcal R$ 含非零向量,则 $g+td\in P$ 对所有 $t\ge0$ 成立,
区域无界,直径为 $+\infty$。二维多面锥只需检查约束边界的两个切向
$(b,-a)$ 与 $(-b,a)$ 是否满足全部齐次不等式。
本题至少一个前向窄角域会排除直线性,故非空交集存在极点;
在此特定输入族中,没有可行边界交点可以判为空集。
无观测输入单独返回整个平面,不按空集处理。
有界非空区域中,一个顶点对应单点,两个顶点对应线段,其余为凸多边形。
空集没有本文定义的有效直径与覆盖圆,程序以显式状态报告观测不相容,
便于检查频道、误差界或输入数据。
\subsection{直径与直径圆的判定}
\begin{proposition}[顶点直径]
设 $P=\operatorname{conv}\{v_1,\ldots,v_m\}$ 非空有界,则
\begin{equation}
D(P)=\max_{i,j}\norm{v_i-v_j}.
\label{eq:diameter}
\end{equation}
\end{proposition}
\begin{proof}
任意 $x,y\in P$ 可表示为凸组合
$x=\sum_i\lambda_i v_i$、$y=\sum_j\mu_jv_j$。
由于系数非负且各自和为 $1$,
\[
\norm{x-y}
=\left\|\sum_{i,j}\lambda_i\mu_j(v_i-v_j)\right\|
\le\sum_{i,j}\lambda_i\mu_j\norm{v_i-v_j}
\le\max_{i,j}\norm{v_i-v_j}.
\]
最远顶点对本身属于 $P$,故上界可达。
\end{proof}
因此无需在整个区域内部搜索,只需枚举顶点对,计算量为 $O(m^2)$。
\begin{proposition}[直径圆覆盖的充要条件]
取最远顶点对 $a,b$,令 $D=\norm{a-b}$、$o=(a+b)/2$。
存在直径等于 $D$ 的圆盘覆盖 $P$,当且仅当
\begin{equation}
\max_{v\in V(P)}\norm{v-o}\le D/2.
\label{eq:diametercircle}
\end{equation}
\end{proposition}
\begin{proof}
若半径 $D/2$ 的圆盘中心为 $z$ 且包含 $a,b$,则
$D\le\norm{a-z}+\norm{z-b}\le D$。
等号迫使 $z=o$。反之,圆盘为凸集,包含全部顶点即包含它们的凸包。
\end{proof}
若条件不成立,输出圆外顶点及超出量
<<<<<<< HEAD
$e=\max_v\norm{v-o}-D/2$,用于量化该圆盘的覆盖误差。
=======
$e=\max_v\norm{v-o}-D/2$,即得到可独立检查的否定证据。
不能仅以直径端点在圆周上判断整个区域被覆盖。
图~\ref{fig:q1geometry}(a) 给出一组三点交会算例:左图为三条前向角域及其交集,
右图为定位区域的放大,虚线为直径圆、实线为最小包围圆。
该算例的定位区域由四个顶点确定,直径为 $23.997$ m,
以该直径为直径的圆半径为 $11.998$ m,
而最远顶点到圆心的距离为 $12.602$ m,故直径圆并不覆盖定位区域;
其最小包围圆半径为 $12.069$ m,满足 $20$ m 的清除判据。
这说明直径不超过 $40$ m 与能否清除是两个不同的判据,覆盖判定必须逐顶点检查。
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
\subsection{反例与可清除半径}
构造等边三角形
\begin{equation}
A=(0,0),\qquad B=(40,0),\qquad C=(20,20\sqrt3).
\end{equation}
其直径为 $40$ m。以 $AB$ 为直径的圆心为 $(20,0)$,半径为 $20$ m,
但 $C$ 到圆心的距离为 $20\sqrt3$ m,位于圆外。
其最小包围圆圆心为 $(20,20/\sqrt3)$,半径
$40/\sqrt3\approx23.094$ m,同样大于清除半径。
图~\ref{fig:q1geometry}(b) 展示两类圆的差别。
\begin{figure}[htbp]
\centering
<<<<<<< HEAD
\begin{tikzpicture}[x=0.095cm,y=0.095cm,font=\small]
\fill[inkblue!10] (0,0)--(40,0)--(20,34.641)--cycle;
\draw[thick] (0,0)--(40,0)--(20,34.641)--cycle;
\draw[warmgray,dashed,thick] (20,0) circle (20);
\draw[inkblue,thick] (20,11.547) circle (23.094);
\fill (0,0) circle (0.6) node[below left] {$A$};
\fill (40,0) circle (0.6) node[below right] {$B$};
\fill (20,34.641) circle (0.6) node[above] {$C$};
\fill[warmgray] (20,0) circle (0.6) node[below] {$o$};
\fill[inkblue] (20,11.547) circle (0.6) node[right] {$m$};
\draw[->,warmgray] (49,-14)--(35,-13);
\node[anchor=west,warmgray] at (49,-14) {直径圆:$r=20$};
\draw[->,inkblue] (49,24)--(41,22);
\node[anchor=west,inkblue] at (49,24) {最小包围圆:$\rho=40/\sqrt3$};
\node at (20,-26) {$AB=BC=CA=40$ m};
\end{tikzpicture}
\caption{等边三角形的直径圆与最小包围圆。}
\label{fig:triangle}
=======
\begin{minipage}[t]{0.49\linewidth}\centering
\includegraphics[width=\linewidth]{geo-q1-convex-intersection-zoom.png}
\end{minipage}\hfill
\begin{minipage}[t]{0.49\linewidth}\centering
\includegraphics[width=\linewidth]{geo-q1-diameter-circle-counterexample.png}
\end{minipage}
\caption{问题一的两组几何。(a) 三点交会算例:定位区域及其直径圆与最小包围圆,
观测取自官方演练案例 \texttt{U5ZW-P9KN-TCAG-R7ST} 的频道 $9$,
几何量由生产求解器 \texttt{mcm26b.geometry.solve} 现算;
(b) 等边三角形解析反例,虚线直径圆 $r=20$ m 不覆盖顶点 $C$,
实线最小包围圆 $\rho=40/\sqrt3\approx23.094$ m 覆盖全部顶点,不是模拟器观测。}
\label{fig:q1geometry}
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
\end{figure}
该形状可由题设的 $\pm1^\circ$ 角域构造出来。例如检测点
$(-1000,0)$、$(540,-500\sqrt3)$、$(520,520\sqrt3)$,
对应示向度为 $1^\circ$、$121^\circ$、$241^\circ$。
各角域的一侧边界给出三角形的三条支撑线,另一侧边界包含整个三角形,
故交集恰为上述三角形。
对一般候选区域,定义最小包围圆
\begin{equation}
\rho(P)=\min_{z\in\mathbb R^2}\max_{g\in P}\norm{g-z},\qquad
m\in\arg\min_z\max_{g\in P}\norm{g-z}.
\label{eq:mec}
\end{equation}
其边界由至多三个支撑点确定,可用 Welzl 随机增量算法计算\cite{welzl}。
实现采用固定随机种子及显式栈,并在输出前检查全部顶点的包含关系。
在精确模型中,若 $\rho(P)\le20$,于 $m$ 清除即可覆盖所有可能真源;
考虑浮点计算后采用 $\rho(P)+\eta_{\mathrm{num}}\le20$ 的充分判据。
$D(P)\le40$ 是必要条件,但并非充分条件。
\subsection{算法一及复杂度}
\begin{algorithmblock}{算法1:有界测向角域交会与覆盖圆判定(对应 algo-1)}
\textbf{输入:}同一频道的检测点、示向度和误差半角。\\
\textbf{输出:}区域状态、顶点、直径、直径圆判定与最小包围圆。
\begin{enumerate}[leftmargin=2em,itemsep=1pt,topsep=4pt]
\item 若观测为空,返回无界;否则归一化坐标,生成 $3n$ 条半平面。
\item 枚举非平行边界交点,仅保留满足全部约束的点。
\item 无可行点时返回空集;存在非零衰退切向时返回无界及无穷直径。
\item 对可行交点去重、求凸包,并区分单点、线段和二维多边形。
\item 枚举最远顶点对,计算 $D$、中点 $o$,检查全部顶点距 $o$ 是否不超过 $D/2$。
\item 求最小包围圆,复核包含关系与数值余量,判断是否满足清除条件。
\end{enumerate}
\end{algorithmblock}
边界交点枚举需 $O(k^2)$ 次,每点校验 $k$ 条约束,主计算量为 $O(k^3)$;
凸包最坏需 $O(k^2\log k)$,直径枚举需 $O(m^2)$。
Welzl 算法在随机排列下具有期望线性时间。该求解流程适合少量观测,
当前入口限制最多 $100$ 个检测点;更大规模可替换半平面交算法。
+160
View File
@@ -0,0 +1,160 @@
\section{问题二:保证接收的第二检测点选择}
\subsection{候选位置集合与安全区域}
设首次有效示向度为 $(s,\theta)$。有效读数除限定角域外,
还说明源距检测点不超过接收半径上界,故
\begin{equation}
K_1=\Omega\cap W(s,\theta,\delta_{\mathrm{eff}})\cap\B(s,R_+).
\label{eq:k1}
\end{equation}
其中 $R_+=1500$ 为有效接收半径上界。
$\direction$ 对应距离大于 $5$ m;式~\eqref{eq:k1} 采用其凸松弛,
使真实可行位置始终包含在所维护的候选集合中。
对非空紧集 $K$ 定义
\begin{equation}
F_K(q)=\max_{g\in K}\norm{q-g},\qquad
\mathcal Q(K)=\{q:F_K(q)\le R_-\}
=\bigcap_{g\in K}\B(g,R_-).
\label{eq:exact-safe}
\end{equation}
该区域内的任何点均在所有可能真源的最小接收范围内。
$F_K$ 是凸函数且为 $1$-Lipschitz,因而 $\mathcal Q(K)$ 是闭凸集。
由式~\eqref{eq:mec} 可知
\begin{equation}
\mathcal Q(K)\ne\varnothing\quad\Longleftrightarrow\quad\rho(K)\le R_-.
\end{equation}
这一表述将第二点的鲁棒可行性转化为最小包围圆判据\cite{boyd}。
在线用外切正多边形表示圆盘,再逐半平面裁剪。
若边数为 $M$、圆半径为 $r$,外切多边形顶点的半径为
$r\sec(\pi/M)$,其最大径向超出量准确写为
\begin{equation}
e_M(r)=r\bigl(\sec(\pi/M)-1\bigr).
\label{eq:outer-error}
\end{equation}
默认 $M=192$。由于整个圆盘已包含在多边形中,
裁剪后得到 $K_1\subseteq P^{\mathrm{out}}$。
算法求外包多边形 $P^{\mathrm{out}}$ 的最小包围圆 $\B(m,\rho_{\mathrm{out}})$。
由三角不等式,充分安全盘为
\begin{equation}
\mathcal Q_{\mathrm{in}}
=\B\!\left(m,R_- -\rho_{\mathrm{out}}-\eta\right),\qquad
\eta=10^{-3}\ {\rm m}.
\label{eq:safe-disk}
\end{equation}
任意 $q\in\mathcal Q_{\mathrm{in}}$ 与真源 $g$ 满足
$\norm{q-g}\le\norm{q-m}+\norm{m-g}\le R_- -\eta$。
因此第二次检测一定为 $\direction$ 或 $\near$。
该构造适用于 $\rho_{\mathrm{out}}+\eta\le R_-$;
当候选区域较大时,程序先在其圆心追加观测,再更新可行区域。
\subsection{首次截断角域的解析候选盘}
为给出不依赖离散多边形的显式答案,暂去掉 $\Omega$,考虑
$S=W(s,\theta,\delta)\cap\B(s,R_+)$。
当 $0\le\delta\le45^\circ$,令
\begin{equation}
a=\frac{R_+}{2\cos\delta},\qquad c=s+a\,u(\theta).
\end{equation}
\begin{proposition}[截断角域最小包围圆]
上述 $S$ 的最小包围圆为 $\B(c,a)$。
\end{proposition}
\begin{proof}
任取 $g=s+t u(\theta+\varphi)$,其中 $0\le t\le R_+$、
$|\varphi|\le\delta$。有
$\norm{g-c}^2=t^2+a^2-2at\cos\varphi$。
该式关于 $t$ 为凸二次式,最大值在区间端点取得。
$t=0$ 时为 $a^2$;$t=R_+$ 时不超过
$R_+^2+a^2-2aR_+\cos\delta=a^2$,故全域被包含。
再取边界三点 $s,p_\pm=s+R_+u(\theta\pm\delta)$。
权重
$\lambda_\pm=1/(4\cos^2\delta)$、
$\lambda_0=1-1/(2\cos^2\delta)$ 非负且和为 $1$,
其加权中心为 $c$。三点均距 $c$ 为 $a$,
对任意圆心 $z$,加权平方距离等于 $a^2+\norm{z-c}^2$,
故至少一点距 $z$ 不小于 $a$。任何包含三点的圆半径均不小于 $a$。
\end{proof}
由于 $K_1\subseteq S$,于是
\begin{equation}
\boxed{
q\in\B\!\left(s+\frac{1500}{2\cos\delta}u(\theta),
1000-\frac{1500}{2\cos\delta}\right)}
\label{eq:q2-answer}
\end{equation}
即为题目所求的一种明确候选区域,实际使用时再内缩 $1$ mm。
该圆盘构成 $\mathcal Q(K_1)$ 的一个显式凸子集。
\begin{table}[htbp]
\centering\small
\caption{解析安全盘参数,由式~\eqref{eq:q2-answer} 直接计算}
\begin{tabular}{lrr}
\toprule
误差半角 & 中心沿首次方向偏移 $a$/m & 候选盘半径/m\\
\midrule
$1^\circ$ & 750.114246 & 249.885754\\
$1.005^\circ$ & 750.115391 & 249.884609\\
\bottomrule
\end{tabular}
\label{tab:safeanalytic}
\end{table}
在线算法继续使用 $\Omega$ 裁剪后的完整候选形状,
通常能够获得更紧的源包围圆与更大的充分安全盘。
解析公式负责解释保证为何存在,多边形流程负责利用已有的全部几何约束。
\begin{figure}[htbp]
\centering
\includegraphics[width=0.70\linewidth]{geo-q2-guaranteed-reception-disk.png}
\caption{第二点解析候选区域。左图为首次截断角域 $S$、
其最小包围圆 $\B(c,a)$ 与保证接收的候选盘 $\B(c,1000-a)$;
右图为半径分解,$a=1500/(2\cos\delta)$。取 $\delta=1^\circ$ 时
$a\approx750.114$ m、$1000-a\approx249.886$ m,与表~\ref{tab:safeanalytic} 一致。}
\label{fig:q2disk}
\end{figure}
\subsection{定位效果指标与有限候选求解}
理想的鲁棒选点模型为
\begin{equation}
\min_{q\in\mathcal Q_{\mathrm{in}}}
\left(\sup_{y\in\mathcal Y(q,K_1)}\rho(K_1\cap O(q,y)),
\frac{\norm{q-p}}{5}\right)_{\mathrm{lex}},
\label{eq:q2minimax}
\end{equation}
其中 $p$ 是当前机器人位置,$y$ 表示可能观测,
$O(q,y)$ 为相应观测可行集,下标表示按字典序比较:
先最小化最坏后验半径,再减少移动耗时。
方向分支取相应角域,近距离分支取 $\B(q,5)$。
这比仅优化交会角更直接地对应最终清除需求。
连续极小极大问题求解成本较高,当前算法采用有限候选近似。
在式~\eqref{eq:safe-disk} 的盘心及半径比例
$0.4,0.75,1.0$ 的三个环上各取 $12$ 个方向,
角度为 $(k+\tfrac12)2\pi/12$,共 $37$ 点。
对每个候选 $q$,在 $P^{\mathrm{out}}$ 边界以不大于 $60$ m 的间距采样,
包含全部顶点。对边界采样点 $g$ 枚举读数
$\operatorname{bearing}(q,g)\pm\delta_{\mathrm{eff}}$,
计算新角域交后的最小包围半径,并另外计算 $\near$ 分支;
取所得最大值作为 $\widehat J(q)$。
选取使 $(\widehat J(q),\norm{q-p}/5)$ 最小的点,
然后将边界采样间距加密至 $15$ m 复核其评分。
$\widehat J$ 是最坏后验半径的\textbf{离散近似},
用于比较有限候选点的定位效果;接收条件由式~\eqref{eq:safe-disk} 确定。
\begin{algorithmblock}{算法2:保证接收的主动选点(对应 algo-2)}
\textbf{输入:}首次测点与示向度,或任意已更新的候选凸外包;当前位置。\\
\textbf{输出:}下一检测点、采样后验评分及接收保证状态。
\begin{enumerate}[leftmargin=2em,itemsep=1pt,topsep=4pt]
\item 初次调用时构造式~\eqref{eq:k1} 的外包多边形;后续调用使用已有外包。
\item 计算最小包围圆和安全盘半径 $b=1000-\rho_{\mathrm{out}}-10^{-3}$。
\item 若 $b<0$,在候选区域圆心追加观测;否则生成 $37$ 个安全候选点。
\item 对每点枚举边界样本与方向端点读数,补入近距离分支,得到 $\widehat J$。
\item 按后验评分优先、移动时间次优选点,并以 $15$ m 采样间距复核。
\item 获得新观测后更新候选集;$\near$ 可就地清除,
$\direction$ 则根据最小包围半径决定清除或追加测量。
\end{enumerate}
\end{algorithmblock}
若候选数为 $N_q$、边界样本数为 $N_g$、多边形顶点数为 $m$,
一次裁剪为 $O(m)$,总计算量主要为
$O(N_qN_g(m+C_{\mathrm{MEC}}(m)))$。
该算法采用问题一的角域模型与最小包围圆方法,通过多边形裁剪逐次更新候选集。
+231
View File
@@ -0,0 +1,231 @@
\section{问题三:全向多源的覆盖搜索与滚动调度}
\subsection{统一时间目标与任务状态}
<<<<<<< HEAD
设整个会话移动距离为 $L$,检测、换频、成功和失败清除次数分别为
$N_m,N_s,N_c^+,N_c^-$。忽略每段移动时间的微秒量化时,总虚拟耗时为
\begin{equation}
T=\frac{L}{5}+5N_m+N_s+5N_c^++3N_c^-.
\label{eq:time}
\end{equation}
优化目标是在全部源清除的约束下最小化 $T$。
题目统计的平均定位清除时间为 $\bar T=T/N_c^+$,包含搜索、定位与清除的全部耗时。
清除请求中的频道指定操作对象,测向机保持原频道;
进入与退出的虚拟耗时为零,因此采用终点自由的开放路径模型。
=======
按式~\eqref{eq:time} 的计时记账,优化目标是在全部源清除的约束下最小化虚拟耗时 $T$。
题目统计的平均定位清除时间是 $\bar T=T/N_c^+$;
不能用每次清除动作的固定 $5$ s 代替。
清除请求中的频道是目标频道,不改变测向机频道;
进入与退出均不增加虚拟时间,因此路径终点自由,无需返回原点。
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
各频道的状态按“未知、已发现、可清除、已清除、已证无源”管理。
首次获得 $\direction$ 或 $\near$ 即确立该频道存在源。
最小包围圆判据决定清除时机,成功响应将状态更新为已清除。
频道的候选集为空,或其所有覆盖站检测结果均为无信号时,可判定该频道无源。
\subsection{七点覆盖的解析证明}
取站点集合
\begin{equation}
S(a)=\{(0,0)\}\cup
\left\{a\left(\cos\frac{k\pi}{3},\sin\frac{k\pi}{3}\right):k=0,\ldots,5\right\}.
\label{eq:seven}
\end{equation}
\begin{proposition}[七点全向覆盖]
当 $0<a/\sqrt3\le R$ 时,任意 $g\in\Omega$ 到最近站点的距离不超过
\begin{equation}
D_{\mathrm{cov}}(a)=
\max\left\{\frac a{\sqrt3},\
\sqrt{R^2+a^2-\sqrt3Ra}\right\}.
\label{eq:coveragebound}
\end{equation}
\end{proposition}
\begin{proof}
设 $g$ 距原点为 $r$,其方位与最近环站的夹角不超过 $\pi/6$。
若 $r\le a/\sqrt3$,选取原点即可。
若 $r\ge a/\sqrt3$,由余弦定理,距最近环站的平方不超过
$r^2+a^2-\sqrt3ar$。
该凸二次式在 $[a/\sqrt3,R]$ 上的最大值于端点取得,
分别为 $a^2/3$ 与 $R^2+a^2-\sqrt3aR$。
\end{proof}
当前 \texttt{bayes} 策略取 $a=1159.7$ m,
由式~\eqref{eq:coveragebound} 得 $D_{\mathrm{cov}}=984.539136$ m,
比最小接收半径小约 $15.461$ m。
因此每个合法全向源至少在一站可被接收。
某频道在七站全部为无信号,就构成该频道不存在源的严格证据。
以“站点、频道”为索引建立检测状态矩阵,记录各站对各频道的检测结果。
\begin{figure}[htbp]
\centering
<<<<<<< HEAD
\begin{tikzpicture}[x=0.0019cm,y=0.0019cm,font=\small]
\begin{scope}
\clip (0,0) circle (1800);
\fill[inkblue!8] (0,0) circle (1000);
\foreach \angle in {0,60,120,180,240,300}{
\fill[inkblue!7] (\angle:1159.7) circle (1000);
\draw[inkblue!30] (\angle:1159.7) circle (1000);
}
\draw[inkblue!30] (0,0) circle (1000);
\end{scope}
\draw[thick] (0,0) circle (1800);
\draw[dashed,warmgray] (0,0) circle (1159.7);
\foreach \angle in {0,60,120,180,240,300}{
\fill[inkblue] (\angle:1159.7) circle (26);
}
\fill[inkblue] (0,0) circle (26) node[below left] {$O$};
\draw[->] (0,0)--(1159.7,0) node[midway,above] {$a$};
\draw[->,warmgray] (0,0)--(30:1800) node[midway,above left] {$R$};
\node[anchor=west,align=left] at (2100,750)
{实线:源位置边界\\虚线:六站所在圆\\浅色圆盘:$1000$ m 接收保证};
\node[anchor=west,align=left] at (2100,-650)
{$a=1159.7$ m\\$R=1800$ m\\最坏距离 $\le984.539$ m};
\end{tikzpicture}
\caption{七点全向覆盖的站点构造与接收圆盘。}
=======
\includegraphics[width=0.42\linewidth]{geo-q3-seven-point-coverage.png}
\caption{七点全向覆盖构造。站点取自配置参数 $a=1159.7$ m,
圆圈为 $1000$ m 接收盘,最差点位于 $(0,-1825)$ 附近,最坏距离 $\le984.539$ m。}
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
\label{fig:seven}
\end{figure}
顺次从原点访问六个环站的开放路径长为 $6a$。
任务总路径由覆盖站间移动以及定位、清除所需的局部移动共同构成。
\subsection{正负观测的双重候选表示}
每个已发现频道维护凸外包 $P_c^{\mathrm{out}}$。
$\direction$ 将候选集与 $\B(s,1500)$ 外切多边形及测向角域相交;
$\near$ 与 $\B(s,5)$ 外切多边形相交。
全向源的 $\sgnless$ 意味着
\begin{equation}
\norm{g-s}>r_{\mathrm{eff}}\ge1000,
\end{equation}
排除圆盘可使可行区域呈非凸形状。为此,采用网格候选集
$\mathcal G_c$ 表示负观测带来的空间约束,同时保留便于几何运算的凸外包。
网格边长为 $\ell=14.65$ m,每格中心与格内点的最大距离为
$\epsilon_g=\ell/\sqrt2$。初始保留所有可能与 $\Omega$ 相交的格,
按以下充分排除条件更新格心 $z$:
\begin{equation}
\begin{array}{ll}
\sgnless: & \norm{z-s}\le1000-\epsilon_g\ \Rightarrow\ \text{删除};\\
\near: & \norm{z-s}>5+\epsilon_g\ \Rightarrow\ \text{删除};\\
\direction: & \norm{z-s}>1500+\epsilon_g\ \Rightarrow\ \text{删除}.
\end{array}
\label{eq:gridupdate}
\end{equation}
方向观测另外检查角差。若 $d=\norm{z-s}>\epsilon_g$,
源所在格引起的方位偏差可由
$\arcsin(\epsilon_g/d)$ 上界;
实现采用距离内缩后的角误差界,并在测点附近保留所有可能方位。
只有角差超过 $\delta_{\mathrm{eff}}$ 加格内角裕量时才删除。
上述排除条件保持真源所在网格的可行性。
因此网格候选集为空时,该频道无源。
若全部候选格心均满足 $\norm{z-s}>1500+\epsilon_g$,
则在 $s$ 测量必无信号,且所有候选网格已在最小接收圆盘外,
其观测的信息增益为零,可在检测状态矩阵中标记该推断结果,
从而节省相应检测与换频时间。
\subsection{信息增益选点与清除判据}
问题二的算法2提供满足接收约束的检测点;
问题三的 \texttt{BayesLoop} 进一步采用网格候选集上的信息增益。
存在保证接收圆盘时,在其内部生成环形候选并补充当前接近方向上的测点;
其余情况下,在网格最小包围圆附近生成环形采样点。
候选集合同时包含当前位置与探索点,分别根据预期信息增益和移动代价评分。
对候选 $q$ 计算
\begin{equation}
J_{\mathrm{IG}}(q)=
\widehat{\mathbb E}\!\left[
\log\frac{|\mathcal G_c^{\,\mathrm{post}}(q,Y)|}{|\mathcal G_c|}
\right]
+\lambda\left(\frac{\norm{p-q}}5+\frac{\norm{q-m_c}}5\right),
\label{eq:ig}
\end{equation}
其中 $\lambda=0.05$,空分支用正的小值截断。
较小的候选集基数比对应较大信息增益,移动项限制为测量而产生的绕行。
评分最多取 $800$ 个子样本及 $7$ 个代表假设,
并以均匀 $r_{\mathrm{eff}}\in[1000,1500]$ 估计接收概率。
该决策采用离散假设上的加权期望,接收半径的均匀分布作为选点先验。
清除判断使用完整候选集合。
凸候选集的清除判据为
\begin{equation}
\rho_c+b_c+0.5\le20,
\label{eq:clearpoly}
\end{equation}
其中 $b_c\ge0$ 为代码的累计附加余量。
凸多边形包含真源,$\rho_c$ 是其最小包围半径;
$b_c$ 与 $0.5$ m 共同提供附加数值裕量。
若负观测使网格更紧,也可使用
\begin{equation}
\rho_{\mathrm{grid}}+\epsilon_g+0.5\le20
\label{eq:cleargrid}
\end{equation}
的网格清除判据,其中 $\rho_{\mathrm{grid}}$ 为候选格心的最小包围半径。
满足任一充分条件即可选择清除点。
利用剩余距离裕量,可将圆心向当前位置平移 $\Delta$;
只要平移后的距离上界仍满足对应不等式,三角不等式便保证清除条件成立。
\subsection{开放路径上的滚动任务排序}
统一任务表包含三类任务:覆盖站的批量扫频、已发现源的定位测量、
满足半径条件的清除。站内检测优先使用测向机当前频道,减少换频次数。
对任务序列 $\pi$,排序代价为
\begin{equation}
C(\pi)=\frac{\norm{p-q_{\pi_1}}}{5}
+\sum_{j=1}^{k-1}\frac{\norm{q_{\pi_j}-q_{\pi_{j+1}}}}5
+\sum_{j=1}^{k}t_{\pi_j}(c_{j-1}),
\label{eq:route}
\end{equation}
其中服务时间 $t_{\pi_j}$ 依赖进入任务时的频道,清除不改变该频道。
从最近邻初序开始,默认最多进行 $4$ 轮开放路径 2-opt 改进;
每次只执行当前序列的首任务,得到新观测后重新生成和排序。
这样新发现的源、收缩后的清除点和已证无源的频道都能及时改变路线。
已停靠位置可以顺带补测:要求此前未在该位置测过、距离保证成立、
尚处于定位阶段,且预测半径有足够收缩。追加测量的费用为检测与换频时间,
其收益来自节省额外移动。题设固定同地误差,因此优先选择新的检测位置。
\subsection{算法三与完成条件}
\begin{algorithmblock}{算法3:七点覆盖、网格更新与滚动调度(对应 algo-3)}
\textbf{输入:}题设参数、七站计划、合法观测接口。\quad
\textbf{输出:}逐频道处理状态与会话统计。
\begin{enumerate}[leftmargin=2em,itemsep=1pt,topsep=4pt]
\item 检验七点覆盖条件,初始化频道状态、检测状态矩阵和网格候选集。
\item 进入后同步记录位置、频道及剩余现实预算,必要时执行有信息的机会测量。
\item 构建覆盖、定位和清除任务;跳过能够证明无信息的检测。
\item 按最近邻与开放路径 2-opt 排序,执行首任务,更新候选集和频道状态。
\item 若满足清除判据,执行清除;成功则更新状态,失败则追加观测。
\item 若已清除 $16$ 个源,或每个频道均已清除或有严格无源证据,则退出。
否则返回步骤2;预算不足或异常时退出并报告未解决频道。
\end{enumerate}
\end{algorithmblock}
“清除数达到 $16$”利用题设数量上界。
对任意已存在的 $16$ 个不同频道,也可提前排除其余未知频道,
但已发现而未清除的源仍必须继续处理。
覆盖条件用于判定搜索范围,清除充分条件用于确定操作位置。
当前每轮追加有效定位测量上限为 $8$;
停滞、协议异常或预算耗尽时,记录各频道状态并终止。
程序以接口返回的现实预算为准,并预留退出时间,
<<<<<<< HEAD
虚拟动作耗时以整数微秒累计,程序运行时间由官方统计单独记录。
=======
实际计时在整数微秒状态上累计,避免用实机运行秒数代替虚拟动作耗时。
\subsection{运行过程示例}
图~\ref{fig:replay3} 回放一局全向演练(案例 \texttt{BBM9-HZG2-77N5-NKKC},$10$ 个源):
左图为移动轨迹、检测示向度与已清除频道,右上方为事件流水,
下方为虚拟时间轴上的检测与清除时刻。
该局说明覆盖扫描、定位测量与清除三类任务的交替关系,
以及为接收向外发射的源而短时越出 $\Omega$ 边界的路径形态;它属于演练留档,不计入正式成绩。
\begin{figure}[htbp]
\centering
\includegraphics[width=0.85\linewidth]{replay-webui-q3-omnidirectional.png}
\caption{全向源演练局(\texttt{BBM9-HZG2-77N5-NKKC})的运行回放。
该局为演练案例,非正式测试记录。}
\label{fig:replay3}
\end{figure}
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
+211
View File
@@ -0,0 +1,211 @@
\section{问题四:混合源的定向覆盖与网格遍历清除}
\subsection{定向观测模型与无信号歧义}
设定向源位于 $g$,朝向为 $\psi$。在检测点 $q$ 可见的充要条件为
\begin{equation}
\norm{q-g}\le r_{\mathrm{eff}},\qquad
(q-g)\cdot u(\psi)\ge0.
\label{eq:visibility}
\end{equation}
边界属于可见半平面。可见且距离不超过 $5$ m 时返回 $\near$,
否则返回 $\direction$;任何一个条件失败均为 $\sgnless$。
问题二的可行区域满足距离约束,定向接收还需满足前向半平面约束。
对于已发现频道,位于该区域内的负观测反映朝向遮挡;
区域外的负观测则同时包含距离与朝向两种解释。
向外发射的边界源需要在圆域外布置接收站点。
适用于混合源的站点集合 $S$ 应满足
\begin{equation}
\forall g\in\Omega,\ \forall\psi\in[0,2\pi),\
\exists s\in S:\quad
\norm{s-g}\le1000,\quad (s-g)\cdot u(\psi)\ge0.
\label{eq:halfcover}
\end{equation}
这是一项针对“位置、朝向”二元状态的全覆盖要求。
\subsection{方格点集的连续覆盖性}
取 $S_{49}=\{(700i,700j):i,j=-3,\ldots,3\}$。
其网格方域包含 $\Omega$,每个源落在某个边长为 $700$ m 的闭方格内。
\begin{proposition}[方格四角的定向覆盖]
$S_{49}$ 满足式~\eqref{eq:halfcover},对全向与任意朝向的定向源均保证发现。
\end{proposition}
\begin{proof}
设包含 $g$ 的方格四角为 $v_1,\ldots,v_4$。
四角距 $g$ 均不超过对角线长 $700\sqrt2<1000$。
由于 $g$ 是四角的凸组合,存在 $\lambda_i\ge0$、
$\sum_i\lambda_i=1$ 使 $\sum_i\lambda_i(v_i-g)=0$。
对任意朝向 $u$ 取内积,得
$\sum_i\lambda_i(v_i-g)\cdot u=0$,
故不可能四个角均满足 $(v_i-g)\cdot u<0$;
至少一角处于前向闭半平面,且同时满足距离条件。
\end{proof}
四角的距离上界与凸组合性质共同保证连续覆盖。
该方案具有显式站点坐标和覆盖距离上界。
\subsection{基于集合覆盖的31点方案}
当前 \texttt{directional} 实现采用 \texttt{q4\_mincover\_v2} 计划。
在候选站点 $s_j$ 和离散目标状态 $(g_i,\psi_i)$ 上定义关联矩阵
\begin{equation}
A_{ij}=\mathbf1\{
\norm{s_j-g_i}\le r_{\mathrm{design}},
\ (s_j-g_i)\cdot u(\psi_i)\ge
\norm{s_j-g_i}\sin\gamma_{\mathrm{design}}\}.
\end{equation}
站点筛选可写为集合覆盖模型
\begin{equation}
\min\sum_j z_j,\qquad
\sum_jA_{ij}z_j\ge1\ \ (\forall i),\qquad z_j\in\{0,1\}.
\label{eq:setcover}
\end{equation}
采用集合覆盖的贪心算法与冗余站点删除求近似解\cite{chvatal},得到 $31$ 个站点。
设计阶段使用 $965$ m 距离与 $5^\circ$ 前向角余量,
最终按 $15$ m 位置间距、$72$ 个朝向采样复核。
验证结果覆盖全部 $3\,256\,200$ 个离散状态,
最坏覆盖距离约为 $990.051$ m。
\begin{figure}[htbp]
\centering
\includegraphics[width=0.70\linewidth]{geo-q4-minimum-cover-31-points.png}
\caption{31 点最小命中集的几何分布(左)与定向源的前向半盘语义(右)。
站点取自计划文件,部分站点位于 $\Omega$ 之外,用于接收向外发射的边界源;
离散校验不等同于连续覆盖证明。}
\label{fig:q4plan}
\end{figure}
从离散覆盖推广到连续覆盖时,需同时计入位置与朝向的离散化误差。
设任意实际位置距某采样位置不超过 $\varepsilon$,
实际朝向距其采样朝向不超过 $\alpha$,覆盖站点距采样位置为 $d$。
由三角不等式及单位向量差的上界,一组充分条件为
\begin{equation}
d+\varepsilon\le1000,\qquad
(s-g_i)\cdot u(\psi_i)\ge
\varepsilon+2d\sin(\alpha/2).
\label{eq:continuous}
\end{equation}
采样网格应覆盖 $\Omega$ 及其边界。本文对 $31$ 点方案采用离散集合覆盖模型,
对 $49$ 点方案采用上述连续覆盖定理;连续近似误差的控制将在未来工作中讨论。
\subsection{位置与朝向联合候选的保守更新}
对定向假设,在每个候选位置网格的中心 $z$ 附加 $H=24$ 个朝向档,
档宽为 $15^\circ$。令 $M_z$ 为仍可能的朝向掩码。
方向与近距离观测先更新位置约束,再保留可能覆盖检测点的朝向;
无信号时只有在整格处于 $1000$ m 保证接收盘内,才可删除必然可见的朝向。
记 $\beta=\operatorname{bearing}(z\to s)$、
$\Delta_\psi=7.5^\circ$,格内方位余量为 $\alpha_z$,
查表舍入余量为 $\eta_\psi=1.5^\circ$。
以 $d_\circ$ 表示圆周最小角差,保守保留条件为
\begin{equation}
\begin{aligned}
\text{正观测:}\quad&
d_\circ(\psi_k,\beta)\le90^\circ+\Delta_\psi+\eta_\psi+\alpha_z,\\
\text{盘内负观测:}\quad&
d_\circ(\psi_k,\beta)>90^\circ-\Delta_\psi-\eta_\psi-\alpha_z.
\end{aligned}
\label{eq:heading}
\end{equation}
第二行保留含有不可见方向的朝向区间,排除整个区间均可见的候选。
若格心距测点过近,格内方位可覆盖整个圆周,此时保留全部朝向。
在定向假设下,上述更新保持真源所在位置与朝向网格的可行性。
混合类型的集合估计扩展见第~\ref{sec:future}~节。
\subsection{可见性筛选与探点选择}
联合候选的用途首先是排序探点。对候选 $q$,
以均匀接收半径假设给出距离权重
\begin{equation}
w_r(d)=
\begin{cases}
1,&d\le1000,\\
(1500-d)/500,&1000<d<1500,\\
0,&d\ge1500.
\end{cases}
\end{equation}
记 $\widetilde{\mathcal G}_c$ 为位置样本集,$b_z(q)$ 为与检测点 $q$ 相容的候选朝向档数,
采用如下\textbf{可见性权重}:
\begin{equation}
V(q)=\frac1{|\widetilde{\mathcal G}_c|}\sum_{z\in\widetilde{\mathcal G}_c}
w_r(\norm{z-q})\,\frac{b_z(q)}{24}.
\label{eq:visibility-score}
\end{equation}
分母 $24$ 为初始朝向档数,$b_z(q)$ 由保留朝向集合与预计算可见角区间的交集确定。
该权重用于衡量各候选点的相对检测价值。
候选族包括算法2给出的基础点、关于候选圆心的对跖点,以及
$0.5\rho,\rho,1.5\rho$ 半径上的八方向环点。
排除已被遮挡的重复点后,保留评分达到当前最大值 $70\%$ 的点,
并从中选择
\begin{equation}
\frac{\norm{p-q}}5+\frac{\norm{q-m}}5+6
\label{eq:q4score}
\end{equation}
最小者。常数 $6$ 是检测加换频的选点评分费用,
真实结算仍按当时频道计算。该规则优先避免完全遮挡的点,
再减少移动及后续接近距离。
联合集不可用时,返回算法2、对跖探点和环探的确定性顺序。
持续遮挡时转入基于距离约束的网格遍历清除。
\subsection{网格遍历清除与计算预算}
当一轮有效定位测量达到 $8$ 次,或连续遮挡探点达到 $4$ 次且候选区域仍较大,
对保守凸候选多边形建立边长 $20$ m 的方格覆盖。
保留所有与多边形相交的格子,并以格心作为清除点。
格内任一点到格心距离满足
\begin{equation}
\norm{g-z}\le10\sqrt2<20.
\label{eq:fallback}
\end{equation}
因清除与源朝向无关,只要真源保留在多边形中,
逐格清除必有一次到达其 $20$ m 内。
按最近邻顺序访问,首次成功即停止该频道的网格遍历。
若外包框宽、高为 $w,h$,保守格数上界为
\begin{equation}
B_{\mathrm{cell}}=
\left(\left\lceil\frac w{20}\right\rceil+2\right)
\left(\left\lceil\frac h{20}\right\rceil+2\right).
\end{equation}
当前格数硬上限为 $400$,现实预算需覆盖
$N_{\mathrm{cell}}\,t_{\mathrm{RTT},95}+t_{\mathrm{reserve}}$。
超过格数或预算上限即跳过并报告未解决;
全部清除点失败则保留候选集并记录异常。
在\textbf{候选区域包含真源、格数满足上限且预算足够}的条件下,
逐格访问可保证完成该源的清除。
\subsection{算法四与终止条件}
\begin{algorithmblock}{算法4:定向集合覆盖、选点与网格遍历清除(对应 algo-4)}
\textbf{输入:}混合源接口,定向覆盖计划与预算。\quad
\textbf{输出:}已清除频道、未解决频道、停止依据。
\begin{enumerate}[leftmargin=2em,itemsep=1pt,topsep=4pt]
\item 加载覆盖计划;默认 $31$ 点按声明分辨率验证,$49$ 点备选有连续证明。
\item 选择最近的待测站,当前频道优先,批量检测仍未知的频道。
\item 更新凸位置候选;首次正观测后建立朝向候选,用于探点可见性筛选。
\item 按移动代价处理已发现频道;满足半径判据则清除,否则继续探点。
\item 遮挡或定位停滞时检查格数与预算,允许则执行 $20$ m 方格遍历清除。
\item 覆盖任务结束后处理未解决频道;主动退出时报告清除数及停止依据。
\end{enumerate}
\end{algorithmblock}
<<<<<<< HEAD
问题四采用站内批量检测与最近邻任务排序。
算法结合位置集合、朝向约束和剩余预算确定测点及清除顺序,
并以各频道的处理状态作为终止时的输出。
=======
问题四使用“本站批量扫频、最近候选圆心服务”的调度骨架,
并未直接复用问题三的统一信息增益任务表。
若要求严格的混合源全清证据,除采用连续覆盖计划外,
还须补全类型联合表示或限制联合集排空的适用范围,并确保全部定位清除任务完成。
本文区分当前实现、可证明条件与尚需完善之处,不把后者追溯为正式测试时已执行的功能。
\subsection{运行过程示例}
图~\ref{fig:replay4} 回放一局含定向源的演练(案例 \texttt{GHSN-M38E-74GD-YLUD}):
左图在轨迹、检测示向度与已清除频道之外,画出每个定向源的前向半盘
($\pm90^\circ$,半径取该源的 $r_{\mathrm{eff}}$),箭头方向即朝向。
图中存在落在接收半径之内、却因位于背向半平面而无信号的检测点,
对应式~\eqref{eq:visibility} 的可见性条件,保留这一解释正是问题四与问题三
在候选更新上的区别;该局同样不计入正式成绩。
\begin{figure}[htbp]
\centering
\includegraphics[width=0.85\linewidth]{replay-webui-q4-directional.png}
\caption{含定向源演练局(\texttt{GHSN-M38E-74GD-YLUD})的运行回放,
紫色半盘为定向源的前向可见区域。该局为演练案例,非正式测试记录。}
\label{fig:replay4}
\end{figure}
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231

Some files were not shown because too many files have changed in this diff Show More