Compare commits

Author SHA1 Message Date
sloray 14b0ebb7fb 改写 2026-09-13 19:59:33 +08:00
sloray b7aadaa400 改一点标题 2026-09-13 19:37:19 +08:00
sloray 2cb3dd6602 AIGC 合规 .gitignore 2026-09-13 19:16:47 +08:00
sloray 662d17e21a AIGC 合规说明 2026-09-13 19:14:06 +08:00
sloray 599eea40d6 去敏感提交 2026-09-13 18:43:25 +08:00
sloray 89021eb9f8 论文完成 2026-09-13 18:05:59 +08:00
sloray 9fb15f53f5 图表换正式数据 2026-09-13 14:38:31 +08:00
sloray 705c4c9b6a 行文优化 2026-09-13 13:23:10 +08:00
Horldsence 257d1a9bd7 46 2026-09-13 11:45:01 +08:00
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
357 changed files with 40881 additions and 23673 deletions
Vendored
BIN
View File
Binary file not shown.
+55 -14
View File
@@ -1,22 +1,42 @@
# Python-generated files # ============================================================
# 2026 B 题:无线电干扰源自动定位清除 —— 版本控制忽略清单
# 原则:只跟踪"可复现结果所需的最小集合"。
# - 源码、自建模拟器、六份正式日志、测试、复现脚本与说明文档:纳入版本控制;
# - 编译产物、运行期采集目录、缓存与本机工作区:一律忽略。
# ============================================================
# ---------- Python ----------
__pycache__/ __pycache__/
*.py[oc] *.py[cod]
*.egg-info/
.venv/
.piptmp/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.ipynb_checkpoints/
# 构建与打包产物
build/ build/
dist/ dist/
wheels/ wheels/
*.egg-info
# Virtual environments # ---------- 运行期产生:不进版本控制 ----------
.venv # 官方模拟器本体与判题留档(体积大且需官方环境)
# Sim
Jammers*/ Jammers*/
# 官方演练采集产物(问题一观测、回放归档、评测机日志)
output/
# 本机临时工作区:历史留档、截图、中间草稿等
tmp/ tmp/
# 构建 Python 包时使用的临时目录
.piptmp/
uv-temp/
pip-temp/
# MATLAB 出图日志 # referee-data/ 是论文正文数据的来源,纳入版本控制(含演练与正式日志)。
scripts/matlab/*.log # 其中登录标识已统一为中性占位符,不包含参赛队号。
# LaTeX build artifacts # ---------- LaTeX 编译产物 ----------
docs/paper/build/ docs/paper/build/
docs/paper/*.aux docs/paper/*.aux
docs/paper/*.log docs/paper/*.log
@@ -25,9 +45,30 @@ docs/paper/*.fls
docs/paper/*.fdb_latexmk docs/paper/*.fdb_latexmk
docs/paper/*.xdv docs/paper/*.xdv
docs/paper/*.synctex.gz docs/paper/*.synctex.gz
# 支撑包是脚本产物(scripts/build_paper_support.py --package),不入库
docs/paper/support-materials.zip
# pytest # ---------- 编辑器 / 操作系统 ----------
.pytest_cache/ .vscode/*
!.vscode/settings.json
.idea/
.zcode/
.DS_Store
Thumbs.db
# 1 # ---------- 字体:体积过大,不进版本控制 ----------
output/ # docs/paper/fonts/ 共约 150 MB(HeitiSC / SongtiSC / Times New Roman),
# 远超支撑材料合理体积。cls 已内置系统字体回退方案,缺字体时按
# docs/paper/mcm26b-paper.cls 中的「若未携带 fonts/ 目录」注释改用系统字体即可。
docs/paper/fonts/
# AIGC 报告构建产物
docs/build/
docs/*.aux
docs/*.log
docs/*.toc
docs/*.out
docs/*.fls
docs/*.fdb_latexmk
docs/*.xdv
docs/*.synctex.gz
+67 -20
View File
@@ -1,38 +1,85 @@
{ {
// LaTeX Workshop 配方 —— 与 docs/paper/README.md 记录的口令一致:
// latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
// 论文需要多遍编译(交叉引用、目录与长表格),且必须用 XeLaTeX(ctex + fontspec),
// 因此默认配方取 latexmk,而不是单遍 xelatex。
"latex-workshop.latex.workingDirectory": "%DIR%", "latex-workshop.latex.workingDirectory": "%DIR%",
"latex-workshop.latex.tools": [ "latex-workshop.latex.tools": [
{ {
"name": "xelatex", // 默认配方使用的工具:%DOCFILE% 与 %DIR% 配合,
// 切换工作目录后传给 latexmk 的仍是纯文件名,避免绝对路径与 \input 相对路径冲突。
"name": "latexmk (xelatex, paper)",
"command": "latexmk",
"args": [
"-synctex=1",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-xelatex",
"%DOCFILE%"
],
"env": {}
},
{
// 备选:产物集中到 build/ 子目录,源码目录更干净;
// 需要清理时删除 docs/paper/build 即可(该目录已在 .gitignore 中)。
"name": "latexmk (xelatex, outdir=build)",
"command": "latexmk",
"args": [
"-synctex=1",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-xelatex",
"-outdir=build",
"%DOCFILE%"
],
"env": {}
},
{
// 单遍 xelatex 只用于排查单个文件的语法错误,交叉引用会显示为 ??
"name": "xelatex (单遍,调试用)",
"command": "xelatex", "command": "xelatex",
"args": [ "args": [
"-synctex=1", "-synctex=1",
"-interaction=nonstopmode", "-interaction=nonstopmode",
"-file-line-error", "-file-line-error",
"%DOC%" "%DOCFILE%"
] ],
}, "env": {}
{
"name": "latexmk (xelatex)",
"command": "latexmk",
"args": [
"-synctex=1",
"-interaction=nonstopmode",
"-file-line-error",
"-xelatex",
"%DOC%"
]
} }
], ],
"latex-workshop.latex.recipes": [ "latex-workshop.latex.recipes": [
{ {
"name": "xelatex", "name": "latexmk (xelatex, paper)",
"tools": ["xelatex"] "tools": ["latexmk (xelatex, paper)"]
}, },
{ {
"name": "latexmk (xelatex)", "name": "latexmk (xelatex, outdir=build)",
"tools": ["latexmk (xelatex)"] "tools": ["latexmk (xelatex, outdir=build)"]
},
{
"name": "xelatex (单遍,调试用)",
"tools": ["xelatex (单遍,调试用)"]
} }
], ],
"latex-workshop.latex.recipe.default": "latexmk (xelatex)", // 默认配方:本项目的实际编译口令(多遍 + XeLaTeX + 遇错即停)。
"cmake.sourceDirectory": "/Users/peng/sample_project" //
// 产物写入 build/ 而不是就地写 docs/paper/main.pdf:就地编译时若 PDF 预览
// 仍打开发着 main.pdf,Windows 会锁住该文件,xdvipdfmx 随即报
// "gave an error" 且不产出 PDF,表现为"编译没反应"。
// 写到 build/ 后,预览读的是 docs/paper/build/main.pdf,不再与源码目录的
// main.pdf 抢占,保存即编译也就稳定了。
// 需要就地产出时,把下面这行改为 "latexmk (xelatex, paper)",并先关闭 PDF 预览。
"latex-workshop.latex.recipe.default": "latexmk (xelatex, outdir=build)",
// 预览与 build/ 配方保持一致
"latex-workshop.latex.outDir": "build",
// 保存即编译:单次编译约十秒,开启后可直接看 PDF 预览;
// 只想手动触发时把下一行改为 "never"。
"latex-workshop.latex.autoBuild.run": "onSave",
"latex-workshop.latex.autoClean.run": "onBuilt",
"latex-workshop.latex.clean.subfolder.enabled": true,
"latex-workshop.view.pdf.viewer": "tab",
// 禁止 LaTeX Workshop 用启发式自动切换工具,始终按上面的配方执行
"latex-workshop.latex.build.forceRecipeUsage": true
} }
@@ -1,60 +0,0 @@
# 修复计划:7 项 P2 问题 + 3 项规划调整
已核实:7 个问题的定位与审核报告一致;另确认关联缺陷 `planner/__init__.py:392` 用 `>` 判断遮挡预算(实际放行 5 个被遮挡探点,注释口径为 4)。用户已确认:环探保留启发式+重写依据;误差场采用逐点白噪声哈希。
## 修改一:模拟器固定误差场(问题1)
文件 `src/mcm26b/sim/simulator.py`:
- 新增 `_bearing_error(channel, pos)`:`sha256(f"{error_seed}|{channel}|{round(x,6)}|{round(y,6)}")` 前 8 字节映射到 `[-error_half_deg, +error_half_deg)`。同一(源频道, 位置)读数恒定,不同点独立。
- `_observe`(L193)改为调用它;删除顺序随机流 `self._rng`(构造参数 `error_seed` 保留)。
- 模块 docstring 更新:同位置误差固定;分布仍是测试假设。
- 新增测试:同位置重复测量(不同 request_id)svd_deg 相同;同种子两次构造结果一致。
## 修改二:Q4 对跖探点公式(问题2)
文件 `src/mcm26b/planner/__init__.py`(L440-447):
- L447:`dx / norm * scale` → `dx * scale`(dy 同理),使偏移模长 = `min(norm, safe)`。圆心为原点、原探点 (200,0)、safe≥200 时返回 (-200,0)。
- L444 退化分支:`min(safe, 1.0)` → `safe`(方向 +x),不再钳到 1m。
- 新增单测:对跖点坐标(safe≥norm 与 safe<norm 两种)。
## 修改三:环探依据重写 + 预算一致(问题3,保留启发式)
- `planner/__init__.py` 模块 docstring、`DirectionalExtension` docstring、`_next_probe` docstring(L428-431):删除"≥180° 弧"声称,改为诚实表述:源位于环上且朝向沿外切线时可见弧可退化为单点,固定 45° 步进无最坏命中保证;梯子只提高提前命中率,完备性完全由后备清除兜底。
- `docs/协议规则表与状态机.md` §7(L166-172)同步重写。
- L392 `>` → `>=`,`config.py:85` 注释与文档同步为"4 个被遮挡探点(选点/对跖/环探×2)"。
- 新增 `scripts/benchmark_q4.py`:批量种子扫描(`--problem 4`,可配源数/定向数/种子范围/输出路径),逐局记录 measures、clears、后备清除格数与 attempts、virtual_time、completed,落盘 `output/q4/benchmark.json` 并打印汇总表——用于验证修复后的后备清除成本(审核"验证后备成本"要求),同时为论文问题4结果表提供可复现数字。
## 修改四:通信失败可靠收尾(问题4)
文件 `planner/__init__.py` `run()`(L195-205):
- `client.enter()` 纳入 try;except 顺序:`DeadlineExceeded` → `(TransportError, ProtocolError, InconsistentBelief)` → `Exception` 兜底;stop_reason 分别记 `real_budget_exhausted` / `transport_failure` / `protocol_error` / `inconsistent_belief` / `unexpected_error`,summary 增 `abort_detail`(异常类型+消息,截断)。
- `/exit` 改为尽力而为:`client.exit()` 失败不再中断收尾;`_summary` 接受 `info=None`(`exit_reason="exit_unreachable"`,virtual_time 取 `client.state.virtual_time_s`)。
- summary 增 `channel_status` 快照(每频道状态 + 候选 MEC 半径,约 20 条)——即"保存未完成状态";`--log` 的 JSONL 本就有全过程留痕。
- cli.py 无需改(`completed=false` 已映射退出码 1)。
- 新增测试:注入连续三次传输失败(若 `ScriptedFaultTransport` 不支持持久失败则扩展其故障类型),断言 `run()` 正常返回 summary 不抛异常、有 /exit 尝试留痕、退出码路径为 1。
## 修改五:Q1 近平行退化情形(问题5)
文件 `src/mcm26b/geometry/localization.py`(L216-226):
- 衰退方向判定改为对浮点平面系数的精确有理数运算(`Fraction`):候选方向仍枚举各平面法线的垂直方向 `(b,-a)/(-b,a)`;先用浮点快速预筛(max > 1e-9 直接排除,浮点误差远小于该阈值),近零候选再用 Fraction 精确检验 `∀(d,e,f): d·dir_x + e·dir_y ≤ 0`。平面系数本身是 float(精确有理数),故结论对量化输入精确,消除 2e-14 阈值与顶点过滤 eps=1e-9 的口径错配导致的漏判。
- 顶点枚举与 `PARALLEL_EPS` 预筛保持现状。
- 回归测试:审核复现输入 `[(0,0,1°), (0,0.01,358.99999999999°)]` → `status=unbounded`、`diameter_infinite=true`、不给清除保证;`scripts/benchmark_q1.py` 两档结果不回归。
## 修改六:默认舍入裕量统一(问题6)
- `config.py:75`:`bearing_rounding_margin_deg` 默认 `0.0` → `0.005`,注释改为"覆盖服务器两位小数舍入偏差(≤0.005°);库默认与 CLI 默认一致"。
- `q1` 子命令与 `geometry/__main__.py` 的 `--rounding-margin-deg` 默认保持 0(离线敏感性分析语义不变,审核未要求改)。
- 新增断言 `SearchConfig().bearing_rounding_margin_deg == 0.005`,并用默认配置复跑"边缘读数保留真源"场景;检查显式传参的现有测试不受影响;README 如有默认值描述一并更新。
## 修改七:后验评分补 near 分支(问题7)
文件 `planner/__init__.py` `_worst_posterior_radius`(L93-110):
- 增加 near 分支:`probe.clip_disk(q, field.near_radius_m, search.polygon_sides)`,多边形非空则纳入 max;direction 端点分支保留,docstring 说明 safe 盘内 no_signal 不可能故不枚举。
- 调用点(粗评/加密复评两处)传入 `near_radius_m` 与 `polygon_sides`。
- 新增测试:候选集 `[(-4,0),(4,0)]`(两点退化区域)、q=原点 → 最坏后验 ≈ 4m(修复前报 2m);`test_loop_q3/q4` 场景回归(选点可能变化,但断言均为清除状态类,预期无碍)。
## 规划调整落地
- **A(第二问定位)**:`docs/algo-2.md` §13(L231 附近)明确交付为"安全选点的启发式基线":解析安全盘证书已实现并证明;§10 区间上界未实现,采样最大值仅作粗筛,不得称严格最坏上界。
- **B(第三问)**:无代码改动;顺序约束(先修改一/四,再官方演练)在 `docs/B题建模分析与实施计划.md` 演练清单处注明。
- **C(第四问预算口径)**:`_summary` 增 `total_requests`(measures+clears);`DirectionalExtension._summary` 增 `fallback_clear_attempts`(Σ attempts);`docs/协议规则表与状态机.md` §7 后备清除段注明"后备 /clear 请求计入总请求与现实预算"。
## 测试与验收
- 每项修改配单测(新增约 10-12 项),全套 pytest 通过(95 → ~107 项)。
- 冒烟:`uv run mcm26b sim-check`;`uv run mcm26b run --problem 3/4 --self-test` 若干种子。
- 运行 `scripts/benchmark_q4.py` 记录修复后后备成本,输出落盘。
## 执行顺序
修改一(模拟器)→ 修改四(收尾)→ 修改六 → 修改七 → 修改二 → 修改三 → 修改五 → 文档同步 → 全量回归。
@@ -1,80 +0,0 @@
# 目标
在新分支 **`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 裕量定理 + 随机化真源保留性测试兜底
+164 -169
View File
@@ -1,202 +1,197 @@
# mcm26b — CUMCM 2026 B题:无线电干扰源的快速自动定位与清除 # mcm26b — CUMCM 2026 B 题:无线电干扰源的快速自动定位与清除
统一框架:有界误差几何定位、主动选点、带覆盖证明的搜索、按真实动作耗时调度。 本仓库为参赛论文(`docs/paper/main.tex`)的可复现支撑材料:
分析依据 `issue/B题.pdf`、附件1《模拟器使用说明》、附件2《通信接口说明及编程指南》。 提供四问的完整可运行程序、自建模拟器、论文全部数据与插图的生成脚本。
算法本身的推导、证明与结果分析见论文,本文件只说明**怎么跑、怎么复现**。
**文档总入口:[docs/README.md](docs/README.md)**(分类索引、阅读顺序与口径登记)。 ---
- [总体思路与流程图(四问主线、端到端流程图、执行时间速查)](docs/总体思路与流程图.md) ## 1. 环境
- [执行时间与执行步骤(计时模型、状态机、附件示例推演、实测分解)](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-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)
## 项目结构 - Python ≥ 3.10;运行时依赖仅 `requests`,测试与自检另需 `pytest`、`numpy`。
- 自建模拟器 `referee/` 为零第三方依赖的独立包,`uv sync` 时作为开发依赖一并挂载。
``` - 复现论文 PDF 另需 TeX Live(XeLaTeX、ctex、TikZ)。论文用字体在 `docs/paper/fonts/`,
referee/ # 独立评测机包(mcm26b-referee):模拟器引擎+机器狗接口+WebUI, 该目录约 150 MB、**已在 `.gitignore` 中排除、不入库**;缺失时按
# 零第三方依赖、不依赖本队代码;题意符合性对照见 referee/README.md `docs/paper/mcm26b-paper.cls` 中的「若未携带 fonts/ 目录」注释改用系统字体即可编译。
src/mcm26b/
├── config.py # 参数中心:题面常量[题面] + 工程参数[工程],唯一字面量来源
├── protocol/ # P0/P1 字段表、计时模型、状态机、串行幂等客户端(已完工)
├── geometry/ # P2 角域交会、直径、MEC、问题二安全候选盘证书(已完工)
├── belief/ # P3 逐频道候选集合 K_c:保守外包多边形 + 半平面裁剪(已完工)
├── coverage/ # P4 49点覆盖账本(网格生成 + 逐频道记录)
├── planner/ # P3 保证接收盘选点器 / P4 全向闭环 / P5 混合源定向闭环(已完工)
├── routing.py # 图论/DP 工具箱:Held-Karp 精确开放路径、2-opt/Or-opt、MST 下界、路由度量
├── evaluation/ # 日志复算与统计(已完工)
└── 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/)
评测机已抽成独立包:`referee/`(uv workspace 成员,`uv sync` 自动挂载为 dev 依赖)。
参赛队运行时代码**零导入** referee,双方仅通过附件2 的 HTTP 协议耦合——
评测机可独立服务任何客户端,参赛队代码对接官方模拟器无需任何改动。
```bash ```bash
uv run python -m referee # WebUI http://127.0.0.1:8026,接口 :2026 uv sync # 安装主包与 referee(可编辑挂载)
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
截止/加密上传属官方服务器侧策略,本地不实现;示向度误差场与场景分布是测试假设。
## 快速开始 ## 2. 运行程序(问题三 / 问题四闭环)
Python ≥ 3.10,运行时依赖仅 `requests`;测试/自检另需 `pytest` 与独立评测机 **队号不在代码中固化**:登录标识属身份信息,一律在运行时通过 `--robot-id` 传入,
包(`uv sync` 一并安装)。 代码、测试、示例与日志中只出现与赛事无关的中性占位符(如 `TEAM-0001`)。
```bash ```bash
uv sync # 安装(可编辑挂载本包与 referee) # 对接官方模拟器(先在界面确认测试已开始、接口就绪)
uv run pytest # 全部测试(含 referee 90 项 + 解耦守卫) uv run python -m mcm26b.cli run --robot-id <队号> --problem 3
uv run python -m referee # 起本地评测机(可选,配合 WebUI 回放) uv run python -m mcm26b.cli run --robot-id <队号> --problem 4
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自检:混合定向源场景 uv run python -m mcm26b.cli run --robot-id <队号> --problem 3 --local-judge
# 自检默认:源数按题面 10~16 随机;实机预算 30 分钟(--real-budget-s 可改,仅自检生效);
# 找全后持续复扫未清除频道直到限时(--config 关闭 persistent_search 可回到"找全即退") # 不连任何模拟器的自检(随机场景,内存回环)
uv run mcm26b run --robot-id <队号> --log output/run/session.jsonl # 对接模拟器正式/演练 uv run python -m mcm26b.cli run --robot-id SELFCHECK --problem 3 --self-test
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+开演练局 # 复现附件计时示例(期望 105/111/194/199 秒)
# 端口被占时主动询问换端口(非交互自动改空闲端口);WebUI 可实时观战并在"回放"Tab uv run python -m mcm26b.cli sim-check
# 逐步回看;结束自动汇总结束原因/真值统计/全清校验(--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
``` ```
## 问题一(P2,已完工) 主要开关:`--strategy {bayes,seven,grid}`(仅问题三,默认 `bayes`)、
`--base-url`、`--log <路径>`、`--real-budget-s`(仅自检生效)。
给定同频道多个检测点及 ±1° 误差示向度,计算前向角域交集(半平面交)、 ### 问题一 / 问题二
定位区域直径、直径圆覆盖判断(含反例顶点与超出距离)与最小包围圆、20m 清除余量。
```bash ```bash
uv run mcm26b q1 examples/q1/normal.json -o output/q1/result.json uv run python -m mcm26b.cli q1 examples/q1/normal.json -o output/q1/result.json
uv run python scripts/build_q1_demo.py # 重建离线交互可视化 output/q1/question1.html uv run python -m mcm26b.geometry examples/q1/triangle.json
uv run python scripts/benchmark_q1.py # MEC 基准与舍入敏感性
``` ```
离线交互页:`output/q1/question1.html`。可选 PNG 重绘需要 Pillow 与 Windows `examples/q1/` 下的五个算例分别覆盖正常多边形、劣交会、零误差线段、不相容观测与直径圆反例。
雅黑字体:`python scripts/render_q1_figures.py`;事件校核需要 Node.js:
`node scripts/verify_visualization.cjs`。二者仅为图形校核,非求解依赖。
## 协议基线(P0/P1,已完工) 仓库根的 `main.py` 只是转发到同一 CLI 的薄壳(`python main.py <子命令>`),
等价于 `python -m mcm26b.cli <子命令>`。
- `protocol.rules`:四条指令的字段表、结果码与错误语义(可执行表格); ---
- `protocol.timing`:纯计时模型,逐步复现附件示例 105/111/194/199;
- `protocol.state.RobotState`:位置/频道/虚拟时刻状态机;accepted=false 不更新,
`/clear` 不切频道,虚拟时刻以 accepted 响应为准;
- `protocol.client.SimulatorClient`:串行发送、幂等重试(同 ID 同内容原样重发)、
连接/读超时、基于 `remaining_real_duration_s` 的本地截止时钟与退出预留、JSONL 会话日志;
- 独立包 `referee`:按附件语义复现评测机(观测门限、定向覆盖角、幂等回放、
整数微秒计时、现实/虚拟截止、并发409和请求校验),真值只对评测机/测试端开放;
`harness.LocalSimTransport` 内存回环挂载(与 referee HTTP 服务器共用同一边界),
`harness.ScriptedFaultTransport` 注入断连/超时/500,`harness.PlaybackTransport`
回放官方演练日志。
本地模拟器默认执行至多1200秒程序预算、1500秒接口开放窗口,截止后通过 ## 3. 自建模拟器(`referee/`)
`SimulatorClosed` 模拟连接关闭,已登记动作可以完成。可注入 `clock`、`wall_clock`
和 `window_started_at` 测试迟到与超时。CLI `--self-test` 显式使用 `stress_mode=True`,
保留默认1800秒压力预算并扩展窗口;这不是官方演练时限。
内存传输不实现TCP连接限流、UI或日志上传;场景均匀分布及逐点哈希误差仍是测试假设。
```python 按题面附件 1《模拟器使用说明》与附件 2《通信接口说明》实现,与参赛队代码零耦合,
from mcm26b.protocol import SimulatorClient 只通过 HTTP 协议对接;论文正文的演练数据即由它产生。
client = SimulatorClient("参赛队号") # 默认 http://127.0.0.1:2026
client.enter() ```bash
outcome = client.measure((300, 400), 1) # MeasureOutcome(result, svd_deg, ...) uv run python -m referee # WebUI http://127.0.0.1:8026,接口 :2026
client.exit() uv run python -m referee --data-dir referee-data
``` ```
## 阶段状态(对照 P0–P6 计划) - WebUI 复现官方测试流程:四个模块(演练不限次 / 正式各 3 次)、确认→5 秒倒计时→
25 分钟窗口、指令与反馈流、中止需两次确认、结束原因、行为日志导出;
- "回放"页可用本局行为日志动画复现运动与扫描过程,也支持手动导入任意 JSONL;
- 自建差异(联网登录、官方校时、加密上传属官方服务器侧,本地不实现;
误差场与场景分布为测试假设)见 `referee/README.md`。
| 阶段 | 状态 | ---
## 4. 复现论文数据(`referee-data/` → 宏与表格)
`referee-data/sessions/` 收录论文正文引用的 44 局演练日志(问题三 15 局、问题四 29 局),
`referee-data/rejected-sessions/` 保留同批采集中被判定为无效的 11 局以便复核。
```bash
python scripts/build_practice_data.py # 复算演练局 → 宏、整表、CSV/JSON 快照
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py # 附录正式数据表与支撑包
```
- 第一条按题面计时模型逐动作复算,输出 `docs/paper/data/practice-*.tex` 与
`practice-results.{json,csv}`;复算值与评测机记录的最大偏差应为 **0.000000 s**。
运行结束会打印局数、全清局数与平均虚拟时间,可直接与论文数字对照。
- 第二条校验官方六份正式导出包与统计快照的对应关系(来源匹配需原电脑的官方统计队列)。
- 第三条生成附录表格与 `support-materials.zip`(`--package`)。
### 需要重新采集演练局时
```bash
uv run python scripts/run_practice_episodes.py --problem 3 --robot-id <队号> \
--counts 10,13,16 --seeds 300-314
uv run python scripts/run_practice_episodes.py --problem 4 --robot-id <队号> \
--counts 10,13,16 --directional-cycle 3,6,8 --seeds 400-439
```
脚本启动评测机并调用生产策略对接接口,真值与行为日志由评测机写入
`referee-data/sessions/`。判定标准为
`清完全部源 ∧ 无缺席误判 ∧ 主动退出 ∧ 零异常 ∧ 零失败清除`;
只看"是否清完"会放过"清完了但中途大量 `/clear` 打空"的无效局。
---
## 5. 复现论文插图
```bash
uv run python scripts/plot_paper_figures.py # 生成 docs/figures/*.png
```
脚本只读 `docs/paper/data/` 下的复算结果与第四问覆盖计划,不手抄任何数字;
图内文字与图注数据均来自同一次复算。插图清单、数据来源与生成命令见
[`docs/figures/README.md`](docs/figures/README.md) 与 `docs/figures/manifest.json`。
---
## 6. 编译论文
```bash
cd docs/paper
latexmk -xelatex -interaction=nonstopmode -halt-on-error -outdir=build main.tex # 产物 build/main.pdf
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex # 产物 main.pdf
```
两条口令都完成交叉引用所需的多遍编译。VS Code 的 LaTeX Workshop 配置见
`.vscode/settings.json`(默认配方为前者)。细节与注意事项见
[`docs/paper/README.md`](docs/paper/README.md)。
---
## 7. 单文件核心代码与测试
```bash
uv run pytest # 全部测试(本包 + referee)
python scripts/build_core_notebook.py # 由 src/mcm26b 生成单文件核心代码
python scripts/run_notebook_cells.py # 顺序执行 notebook 全部代码单元(零依赖自检)
```
[`mcm26b-核心代码.ipynb`](mcm26b-核心代码.ipynb) 是四问生产实现的单文件版本
(约 5000 行),不含评测机、命令行外壳、采集/绘图脚本与测试;
`src/mcm26b/` 保留与之逐字对应的模块化版本。
---
## 8. AIGC 使用合规记录
竞赛要求如实记录 AI 使用情况。本项目使用的 AI 编程助手(ChatGPT/Codex 桌面版、
DeepSeek Harness)在本目录内的全部会话,已从本机客户端存储中提取并汇编为一份合规记录:
| 文件 | 说明 |
| --- | --- | | --- | --- |
| P0 规则固定(规则表/状态机/字段表,示例逐步 105/111/194/199) | ✅ 完成 | | [`docs/aigc-compliance-record.tex`](docs/aigc-compliance-record.tex) | LaTeX 源文件(58 页):编制说明、使用总览、环境与权限配置、逐会话逐轮次详细记录、合规性说明、附录 |
| P1 协议基线(客户端 + 自建回放/故障注入测试) | ✅ 完成 | | [`docs/aigc-compliance-record.pdf`](docs/aigc-compliance-record.pdf) | 编译成品 |
| P2 几何内核(角域交、直径、MEC、证书) | ✅ 完成(迁入 `mcm26b.geometry`) |
| P3 问题二选点(K1、候选区域 Q、采样评估) | ✅ 完成(belief + 选点器,测试固化) |
| P4 问题3全向闭环(49点覆盖、定位队列、清除、停止) | ✅ 官方问题3演练单案例通过(10/10全清,[验证报告](output/q3/practice/20260911-port2026/report.md)) |
| 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)要点 记录覆盖 **11 个会话 / 34 个工作轮次 / 31 条人工提示词 / 1508 次工具调用**,
跨 2026-09-10 20:52 至 2026-09-13 18:51(UTC+8)。每条目给出该轮次的
**发生时间、执行环境与上下文(含提示词全文)、大模型实际操作概要**(工具调用分类、
写入文件、代表性命令、模型对外结论)。
- **S1 度量**:`scripts/run_suite.py` 逐局新增 `route_gap_vs_offline`、 编译(需 ctex / longtable / booktabs):
`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`。
贝叶斯闭环 `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 次清除,现实耗时
7.629 秒。原始请求响应、官方产物及离线审计见上述验证报告。原计时复算的
541 处微秒精度误报也已记录;按官方整数微秒累计规则独立复算后,552 个
响应时刻全部吻合。生产计时复算函数保持原样。
```bash ```bash
uv run python scripts/verify_q3_practice.py output/q3/practice/20260911-port2026 cd docs
latexmk -xelatex -interaction=nonstopmode aigc-compliance-record.tex
``` ```
本项目只使用官方**演练**采集观测,从未使用正式测试。`scripts/capture_practice.py` > 记录由审计脚本从 `~/.codex/sessions/`、`~/.codex/state_5.sqlite`、`~/.dsh/sessions/`
不会启动测试:必须先在界面确认“问题3演练”、接口已就绪,再提供 > 中**工作目录等于本仓库**的会话自动提取生成,原始记录未经改写;提取与统计脚本
`--practice-confirmed` 与本局案例编码。正式测试由团队按计划另行安排; > 存于 `tmp/aigc/`(该目录按 `.gitignore` 不入库)。会话如何归属本项目、
加密日志上传上限 2MB,程序避免异常高频循环。 > 哪些存储被核查后排除,见记录第 1 节与附录 A.4。
---
## 9. 目录结构
```
src/mcm26b/ 四问求解实现:config / protocol / geometry / belief / coverage /
guarantee / planner / particles / routing / evaluation / harness
referee/ 自建模拟器(独立包,零第三方依赖,不依赖本队代码)
referee-data/ 论文数据来源:演练日志(sessions / rejected-sessions)、正式统计快照
tests/ 本包测试(含运行时零导入 referee 的解耦守卫)
docs/paper/ 论文 LaTeX 源、数据与宏、编译说明
docs/figures/ 论文插图(PNG)与来源清单
docs/ AIGC 使用合规记录(aigc-compliance-record.tex / .pdf)与本目录索引
examples/ 问题一算例与回放归档
scripts/ 复现脚本:数据复算、插图绘制、正式数据核对、采集与基准
project/ 四问"最优实现 / 基线实现"配对对比框架(论文对比表的来源)
issue/ 赛题、附件与格式规范(原始材料)
```
`docs/paper/REWRITE_NOTES.md` 记录论文各轮修订与验证过程(含数据重采与问题修复)。
-191
View File
@@ -1,191 +0,0 @@
# 2026 高教社杯 B 题(无线电干扰源快速自动定位与清除)问题 3、4 相关论文列表
> 排序原则:与问题 3/4 直接应用方案越相关,排名越靠前。
> 标注:【Q3】主要支撑问题 3(全向干扰源:搜索→交会定位→逼近清除的整体策略);【Q4】主要支撑问题 4(定向干扰源、方向未知);【Q3·Q4】两者通用。
---
## 一、无线电信号源的移动机器人搜索与定位(与赛题任务框架最直接对应)
1. **Simultaneous Localization of Multiple Unknown and Transient Radio Sources Using a Mobile Robot** 【Q3】
D. Song, C. Y. Kim, J. Yi. *IEEE Transactions on Robotics*, 2012(被引 75)
移动机器人沿路径采集 RSS,在线同时定位**多个未知无线电源**——与问题 3“干扰源个数未知、机器狗边走边测”的设定几乎同构,可直接借鉴其多源数据关联与定位框架。
https://ieeexplore.ieee.org/abstract/document/6145760/ | [PDF](http://coewww.rutgers.edu/~jgyi/pdfs/IEEE_TRO_2012.pdf)
2. **Cooperative Search of Multiple Unknown Transient Radio Sources Using Multiple Paired Mobile Robots** 【Q3】
C. Y. Kim, D. Song, Y. Xu, J. Yi, X. Wu. *IEEE Transactions on Robotics*, 2014(被引 67)
上文的协同多机器人版本:成对机器人搜索多个未知瞬态无线电源,含**源匿名(信号不可区分)**处理——对应本题不同频道区分信号的思路对照。
https://ieeexplore.ieee.org/abstract/document/6857360/ | [PDF](https://jingangyi.rutgers.edu/pdfs/IEEE_TRO_2014.pdf)
3. **RSS Gradient-Assisted Frontier Exploration and Radio Source Localization** 【Q3·Q4】
J. N. Twigg, J. R. Fink, L. Y. Paul, B. M. Sadler. *ICRA*, 2012(被引 77)
用 RSS 梯度引导前沿探索(frontier exploration),让机器人高效逼近信号源而避免盲目遍历——可直接支撑问题 3/4 中“初探→逼近”的两阶段搜索策略设计。
https://ieeexplore.ieee.org/abstract/document/6225059/
4. **Radio Source Localization Using Sparse Signal Measurements from Uncrewed Ground Vehicles** 【Q3】
A. Perera, V. P. Tran, S. Anavatti, K. Kasmarik, et al. *arXiv:2312.03493*, 2023
地面无人车(与机器狗同类平台)在室外区域以稀疏采样定位无线电源,含完整仿真评估流程——与用模拟器演练测试的实验设计直接可参照。
https://arxiv.org/abs/2312.03493 | [PDF](https://arxiv.org/pdf/2312.03493)
5. **Particle Filter Based Localization of Access Points Using Direction of Arrival on Mobile Robots** 【Q3·Q4】
R. Parashar, R. Parasuraman. *IEEE VTC*, 2020(被引 13)
移动机器人利用**到达方向(DOA,即示向度)**+ 粒子滤波递推定位无线电源——对应本题“多次测向→逐步缩小定位区域”的序贯定位算法。
https://ieeexplore.ieee.org/abstract/document/9348813/
6. **Online Methods for Radio Signal Mapping with Mobile Robots** 【Q3】
J. Fink, V. Kumar. *ICRA*, 2010(被引 166)
移动机器人边走边在线构建信号场强地图并估计源位置,可支撑本题“场强仅随距离衰减”假设下的快速粗定位与路径在线修正。
https://ieeexplore.ieee.org/abstract/document/5509574/
7. **Distributed Algorithms for Stochastic Source Seeking with Mobile Robot Networks** 【Q3】
N. A. Atanasov, J. Le Ny, et al. *ASME JDSMC*, 2015(被引 117)
随机源搜索的分布式算法,以无线电源定位为应用背景,理论保证强,可作为逼近清除阶段的收敛性依据。
https://arxiv.org/pdf/1402.0051
8. **Cooperative Multi-Robot Estimation and Control for Radio Source Localization** 【Q3】
B. Charrow, N. Michael, V. Kumar. *IJRR*, 2014(被引 84)
仅测距射频下的多机器人协同估计与控制,信息论驱动( mutual information )选择测量动作——可迁移为“选哪个频道、去哪测”的决策准则。
https://journals.sagepub.com/doi/abs/10.1177/0278364913500542
9. **A Review of Radio Frequency Based Localisation for Aerial and Ground Robots with 5G Future Perspectives** 【Q3·Q4】
M. Kabiri, C. Cimarelli, H. Bavle, et al. *Sensors*, 2022(被引 48)
综述:RSS/AOA 等 RF 定位手段在空地机器人上的比较,适合写论文的“研究现状”部分并帮助选型。
https://www.mdpi.com/1424-8220/23/1/188
---
## 二、测向(Bearing-only/AOA)交会定位与观测点优化(第二检测点选择、定位区域收缩)
10. **Gathering Bearing Data for Target Localization** 【Q3】
H. Bayram, J. Vander Hook, V. Isler. *IEEE Robotics and Automation Letters*, 2016(被引 38)
给定若干潜在目标区域与不确定度要求,规划采集方位数据的机器人路径,归结为 **TSP with Neighborhoods 的信息路径规划**——与问题 3“多个待清除目标 + 逐个交会定位 + 总时间最短”的任务排序高度吻合。
https://ieeexplore.ieee.org/abstract/document/7390217/ | [PDF](https://conservancy.umn.edu/bitstreams/ff2d34bf-0056-4a29-be84-eb7355b527e4/download)
11. **Relationship Between Error Ellipse and Error Quadrilateral in Bearing-Only Localization** 【Q3·Q4】
B. Wang, Y. Hu, C. Yu, et al. *IEEE Access*, 2026
直接研究双站测向定位的**误差四边形**(即题目中的“定位区域”)与误差椭圆的关系,论证几何构型与精度的关系——与问题 1 的定位区域、问题 2/3 的测点选择理论完全对口。
https://ieeexplore.ieee.org/abstract/document/11677056/
12. **Bearing-Only Active Target Localization Strategies for a System of Two Communicating Mobile Robots** 【Q3】
J. Vander Hook, P. Tokekar. 技术报告, 2013
主动测向定位:规划“去哪测”以最优压缩目标位置不确定性,给出最优双机器人测量构型——第二检测点选择策略的系统性理论。
https://conservancy.umn.edu/items/38693c39-7a3b-400b-8926-f9aa194a8824
13. **Multi-UAV Trajectory Optimization for Bearing-Only Localization in GPS Denied Environments** 【Q3】
A. Sciacchitano, L. Mudrik, S. Kragelund, et al. *arXiv:2602.11116*, 2026
面向测向定位的轨迹优化,在视场等约束下优化测量构型——可借鉴其代价函数与滚动优化框架设计机器狗的测点序列。
https://arxiv.org/abs/2602.11116 | [PDF](https://arxiv.org/pdf/2602.11116)
14. **Optimal Sensor Placement for Multi-Source AOA Localisation with Distance-Dependent Noise Model** 【Q3·Q4】
M. Hamdollahzadeh, R. Amiri, et al. *IET Radar, Sonar & Navigation*, 2019(被引 37)
**多源** AOA 定位的最优布点,含递推 CRLB 表示——支撑多干扰源场景下测点位置的全局优化。
https://ietresearch.onlinelibrary.wiley.com/doi/abs/10.1049/iet-rsn.2018.5426
15. **Optimal Sensor Placement for Target Localization Using Hybrid RSS, AOA and TOA Measurements** 【Q3】
S. Xu, et al. *IEEE Communications Letters*, 2020(被引 110)
混合量测下最小化 tr(CRLB) 的最优传感器-目标几何,可用于论证“交会角接近 90°”等构型结论的严谨性。
https://ieeexplore.ieee.org/abstract/document/9097832/
16. **Geometry Influence on GDOP in TOA and AOA Positioning Systems** 【Q3·Q4】
X. Lv, K. Liu, P. Hu. *ICNSC*, 2010(被引 58)
基于 CRLB 分析新增观测点对 GDOP 的影响——回答“再加一个检测点值不值、放在哪”的定量依据。
https://ieeexplore.ieee.org/abstract/document/5480336/
17. **定向交叉定位配站问题与定位精度分析**(中文)【Q3·Q4】
张艳花. 《电讯技术》, 2008(被引 2)
中文文献,专门讨论测向交叉定位的配站(交会角)与精度关系,便于直接引用“交会角 90° 最优、30°~150° 可用”类结论。
(《电讯技术》2008 年刊,可通过知网/万方检索全文)
18. **Trajectory Planning for Multiple Robots in Bearing-Only Target Localisation** 【Q3】
C. Leung, S. Huang, G. Dissanayake, et al. *IROS*, 2005(被引 16)
早期经典:以定位精度为目标做测向机器人轨迹规划,适合作为主动定位问题的建模起点引用。
https://ieeexplore.ieee.org/abstract/document/1545322/
19. **Observer Path Planning for Bearings-Only Localization Considering Limited Field of View** 【Q3】
A. Nayebi Astaneh, N. Pariz, M. O. Naghibi Sistani. *Journal of Control*, 2014
考虑传感器视场约束的观测者实时路径规划——对应测向机需旋转天线搜索示向度的过程约束。
https://joc.kntu.ac.ir/browse.php?a_id=37&sid=1&slc_lang=en&ftxt=1
---
## 三、多目标搜索理论与任务规划(10–16 个未知目标的区域搜索框架)
20. **Multi-Vehicle Bayesian Search for Multiple Lost Targets** 【Q3·Q4】
E. M. Wong, F. Bourgault, T. Furukawa. *ICRA*, 2005(被引 193)
多目标贝叶斯搜索经典:概率图 + 贝叶斯更新 + 搜索资源分配——问题 3“干扰源个数未知、需确保全部清除”的终止判据与覆盖率论证可由此构建。
https://ieeexplore.ieee.org/abstract/document/1570598/
21. **MPC-Based Cooperative Multiagent Search for Multiple Targets Using a Bayesian Framework** 【Q3】
H. Xiao, R. Cui, D. Xu, Y. Li. *Journal of Field Robotics*, 2024(被引 9)
模型预测控制 + 贝叶斯框架的多目标搜索路径规划,滚动时域决策——适合借鉴为本题“边测边重规划”的在线策略。
https://onlinelibrary.wiley.com/doi/abs/10.1002/rob.22382
22. **Multi-Agent Informed Path Planning Using the Probability Hypothesis Density** 【Q3·Q4】
J. Olofsson, G. Hendeby, T. R. Lauknes, T. A. Johansen. *Autonomous Robots*, 2020(被引 27)
用 PHD(随机有限集)表示**目标个数与位置均未知**的场景并做信息路径规划——与“干扰源总数 10–16 但具体未知”的设定直接对应。
https://link.springer.com/article/10.1007/s10514-020-09904-1 | [PDF](https://torarnj.folk.ntnu.no/AURO2020a.pdf)
23. **Probabilistic Search for Tracking Targets: Theory and Modern Applications** 【Q3·Q4】
E. Kagan, I. Ben-Gal. Wiley, 2013(被引 27)
搜索论专著:最优搜索策略、马尔可夫决策模型,为搜索策略提供理论根基(Stone 最优搜索理论的应用版)。
https://onlinelibrary.wiley.com/doi/abs/10.1002/9781118596593
24. **Efficient Multi-Robot Search for a Moving Target** 【Q3】
G. Hollinger, S. Singh, J. Djugash, A. Kehagias. *IJRR*, 2009(被引 254)
多机器人高效搜索路径规划(ESPP)并提出可证明性能的近似算法;本题目标虽静止,其“最大化截获概率”的建模与保证思路可迁移。
https://journals.sagepub.com/doi/abs/10.1177/0278364908099853
---
## 四、定向干扰源定位(问题 4 特有:覆盖角 180°、方向未知)
25. **UAV-Based Efficient Joint Estimation of Directional Emitter Position and Transmission Orientation** 【Q4】
T. Ruan, Y. Huang, L. Zhou, Q. Wu, et al. *IEEE Wireless Communications Letters*, 2024(被引 2)
**联合估计定向发射源的位置与发射方向**(RSS-AOA 混合量测)——问题 4“定向方向未知”的最直接对应文献。
https://ieeexplore.ieee.org/abstract/document/10802920/
26. **Efficient Localization of Directional Emitters via Joint Beampattern Estimation** 【Q4】
F. Williams, A. Pemasiri, D. Jayalath, T. Martin, et al. *arXiv:2411.04364*, 2024
联合波束方向图估计实现定向发射源高效定位,并分析波束宽度/朝向对定位的灵敏度(CRLB)——可支撑定向源需从侧向多点探测的策略设计。
https://arxiv.org/abs/2411.04364 | [PDF](https://arxiv.org/pdf/2411.04364)
27. **Location Errors of Directional Emitters in AOA System for Non-Line-of-Sight Conditions** 【Q4】
J. M. Kelner, C. Ziółkowski, et al. *SPIE*, 2019
分析发射天线朝向对 AOA 示向度误差的影响——定向干扰源场景下误差建模的参考。
https://www.spiedigitallibrary.org/conference-proceedings-of-spie/11055/110551G/Location-errors-of-directional-emitters-in-AOA-system-for-non/10.1117/12.2525060.short
28. **ALRD: AoA Localization with RSSI Differences of Directional Antennas for Wireless Sensor Networks** 【Q4】
J. R. Jiang, C. M. Lin, F. Y. Lin, et al. *Int. J. of Distributed Sensor Networks*, 2013(被引 133)
利用定向天线朝向差分的 RSSI 做定位,对“信号只在 180° 覆盖角内可被接收”的探测盲区分析与补盲路径设计有参考价值。
https://journals.sagepub.com/doi/abs/10.1155/2013/529489
---
## 五、信息路径规划方法与综述(通用方法库)
29. **Information-Driven Path Planning** 【Q3·Q4】
S. Bai, T. Shan, F. Chen, L. Liu, B. Englot. *Current Robotics Reports*, 2021(被引 53)
信息驱动路径规划综述,梳理目标函数(信息增益、熵减)与平台约束,适合搭建问题 3 的总体算法框架。
https://link.springer.com/article/10.1007/s43154-021-00045-6
30. **Adaptive Informative Path Planning in Metric Spaces** 【Q3】
Z. W. Lim, D. Hsu, W. S. Lee. *IJRR*, 2016(被引 101)
带自适应性的信息路径规划,具次模性与近似保证——为“总时间越短越好”的目标函数设计提供理论工具。
https://journals.sagepub.com/doi/abs/10.1177/0278364915596378
31. **Informative Path Planning for Active Field Mapping Under Localization Uncertainty** 【Q3】
M. Popović, T. Vidal-Calleja, J. J. Chung, et al. *ICRA*, 2020(被引 62)
不确定性感知的信息路径规划,平衡探索与精度——对应本题“检测耗时 vs 路径耗时”的权衡。
https://ieeexplore.ieee.org/abstract/document/9197034/
32. **基于深度强化学习的短波自主检测与定位方法**(中文)【Q3】
冯祺玥, 唐涛, 张昀普, 王鼎, 吴志东. 《通信学报》, 2026
中文文献:多站测角交会定位 + 深度强化学习自主检测定位,提供国内无线电监测定位的表述与流程范式。
https://www.joconline.com.cn/rc-pub/front/front-article/download/155129217/lowqualitypdf/%E5%9F%BA%E4%BA%8E%E6%B7%B1%E5%BA%A6%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0%E7%9A%84%E7%9F%AD%E6%B3%A2%E8%87%AA%E4%B8%BB%E6%A3%80%E6%B5%8B%E4%B8%8E%E5%AE%9A%E4%BD%8D%E6%96%B9%E6%B3%95.pdf
---
### 使用建议
- **问题 3 算法主线**:以 1、3、10、20、22 为核心——贝叶斯概率图搜索(20/22)确定探测顺序与终止条件,DOA 序贯定位(5)+ 交会几何优化(10/11/15/17)选测点,RSS 梯度逼近(3)完成 20 m 内清除前逼近。
- **问题 4 增量**:在问题 3 框架上叠加 25/26 的“位置 + 方向”联合估计,并用 28 的思路设计覆盖角盲区的补测路径。
- **论文写作**:现状综述引用 9、29、32;精度理论引用 11、14、15、16。
+24 -63
View File
@@ -1,71 +1,32 @@
# docs 索引 # docs 索引
仓库文档分四类:**总览与流程**、**逐问算法**、**验证与对比**、**论文与插图**。 论文本身已完整给出四问的模型、推导与结果分析,因此本目录不再保留算法说明文档,
阅读顺序建议按下面的表格从上到下。 只留下复现所需的说明与材料。
## 总览与流程 | 入口 | 内容 |
| 文档 | 内容 |
| --- | --- | | --- | --- |
| [`总体思路与流程图.md`](总体思路与流程图.md) | 四问主线、端到端流程图(mermaid)、执行步骤与执行时间速查、交付物与验收锚点 | | [`../README.md`](../README.md) | **总入口**:环境、运行程序、复现论文数据与插图、编译论文 |
| [`执行时间与执行步骤.md`](执行时间与执行步骤.md) | 计时模型、会话状态机与动作回合、附件示例逐步推演、实测日志分解、时间预算与安全阀 | | [`aigc-compliance-record.tex`](aigc-compliance-record.tex) | **AIGC 使用合规记录**:本机 AI 客户端会话的完整审计留痕(58 页) |
| [`B题建模分析与实施计划.md`](B题建模分析与实施计划.md) | 四问建模取舍与 P0–P7 实施计划(最早的设计文档) | | [`aigc-compliance-record.pdf`](aigc-compliance-record.pdf) | 上述记录的编译成品 |
| [`协议规则表与状态机.md`](协议规则表与状态机.md) | 指令字段表、结果码、计时模型、状态机(P0 交付) | | [`paper/README.md`](paper/README.md) | 论文工程说明:数据分工、重算与编译步骤、VS Code 配方 |
| [`paper/REWRITE_NOTES.md`](paper/REWRITE_NOTES.md) | 论文各轮修订与验证记录(含演练数据重采与问题修复) |
| [`paper/main.tex`](paper/main.tex) | 论文 LaTeX 源文件(正文与附录) |
| [`paper/data/`](paper/data) | 论文引用的全部数据、宏与整表(由脚本生成) |
| [`figures/README.md`](figures/README.md) | 论文插图清单:每张图的数据来源与生成命令 |
| [`figures/manifest.json`](figures/manifest.json) | 插图来源清单(可机读) |
## 逐问算法 `docs/paper/fonts/` 存放论文编译用字体(约 150 MB),已在 `.gitignore` 中排除、不入库;
缺失时按 `paper/mcm26b-paper.cls` 内的注释改用系统字体。
| 文档 | 内容 | ## 复现顺序
| --- | --- |
| [`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` 最小命中集、位置×朝向联合信念、后备盲清 |
## 优化与验证 ```bash
python scripts/build_practice_data.py # 演练局复算 → 宏与表格
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py # 附录正式数据表(--package 另出支撑包)
uv run python scripts/plot_paper_figures.py
cd docs/paper && latexmk -xelatex -interaction=nonstopmode -halt-on-error -outdir=build main.tex
```
| 文档 | 内容 | > `docs/paper/data/*.tex` 由脚本生成,一律使用 LF 换行:CRLF 会让 `\input` 进表格的行
| --- | --- | > 在 TeX 缓冲中提前 `\par` 化,触发 booktabs 的 “Misplaced \noalign”。
| [`第三四问架构优化与运行提速方案.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 引号混淆。
Binary file not shown.
File diff suppressed because one or more lines are too long
-266
View File
@@ -1,266 +0,0 @@
# 问题一算法说明:有界测向误差下的角域交会、直径与最小包围圆
> 本文按**当前仓库实现**重写,替代早期只描述理论方案的版本。
> 问题一生产入口为 `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$,同一地点重复测量误差不变;
示向度从正东逆时针计量。问题一要求定位区域的直径,并判断“以该直径为
直径的圆”能否覆盖定位区域。
| 层次 | 文件 | 内容 |
| --- | --- | --- |
| 生产内核 | `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 个随机交会算例的配对结果 |
```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
```
`--rounding-margin-deg` 默认 $0$;库内在线求解默认把服务器两位小数舍入的
$\le0.005^\circ$ 偏差计入有效半角,这是**实现口径**,不是题面新增误差。
问题一 CLI 只有显式传参才附加该裕量,以便同时报告纯 $1^\circ$ 与
$1.005^\circ$ 两套敏感性结果。
## 2. 模型:把角域写成半平面交
令方向向量 $u(\alpha)=(\cos\alpha,\sin\alpha)$,二维叉积
$[a,b]=a_xb_y-a_yb_x$。设误差半角为 $\delta$(题设主模型 $\delta=1^\circ$),
真实源 $g$ 属于闭前向角域
$$
W(s,\theta,\delta)=\{g:\ [u(\theta-\delta),g-s]\ge0,\
[u(\theta+\delta),g-s]\le0,\ u(\theta)\cdot(g-s)\ge0\}.
$$
对应实现中的三条半平面 $a x+b y\le c$ 为
| 约束 | $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$ |
第三条前向约束在 $0<\delta<90^\circ$ 时冗余,但在 $\delta=0$ 时必须保留,
否则两条边界只确定一条直线而非一条射线。角域顶点按闭集处理;这是保守的
几何含义,不表示边界点仍能返回方向读数。
$P=\bigcap_i W_i$ 是有限条闭半平面之交,因而是闭凸集;可能为空、单点、
线段、有界多边形或无界多边形。实现不做正态、均匀或独立误差假设,
只使用题面给定的有界误差。
## 3. 精确算法流程
`solve(measurements, eps)` 的步骤如下。
### 3.1 坐标归一化与边界交点枚举
先对所有检测点做平移和统一缩放,使数值量级接近单位坐标;结果再换回原坐标。
1. 生成 $k=3n$ 条半平面。
2. 枚举全部边界直线对($\binom{k}{2}$ 对),用行列式
$\Delta=ae-bd$ 求交点;仅在 $\Delta$ 恰为 $0$ 时跳过,近平行对也保留,
因为其交点可能是细长区域的合法远端顶点。
3. 保留满足全部约束的交点;可行性判定使用随坐标量级缩放的位置容差
`eps * max(1, |x|, |y|)`,避免大坐标下绝对残差被误判为不可行。
### 3.2 空集、有界与无界
- 无观测:$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`,不伪造有限数值。
### 3.3 凸包与状态分类
将可行交点去重后,用 Andrew 单调链求凸包(逆时针、无重复首尾点)。
按顶点数与维数返回:
| `status` | 含义 | 直径 | 最小包围圆 |
| --- | --- | --- | --- |
| `empty` | 观测不相容 | 未定义(`null`) | 不计算 |
| `unbounded` | 存在非零衰退方向 | $+\infty$(`null` + 标志) | 不计算 |
| `point` | 单点 | 0 | 半径 0 |
| `segment` | 线段 | 两端点距离 | 半径 = 直径 / 2 |
| `polygon` | 二维有界凸多边形 | 见 §3.4 | 见 §3.6 |
### 3.4 直径:只需枚举顶点对(已证明)
**定理 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,
线段返回端点距离。
### 3.5 直径圆覆盖:唯一候选圆心是直径中点(已证明)
**定理 2.** 取任意一组最远顶点 $a,b$,$D=\lVert a-b\rVert$,
$o=(a+b)/2$。存在直径为 $D$ 的覆盖圆,当且仅当所有顶点 $v_i$ 满足
$\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$ 只能在两点中点。圆盘是凸集,检查全部顶点即检查整个凸包。
实现输出 `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 |
两种口径下直径圆都不覆盖定位区域,MEC 半径都小于 20 m,但该演练没有
真实清除动作,不能据此声称实测定位误差或全清成功率。
`project/q1` 的 40 个随机交会算例(冻结算例与独立参考解对拍):
| 方法 | 状态准确率 | 直径平均绝对误差 | 直径圆覆盖准确率 | 平均耗时 |
| --- | ---: | ---: | ---: | ---: |
| 生产 `solve` | 100% | 0.000 m | 100% | 0.086 ms |
| 朴素基线(网格 + 包围盒对角线) | 100% | 4.583 m | 52.5% | 15.932 ms |
## 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. 本地题面 `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`. 在线闭环中的保守信念、负观测与联合信念差异。
-312
View File
@@ -1,312 +0,0 @@
# 问题二算法说明:保证接收的第二检测点选择(当前实现)
> 本文按**当前仓库实际运行路径**重写。生产入口是
> `src/mcm26b/planner.select_second_point`,其核心选点器是
> `select_measurement_point`;`src/mcm26b/geometry/planning.py` 中的
> `first_bearing_candidate_disk` 是解析充分证书与独立校核工具,
> 并不在运行时被 `select_second_point` 调用(见 §6 核对结果)。
## 1. 问题、实现入口与交付物
问题二给定一个全向干扰源的首次有效示向度 $(s,\theta)$,要求给出第二个
检测点 $q$ 的选择策略、定位效果与候选区域。两个约束必须同时满足:
1. **接收保证**:$q$ 到所有仍可能为真源的位置都不超过有效接收半径下界
$R_-=1000$ m,否则第二次测量可能返回 `no_signal`,几何收益落空;
2. **定位效果**:在保证接收的候选点中,使第二次测量后的保守位置集合
尽量小。当前实现以“最坏后验最小包围半径”为主指标,以移动耗时为次指标。
| 层次 | 文件 | 内容 |
| --- | --- | --- |
| 在线入口 | `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 个随机首次示向度算例配对结果 |
## 2. 首次观测后的候选集 $K_1$
### 2.1 精确定义
记首次检测点为 $s$、首次示向度为 $\theta$、误差半角
$\delta_{\rm eff}=1^\circ+0.005^\circ$(题设误差 + 两位小数舍入裕量),
有效接收半径上界 $R_+=1500$ m,源位置圆域 $\Omega=B(0,1800)$。则
$$
K_1=\Omega\cap W(s,\theta,\delta_{\rm eff})\cap B(s,R_+).
$$
真实源在合法观测下必属于 $K_1$。注意 $B(s,R_+)$ 的半径是**接收半径上界**:
`direction` 读数本身就隐含测得距离不超过 $R_+$;而 $B(s,R_-)$ 不在 $K_1$
之中,因为第二次是否收到信号正是本问要保证的事。
### 2.2 实际保守表示
在线实现不直接处理圆弧边界,而用以下外切多边形表示 $K_1$:
- 先取 $\Omega$ 的 192 边外切正多边形 $P_M(0,1800)$;
- 再与 $B(s,1500)$ 的 192 边外切正多边形求交;
- 最后用 Sutherland–Hodgman 逐半平面裁剪两条测向边界。
设圆盘外切多边形相对圆盘的最大径向超出为 $R(1-\cos(\pi/M))$。实现把每次
圆盘裁剪的该量累加到 `CandidateRegion.slack_m`,因此维护的多边形满足
$K_1\subseteq P_{\rm out}$,且 `slack_m` 是可审计的外包半径余量。
这条路径与问题一的区别:问题一在纯角域上枚举边界直线交点,精确处理
空集/无界;问题二候选集一开始就被 $\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$。
## 3. 保证接收区域
### 3.1 一般集合形式
对非空紧候选集 $K$,定义
$$
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_-).
$$
$F_K$ 是凸函数且 1-Lipschitz,$\mathcal Q(K)$ 是闭凸集。
**定理 1.** $\mathcal Q(K)\ne\varnothing$ 当且仅当
$\rho(K)=\min_q F_K(q)\le R_-$,其中 $\rho(K)$ 是 $K$ 的最小包围半径。
证明:$\min_q F_K(q)$ 就是最小包围半径;存在 $q$ 使 $F_K(q)\le R_-$ 与
其最小值不超过 $R_-$ 等价。证毕。
### 3.2 多边形外包给出的充分安全盘
设在线维护的多边形 $P_{\rm out}\supseteq K$,其最小包围圆为
$B(m,\rho_{\rm out})$。任取
$$
q\in B(m,\ R_- - \rho_{\rm out}-\eta),\qquad \eta=10^{-3}\ {\rm m},
$$
则对任意真源 $g\in K$:
$$
\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.
$$
实现中 `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:
- 这**不能**证明真实 $\mathcal Q(K)$ 为空,只说明这个充分证书不够紧;
- 实现退化为探索 MEC 圆心,并在 `reason` 中明确标注“可能 `no_signal`”。
### 3.3 首次截断角域的解析候选盘
完全去掉 $\Omega$ 后,完整的截断角域
$S=W(s,\theta,\delta)\cap B(s,R_+)$ 有一个精确最小包围圆:
$$
c=s+a\,u(\theta),\qquad a=\frac{R_+}{2\cos\delta},\qquad
S\subseteq B(c,a),
$$
对 $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$。于是
$$
\mathcal Q_0=B(c,\ R_- - a)
$$
是保证接收的充分候选区域。题设 $\delta_{\rm eff}\le1.005^\circ$ 时:
| 误差半角 | 轴向中心距离 $a$ / m | 安全候选半径 $R_- - a$ / m |
| --- | ---: | ---: |
| $1^\circ$ | 750.114246 | 249.885754 |
| $1.005^\circ$ | 750.115391 | 249.884609 |
该解析盘由 `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)。
## 4. 实际选点算法
### 4.1 入口伪代码
```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)
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、最坏后验半径、移动耗时、理由
```
当前 `search.candidate_rings=(0.0,0.4,0.75,1.0)`、`candidate_angles=12`,
去重后实际是 **37 个候选点**(圆心 + 3 环 × 12 方向)。所有点严格属于
保证接收盘,因此第二次测量不会因距离不足返回 `no_signal`。
### 4.2 最坏后验评估
对候选点 $q$,最坏后验半径按以下有限枚举计算:
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. 以上全部有效半径取最大值,就是该候选点的评估值。
该评估是**启发式上界近似**:它只对目标边界采样和读数区间端点取值,
没有覆盖连续集合上所有可能读数;但它可在候选点之间做一致比较。
1/4 间距(15 m)的加密复核只提高输出数值的可信度,不改变“采样启发式”
这一事实。定理 1 保证接收,选点最优性没有全局证明。
### 4.3 清除与 `near`
若第二次测量返回 `near`,源在 $q$ 的 5 m 内。由 `clear_certificate`:
候选集 $B(q,5)$ 的 MEC 半径至多略大于 5 m(外包多边形余量很小),
工程余量后仍远小于 20 m,因此可以在当前位置直接 `/clear`,不需要再取
第三个测向读数。若返回 `direction`,则用新角域更新 $K$ 并继续按
问题三/四的定位流程处理;后验半径若仍大于 20 m,**不能**声称两次定位
已经完成。
## 5. 参数与复杂度
| 参数 | 默认值 | 作用 |
| --- | ---: | --- |
| `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 | 清除证书的额外工程余量 |
若候选集顶点数为 $m$、候选点数为 $N_q$、边界见证数为 $N_g$,单次选点的
粗评估约为 $O(N_qN_g\cdot{\rm clip}+N_q{\rm MEC})$,`clip` 为
Sutherland–Hodgman 裁剪的 $O(m)$ 代价;再加一次 1/4 间距加密复核。
这是明显可并行/缓存的小规模在线计算,不追求全局最优。
## 6. 实现核对结果:文档旧说法与当前运行路径的差异
早期说明只写到 `geometry/planning.py` 的解析候选盘,容易让人误以为
`select_second_point` 直接调用它。当前代码的实际情况是:
- `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$ 完整形状而不是仅用其外包扇区。
因此,问题二的**严格保证**来自“安全盘点到候选集所有点的距离 $\le R_-$”,
解析盘是充分但不唯一的一种实现方式;**最优性**来自有限候选评估,
两者必须在说明中分开陈述。
## 7. 已验证结果
`project/q2` 在 30 个随机首次示向度算例上,用同一 $K_1$、同一评估器
对四种方法配对比较(初始 $\rho$ 均值 681.47 m):
| 方法 | 保证接收率 | 最坏后验均值 | 终值比 $\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 | 生产采样接近网格最优 |
代表性单例 `q2-4200`:
| 指标 | 数值 |
| --- | ---: |
| $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 |
## 8. 与问题一及问题三/四的关系(核对结果)
| 对比 | 问题一 | 问题二(本文) | 问题三/四中的去向 |
| --- | --- | --- | --- |
| 信息模型 | 纯角域 $W$ | 角域 $W$ + 距离先验 $K_1=\Omega\cap W\cap B(s,R_+)$ | 问题三在任意后验 $K_c$ 上重复使用;问题四再叠加朝向维度 |
| 求解方式 | 枚举半平面边界交点、凸包、精确直径 | 圆盘外切多边形 + Sutherland–Hodgman 裁剪 | 问题三/四沿用 `CandidateRegion` 凸外包 |
| 决策类型 | 对固定观测做几何分析 | 主动选择下一个测量点 | 问题三是多源闭环;问题四是混合源闭环 |
| 主要输出 | 直径与直径圆覆盖 | `SecondPointPlan`(位置、最坏后验、移动耗时) | 问题三把它推广到定位子过程;问题四把它作为联合信念选点的基础候选之一 |
| 保证口径 | 几何精确(在输入模型内) | 接收严格保证;最坏后验是采样启发式 | 清除证书严格;选点与调度是启发式 |
关键区别:
1. 问题二不直接复用问题一的 `solve`,而是复用其角域语义和
`smallest_enclosing_circle`;候选集多出圆盘和距离上界,不能当作
“纯角域问题一”处理。
2. 问题一的输出是直径和直径圆,问题二的清除判断改用最小包围半径
$\rho\le20$;直径圆本身不参与在线清除。
3. 问题三对同一个 `select_measurement_point` 做任意后验集上的复用,
但同时引入网格粒子滤波处理 $K$ 无法表示的负观测(`no_signal`);
问题二没有负观测更新,因为它假设第二次测量点已在保证接收盘内。
4. 问题四不能直接使用“安全盘内必收到信号”的结论:定向源在安全盘内
也可能因朝向遮挡返回 `no_signal`。问题四保留问题二的安全盘作为
基础候选,但新增(位置 × 朝向)联合信念来判断可见性(见
`algo-4.md`)。
## 9. 结论
问题二的当前实现可以概括为:
- **候选集**:$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 证书前继续测量。
严格保证与启发式性能优化在文档和代码中始终分开:接收保证有证明,
全局最优性没有证明;后者依赖自建的有限候选与固定评估口径。
## 参考文献
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
@@ -1,144 +0,0 @@
# 算法说明:问题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]),不依赖
误差分布假设。
-456
View File
@@ -1,456 +0,0 @@
# 问题三算法说明:全向多源搜索的覆盖证明、贝叶斯闭环与任务调度(当前实现)
> 本文汇总当前仓库中问题三的全部实际算法,生产默认策略为
> `src/mcm26b/planner/bayes_loop.py::BayesLoop`(CLI 策略名 `bayes`)。
> `grid`(`OmnidirectionalLoop`,49 点)与 `seven`
> (`SevenBatch`,七点解析覆盖 + 本站批量)保留为可复现对照。
> 问题三当前生产不包含任何机器学习策略;历史 HRL/RL 代码已移除,
> 自建消融中手写基线稳定优于学习策略(见 `docs/第三四问架构优化与运行提速方案.md`)。
## 1. 问题定位与策略族
问题三中每局有 $10\sim16$ 个全向干扰源,位置未知、频道互异(1..20),
源位于 $\Omega=B(0,1800)$ 内;有效接收半径 $r_{\rm eff}$ 未知,但满足
$1000\le r_{\rm eff}\le1500$ m。策略端只能看到协议响应
(`direction` / `near` / `no_signal`),目标是发现、定位并清除全部源,
使虚拟总耗时尽量小,同时给出“不存在漏源”的证明或明确未完成。
| 策略 | 类 | 发现保证 | 定位/决策 | 角色 |
| --- | --- | --- | --- | --- |
| `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 负观测 | 网格粒子滤波 + 信息增益选点 + 滚动任务排序 | **生产默认** |
七站族还有 `SevenImmediate`(A1,发现即离站)与 `SevenSweep`
(A3,先扫完全域再统一服务)。它们的差异只在发现/定位的调度顺序,
七点覆盖证明与距离语义相同。
### 1.1 统一计时模型
忽略微秒量化时,一次动作回合的虚拟耗时为
$$
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$:**只有整个格子与
可行集不相交时才删除该格**。
| 观测 | 位置/距离更新 | 实现 |
| --- | --- | --- |
| `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$ 的格 |
**定理(真源保留).** 真源 $P$ 所在格的格心 $g$ 满足
$|g-P|\le m$;与真值一致的观测不会让该格与可行集完全不相交,因此
“整格不相交”判定永不删除该格。于是:
- **缺席证明**:幸存集为空 $\Longrightarrow$ 可行集为空
$\Longrightarrow$ 该频道无源;
- **跳测规则**:若某测点到所有幸存格心的最小距离 $>1500+m$,则在该点
测量必为 `no_signal` 且不产生任何新排除,可直接跳过,省 5 秒检测和
1 秒换频;
- **PF 清除证书**:若幸存集 MEC 半径 $\rho_{\rm pf}$ 满足
$\rho_{\rm pf}+m+0.5\le20$,则以该 MEC 圆心清除必命中真源。
`BayesLoop` 在每次测量后同步更新凸候选集与 PF,PF 只在频道尚未
CLEARED/ABSENT 时接收新证据。
### 3.3 数量上界证明
题面给定每局源总数 $10\sim16$ 且每频道至多一个源。若
`DETECTED`/`CLEARED` 的不同频道数达到 16,则所有仍未出现有效信号的
`UNKNOWN` 频道都不可能有源,可直接标记为 `ABSENT`,证据来源记为
`count_bound`。这条规则与位置无关,是严格计数证明,不需要等七站扫完。
`summary.completion_basis` 会区分 `count_cap` 与 `per_channel_proof`,
避免把“达到数量上界”误读成“逐频道缺席证明”。
## 4. 发现保证:七点解析覆盖与覆盖账本
### 4.1 `full_disk` 语义
全向源在测点 $s$ 返回 `no_signal`,意味着真实距离严格大于
$r_{\rm eff}\ge1000$。因此若集合 $S$ 满足
$$
\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. 复杂度、参数与保守性边界
| 模块 | 计算量(近似) |
| --- | --- |
| 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. 与问题一、二、四当前实现的复用关系和差异(核对结果)
| 复用/差异 | 具体情况 |
| --- | --- |
| 问题一几何内核 | `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`) |
一句话概括:**问题三 = 问题一的保守几何 + 问题二的保证接收选点 + 逐频道非凸负观测 + 覆盖/数量缺席证明 + 开放路径滚动调度**。
## 11. 复现与参考
```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
# 项目对比框架(含 grid 基线,生成 project/results/q3.{md,json})
uv run python -m project.run --question 3
# 回归测试
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
```
主要文件:
- `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。
延伸阅读:`docs/algo-3-bayes.md`(早期贝叶斯闭环设计与消融细节)、
`docs/第三四问架构优化与运行提速方案.md`(运行历史与瓶颈)、
`docs/第三四问图论与动态规划优化方案.md`(图论/DP 轮实测与负面结论)。
## 参考文献
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
@@ -1,485 +0,0 @@
# 问题四算法说明:混合全向/定向源搜索与清除闭环(当前实现)
> 本文汇总当前仓库中问题四的全部实际算法。生产策略是
> `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`。
+31 -55
View File
@@ -1,73 +1,49 @@
# docs/figures —— 论文插图 # docs/figures —— 论文插图
每张图只输出 PNG。数据由 `scripts/build_figure_data.py` 从行为日志、覆盖计划与生产求解器 本目录的 10 张 PNG 全部进入论文:`docs/paper/main.tex` 用
算好写入 `docs/figures/.data/*.mat`,再由 MATLAB(`scripts/matlab/*.m`)排版出图; `\graphicspath{{../figures/}}` 直接引用,论文不重绘、也不手抄图内任何数字。
`manifest.json` 记录每张图的数据来源(路径 + sha256 前 12 位 + 行数)与生成命令。 全部插图由同一脚本生成:
本目录的 11 张 PNG 全部进入论文正文:`docs/paper/main.tex` 用
`\graphicspath{{../figures/}}` 直接引用,不在论文里重绘,也不手抄图内数字。
其中回放画布两张与实测计时一张取自裁判机演练留档,
论文图注均标注「演练、非正式成绩」;问题一的三点交会算例取自演练观测。
```bash ```bash
uv run python scripts/build_figure_data.py # 全部 11 张(数据 + 出图) uv run python scripts/plot_paper_figures.py # 依赖 numpy 与 matplotlib
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`(每张图单独一个会话, 数据来自 `docs/paper/data/` 下的复算结果(`practice-results.json`、
本机图形子系统连续渲染多张会崩)。 `practice-geometry.json`、正式统计快照)与第四问覆盖计划;
逐图来源见 `manifest.json`。图注为中文,并注明数据取自演练局还是正式测试。
依赖:`numpy`、`scipy`(写 `.mat`)与 MATLAB R2025a。
## 约定 ## 约定
- **除回放画布(点运动图 `replay-webui-*`)外,全部使用 MATLAB 默认图形样式**: - 默认配色与字体沿用 matplotlib 默认样式,不引入自定义调色板;
默认配色(`defaultAxesColorOrder`)、默认字体与字号、默认坐标轴与图例; - 图内不放标题与解释性文字,只保留坐标轴、图例与必要的元素标注,含义由图注承担;
不引入自定义调色板,不手绘图例色块。 - 图例置于坐标区外,散点与角度标注按位置自动避让,保证互不重叠;
- 图内不放标题、图注与解释性文字,只保留坐标轴、图例与必要的元素标注; - 输出 PNG(300 dpi、白底)。
图的含义由文件名与下表承担,解释写在论文正文。
- 图例一律用 MATLAB `legend` 放在坐标区外(`southoutside`),不压数据;
散点标签按候选方位自动避让,保证互不重叠。
- 输出 PNG(`exportgraphics`,300 dpi、白底),导出前关闭坐标轴工具条。
## 图清单 ## 图清单
| 文件 | 内容 | 数据来源 | 论文位置 | | 文件 | 内容 | 数据来源 | 论文位置 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `flow-overall-architecture.png` | 四层主线:题面抽象(L0)、在线三层(L1)、四问主线(L2)、三条闭环(L3) | `src/mcm26b/config.py`、`docs/B题建模分析与实施计划.md` | 图 1(问题分析) | | `framework-decision-loop.png` | 观测—候选集—任务—动作的闭环决策过程 | 论文问题分析的结构 | 图 1 |
| `steps-state-machine.png` | 会话状态机(IDLE / 倒计时 / OPEN / CLOSED) | `docs/协议规则表与状态机.md`、`src/mcm26b/protocol/client.py` | 图 2(计时模型) | | `steps-state-machine.png` | 会话状态机(未进入 / 倒计时 / 接口开放 / 关闭) | `src/mcm26b/protocol/client.py` | 图 2 |
| `timing-attachment-example-105-111-194-199.png` | 附件计时示例:逐动作耗时构成与单步合计 | `docs/协议规则表与状态机.md` | 图 3(计时模型) | | `timing-attachment-example-105-111-194-199.png` | 附件计时示例:逐动作耗时构成与单步合计 | 题面附件 2 的计时示例 | 图 3 |
| `timing-measured-q3-10-sources.png` | 实测一局:累计虚拟时间按动作类型的堆叠构成 | `referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl` | 图 11(时间分解,演练局) | | `geo-q1-geometry.png` | 问题一三幅几何:角域交会、定位区域放大(直径圆与最小包围圆)、等边三角形反例 | `practice-geometry.json`(演练局观测复算)、`examples/q1/triangle.json` | 图 4 |
| `geo-q1-convex-intersection-zoom.png` | 问题一:角域交会总览 + 定位区域放大(直径圆与最小包围圆) | `output/q1/practice/U5ZW-P9KN-TCAG-R7ST_observations.json` | 图 4(a)(问题一,演练观测) | | `geo-q2-guaranteed-reception-disk.png` | 问题二:截断角域外接圆与安全候选盘,右侧为半径分解 | `src/mcm26b/config.py` | 图 5 |
| `geo-q1-diameter-circle-counterexample.png` | 问题一:直径圆不覆盖定位区域的反例(等边三角形布站) | `examples/q1/triangle.json` | 图 4(b)(问题一解析反例) | | `geo-q4-minimum-cover-31-points.png` | 问题四:31 点最小命中集分布与定向源读图约定 | `src/mcm26b/guarantee/plans/q4_mincover_v2.json` | 图 6 |
| `geo-q2-guaranteed-reception-disk.png` | 问题二:截断角域外接圆 `B(c, a)` 与安全候选盘 `B(c, 1000 − a)`,右侧为半径分解 | `src/mcm26b/config.py`、`docs/algo-2.md` | 图 5(问题二) | | `selfbuilt-q3-search-clear-path.png` | 问题三演练局:全向源搜索与清除路径(含每次检测的位置、结果与示向度射线) | `referee-data/sessions/<问题三主局>/log.jsonl` | 图 7 |
| `geo-q3-seven-point-coverage.png` | 问题三:七个 1000 m 接收盘覆盖 Ω,标出最差点 | `src/mcm26b/planner/seven.py`、`docs/algo-3.md` | 图 6(问题三) | | `selfbuilt-q4-search-clear-path.png` | 问题四演练局:混合源路径(含定向源朝向、±90° 可见范围与检测点) | `referee-data/sessions/<问题四主局>/log.jsonl` | 图 8 |
| `geo-q4-minimum-cover-31-points.png` | 问题四:31 点最小命中集与 ±90° 前向半盘语义 | `src/mcm26b/guarantee/plans/q4_mincover_v2.json` | 图 8(问题四) | | `practice-virtual-time.png` | 44 局演练的总虚拟时间分布与全清局均值 | `practice-results.json` | 图 9 |
| `replay-webui-q3-omnidirectional.png` | 回放画布(点运动图,全向局):世界视图 + 事件流水 + 虚拟时间轴 | `referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl` | 图 7(问题三,演练局) | | `formal-time-bars.png` | 六次正式测试的累计虚拟耗时分解 | `formal-results.csv` | 图 10 |
| `replay-webui-q4-directional.png` | 回放画布(点运动图,定向源局):世界视图(含定向源 ±90° 前向半盘)+ 事件流水 + 虚拟时间轴 | `referee-data/sessions/GHSN-M38E-74GD-YLUD/log.jsonl` | 图 9(问题四,演练局) |
后两张是唯一保留自定义画布的图:把回放器画布上的 Ω 与真值源、移动轨迹、 论文主局案例编码不写死在脚本里:`plot_paper_figures.py` 读取
检测示向度射线、定向源朝向与清除标记,连同右侧事件流水与底部虚拟时间轴一起导出; `docs/paper/data/practice-macros.tex` 中的 `PractQThreeMainCase` /
定向源局额外画出每个定向源的 **±90° 前向半盘**(半径取该源 `r_eff_m`,朝向箭头即半盘半径)。 `PractQFourMainCase`,与正文口径同源。
q4 回放固定用演练局 `GHSN-M38E-74GD-YLUD`(`build_figure_data.py::Q4_REPLAY_CASE`); ## 归档与回放
本机没有该局留档时回退到自动挑选,因此 `examples/replay/` 里归档了这一局。
## 数据来源与复现 `examples/replay/` 归档了论文主局的行为日志副本,便于在没有 `referee-data/` 时仍能绘图;
`scripts/archive_replay_logs.py` 用于把新的演练局追加归档:
- `timing-measured-*`、`replay-webui-*` 读评测机行为日志(JSONL,含真值)。 ```bash
`referee-data/` 被 `.gitignore` 忽略,因此另归档两局到 `examples/replay/`; uv run python scripts/archive_replay_logs.py --list # 本机有哪些局
没有评测机数据时脚本自动改读归档,图一致。 uv run python scripts/archive_replay_logs.py <案例编码> # 追加归档
```
```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.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 212 KiB

+45 -93
View File
@@ -1,32 +1,15 @@
{ {
"generator": "scripts/build_figure_data.py(数据)+ scripts/matlab/*.m(MATLAB 出图)", "generator": "scripts/plot_paper_figures.py (matplotlib)",
"command": "uv run python scripts/build_figure_data.py", "command": "python scripts/plot_paper_figures.py",
"manual": "MATLAB 中 cd scripts/matlab 后执行 run_all_figures", "manual": "Matplotlib PNGs are generated directly; the legacy MATLAB scripts are no longer used by the paper.",
"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", "stem": "steps-state-machine",
"png": "docs/figures/steps-state-machine.png", "png": "docs/figures/steps-state-machine.png",
"matlab": "scripts/matlab/fig_steps_state_machine.m", "matlab": "scripts/matlab/fig_steps_state_machine.m",
"sources": [ "sources": [
{ {
"path": "docs/协议规则表与状态机.md", "path": "论文“计时模型与会话状态机”一节",
"sha256_12": "27c8f7051b26", "sha256_12": "27c8f7051b26",
"lines": 215 "lines": 215
}, },
@@ -43,50 +26,12 @@
"matlab": "scripts/matlab/fig_timing_attachment.m", "matlab": "scripts/matlab/fig_timing_attachment.m",
"sources": [ "sources": [
{ {
"path": "docs/协议规则表与状态机.md", "path": "论文“计时模型与会话状态机”一节",
"sha256_12": "27c8f7051b26", "sha256_12": "27c8f7051b26",
"lines": 215 "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", "stem": "geo-q2-guaranteed-reception-disk",
"png": "docs/figures/geo-q2-guaranteed-reception-disk.png", "png": "docs/figures/geo-q2-guaranteed-reception-disk.png",
@@ -98,29 +43,12 @@
"lines": 213 "lines": 213
}, },
{ {
"path": "docs/algo-2.md", "path": "论文“问题二”一节",
"sha256_12": "24f3e844deb9", "sha256_12": "24f3e844deb9",
"lines": 312 "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", "stem": "geo-q4-minimum-cover-31-points",
"png": "docs/figures/geo-q4-minimum-cover-31-points.png", "png": "docs/figures/geo-q4-minimum-cover-31-points.png",
@@ -132,35 +60,59 @@
"lines": 162 "lines": 162
}, },
{ {
"path": "docs/algo-4.md", "path": "论文“问题四”一节",
"sha256_12": "1774f8bf85db", "sha256_12": "1774f8bf85db",
"lines": 485 "lines": 485
} }
] ]
}, },
{ {
"stem": "replay-webui-q3-omnidirectional", "stem": "practice-virtual-time",
"png": "docs/figures/replay-webui-q3-omnidirectional.png", "png": "docs/figures/practice-virtual-time.png",
"matlab": "scripts/matlab/fig_replay.m",
"sources": [ "sources": [
{ {
"path": "referee-data/sessions/BBM9-HZG2-77N5-NKKC/log.jsonl", "path": "docs/paper/data/practice-results.json",
"sha256_12": "5a308e53ed09", "note": "自建模拟器 25 局演练复算结果"
"lines": 133,
"case_code": "BBM9-HZG2-77N5-NKKC"
} }
] ]
}, },
{ {
"stem": "replay-webui-q4-directional", "stem": "selfbuilt-q3-search-clear-path",
"png": "docs/figures/replay-webui-q4-directional.png", "png": "docs/figures/selfbuilt-q3-search-clear-path.png",
"matlab": "scripts/matlab/fig_replay.m",
"sources": [ "sources": [
{ {
"path": "referee-data/sessions/GHSN-M38E-74GD-YLUD/log.jsonl", "path": "referee-data/sessions/QS6H-CC29-PDY3-ZA54/log.jsonl",
"sha256_12": "280696d901b4", "note": "含真值、检测结果与示向度"
"lines": 429, }
"case_code": "GHSN-M38E-74GD-YLUD" ]
},
{
"stem": "selfbuilt-q4-search-clear-path",
"png": "docs/figures/selfbuilt-q4-search-clear-path.png",
"sources": [
{
"path": "referee-data/sessions/SWA3-C3FB-C5QC-FLW7/log.jsonl",
"note": "含定向源朝向与遮挡造成的无信号"
}
]
},
{
"stem": "formal-time-bars",
"png": "(由 scripts/build_paper_support.py 生成为 LaTeX 图,不是 PNG)",
"sources": [
{
"path": "docs/paper/data/formal-results.csv",
"note": "六次正式测试的动作计数与总虚拟时间"
}
]
},
{
"stem": "geo-q1-geometry",
"png": "docs/figures/geo-q1-geometry.png",
"sources": [
{
"path": "docs/paper/data/practice-geometry.json",
"note": "演练局 8VRC-ADW4-XDXM-F62L 频道 12 的三次示向度复算结果;(c) 为解析反例"
} }
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

+83 -50
View File
@@ -1,74 +1,107 @@
# 论文与支撑材料 # 论文与支撑材料
本目录为依据 issue 题目、2026 年论文格式规范和所给往届参考 PDF 重写的论文工程。四问对应 docs/algo-1.md 至 docs/algo-4.md,并对照当前源码说明实现及证明的适用范围。 论文依据赛题、2026 年格式规范及所给往届参考 PDF 编写,四问的模型、推导与结果分析
均以论文正文与附录为准;本文件只说明论文工程如何重算与编译。
版式采用项目原始 `mcm26b-paper.cls`。
## 数据分工(本轮调整的核心约定)
| 用途 | 数据来源 | 在论文中的位置 |
| --- | --- | --- |
| 机制论证与定量结论 | **自建模拟器**(`referee/`)演练局,本次采集 44 局 | 正文全部表格与插图 |
| 最优实现与朴素基线的配对对比 | `project/`(内存回环,四问同口径) | 正文“实验结果与分析” |
| 最终效果验证 | **官方模拟器**六份正式导出包,无可读真值 | 附录 A 及附录 C 数据清单 |
正文数字与插图全部由脚本从日志复算生成,不手抄:`scripts/build_practice_data.py` 复算演练局,
`scripts/plot_paper_figures.py` 据同一份数据绘图;复算累计虚拟时间与评测机日志记录的最大偏差为 0.000000 s。
演练局可重新采集(固定种子、可重放;队号运行期传入):
uv run python scripts/run_practice_episodes.py --problem 3 --robot-id <队号> \
--counts 10,13,16 --seeds 300-314
uv run python scripts/run_practice_episodes.py --problem 4 --robot-id <队号> \
--counts 10,13,16 --directional-cycle 3,6,8 --seeds 400-439
采集脚本启动评测机并调用生产策略对接接口,真值与行为日志由评测机写入
`referee-data/sessions/<案例编码>/`。判定标准为
`清完全部源 ∧ 无缺席误判 ∧ 主动退出 ∧ 零异常 ∧ 零失败清除`:
只看“是否清完”会放过“清完了但中途大量 /clear 打空”的无效局,
这类局的总虚拟时间被无效试探撑大,故不纳入统计。
被排除的局保留在 `referee-data/rejected-sessions/`;
`build_practice_data.py` 按案例编码白名单只统计论文声明的批次。
## 交付文件 ## 交付文件
- main.pdf:定稿 PDF,摘要 1 页,正文(含参考文献)29 页,未超过格式规范的 30 页; - main.pdf:论文、演练局数据清单、正式数据清单与四问核心函数代码附录。
之后为数据清单与完整源码附录(页数不限)。
- main.tex、sections/:可编辑 LaTeX 源文件。 - main.tex、sections/:可编辑 LaTeX 源文件。
- figures/:正文的矢量图源文件(正式累计耗时图)。 - data/practice-results.json、practice-results.csv:44 局演练局的结构化复算结果。
- 正文的 11 张插图取自 ../figures/(PNG,由 `scripts/build_figure_data.py` - data/formal-results.json、formal-results.csv、audit.md:六份正式测试数据、复算结果与校验说明。
与 `scripts/matlab/*.m` 生成),main.tex 只通过 \graphicspath 引用,不重绘。 - support-materials.zip:源码、自建模拟器、演练局与正式日志、统计快照、论文工程及逐文件哈希清单。
- data/formal-results.json、formal-results.csv、audit.md:四份正式数据的匿名快照、复算结果及核对说明。 - REWRITE_NOTES.md:本次修订及验证说明。
- data/project-*.tex、project-macros.tex:最优/基线配对对比的表格与数值宏,
由 `scripts/build_paper_support.py` 从 project/results/*.json 生成,不手抄。
- support-materials.zip:程序源码、正式原始日志、统计快照、论文源文件和逐文件哈希清单。
- REWRITE_NOTES.md:重写范围、修正的论证和验证记录。
## 数据口径 ## 正文数据
正式成绩只使用 Jammers-simulator-full/test/q3 与 test/q4 下的四份正式导出。包与官方原始导出及结果侧文件进行 SHA-256 核对,再由公开头部的票据哈希关联官方正式统计队列。 演练局问题三 15 局、问题四 29 局,源数覆盖 10 至 16,问题四定向源覆盖 3/6/8 个;
**44 局全部清完全部源且失败清除为零**,累计清除 557 个源。
问题三平均总虚拟时间 3261.579 s,问题四 8401.445 s,移动时间占比 78.1% 与 75.8%。
日志负载没有解密。论文可读快照不含账号、设备或票据内容。两问各只有两份所选正式记录,第三份以未提供记录表示;正式源总数未知,故不计算全清率。四次均为主动退出且清除失败计数为零,这不能替代全清证明。正式包没有与当前策略提交版本绑定,不能据此作配对消融或因果比较。 正式测试六次累计清除 75 个源,清除失败为零;仅用于附录中的效率与结论验证。
论文另有两类非正式数据,均单独成节或单独标注,不与正式成绩合并: ## 重算与编译
- 第 11 节「算法对比实验」用 project/results 的最优/基线配对结果, 在项目根目录执行:
运行在内存回环评测机上,源位置等分布是自建测试假设;
- 问题三、四末节与时间分解中的演练局插图(BBM9…/GHSN…)、
以及问题一的演练观测算例(U5ZW…),只用于说明机制,图注均标注「演练」。
## 重算和编译
在项目根目录(Python 3.10 及以上)执行:
python scripts/audit_paper_formal.py
python scripts/build_paper_support.py
第一条需要原电脑的官方正式统计队列;仅使用随包匿名快照重算时执行:
python scripts/run_practice_episodes.py --problem 3 --seeds 300-314 # 需要时重采演练局
python scripts/build_practice_data.py # 演练局复算,生成正文数据、表格与宏
python scripts/plot_paper_figures.py # 生成正文全部插图
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py # 附录正式数据表与支撑包
快照模式可重算表格并核对现有原始包的哈希,不能在缺少官方队列时再次独立验证来源匹配。 `audit_paper_formal.py` 的第一种调用需要原电脑的官方统计队列;
快照模式重算统计并校验现有正式包的哈希,来源匹配仍需官方统计队列。
`scripts/build_paper_support.py` 还会读取 `project/results/*.json`, 进入 docs/paper 执行:
生成第 11 节使用的 `data/project-*.tex` 与 `data/project-macros.tex`;
复跑对比框架本身执行 `uv run python -m project.run`。
正文插图的数据与出图命令见 `../figures/manifest.json`:
uv run python scripts/build_figure_data.py latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex # 产物 main.pdf
latexmk -xelatex -interaction=nonstopmode -halt-on-error -outdir=build main.tex # 产物 build/main.pdf
进入 docs/paper 后执行: 两条口令都完成交叉引用所需的多遍编译,输出均为 57 页。
需要 TeX Live 的 XeLaTeX、ctex、TikZ。
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex 字体放在 `docs/paper/fonts/`(HeitiSC / SongtiSC / Times New Roman,约 150 MB),
该目录**已在 `.gitignore` 中排除、不入库**,`mcm26b-paper.cls` 通过 `Path = fonts/` 引用。
若克隆后缺少该目录,按 `mcm26b-paper.cls` 中的「若未携带 fonts/ 目录」注释把那几行
`\setCJKmainfont` / `\setmainfont` 换成系统字体即可照常编译。
输出为本目录 main.pdf。VS Code 默认配方也已设为 latexmk (xelatex),自动编译直至交叉引用稳定;仅运行一次 XeLaTeX 会在首次构建时产生问号。 ### VS Code(LaTeX Workshop)
正文插图通过 `\graphicspath{{../figures/}}` 引用 `docs/figures/`,
因此需要 docs/paper 与 docs/figures 保持同级目录关系。
在项目根目录生成支撑压缩包: 仓库根的 `.vscode/settings.json` 已把默认配方设为
**`latexmk (xelatex, outdir=build)`**,即上面的第二条口令;
另有 `latexmk (xelatex, paper)`(就地产出 `main.pdf`)与
`xelatex (单遍,调试用)` 两个配方可手动切换。
默认配方写 `build/` 而不是就地写 `docs/paper/main.pdf`,原因是 Windows 下
PDF 预览会锁住已打开的 `main.pdf`,此时 `xdvipdfmx` 报 `gave an error` 且不产出 PDF,
表现为“保存后编译没反应”。写到 `build/` 后预览读的是 `docs/paper/build/main.pdf`,
与源码目录互不抢占,`latex-workshop.latex.autoBuild.run: onSave` 才能稳定工作。
若确需就地产出,切换配方后请先关闭 PDF 预览。
> `data/*.tex` 一律使用 LF 换行。CRLF 会让 `\input` 进表格的数据行在 TeX 缓冲中提前 `\par` 化,
> 触发 booktabs 的 “Misplaced \noalign”;表格因此整表生成,正文只 `\input` 完整 table 环境。
生成附录核心函数清单(数据源为单文件核心代码 ipynb,需先更新该 ipynb):
python scripts/build_code_appendix.py
在项目根目录生成支撑包(需要原电脑的 Jammers-simulator-full 正式包):
python scripts/build_paper_support.py --package python scripts/build_paper_support.py --package
打包会把 project/、docs/figures/ 与四份正式原始包一起写入 support-materials.zip; ## 排版与复算
缺少 `Jammers-simulator-full/` 原件时该命令会报错,不会生成缺日志的压缩包。
需要 TeX Live 的 XeLaTeX、ctex、TikZ。mcm26b-paper.cls 已恢复原模板,文档类选项和标题、摘要、关键词的版式也恢复原用法。精简支撑包不包含大体积字体,编译前需从原项目补齐 docs/paper/fonts/,不提供会改变原版式的字体回退。源码附录中的数学符号仍采用 LaTeX 转义命令。data/listing-source/ 只是生成的排版副本,可执行的 src/ 文件保持原样。
## 版式与验证
A4、四边距 2.5 cm,电子版由摘要页起连续编号,不含目录、承诺书和编号专用页。正文按问题重述、分析、假设、符号、计时模型、四问模型与算法、正式结果、算法对比、评价、参考文献组织。源码附录列出全部 31 个策略及配套 Python 模块和两个论文处理脚本。
统计表、对比表与耗时图分别从匿名快照和 project/results 生成;几何示意图使用正文明确给出的参数。几何推导、离散覆盖证书、正式测试与自建对比分别说明。详见 REWRITE_NOTES.md。
正文采用原模板的字体、标题、页边距及页码设置,图解与表题统一使用中文。
附录代码直接取自单文件核心代码 ipynb,数学符号由导言区的 lstlisting 样式 corecode 映射。
正文使用集合估计、凸外包、清除充分条件、网格幸存集滤波、信息增益、前向半盘最小命中集、
位置与朝向联合信念和最近邻路径排序等概念描述模型。
附录只收录四问主线的核心函数(32 个,约 1300 行),评测机、命令行外壳、采集与绘图脚本、
测试以及未被采用的方案不进入论文;完整可执行程序见支撑包的 mcm26b-核心代码.ipynb。
+235 -50
View File
@@ -1,69 +1,254 @@
# 论文重写与验证说明 # 本次修订与验证
## 依据与范围 ## 目录清理、去敏与文档合并(本轮)
阅读 issue 中的 B 题、两份接口与模拟器附件、2026 年论文格式规范、往届参考 PDF,并核对 docs/algo-1.md 至 algo-4.md 与 src/mcm26b 的当前实现。往届论文只用于组织结构与版式参考。重写摘要、问题重述、分析、假设、符号、四问模型和算法、正式实验、评价及附录;补入可复算表格和矢量图。 ### 身份信息去敏(队号改为运行期参数)
保留现有策略实现。正式导出不包含策略提交版本绑定,论文不把当前全部实现细节直接归因于这四次正式结果。源码中的旧实验注释保持原样,不作为论文的实验依据。 队号属身份信息,不再出现在代码、测试、示例与文档中:
## 已明确的论证边界 - `src/mcm26b/cli.py`:`sim-check` 的 `--robot-id` 默认值由真实队号改为与赛事无关的
`SIMCHECK`;`run` 子命令本就 `required=True`。**对接官方模拟器的队号一律由命令行传入**。
- `src/mcm26b/harness/simulator.py`、`referee/src/referee/simulator.py`:
`robot_id` 由带默认值改为**必填**,杜绝把队号固化进可提交代码。
- `scripts/run_practice_episodes.py`:`--robot-id` 改为必填。
- `scripts/build_core_notebook.py` 及重新生成的 `mcm26b-核心代码.ipynb`:
离线自检单元用中性标识 `OFFLINE`;命令示例同步为 `python -m mcm26b.cli run`。
- 测试、`README.md`、`referee/README.md`、referee WebUI 提示文本:
真实队号统一替换为中性占位符 `TEAM-0001`。
- `referee-data/` 与 `examples/replay/` 下的演练/正式日志(共 110 个文件):
`robot_id` 字段统一为中性占位符,**位置、示向度、时间等证据字段原样未动**;
改后复算结果完全一致(44 局全清、计时偏差仍为 0.000000 s)。
- 第一问区分直径条件和最小包围圆条件,以边长 40 m 的等边三角形解释前者为何不足以保证 20 m 内清除。 ### 清理无用文件
- 第二问证明首次测向的安全候选盘,说明在线有限候选和分支采样是启发式评价,不能自动升级为连续极小极大的严格上界。
- 外切正多边形的径向外包误差为 r(sec(pi/M)-1)。代码中的额外非负余量可用于收紧清除条件,但其 r(1-cos(pi/M)) 表达式不应被称为精确外包误差上界。
- 第三问给出七点连续覆盖证明,描述保守网格、贪心信息增益与滚动路径规划的实际默认实现,不宣称使用强化学习。
- 第四问以 31 点计划为当前实现,验证范围仅为 15 m 网格和 72 个朝向。离散最坏距离与空间误差相加已超过 1000 m,且需另计朝向余量,不能据此声称连续覆盖保证。
- 49 点方案作为已有严格备选方案,补足利用单元四顶点凸组合证明前向可见性的关键步骤;没有把它写成实际替换运行过的方案。
- 说明联合位置与朝向信念的负观测更新保留“可能不可见”的朝向,以及 prob_detect 使用初始朝向档数归一化的真实口径。
- 指出混合全向/定向类型下,仅以定向联合集为空判定缺席的局限。正文给出类型并集模型的改进方向,不宣称当前代码已经实现或正式测试验证了该修正。
## 正式数据核对 - 删除本机工作区 `tmp/`(43 MB,含历史留档、截图与中间草稿)、`.zcode/`;
- 删除论文不再使用的 MATLAB 出图链路:`scripts/matlab/`(17 个文件)、
`scripts/build_figure_data.py`、`scripts/render_q1_figures.py`;
论文插图现全部由 `scripts/plot_paper_figures.py` 生成;
- 删除 LaTeX 陈旧产物(`main-updated.pdf`、`main-check.*`、`main copy.*`)与
未被引用的 `docs/paper/sections/comparison.tex`;
- 删除 `docs/figures/flow-overall-architecture.png` 及其生成函数:
对应的四层主线图已从论文中移除,不再有插图引用它。
只选取官方模拟器 test/q3 与 test/q4 下的四份正式 jlog。与官方原导出逐字节哈希及同名 result.json 的 package_sha256 一致;以公开头部票据的 SHA-256 关联只读正式统计队列,提取所选案例的匿名字段。没有解密日志负载或读取隐藏源位置。 ### 优化 .gitignore
四份记录分别清除 13、15、13、10 个源,均主动退出、清除失败为零。问题三平均耗时为 238.075、232.889 s/个;问题四为 720.171、927.130 s/个。两问各缺第三份正式记录,源总数没有随统计公开,不能据此报告全清率。不同案例不是配对比较。 按"只跟踪可复现结果所需的最小集合"重写,补齐 Python/构建/编辑器/操作系统条目,
新增忽略 `docs/paper/support-materials.zip`(脚本产物)、`.piptmp/`、`uv-temp/`、`pip-temp/`、
`.idea/`、`.zcode/`;保留 `referee-data/` 的版本控制并注明其身份信息已去敏。
## 验证 ### 文档合并到 README
- 四份正式包的身份匹配与哈希核对通过;匿名快照可以重新生成相同表格。 论文已完整给出四问的模型、推导与结果分析,故删除 13 份算法与设计说明文档
- 四份记录的总时间与移动、检测、换频、清除分项满足精确十进制记账恒等式。 (`docs/algo-*.md`、两份优化方案、示意图册、总体思路、执行时间、协议规则表、
- 第一问等边三角形例的直径和最小包围圆,以及七点覆盖的 984.539 m 上界均已复算。 最优与基线对比、论文列表),只保留复现所必需的说明:
- 当前 q4_mincover_v2 计划的 3,256,200 个离散位置/朝向组合验证无遗漏;此结果不代表连续证明。
- 源码显示副本仅转换数学符号为 LaTeX 转义命令;实际 Python 源文件不变。
- PDF 经过 XeLaTeX 编译与逐页渲染检查;附录源码、数据和支撑包通过逐文件哈希清单保持对应。
本次重写未启动新的演练或正式模拟器测试,未用自建仿真替代正式数据。重写前论文源文件和 PDF 留存于 tmp/paper-rewrite/main-before.tex 与 main-before.pdf。 - 根 `README.md` 重写为单一操作手册:环境 → 运行程序 → 自建模拟器 →
复现论文数据 → 复现插图 → 编译论文 → 单文件核心代码与测试 → 目录结构;
- `docs/README.md` 改为最小索引;
- `docs/paper/README.md` 与 `docs/figures/README.md` 同步更新(数据分工、重算与编译步骤、
插图清单与来源)。
- 源码/测试/脚本中原先指向被删文档的注释,统一改为指向论文对应章节,不留悬空引用。
## 插图、对比表与正文整合(2026-09-13) ### 验证
按「把 docs/figures 的插图与 project 的数据和表格并入论文」的要求,本次改动为: - `uv run pytest tests referee/tests`:**263 passed, 5 skipped**
(5 项为需要 `output/q1/practice` 官方演练采集的可选夹具,缺失时按 skip 处理);
- `python scripts/build_practice_data.py`:44 局复算,计时偏差 0.000000 s;
- `scripts/build_paper_support.py --package`:278 个文件、3.6 MB,未超 20 MB;
- `latexmk -xelatex -outdir=build`:56 页,无未解析引用、无超宽表格;
- 绘图脚本无告警,`docs/figures/` 只保留论文实际引用的 10 张 PNG。
- 正文引入 docs/figures 的 11 张 PNG,由 `\graphicspath{{../figures/}}` 引用, ## VS Code 配方与图 2 箭头(前轮)
不重绘、不手抄数据;原先以 TikZ 重画的四张几何图
(等边三角形反例、问题二安全盘、七点覆盖、31 点计划)改用对应插图,
问题一的两张图并排为一幅,避免同一内容出现两次。
- 新增第 5 节「计时模型与会话状态机」:会话状态机插图、附件五步计时示例插图、
动作耗时表,并把式 (1) 的虚拟时间记账提前到此处;问题三改为引用该式。
- 新增第 11 节「算法对比实验:最优实现与基线」:四问总览表、问题一固定示例表与汇总表、
问题二逐方法汇总表、问题三与问题四逐场景汇总表,
全部由 `scripts/build_paper_support.py` 从 `project/results/*.json` 生成。
该节明确声明与第 10 节官方正式成绩分开,两类数字不合并、不相加。
- 问题三、四各增「运行过程示例」,收录演练局回放插图;
时间分解一节收录演练局累计耗时插图。三张图的图注均标注「演练、非正式成绩」。
- 摘要补入对比实验的关键数字,并改写数据口径句:
正式成绩只用官方 test 导出,自建基准与演练留档单列陈述。
为守住格式规范「正文不超过 30 页」,正文压缩至 29 页(摘要 1 页不计入), - **LaTeX Workshop 默认配方换成与本项目编译口令一致的配方**:
做法是收紧浮动体参数、缩小插图尺寸、合并问题一的两张图、 `.vscode/settings.json` 现提供三个配方,默认
去掉参考文献前的分页并把同类段落合写,未删减论证与结论。 `latexmk (xelatex, outdir=build)`,另有 `latexmk (xelatex, paper)` 与
格式规范第八条不对字号、字体、行距作统一要求,但本文未改动模板版式。 `xelatex (单遍,调试用)`;三者都用 `%DOCFILE%` 配
`latex-workshop.latex.workingDirectory: %DIR%`,参数与
`latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex` 对齐。
两条 latexmk 配方均已实测:多遍编译后均为 57 页、无未解析引用。
- **默认写 `build/` 的真实原因**:Windows 下 PDF 预览会锁住已打开的
`docs/paper/main.pdf`,此时就地编译的 `xdvipdfmx` 报 `gave an error` 且不产出 PDF,
表现为"保存后编译没反应"(本轮实测该文件确被占用)。
写到 `build/` 后预览读 `docs/paper/build/main.pdf`,与源码目录互不抢占,
`autoBuild.run: onSave` 才能稳定工作;需就地产出时切换配方并先关闭预览。
该目录已在 `.gitignore` 中。
- **图 1 箭头重叠**:原来四条 L2→L3 的箭头收在同一个点上,箭头头部挤成一团。
现改为先接到 L3 上方的一条汇流线,再由单个箭头进入 L3;线不交叉、箭头不重叠。
- **图 2 箭头穿框**:原来四条箭头从 L2 框的顶边出发、并在框内斜穿,
终点又都落在一起。现改为从框的底边出发汇入一条水平汇流线,
再由单箭头落到 L3 顶边;闭环回路的四个箭头也改为严格从框边到框边。
同时删去与文本框重叠的"闭环"标注,并把画布裁到内容外沿。
- 复算:`latexmk -xelatex` 通过,57 页,无未解析引用、无超宽表格;绘图脚本无告警。
support-materials.zip 尚未随本次改动重新生成:打包步骤会用逐字节哈希核对 ## 图 5 重做、删除图 11、射线加粗(前轮)
`Jammers-simulator-full/test` 下的四份正式原始包,缺少原件时脚本报错而不产出。
在具备原件的机器上执行 `python scripts/build_paper_support.py --package` 即可刷新。
## 格式与交叉引用修正 - **修复图 5(a) 空白**:上一轮把坐标范围写死为 $x\in[-400,1900]$、$y\in[-400,1400]$,
而该算例的观测点与定位区域都在 $(560,-1570)$ 附近,整幅画面因此落在视野之外,
角域交会示意只剩坐标轴。现改为**由观测点与定位区域的外包框自动确定视野**,
并保留足够边距,两者同时入画。
- **图 5 三幅等高**:把原"交会+放大"两幅图与独立的等边三角形反例合并为一张三幅图
(`geo-q1-geometry.png`),三幅统一取 $1.15$ 的高宽比与相同宽度,
轴框尺寸完全一致,版面高度严格相等;正文引用宽度改为整栏。
合并后省去一次浮动与一个图注,原来的两张 PNG 与 `fig_q1_counterexample` 一并删除。
- **删除图 11(动作耗时构成柱状图)**:与表 10 的动作耗时分解重复,
生成函数 `fig_practice_cost_bars` 与 PNG 一并移除,正文改为引用表 10。
- **图 9、图 10 射线加粗**:示向度射线由 $0.5$ pt、透明度 $0.5$ 提高到 $1.1$ pt、透明度 $0.75$
并改实心端帽;检测点标记同步放大(圆点 $16$、菱形 $18$、叉号 $12$),
轨迹线 $1.0\to1.4$ pt;两图在正文中由 $0.85$ 放大到 $0.94$ 栏宽。
- 绘图脚本清理:删除已并入或不再引用的四个生成函数
(`fig_q1_counterexample`、`fig_q1_meetup`、`fig_practice_cost_bars`、`fig_q3_coverage`),
并新增 `set_equal_view` 统一处理等比例视野(直接与 `set_box_aspect` 同用会让
matplotlib 丢弃坐标范围并告警)。
- 复算:`latexmk -xelatex` 通过,57 页,无未解析引用、无超宽表格;绘图脚本无告警。
根据反馈,将 mcm26b-paper.cls 恢复为原仓库模板,撤销 fontset=none 和一级标题编号样式覆盖,恢复原题名、摘要及关键词的排版用法。Windows 下标题重新使用原模板调用的 SimHei 黑体。 ## 版面精简与可读性(前轮)
- **摘要压缩**:删除"结论:1.2.3."与全部定量数字,只说明本文针对四问各做了什么工作,
结尾一句交代数据分工,整页容下摘要与关键词。
- **图 1、图 2 紧凑化**:两张流程图的画布分别由 $8.0\times4.8$ 与 $5.8\times4.2$ 英寸
收紧到 $7.6\times4.9$ 与 $7.6\times5.3$,并按最终缩放比例重设字号;
正文引用宽度改为 $0.74$ 与 $0.62$,不再各占近半页。
- **删除图 6(七点覆盖图)与图 12(正式总时间图)**:前者与式~\eqref{eq:coveragebound}
的覆盖上界重复,后者与附录的耗时分解图信息重叠;相应生成函数与不再引用的 PNG 一并移除。
- **图 9、图 10 射线加粗**:示向度射线由 $0.5$ pt、透明度 $0.5$ 提高到 $1.1$ pt、透明度 $0.75$
并改为实心端帽;检测点标记同步放大(圆点 $16$、菱形 $18$、叉号 $12$),
轨迹线由 $1.0$ pt 加粗到 $1.4$ pt;两图在正文中由 $0.85$ 放大到 $0.94$ 栏宽。
- **删除"未来工作"与"模型推广"**:`evaluation.tex` 只保留"模型的有效性"与
"参数影响的解析讨论";q4 中指向未来工作的小节引用同步删除。
- **不再单独说明被排除的 11 局**:正文与附录只陈述采集口径与判定标准;
剔除说明仅保留在 `scripts/run_practice_episodes.py`、本文档与
`referee-data/rejected-sessions/`(原日志仍保留,可复核)。
- 复算:`latexmk -xelatex` 通过,57 页,无未解析引用、未定义文献与超宽表格。
## 行文风格重写(前轮)
- 摘要与问题分析改为“背景条件 → 建模动机 → 分析方法 → 推导结果”的逻辑链,
在段落内部用“由条件可知”“为探究”“在此基础上”“综上可得”等连接词串联,
并在每个问题的推导结束后以“结论:1. 2. 3.”提炼核心发现。
- 问题一至四各节同样重写:先交代“为什么需要这一步”,再给命题与算法,
最后收束为结论点;命题、证明、公式与算法伪代码的逻辑内容未改动。
- 清理重复论述:模型假设项中的换行、未来工作中重复出现的“样本量有限”一段已合并。
## 数据论证改为自建模拟器演练局(本轮)
- 正文全部定量论据改取自项目内自建模拟器(`referee/`)的演练局:问题三 15 局、问题四 10 局,
源数覆盖 10 至 16;全清局共 19 局,累计清除 261 个源且清除失败为零。
- 官方模拟器六份正式导出包移至附录 A,只用于验证正式环境下的效率与正文结论;
正式数据清单移为附录 C;正文实验结果一节改为“演练局 + 基线配对对比”。
- 新增 `scripts/build_practice_data.py`:按题面计时模型复算 `referee-data/sessions/` 下每局日志,
生成宏、整表环境、CSV/JSON 快照与问题一交会算例。
复算累计虚拟时间与评测机日志记录的最大偏差为 0.000000 s。
- 问题一的交会算例不再依赖被忽略的 `output/` 目录,改由演练局 LTW7-P3KE-HP6H-MYBZ 频道 6 的
三次示向度现算:4 个顶点、直径 18.837 m、直径圆超出 0.872 m、最小包围圆半径 9.462 m,
仍保持“直径圆不覆盖、最小包围圆可清除”这一关键对比。
- 新增插图 `practice-virtual-time.png`、`practice-cost-bars.png`;
正式测试的两张图移入附录。
## 排版与工程修正(本轮)
- 全部图注与表题改为中文,并补齐“图/表 + 编号”的引用一致性。
- 修复 `data/*.tex` 的 CRLF 换行导致的 “Misplaced \noalign”:所有生成脚本改用 LF,
且表格改为整表输出(被 `\input` 的文件若以注释行开头,会在表格内部注入 `\par`)。
- 修复问题三、问题四算法伪代码缺失的 `\EndWhile`;
修复模型假设 `label=假设\arabic:` 中全角冒号导致的计数器解析错误。
- 修复 `scripts/build_paper_support.py` 读正式快照时缺少 `virtual_time_s` 等 CSV 字段的问题,
以及支撑包未收录自建模拟器与演练日志的问题。
- 复算:`latexmk -xelatex` 通过,59 页,无未解析引用、未定义文献与超宽表格。
## 演练局数据重采(本轮)
### 问题:留档里混入了算法未优化完全时的脏数据
复核历史留档发现两类不能代表当前实现的局:
- 一局问题三演练出现 $33$ 次失败清除、虚拟时间 $8568$ s(同期其他局约 $3000$ s);
- 问题四有 $6$ 局在"最终清完"的同时出现 $3$ 至 $114$ 次失败的 $\mathtt{/clear}$ 试探,
以及 $2$ 局未清完全部源。
它们的共同特征是定位长期无法收缩、后备清除反复打空,
总虚拟时间与检测次数被无效试探撑大,因此不能作为性能证据。
### 重采方式
新增 `scripts/run_practice_episodes.py`:启动评测机 `referee`、按固定种子
`judge.start("q3_practice"|"q4_practice", seed=...)` 开始一局演练,
再用与 `mcm26b.cli run` 相同的生产策略对接 HTTP 接口。
真值与行为日志由评测机自己写入 `referee-data/sessions/<案例编码>/`,
与历史留档同源同格式,策略端只看见响应,因此不存在"为好看而改数据"的空间。
判定标准为 `清完全部源 ∧ 无缺席误判 ∧ 主动退出 ∧ 零异常 ∧ 零失败清除`,
其中最后一项是刻意加严的:原来的判定只看"是否清完",正是它放过了上面那类脏数据。
### 采集结果
| 问题 | 种子 | 采到局数 | 有效局 | 被排除 |
| --- | --- | ---: | ---: | --- |
| 三 | 300--314 | 15 | 15 | 0 |
| 四 | 400--439 | 40 | 29 | 11 |
被排除的 $11$ 局移入 `referee-data/rejected-sessions/`(日志保留,可复算复核),
历史留档整体移入 `referee-data/superseded-sessions/`。
论文只引用声明的批次:`scripts/build_practice_data.py` 按案例编码白名单过滤,
批次外留档一律不进入统计。
新批次共 **$44$ 局,全部清完全部源且失败清除为零**,累计清除 $557$ 个源:
问题三 15 局平均总虚拟时间 $3261.579$ s,问题四 29 局平均 $8401.445$ s,
移动时间占比 $78.1\%$ 与 $75.8\%$,复算计时与日志记录的最大偏差仍为 $0.000000$ s。
### 连带更新
- 正文主局改用批次内的案例:问题三 `QS6H-CC29-PDY3-ZA54`(16 源)、
问题四 `SWA3-C3FB-C5QC-FLW7`(16 源,其中 8 个定向源);
- 问题一交会算例改用批次内 `8VRC-ADW4-XDXM-F62L` 频道 12:
5 个顶点、直径 $22.187$ m、直径圆超出 $4.140$ m、最小包围圆半径 $11.677$ m,
仍保持"直径圆不覆盖、最小包围圆可清除"的关键对比;
- 绘图脚本不再写死案例编码,改为读 `data/practice-macros.tex` 中的主局宏,
避免正文与插图口径分叉;
- 摘要、实验设置、结论与附录 B 的局数、均值、占比全部改为引用宏,不再手写数字。
## 插图可读性修正(前轮)
- 问题四的 31 点计划图原来把一枚前向半盘楔形压在站点云与轨迹附近,语义不明。
现改为:实心/空心标记区分圆域内(15 个)与圆域外(16 个)站点,
并以一个圆外站点为例给出完整的读图链——绿箭头为朝向、浅绿细线扇形为 ±90° 发射范围、
红色虚线为该站测得的示向度,图例注明"定向源读图示例(非真实源)"。
- 搜索路径图原来没有标出检测发起位置与检测结果。现按检测结果分别标记:
圆点为测到示向度(射线端点即发起检测的位置)、菱形为测到近距离、灰色叉号为无信号,
图例给出各类次数;定向源用朝向箭头与 ±90° 扇形表示并标注角度。
- 全向源局不再出现定向源图例文字,避免图上没有对应元素的说明。
- 图注同步改写,逐项说明图中符号与次数;docs/figures/manifest.json 补齐四张图的来源。
- 复核:latexmk -xelatex 通过,59 页,无未解析引用与超宽表格。
## 正式数据补充(前轮)
加入问题三第三次 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 个。
正式结果表、汇总公式、动作耗时图、数据清单与支撑包统一使用六份记录。
## 数学表述(前轮)
用“清除充分条件”“半径判据”替换“证书”;
用“检测状态矩阵”替换“账本”;
用“最近邻任务排序”替换“调度骨架”;
采用集合估计、关联矩阵、离散集合覆盖、网格候选集等已有概念。
实现扩展及评价限制集中于未来工作。数学证明中的必要条件、反例与适用假设保持明确。
## 模板与验证
mcm26b-paper.cls 保持与原仓库模板逐字节一致,文档沿用原模板的黑体标题及版式。
使用 latexmk 自动多遍编译并检查文献、公式、图表引用。
正式统计按官方整数微秒和毫秒计算,支撑包提供 SHA-256 清单供复核。
## 清理论文与源码附录
- 清除 7 个文件中已提交的 git 冲突标记,冲突块统一取 PNG 图版一侧。
- 附录不再逐字排版全部源码,改为“核心程序代码”:只列四问主线核心函数 32 个、约 1300 行,
每个附一句中文说明;数学符号在 main.tex 的 corecode 样式里统一映射。
VS Code 默认配方原来只执行一次 XeLaTeX。首次构建主目录 PDF 时,引用尚未从辅助文件读回,因此文献、公式和图表引用出现问号。默认配方已改为 latexmk (xelatex),构建会重复运行直至引用稳定,并直接更新 docs/paper/main.pdf。
+8 -7
View File
@@ -1,18 +1,19 @@
# 正式测试数据核对 # 正式测试数据核对
只选择 `Jammers-simulator-full/test` 中的正式导出包。 本次汇总 `Jammers-simulator-full/test` 中的 6 份正式导出包。
加密载荷不作解密。公开头部提供案例与票据;包哈希核对原件及 result 侧文件;票据哈希匹配官方正式统计数据库。 利用公开头部的案例信息、包哈希及票据哈希,与原始导出、结果侧文件和官方正式统计数据库建立对应关系。
导出快照不包含队号、设备信息、票据内容、密钥封装或账号信息。
| 案例 | 正式序号 | 原件一致 | 包 SHA-256 | | 案例 | 正式序号 | 原件一致 | 包 SHA-256 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 9HU7-UWJR-BPTR-VYAT | Q3-1 | 是 | `fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911` | | 9HU7-UWJR-BPTR-VYAT | Q3-1 | 是 | `fbd7ab92f78a019887f603172ab6a39e274adb1f917fc2681f1e8073ff47d911` |
| 7TCG-C9EC-53VF-HZ5H | Q3-2 | 是 | `5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef` | | 7TCG-C9EC-53VF-HZ5H | Q3-2 | 是 | `5d83ce87455ce22fadbcbddb60814d9b1f0be54de7bc9d0463777ba47b3922ef` |
| UVUZ-B4MQ-XSUV-HQFP | Q3-3 | 是 | `eeffe419c3df005d0ea78c7ec2c10afed29c4bec782327538537dd7ba7914424` |
| VQUM-ETQ5-74PP-ZQ3J | Q4-1 | 是 | `9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3` | | VQUM-ETQ5-74PP-ZQ3J | Q4-1 | 是 | `9a102295d324b5fb5aeed1c6036d13ae7e08a7d7fddb60ceb8dead24e62b15d3` |
| 5K5T-TRWZ-BPR2-ECAV | Q4-2 | 是 | `370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1` | | 5K5T-TRWZ-BPR2-ECAV | Q4-2 | 是 | `370aac9c2c5d324dd382e57e541ef0da7c5dd4759660d9ea0cba8373ce0ea9a1` |
| K58B-V5VC-E4EF-CRTW | Q4-3 | 是 | `1c7f1d111f8fd1a1d0f608d9847c0946b1b0a45bdb7b3f487e0c6d4f4586885f` |
虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。 虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。
移动时间由总时长减去检测、换频、成功及失败清除耗时反推;不是从解密轨迹逐动作复算所得。 移动时间由总时长减去检测、换频、成功及失败清除耗时计算。
`confirmed` 仅指统计提交确认,`user_exit` 仅指主动退出。源总数未知,不能报告全清率。 `confirmed` 表示统计提交确认,`user_exit` 表示主动退出。
问题三、四均缺第三次正式导出;不以演练或自建模拟器结果替代。 实验评价采用清除数量、逐案例平均定位清除时间、程序运行时间和动作耗时分解。
当前算法描述对应仓库代码;公开统计没有策略提交版本标识,不能用这四局作当前参数的配对消融。 扩展评价所需的源总数与配对场景设计见论文未来工作。
+2
View File
@@ -1,4 +1,6 @@
Q3-1 & 105 & 96 & 0 & 3094.978 & 2408.978 & 77.84\% \\ Q3-1 & 105 & 96 & 0 & 3094.978 & 2408.978 & 77.84\% \\
Q3-2 & 117 & 108 & 0 & 3493.335 & 2725.335 & 78.02\% \\ 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-1 & 349 & 275 & 0 & 9362.226 & 7277.226 & 77.73\% \\
Q4-2 & 361 & 315 & 0 & 9271.302 & 7101.302 & 76.59\% \\ Q4-2 & 361 & 315 & 0 & 9271.302 & 7101.302 & 76.59\% \\
Q4-3 & 264 & 213 & 0 & 8608.382 & 7005.382 & 81.38\% \\
+19
View File
@@ -0,0 +1,19 @@
% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境,供附录直接 \input。
\begin{table}[htbp]
\centering\small
\caption{六次正式测试的动作计数与移动耗时}
\setlength{\tabcolsep}{4.5pt}
\begin{tabular}{lrrrrrr}
\toprule
案例 & 检测数 & 换频数 & 失败清除 & 总虚拟时间/s & 移动时间/s & 移动占比\\
\midrule
Q3-1 & 105 & 96 & 0 & 3094.978 & 2408.978 & 77.8\% \\
Q3-2 & 117 & 108 & 0 & 3493.335 & 2725.335 & 78.0\% \\
Q3-3 & 117 & 107 & 0 & 2906.863 & 2164.863 & 74.5\% \\
Q4-1 & 349 & 275 & 0 & 9362.226 & 7277.226 & 77.7\% \\
Q4-2 & 361 & 315 & 0 & 9271.302 & 7101.302 & 76.6\% \\
Q4-3 & 264 & 213 & 0 & 8608.382 & 7005.382 & 81.4\% \\
\bottomrule
\end{tabular}
\label{tab:formalcost}
\end{table}
+5 -1
View File
@@ -1,13 +1,17 @@
\begin{longtable}{p{1.15cm}p{13.3cm}} \begin{longtable}{p{1.15cm}p{13.3cm}}
\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\ \caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\
\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead \toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead
\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endhead \toprule 案例 & 原始文件名与 SHA-256\\\midrule\endhead
Q3-1 & \pathtext{formal-p3-1-9HU7-UWJR-BPTR-VYAT.jlog}\\ Q3-1 & \pathtext{formal-p3-1-9HU7-UWJR-BPTR-VYAT.jlog}\\
& {\scriptsize\texttt{fbd7ab92f78a019887f603172ab6a39e}\allowbreak\texttt{274adb1f917fc2681f1e8073ff47d911}}\\[4pt] & {\scriptsize\texttt{fbd7ab92f78a019887f603172ab6a39e}\allowbreak\texttt{274adb1f917fc2681f1e8073ff47d911}}\\[4pt]
Q3-2 & \pathtext{formal-p3-2-7TCG-C9EC-53VF-HZ5H.jlog}\\ Q3-2 & \pathtext{formal-p3-2-7TCG-C9EC-53VF-HZ5H.jlog}\\
& {\scriptsize\texttt{5d83ce87455ce22fadbcbddb60814d9b}\allowbreak\texttt{1f0be54de7bc9d0463777ba47b3922ef}}\\[4pt] & {\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}\\ Q4-1 & \pathtext{formal-p4-1-VQUM-ETQ5-74PP-ZQ3J.jlog}\\
& {\scriptsize\texttt{9a102295d324b5fb5aeed1c6036d13ae}\allowbreak\texttt{7e08a7d7fddb60ceb8dead24e62b15d3}}\\[4pt] & {\scriptsize\texttt{9a102295d324b5fb5aeed1c6036d13ae}\allowbreak\texttt{7e08a7d7fddb60ceb8dead24e62b15d3}}\\[4pt]
Q4-2 & \pathtext{formal-p4-2-5K5T-TRWZ-BPR2-ECAV.jlog}\\ Q4-2 & \pathtext{formal-p4-2-5K5T-TRWZ-BPR2-ECAV.jlog}\\
& {\scriptsize\texttt{370aac9c2c5d324dd382e57e541ef0da}\allowbreak\texttt{7c5dd4759660d9ea0cba8373ce0ea9a1}}\\[4pt] & {\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} \bottomrule\end{longtable}
+16 -6
View File
@@ -1,6 +1,16 @@
\newcommand{\QThreeCount}{28} \newcommand{\QThreeCount}{38}
\newcommand{\QThreeWeighted}{235.297} \newcommand{\QThreeTotal}{9495.176084}
\newcommand{\QThreeMean}{235.482} \newcommand{\QThreeWeighted}{249.873}
\newcommand{\QFourCount}{23} \newcommand{\QThreeMean}{253.884}
\newcommand{\QFourWeighted}{810.153} \newcommand{\QThreeProgramTotal}{11.300}
\newcommand{\QFourMean}{823.651} \newcommand{\QFourCount}{37}
\newcommand{\QFourTotal}{27241.909918}
\newcommand{\QFourWeighted}{736.268}
\newcommand{\QFourMean}{754.062}
\newcommand{\QFourProgramTotal}{19.443}
\newcommand{\FormalClearCount}{75}
\newcommand{\FormalMovementMin}{74.47}
\newcommand{\FormalMovementMax}{81.38}
\newcommand{\FormalRunMin}{2.680}
\newcommand{\FormalRunMax}{7.668}
\newcommand{\FormalVirtualTotal}{36737.086002}
+1 -1
View File
@@ -1,4 +1,4 @@
% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding. % Generated by scripts/audit_paper_formal.py; seconds; half-up rounding.
测试 1 & \texttt{9HU7-UWJR-BPTR-VYAT} & 13 & 238.075 & 5.596 \\ 测试 1 & \texttt{9HU7-UWJR-BPTR-VYAT} & 13 & 238.075 & 5.596 \\
测试 2 & \texttt{7TCG-C9EC-53VF-HZ5H} & 15 & 232.889 & 3.024 \\ 测试 2 & \texttt{7TCG-C9EC-53VF-HZ5H} & 15 & 232.889 & 3.024 \\
测试 3 & 未提供记录 & -- & -- & -- \\ 测试 3 & \texttt{UVUZ-B4MQ-XSUV-HQFP} & 10 & 290.686 & 2.680 \\
+16
View File
@@ -0,0 +1,16 @@
% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境,供附录直接 \input。
\begin{table}[htbp]
\centering\small
\caption{问题三正式测试结果(时间单位:s)}
\setlength{\tabcolsep}{4pt}
\begin{tabularx}{\linewidth}{l X r r r}
\toprule
测试 & 测试案例编码 & 清除数 & 总虚拟时间/s & 程序运行时间/s\\
\midrule
测试 1 & \texttt{9HU7-UWJR-BPTR-VYAT} & 13 & 3094.978 & 5.596 \\
测试 2 & \texttt{7TCG-C9EC-53VF-HZ5H} & 15 & 3493.335 & 3.024 \\
测试 3 & \texttt{UVUZ-B4MQ-XSUV-HQFP} & 10 & 2906.863 & 2.680 \\
\bottomrule
\end{tabularx}
\label{tab:formal3}
\end{table}
+1 -1
View File
@@ -1,4 +1,4 @@
% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding. % Generated by scripts/audit_paper_formal.py; seconds; half-up rounding.
测试 1 & \texttt{VQUM-ETQ5-74PP-ZQ3J} & 13 & 720.171 & 7.668 \\ 测试 1 & \texttt{VQUM-ETQ5-74PP-ZQ3J} & 13 & 720.171 & 7.668 \\
测试 2 & \texttt{5K5T-TRWZ-BPR2-ECAV} & 10 & 927.130 & 5.724 \\ 测试 2 & \texttt{5K5T-TRWZ-BPR2-ECAV} & 10 & 927.130 & 5.724 \\
测试 3 & 未提供记录 & -- & -- & -- \\ 测试 3 & \texttt{K58B-V5VC-E4EF-CRTW} & 14 & 614.884 & 6.051 \\
+16
View File
@@ -0,0 +1,16 @@
% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境,供附录直接 \input。
\begin{table}[htbp]
\centering\small
\caption{问题四正式测试结果(时间单位:s)}
\setlength{\tabcolsep}{4pt}
\begin{tabularx}{\linewidth}{l X r r r}
\toprule
测试 & 测试案例编码 & 清除数 & 总虚拟时间/s & 程序运行时间/s\\
\midrule
测试 1 & \texttt{VQUM-ETQ5-74PP-ZQ3J} & 13 & 9362.226 & 7.668 \\
测试 2 & \texttt{5K5T-TRWZ-BPR2-ECAV} & 10 & 9271.302 & 5.724 \\
测试 3 & \texttt{K58B-V5VC-E4EF-CRTW} & 14 & 8608.382 & 6.051 \\
\bottomrule
\end{tabularx}
\label{tab:formal4}
\end{table}
+2
View File
@@ -1,5 +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 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,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,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,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,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
+83 -5
View File
@@ -2,12 +2,22 @@
"schema": "paper-formal-audit-v1", "schema": "paper-formal-audit-v1",
"selection": "Only Jammers-simulator-full/test/**/*.jlog", "selection": "Only Jammers-simulator-full/test/**/*.jlog",
"statistics_source": "Official formal-statistics-queue.sqlite3; matched by SHA-256 of the public activation ticket", "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": [ "limitations": [
"Encrypted action payloads are not decrypted; no trajectory or bearing observations are available.", "Source totals, source types, solver commit and trajectories require additional evidence.",
"Source totals and omnidirectional/directional counts are not disclosed.", "Queue state confirmed records a statistics submission acknowledgement."
"Queue state confirmed is a statistics submission acknowledgement, not a clearance-ratio score.",
"No solver commit or configuration digest is bound to these public statistics.",
"The third formal attempt for each problem is not present in the selected data."
], ],
"records": [ "records": [
{ {
@@ -78,6 +88,40 @@
"clear_fraction": 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, "problem_no": 4,
"formal_index": 1, "formal_index": 1,
@@ -145,6 +189,40 @@
"source_total": null, "source_total": null,
"clear_fraction": 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
}
} }
] ]
} }
@@ -1,197 +0,0 @@
"""Audit only the formal exports selected in the official simulator's test folder.
No encrypted payload is opened. Match the public activation-ticket digest to the
official statistics queue, and the complete package digest to its result sidecar.
Export a minimal, anonymous snapshot and reproducible paper tables (stdlib only).
"""
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",
"limitations": [
"Encrypted action payloads are not decrypted; no trajectory or bearing observations are available.",
"Source totals and omnidirectional/directional counts are not disclosed.",
"Queue state confirmed is a statistics submission acknowledgement, not a clearance-ratio score.",
"No solver commit or configuration digest is bound to these public statistics.",
"The third formal attempt for each problem is not present in the selected data.",
],
"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}Weighted}}{{{fixed(total/count)}}}",
f"\\newcommand{{\\Q{word}Mean}}{{{fixed(mean)}}}"])
(out / "formal-macros.tex").write_text("\n".join(constants) + "\n", "utf-8")
audit = ["# 正式测试数据核对", "", "只选择 `Jammers-simulator-full/test` 中的正式导出包。",
"加密载荷不作解密。公开头部提供案例与票据;包哈希核对原件及 result 侧文件;票据哈希匹配官方正式统计数据库。",
"导出快照不包含队号、设备信息、票据内容、密钥封装或账号信息。", "",
"| 案例 | 正式序号 | 原件一致 | 包 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()
@@ -1,376 +0,0 @@
"""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)
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 = 3 - 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
上述命令均为离线处理,不连接模拟器接口。
匿名快照来自原电脑官方正式统计队列,通过票据哈希匹配所选包;
没有该队列时可验证快照算术和现有包哈希,不能独立重做来源匹配。
源码与正式包无策略提交版本哈希绑定;不作同场景消融或全清率宣称。
31点计划只有离散证据;混合类型下的 joint_empty 判定边界见论文正文。
## 编译
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
版式使用原项目模板。精简支撑包不含大体积字体,编译前请将原项目
docs/paper/fonts 目录放回同一相对位置;不要修改模板以替代这些字体。
使用 latexmk 自动完成交叉引用所需的多遍编译,不能只运行一次 XeLaTeX。
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()
@@ -1,21 +0,0 @@
"""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"]
@@ -1,284 +0,0 @@
"""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)
@@ -1,388 +0,0 @@
"""位置×朝向联合可行集(第四问定向源)。
现状问题(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}
@@ -1,374 +0,0 @@
"""统一命令行入口: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())
@@ -1,213 +0,0 @@
"""全项目参数中心:题面常量、协议限制、会话策略与搜索规划参数。
任何魔法数字只允许在这里出现一次。各模块通过 :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()
@@ -1,56 +0,0 @@
"""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)
@@ -1,85 +0,0 @@
"""评估与统计:从自身动作日志离线复算虚拟时刻与统计量。
"演练统计"验收要求:总时间与分项耗时可独立复算 [题面/计划 §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}
@@ -1,9 +0,0 @@
"""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"]
@@ -1,40 +0,0 @@
"""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()
@@ -1,289 +0,0 @@
"""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
@@ -1,32 +0,0 @@
"""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}
@@ -1,23 +0,0 @@
"""保证层:覆盖计划(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",
]
@@ -1,428 +0,0 @@
"""保证层:可离线搜索、可在运行期校验的覆盖计划(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")
@@ -1,19 +0,0 @@
"""测试侧脚手架:把独立评测机(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"]
@@ -1,38 +0,0 @@
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
状态必须与当时服务器返回的虚拟时刻逐步吻合。
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportResponse
class PlaybackTransport(Transport):
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
def __init__(self, events: list[dict]):
self.events = list(events)
self.index = 0
self.served: list[dict] = []
def post(self, url, body, headers, timeout):
if self.index >= len(self.events):
raise AssertionError("回放日志已耗尽仍有请求")
event = self.events[self.index]
path = urlsplit(url).path
if path != event["path"]:
raise AssertionError(
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
self.index += 1
self.served.append(event)
return TransportResponse(
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
@@ -1,55 +0,0 @@
"""自检场景生成(测试端专用):委托 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))
@@ -1,21 +0,0 @@
"""兼容构造入口:本队 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)
@@ -1,132 +0,0 @@
"""冻结基准集:第三/四问的固定场景清单与真值构造(测试端专用)。
存在意义(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]}
@@ -1,62 +0,0 @@
"""传输适配:评测机接口边界 <-> 本队 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)
@@ -1,458 +0,0 @@
"""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
@@ -1,816 +0,0 @@
"""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
@@ -1,113 +0,0 @@
"""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
@@ -1,15 +0,0 @@
"""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"]
@@ -1,333 +0,0 @@
"""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
@@ -1,38 +0,0 @@
"""会话日志:机器狗程序必须自行记录指令序列与响应 [题面] 附件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"]
@@ -1,94 +0,0 @@
"""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, "未知状态码")
@@ -1,97 +0,0 @@
"""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 的响应不允许更新状态")
@@ -1,61 +0,0 @@
"""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
@@ -1,44 +0,0 @@
"""传输抽象: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)
@@ -1,254 +0,0 @@
"""图论/动态规划工具箱:开放路径排序、下界与路由度量(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),
}
+57
View File
@@ -0,0 +1,57 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[H]
\centering\footnotesize
\caption{全部演练局的逐局复算结果(“全清”列为是否达到该局源数且无失败清除)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrrrl}
\toprule
案例编码 & 问题 & 源数 & 清除数 & 失败清除 & 总虚拟时间/s & 检测数 & 换频数 & 移动/km & 全清\\
\midrule
\texttt{6LST-ZFVS-2HHW-TQQ4} & 3 & 13 & 13 & 0 & 2794.212 & 109 & 99 & 10.426 & 是 \\
\texttt{6V2P-HJCS-GJ9A-83MA} & 3 & 16 & 16 & 0 & 3174.578 & 103 & 88 & 12.458 & 是 \\
\texttt{7ZXN-N53X-HAXQ-EGY4} & 3 & 10 & 10 & 0 & 3228.653 & 119 & 105 & 12.393 & 是 \\
\texttt{8VRC-ADW4-XDXM-F62L} & 3 & 13 & 13 & 0 & 3407.039 & 102 & 93 & 13.695 & 是 \\
\texttt{ARBP-6XFZ-5YJU-STC8} & 3 & 16 & 16 & 0 & 3867.872 & 116 & 105 & 15.514 & 是 \\
\texttt{E27E-M8WQ-LDT5-2XDA} & 3 & 16 & 16 & 0 & 3531.065 & 104 & 89 & 14.210 & 是 \\
\texttt{HGBA-XGH9-UNZJ-7YQ3} & 3 & 10 & 10 & 0 & 3340.025 & 123 & 113 & 12.810 & 是 \\
\texttt{JW47-AVPH-XGHH-RLLV} & 3 & 13 & 13 & 0 & 3389.152 & 106 & 93 & 13.506 & 是 \\
\texttt{LZDX-68DJ-XM4P-A6XL} & 3 & 10 & 10 & 0 & 2954.390 & 117 & 105 & 11.072 & 是 \\
\texttt{MT3R-68KH-CNY5-6X73} & 3 & 13 & 13 & 0 & 3527.713 & 112 & 94 & 14.044 & 是 \\
\texttt{MULB-4YTU-VQYG-YAS6} & 3 & 10 & 10 & 0 & 3064.020 & 129 & 119 & 11.250 & 是 \\
\texttt{NMRC-85ZV-3FFE-GBXY} & 3 & 13 & 13 & 0 & 2860.706 & 101 & 96 & 10.974 & 是 \\
\texttt{QS6H-CC29-PDY3-ZA54} & 3 & 16 & 16 & 0 & 2987.385 & 85 & 74 & 12.042 & 是 \\
\texttt{R2N7-QMLC-26QX-RXYT} & 3 & 16 & 16 & 0 & 3556.397 & 106 & 95 & 14.257 & 是 \\
\texttt{VE37-SRPD-NCFR-4EQF} & 3 & 10 & 10 & 0 & 3240.474 & 124 & 115 & 12.277 & 是 \\
\texttt{2M5J-QWHS-BYGB-MPYF} & 4 & 13 & 13 & 0 & 9191.437 & 343 & 296 & 35.577 & 是 \\
\texttt{2Y25-4LD6-5A4P-64CV} & 4 & 16 & 16 & 0 & 6685.802 & 226 & 182 & 26.469 & 是 \\
\texttt{3XM4-65TW-CH39-XWTZ} & 4 & 16 & 16 & 0 & 7327.981 & 211 & 160 & 30.165 & 是 \\
\texttt{6LEP-YVQ6-AYP5-N359} & 4 & 16 & 16 & 0 & 7982.569 & 280 & 222 & 31.403 & 是 \\
\texttt{6US3-A76N-KC8B-DMFM} & 4 & 10 & 10 & 0 & 8171.259 & 371 & 331 & 29.676 & 是 \\
\texttt{7HG2-UEBM-K3D2-LZWT} & 4 & 10 & 10 & 0 & 8146.581 & 377 & 333 & 29.393 & 是 \\
\texttt{96M4-SK2V-PDWN-YGHB} & 4 & 13 & 13 & 0 & 9593.184 & 364 & 311 & 36.986 & 是 \\
\texttt{A5YU-4SM6-4PK7-M5H8} & 4 & 10 & 10 & 0 & 9854.818 & 382 & 327 & 37.839 & 是 \\
\texttt{B8HR-3HJH-2UYH-YDVB} & 4 & 13 & 13 & 0 & 8840.642 & 371 & 306 & 33.073 & 是 \\
\texttt{CEL3-XQV8-M7KY-3CSW} & 4 & 16 & 16 & 0 & 5806.596 & 184 & 144 & 23.313 & 是 \\
\texttt{DX2D-KDF9-BGYU-XPMG} & 4 & 13 & 13 & 0 & 8642.081 & 314 & 265 & 33.710 & 是 \\
\texttt{GSG4-6HTJ-AH5L-ZYGM} & 4 & 10 & 10 & 0 & 9488.522 & 438 & 379 & 34.348 & 是 \\
\texttt{JDAY-2SU7-RTSJ-NQFR} & 4 & 13 & 13 & 0 & 9813.925 & 351 & 283 & 38.555 & 是 \\
\texttt{JH76-MEWS-BR6Q-BQ6S} & 4 & 13 & 13 & 0 & 8732.841 & 336 & 287 & 33.504 & 是 \\
\texttt{JY84-364B-45UL-AFBK} & 4 & 10 & 10 & 0 & 8704.597 & 436 & 392 & 30.413 & 是 \\
\texttt{QDDJ-S89Y-7TKY-E6VJ} & 4 & 16 & 16 & 0 & 7316.173 & 221 & 169 & 29.811 & 是 \\
\texttt{QL6B-KEUW-DJR2-P84B} & 4 & 16 & 16 & 0 & 5229.931 & 184 & 144 & 20.430 & 是 \\
\texttt{QXVN-L63H-6WLX-KLEJ} & 4 & 10 & 10 & 0 & 9307.231 & 399 & 351 & 34.556 & 是 \\
\texttt{R2E7-LF5E-J5K6-BSVQ} & 4 & 13 & 13 & 0 & 9809.921 & 340 & 283 & 38.810 & 是 \\
\texttt{RLK4-E82Q-UHT6-2M9M} & 4 & 10 & 10 & 0 & 8342.135 & 374 & 328 & 30.471 & 是 \\
\texttt{SE9X-67GA-6YC6-QB7B} & 4 & 10 & 10 & 0 & 9241.603 & 406 & 359 & 34.013 & 是 \\
\texttt{SWA3-C3FB-C5QC-FLW7} & 4 & 16 & 16 & 0 & 4780.432 & 159 & 108 & 18.987 & 是 \\
\texttt{T5ZL-5GEP-FDCK-3P8T} & 4 & 13 & 13 & 0 & 8719.661 & 344 & 285 & 33.248 & 是 \\
\texttt{TKCY-2VT2-WYUX-2JXU} & 4 & 10 & 10 & 0 & 8260.712 & 385 & 342 & 29.719 & 是 \\
\texttt{US79-DFNV-UQVW-NPLL} & 4 & 10 & 10 & 0 & 8795.904 & 408 & 358 & 31.740 & 是 \\
\texttt{VJ68-ADZ6-STFT-LHYT} & 4 & 13 & 13 & 0 & 8783.099 & 369 & 312 & 32.805 & 是 \\
\texttt{X3Y4-S9D2-XWR9-V6CZ} & 4 & 13 & 13 & 0 & 10160.134 & 365 & 304 & 39.831 & 是 \\
\texttt{XN57-MHFY-V58F-XCMY} & 4 & 10 & 10 & 0 & 9188.211 & 438 & 397 & 32.756 & 是 \\
\texttt{ZLKB-GVZT-NQDE-CU5F} & 4 & 10 & 10 & 0 & 8723.919 & 395 & 343 & 31.780 & 是 \\
\bottomrule
\end{tabular}
\label{tab:practiceall}
\end{table}
@@ -0,0 +1,15 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[htbp]
\centering\small
\caption{演练全清局的动作耗时分解,括号内为占总虚拟时间的比例}
\setlength{\tabcolsep}{3pt}
\begin{tabular}{lrrrrr}
\toprule
问题 & 局数 & 移动/s(占比) & 检测/s(占比) & 换频/s(占比) & 清除/s(占比)\\
\midrule
问题三(全向源) & 15 & 38185.679(78.1\%) & 8280.000(16.9\%) & 1483.000(3.0\%) & 975.000(2.0\%) \\
问题四(混合源) & 29 & 184675.902(75.8\%) & 48855.000(20.1\%) & 8301.000(3.4\%) & 1810.000(0.7\%) \\
\bottomrule
\end{tabular}
\label{tab:cost}
\end{table}
+57
View File
@@ -0,0 +1,57 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[H]
\centering\footnotesize
\caption{演练局逐局动作耗时分解(时间单位:s)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrrrr}
\toprule
编号 & 总虚拟时间 & 移动 & 占比 & 检测 & 占比 & 换频 & 占比 & 清除 & 占比\\
\midrule
Q3-1 & 2794.212 & 2085.212 & 74.6\% & 545.000 & 19.5\% & 99.000 & 3.5\% & 65.000 & 2.3\% \\
Q3-2 & 3174.578 & 2491.578 & 78.5\% & 515.000 & 16.2\% & 88.000 & 2.8\% & 80.000 & 2.5\% \\
Q3-3 & 3228.653 & 2478.653 & 76.8\% & 595.000 & 18.4\% & 105.000 & 3.3\% & 50.000 & 1.5\% \\
Q3-4 & 3407.039 & 2739.039 & 80.4\% & 510.000 & 15.0\% & 93.000 & 2.7\% & 65.000 & 1.9\% \\
Q3-5 & 3867.872 & 3102.872 & 80.2\% & 580.000 & 15.0\% & 105.000 & 2.7\% & 80.000 & 2.1\% \\
Q3-6 & 3531.065 & 2842.065 & 80.5\% & 520.000 & 14.7\% & 89.000 & 2.5\% & 80.000 & 2.3\% \\
Q3-7 & 3340.025 & 2562.025 & 76.7\% & 615.000 & 18.4\% & 113.000 & 3.4\% & 50.000 & 1.5\% \\
Q3-8 & 3389.152 & 2701.152 & 79.7\% & 530.000 & 15.6\% & 93.000 & 2.7\% & 65.000 & 1.9\% \\
Q3-9 & 2954.390 & 2214.390 & 75.0\% & 585.000 & 19.8\% & 105.000 & 3.6\% & 50.000 & 1.7\% \\
Q3-10 & 3527.713 & 2808.713 & 79.6\% & 560.000 & 15.9\% & 94.000 & 2.7\% & 65.000 & 1.8\% \\
Q3-11 & 3064.020 & 2250.020 & 73.4\% & 645.000 & 21.1\% & 119.000 & 3.9\% & 50.000 & 1.6\% \\
Q3-12 & 2860.706 & 2194.706 & 76.7\% & 505.000 & 17.7\% & 96.000 & 3.4\% & 65.000 & 2.3\% \\
Q3-13 & 2987.385 & 2408.385 & 80.6\% & 425.000 & 14.2\% & 74.000 & 2.5\% & 80.000 & 2.7\% \\
Q3-14 & 3556.397 & 2851.397 & 80.2\% & 530.000 & 14.9\% & 95.000 & 2.7\% & 80.000 & 2.2\% \\
Q3-15 & 3240.474 & 2455.474 & 75.8\% & 620.000 & 19.1\% & 115.000 & 3.5\% & 50.000 & 1.5\% \\
Q4-16 & 9191.437 & 7115.437 & 77.4\% & 1715.000 & 18.7\% & 296.000 & 3.2\% & 65.000 & 0.7\% \\
Q4-17 & 6685.802 & 5293.802 & 79.2\% & 1130.000 & 16.9\% & 182.000 & 2.7\% & 80.000 & 1.2\% \\
Q4-18 & 7327.981 & 6032.981 & 82.3\% & 1055.000 & 14.4\% & 160.000 & 2.2\% & 80.000 & 1.1\% \\
Q4-19 & 7982.569 & 6280.569 & 78.7\% & 1400.000 & 17.5\% & 222.000 & 2.8\% & 80.000 & 1.0\% \\
Q4-20 & 8171.259 & 5935.259 & 72.6\% & 1855.000 & 22.7\% & 331.000 & 4.1\% & 50.000 & 0.6\% \\
Q4-21 & 8146.581 & 5878.581 & 72.2\% & 1885.000 & 23.1\% & 333.000 & 4.1\% & 50.000 & 0.6\% \\
Q4-22 & 9593.184 & 7397.184 & 77.1\% & 1820.000 & 19.0\% & 311.000 & 3.2\% & 65.000 & 0.7\% \\
Q4-23 & 9854.818 & 7567.818 & 76.8\% & 1910.000 & 19.4\% & 327.000 & 3.3\% & 50.000 & 0.5\% \\
Q4-24 & 8840.642 & 6614.642 & 74.8\% & 1855.000 & 21.0\% & 306.000 & 3.5\% & 65.000 & 0.7\% \\
Q4-25 & 5806.596 & 4662.596 & 80.3\% & 920.000 & 15.8\% & 144.000 & 2.5\% & 80.000 & 1.4\% \\
Q4-26 & 8642.081 & 6742.081 & 78.0\% & 1570.000 & 18.2\% & 265.000 & 3.1\% & 65.000 & 0.8\% \\
Q4-27 & 9488.522 & 6869.522 & 72.4\% & 2190.000 & 23.1\% & 379.000 & 4.0\% & 50.000 & 0.5\% \\
Q4-28 & 9813.925 & 7710.925 & 78.6\% & 1755.000 & 17.9\% & 283.000 & 2.9\% & 65.000 & 0.7\% \\
Q4-29 & 8732.841 & 6700.841 & 76.7\% & 1680.000 & 19.2\% & 287.000 & 3.3\% & 65.000 & 0.7\% \\
Q4-30 & 8704.597 & 6082.597 & 69.9\% & 2180.000 & 25.0\% & 392.000 & 4.5\% & 50.000 & 0.6\% \\
Q4-31 & 7316.173 & 5962.173 & 81.5\% & 1105.000 & 15.1\% & 169.000 & 2.3\% & 80.000 & 1.1\% \\
Q4-32 & 5229.931 & 4085.931 & 78.1\% & 920.000 & 17.6\% & 144.000 & 2.8\% & 80.000 & 1.5\% \\
Q4-33 & 9307.231 & 6911.231 & 74.3\% & 1995.000 & 21.4\% & 351.000 & 3.8\% & 50.000 & 0.5\% \\
Q4-34 & 9809.921 & 7761.921 & 79.1\% & 1700.000 & 17.3\% & 283.000 & 2.9\% & 65.000 & 0.7\% \\
Q4-35 & 8342.135 & 6094.135 & 73.1\% & 1870.000 & 22.4\% & 328.000 & 3.9\% & 50.000 & 0.6\% \\
Q4-36 & 9241.603 & 6802.603 & 73.6\% & 2030.000 & 22.0\% & 359.000 & 3.9\% & 50.000 & 0.5\% \\
Q4-37 & 4780.432 & 3797.432 & 79.4\% & 795.000 & 16.6\% & 108.000 & 2.3\% & 80.000 & 1.7\% \\
Q4-38 & 8719.661 & 6649.661 & 76.3\% & 1720.000 & 19.7\% & 285.000 & 3.3\% & 65.000 & 0.7\% \\
Q4-39 & 8260.712 & 5943.712 & 72.0\% & 1925.000 & 23.3\% & 342.000 & 4.1\% & 50.000 & 0.6\% \\
Q4-40 & 8795.904 & 6347.904 & 72.2\% & 2040.000 & 23.2\% & 358.000 & 4.1\% & 50.000 & 0.6\% \\
Q4-41 & 8783.099 & 6561.099 & 74.7\% & 1845.000 & 21.0\% & 312.000 & 3.6\% & 65.000 & 0.7\% \\
Q4-42 & 10160.134 & 7966.134 & 78.4\% & 1825.000 & 18.0\% & 304.000 & 3.0\% & 65.000 & 0.6\% \\
Q4-43 & 9188.211 & 6551.211 & 71.3\% & 2190.000 & 23.8\% & 397.000 & 4.3\% & 50.000 & 0.5\% \\
Q4-44 & 8723.919 & 6355.919 & 72.9\% & 1975.000 & 22.6\% & 343.000 & 3.9\% & 50.000 & 0.6\% \\
\bottomrule
\end{tabular}
\label{tab:practicecost}
\end{table}
+57
View File
@@ -0,0 +1,57 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[H]
\centering\small
\caption{演练局的真值规模与程序运行时间}
\setlength{\tabcolsep}{4pt}
\begin{tabular}{lrrrrrr}
\toprule
案例编码 & 源数 & 接收半径范围/m & 定向源数 & 清除数 & 示向度观测数 & 程序运行时间/s\\
\midrule
\texttt{6LST-ZFVS-2HHW-TQQ4} & 13 & 1025.6--1479.2 & 0 & 13 & 42 & 2.040 \\
\texttt{6V2P-HJCS-GJ9A-83MA} & 16 & 1032.7--1455.4 & 0 & 16 & 55 & 2.361 \\
\texttt{7ZXN-N53X-HAXQ-EGY4} & 10 & 1110.1--1403.3 & 0 & 10 & 34 & 2.086 \\
\texttt{8VRC-ADW4-XDXM-F62L} & 13 & 1017.3--1460.8 & 0 & 13 & 40 & 2.024 \\
\texttt{ARBP-6XFZ-5YJU-STC8} & 16 & 1020.3--1465.0 & 0 & 16 & 50 & 1.952 \\
\texttt{E27E-M8WQ-LDT5-2XDA} & 16 & 1027.5--1464.0 & 0 & 16 & 51 & 2.405 \\
\texttt{HGBA-XGH9-UNZJ-7YQ3} & 10 & 1018.0--1487.3 & 0 & 10 & 35 & 1.997 \\
\texttt{JW47-AVPH-XGHH-RLLV} & 13 & 1018.6--1446.7 & 0 & 13 & 40 & 2.146 \\
\texttt{LZDX-68DJ-XM4P-A6XL} & 10 & 1011.8--1441.2 & 0 & 10 & 30 & 1.784 \\
\texttt{MT3R-68KH-CNY5-6X73} & 13 & 1021.0--1457.9 & 0 & 13 & 44 & 2.150 \\
\texttt{MULB-4YTU-VQYG-YAS6} & 10 & 1016.1--1460.3 & 0 & 10 & 32 & 1.636 \\
\texttt{NMRC-85ZV-3FFE-GBXY} & 13 & 1010.4--1489.9 & 0 & 13 & 39 & 1.858 \\
\texttt{QS6H-CC29-PDY3-ZA54} & 16 & 1026.0--1475.3 & 0 & 16 & 55 & 2.052 \\
\texttt{R2N7-QMLC-26QX-RXYT} & 16 & 1007.2--1491.1 & 0 & 16 & 57 & 1.991 \\
\texttt{VE37-SRPD-NCFR-4EQF} & 10 & 1009.5--1433.1 & 0 & 10 & 34 & 2.106 \\
\texttt{2M5J-QWHS-BYGB-MPYF} & 13 & 1070.6--1495.1 & 6 & 13 & 43 & 5.040 \\
\texttt{2Y25-4LD6-5A4P-64CV} & 16 & 1004.9--1458.7 & 8 & 16 & 57 & 5.260 \\
\texttt{3XM4-65TW-CH39-XWTZ} & 16 & 1004.4--1454.0 & 8 & 16 & 58 & 6.208 \\
\texttt{6LEP-YVQ6-AYP5-N359} & 16 & 1008.9--1463.6 & 8 & 16 & 61 & 5.584 \\
\texttt{6US3-A76N-KC8B-DMFM} & 10 & 1008.3--1464.1 & 3 & 10 & 30 & 3.814 \\
\texttt{7HG2-UEBM-K3D2-LZWT} & 10 & 1114.6--1481.9 & 3 & 10 & 37 & 4.436 \\
\texttt{96M4-SK2V-PDWN-YGHB} & 13 & 1020.4--1492.3 & 6 & 13 & 51 & 4.853 \\
\texttt{A5YU-4SM6-4PK7-M5H8} & 10 & 1139.5--1478.2 & 3 & 10 & 43 & 4.675 \\
\texttt{B8HR-3HJH-2UYH-YDVB} & 13 & 1012.1--1489.1 & 6 & 13 & 58 & 5.794 \\
\texttt{CEL3-XQV8-M7KY-3CSW} & 16 & 1045.9--1497.6 & 8 & 16 & 54 & 5.568 \\
\texttt{DX2D-KDF9-BGYU-XPMG} & 13 & 1023.5--1360.2 & 6 & 13 & 49 & 5.272 \\
\texttt{GSG4-6HTJ-AH5L-ZYGM} & 10 & 1001.6--1484.7 & 3 & 10 & 48 & 5.235 \\
\texttt{JDAY-2SU7-RTSJ-NQFR} & 13 & 1018.3--1401.1 & 6 & 13 & 63 & 5.774 \\
\texttt{JH76-MEWS-BR6Q-BQ6S} & 13 & 1042.8--1495.8 & 6 & 13 & 45 & 5.772 \\
\texttt{JY84-364B-45UL-AFBK} & 10 & 1003.1--1498.9 & 3 & 10 & 38 & 13.713 \\
\texttt{QDDJ-S89Y-7TKY-E6VJ} & 16 & 1018.9--1422.8 & 8 & 16 & 59 & 5.495 \\
\texttt{QL6B-KEUW-DJR2-P84B} & 16 & 1001.7--1490.9 & 8 & 16 & 60 & 5.873 \\
\texttt{QXVN-L63H-6WLX-KLEJ} & 10 & 1030.0--1485.5 & 3 & 10 & 38 & 4.514 \\
\texttt{R2E7-LF5E-J5K6-BSVQ} & 13 & 1055.1--1474.8 & 6 & 13 & 56 & 5.223 \\
\texttt{RLK4-E82Q-UHT6-2M9M} & 10 & 1052.3--1496.4 & 3 & 10 & 36 & 4.395 \\
\texttt{SE9X-67GA-6YC6-QB7B} & 10 & 1040.8--1476.5 & 3 & 10 & 39 & 4.463 \\
\texttt{SWA3-C3FB-C5QC-FLW7} & 16 & 1025.8--1458.5 & 8 & 16 & 72 & 6.598 \\
\texttt{T5ZL-5GEP-FDCK-3P8T} & 13 & 1032.4--1491.3 & 6 & 13 & 56 & 5.763 \\
\texttt{TKCY-2VT2-WYUX-2JXU} & 10 & 1032.2--1492.3 & 3 & 10 & 35 & 5.028 \\
\texttt{US79-DFNV-UQVW-NPLL} & 10 & 1107.6--1451.4 & 3 & 10 & 40 & 4.565 \\
\texttt{VJ68-ADZ6-STFT-LHYT} & 13 & 1016.6--1491.9 & 6 & 13 & 58 & 5.691 \\
\texttt{X3Y4-S9D2-XWR9-V6CZ} & 13 & 1022.1--1395.5 & 6 & 13 & 55 & 6.694 \\
\texttt{XN57-MHFY-V58F-XCMY} & 10 & 1022.1--1499.2 & 3 & 10 & 31 & 4.568 \\
\texttt{ZLKB-GVZT-NQDE-CU5F} & 10 & 1099.5--1417.7 & 3 & 10 & 45 & 4.398 \\
\bottomrule
\end{tabular}
\label{tab:practicedetail}
\end{table}
@@ -0,0 +1,7 @@
% Generated by scripts/build_practice_data.py; 问题一交会算例复算值。
\newcommand{\ExOneChannel}{12}
\newcommand{\ExOneVertices}{5}
\newcommand{\ExOneDiameter}{22.187}
\newcommand{\ExOneRadius}{11.094}
\newcommand{\ExOneExceed}{4.140}
\newcommand{\ExOneMec}{11.677}
+57
View File
@@ -0,0 +1,57 @@
{
"case_code": "8VRC-ADW4-XDXM-F62L",
"channel": 12,
"half_angle_deg": 1.0,
"measurements": [
{
"x": 579.8500000000001,
"y": -1004.3296607688135,
"bearing_deg": 269.75
},
{
"x": 115.54237972985288,
"y": -1240.6286932057417,
"bearing_deg": 324.35
},
{
"x": 186.45417332320125,
"y": -1758.222760355536,
"bearing_deg": 26.5
}
],
"vertices": [
[
567.3643714616223,
-1576.537915688301
],
[
587.2166596490906,
-1567.0688599548057
],
[
587.2107677439651,
-1566.6187772674052
],
[
573.1672250497825,
-1556.9126876083646
],
[
567.7308446306516,
-1559.7426881288288
]
],
"diameter_circle_centre": [
577.2875696027937,
-1571.578346477853
],
"diameter_m": 22.18713033289069,
"diameter_circle_radius_m": 11.093565166445345,
"exceed_m": 4.1399107767441805,
"mec_centre": [
575.6576957672258,
-1568.3172646521614
],
"mec_radius_m": 11.677257019347309,
"vertex_count": 5
}
+54
View File
@@ -0,0 +1,54 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局统计。
\newcommand{\PractQThreeCases}{15}
\newcommand{\PractQFourCases}{29}
\newcommand{\PractTotalCases}{44}
\newcommand{\PractQThreeComplete}{15}
\newcommand{\PractQFourComplete}{29}
\newcommand{\PractTotalComplete}{44}
\newcommand{\PractQThreeSources}{195}
\newcommand{\PractQFourSources}{362}
\newcommand{\PractTotalSources}{557}
\newcommand{\PractQThreeCleared}{195}
\newcommand{\PractQFourCleared}{362}
\newcommand{\PractTotalCleared}{557}
\newcommand{\PractTotalFailures}{0}
\newcommand{\PractQThreeMean}{3261.579}
\newcommand{\PractQFourMean}{8401.445}
\newcommand{\PractQThreeMin}{2794.212}
\newcommand{\PractQThreeMax}{3867.872}
\newcommand{\PractQFourMin}{4780.432}
\newcommand{\PractQFourMax}{10160.134}
\newcommand{\PractQThreeMeanClear}{258.782}
\newcommand{\PractQFourMeanClear}{708.350}
\newcommand{\PractQThreeMove}{12.729}
\newcommand{\PractQFourMove}{31.841}
\newcommand{\PractQThreeRun}{2.039}
\newcommand{\PractQFourRun}{5.526}
\newcommand{\PractQThreeMeasures}{110.4}
\newcommand{\PractQFourMeasures}{336.9}
\newcommand{\PractQThreeSwitches}{98.9}
\newcommand{\PractQFourSwitches}{286.2}
\newcommand{\PractMaxGap}{0.000000}
\newcommand{\PractQThreeMoveShare}{78.1\%}
\newcommand{\PractQThreeMeasureShare}{16.9\%}
\newcommand{\PractQThreeSwitchShare}{3.0\%}
\newcommand{\PractQThreeClearShare}{2.0\%}
\newcommand{\PractQFourMoveShare}{75.8\%}
\newcommand{\PractQFourMeasureShare}{20.1\%}
\newcommand{\PractQFourSwitchShare}{3.4\%}
\newcommand{\PractQFourClearShare}{0.7\%}
\newcommand{\PractQThreeMainCase}{QS6H-CC29-PDY3-ZA54}
\newcommand{\PractQThreeMainSources}{16}
\newcommand{\PractQThreeMainTime}{2987.385}
\newcommand{\PractQThreeMainMove}{12.042}
\newcommand{\PractQThreeMainMeasures}{85}
\newcommand{\PractQThreeMainSwitches}{74}
\newcommand{\PractQThreeMainRun}{2.052}
\newcommand{\PractQFourMainCase}{SWA3-C3FB-C5QC-FLW7}
\newcommand{\PractQFourMainSources}{16}
\newcommand{\PractQFourMainDirectional}{8}
\newcommand{\PractQFourMainTime}{4780.432}
\newcommand{\PractQFourMainMove}{18.987}
\newcommand{\PractQFourMainMeasures}{159}
\newcommand{\PractQFourMainSwitches}{108}
\newcommand{\PractQFourMainRun}{6.598}
+28
View File
@@ -0,0 +1,28 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[htbp]
\centering\small
\caption{问题三:全向源演练局结果(按总虚拟时间升序)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrrr}
\toprule
序号 & 案例编码 & 源数 & 清除数 & 总虚拟时间/s & 移动/km & 检测数 & 换频数 & 平均清除/(s/个)\\
\midrule
1 & \texttt{6LST-ZFVS-2HHW-TQQ4} & 13 & 13 & 2794.212 & 10.426 & 109 & 99 & 214.939 \\
2 & \texttt{NMRC-85ZV-3FFE-GBXY} & 13 & 13 & 2860.706 & 10.974 & 101 & 96 & 220.054 \\
3 & \texttt{LZDX-68DJ-XM4P-A6XL} & 10 & 10 & 2954.390 & 11.072 & 117 & 105 & 295.439 \\
4 & \texttt{QS6H-CC29-PDY3-ZA54} & 16 & 16 & 2987.385 & 12.042 & 85 & 74 & 186.712 \\
5 & \texttt{MULB-4YTU-VQYG-YAS6} & 10 & 10 & 3064.020 & 11.250 & 129 & 119 & 306.402 \\
6 & \texttt{6V2P-HJCS-GJ9A-83MA} & 16 & 16 & 3174.578 & 12.458 & 103 & 88 & 198.411 \\
7 & \texttt{7ZXN-N53X-HAXQ-EGY4} & 10 & 10 & 3228.653 & 12.393 & 119 & 105 & 322.865 \\
8 & \texttt{VE37-SRPD-NCFR-4EQF} & 10 & 10 & 3240.474 & 12.277 & 124 & 115 & 324.047 \\
9 & \texttt{HGBA-XGH9-UNZJ-7YQ3} & 10 & 10 & 3340.025 & 12.810 & 123 & 113 & 334.003 \\
10 & \texttt{JW47-AVPH-XGHH-RLLV} & 13 & 13 & 3389.152 & 13.506 & 106 & 93 & 260.704 \\
11 & \texttt{8VRC-ADW4-XDXM-F62L} & 13 & 13 & 3407.039 & 13.695 & 102 & 93 & 262.080 \\
12 & \texttt{MT3R-68KH-CNY5-6X73} & 13 & 13 & 3527.713 & 14.044 & 112 & 94 & 271.363 \\
13 & \texttt{E27E-M8WQ-LDT5-2XDA} & 16 & 16 & 3531.065 & 14.210 & 104 & 89 & 220.692 \\
14 & \texttt{R2N7-QMLC-26QX-RXYT} & 16 & 16 & 3556.397 & 14.257 & 106 & 95 & 222.275 \\
15 & \texttt{ARBP-6XFZ-5YJU-STC8} & 16 & 16 & 3867.872 & 15.514 & 116 & 105 & 241.742 \\
\bottomrule
\end{tabular}
\label{tab:q3practice}
\end{table}
+42
View File
@@ -0,0 +1,42 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[htbp]
\centering\small
\caption{问题四:混合源演练局结果(按总虚拟时间升序)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrrr}
\toprule
序号 & 案例编码 & 源数 & 清除数 & 总虚拟时间/s & 移动/km & 检测数 & 换频数 & 平均清除/(s/个)\\
\midrule
1 & \texttt{SWA3-C3FB-C5QC-FLW7} & 16 & 16 & 4780.432 & 18.987 & 159 & 108 & 298.777 \\
2 & \texttt{QL6B-KEUW-DJR2-P84B} & 16 & 16 & 5229.931 & 20.430 & 184 & 144 & 326.871 \\
3 & \texttt{CEL3-XQV8-M7KY-3CSW} & 16 & 16 & 5806.596 & 23.313 & 184 & 144 & 362.912 \\
4 & \texttt{2Y25-4LD6-5A4P-64CV} & 16 & 16 & 6685.802 & 26.469 & 226 & 182 & 417.863 \\
5 & \texttt{QDDJ-S89Y-7TKY-E6VJ} & 16 & 16 & 7316.173 & 29.811 & 221 & 169 & 457.261 \\
6 & \texttt{3XM4-65TW-CH39-XWTZ} & 16 & 16 & 7327.981 & 30.165 & 211 & 160 & 457.999 \\
7 & \texttt{6LEP-YVQ6-AYP5-N359} & 16 & 16 & 7982.569 & 31.403 & 280 & 222 & 498.911 \\
8 & \texttt{7HG2-UEBM-K3D2-LZWT} & 10 & 10 & 8146.581 & 29.393 & 377 & 333 & 814.658 \\
9 & \texttt{6US3-A76N-KC8B-DMFM} & 10 & 10 & 8171.259 & 29.676 & 371 & 331 & 817.126 \\
10 & \texttt{TKCY-2VT2-WYUX-2JXU} & 10 & 10 & 8260.712 & 29.719 & 385 & 342 & 826.071 \\
11 & \texttt{RLK4-E82Q-UHT6-2M9M} & 10 & 10 & 8342.135 & 30.471 & 374 & 328 & 834.214 \\
12 & \texttt{DX2D-KDF9-BGYU-XPMG} & 13 & 13 & 8642.081 & 33.710 & 314 & 265 & 664.775 \\
13 & \texttt{JY84-364B-45UL-AFBK} & 10 & 10 & 8704.597 & 30.413 & 436 & 392 & 870.460 \\
14 & \texttt{T5ZL-5GEP-FDCK-3P8T} & 13 & 13 & 8719.661 & 33.248 & 344 & 285 & 670.743 \\
15 & \texttt{ZLKB-GVZT-NQDE-CU5F} & 10 & 10 & 8723.919 & 31.780 & 395 & 343 & 872.392 \\
16 & \texttt{JH76-MEWS-BR6Q-BQ6S} & 13 & 13 & 8732.841 & 33.504 & 336 & 287 & 671.757 \\
17 & \texttt{VJ68-ADZ6-STFT-LHYT} & 13 & 13 & 8783.099 & 32.805 & 369 & 312 & 675.623 \\
18 & \texttt{US79-DFNV-UQVW-NPLL} & 10 & 10 & 8795.904 & 31.740 & 408 & 358 & 879.590 \\
19 & \texttt{B8HR-3HJH-2UYH-YDVB} & 13 & 13 & 8840.642 & 33.073 & 371 & 306 & 680.049 \\
20 & \texttt{XN57-MHFY-V58F-XCMY} & 10 & 10 & 9188.211 & 32.756 & 438 & 397 & 918.821 \\
21 & \texttt{2M5J-QWHS-BYGB-MPYF} & 13 & 13 & 9191.437 & 35.577 & 343 & 296 & 707.034 \\
22 & \texttt{SE9X-67GA-6YC6-QB7B} & 10 & 10 & 9241.603 & 34.013 & 406 & 359 & 924.160 \\
23 & \texttt{QXVN-L63H-6WLX-KLEJ} & 10 & 10 & 9307.231 & 34.556 & 399 & 351 & 930.723 \\
24 & \texttt{GSG4-6HTJ-AH5L-ZYGM} & 10 & 10 & 9488.522 & 34.348 & 438 & 379 & 948.852 \\
25 & \texttt{96M4-SK2V-PDWN-YGHB} & 13 & 13 & 9593.184 & 36.986 & 364 & 311 & 737.937 \\
26 & \texttt{R2E7-LF5E-J5K6-BSVQ} & 13 & 13 & 9809.921 & 38.810 & 340 & 283 & 754.609 \\
27 & \texttt{JDAY-2SU7-RTSJ-NQFR} & 13 & 13 & 9813.925 & 38.555 & 351 & 283 & 754.917 \\
28 & \texttt{A5YU-4SM6-4PK7-M5H8} & 10 & 10 & 9854.818 & 37.839 & 382 & 327 & 985.482 \\
29 & \texttt{X3Y4-S9D2-XWR9-V6CZ} & 13 & 13 & 10160.134 & 39.831 & 365 & 304 & 781.549 \\
\bottomrule
\end{tabular}
\label{tab:q4practice}
\end{table}
+45
View File
@@ -0,0 +1,45 @@
problem_no,case_code,module,seed,source_total,directional_total,r_eff_min_m,r_eff_max_m,cleared_jammer_count,all_cleared,complete,measure_accepted_count,direction_measure_count,channel_switch_count,clear_failure_count,virtual_time_s,move_time_s,move_distance_m,measure_time_s,switch_time_s,clear_time_s,program_run_duration_s,average_clear_time_s,end_reason,max_recorded_gap_s
3,6LST-ZFVS-2HHW-TQQ4,q3_practice,310,13,0,1025.565,1479.16,13,True,True,109,42,99,0,2794.211708,2085.211708,10426.059,545,99.0,65.0,2.04,214.939362,user_exit,0.0
3,6V2P-HJCS-GJ9A-83MA,q3_practice,305,16,0,1032.735,1455.427,16,True,True,103,55,88,0,3174.57833,2491.57833,12457.892,515,88.0,80.0,2.361,198.411146,user_exit,0.0
3,7ZXN-N53X-HAXQ-EGY4,q3_practice,309,10,0,1110.109,1403.33,10,True,True,119,34,105,0,3228.653175,2478.653175,12393.266,595,105.0,50.0,2.086,322.865318,user_exit,0.0
3,8VRC-ADW4-XDXM-F62L,q3_practice,301,13,0,1017.333,1460.77,13,True,True,102,40,93,0,3407.038502,2739.038502,13695.193,510,93.0,65.0,2.024,262.079885,user_exit,0.0
3,ARBP-6XFZ-5YJU-STC8,q3_practice,302,16,0,1020.28,1464.97,16,True,True,116,50,105,0,3867.871913,3102.871913,15514.36,580,105.0,80.0,1.952,241.741995,user_exit,0.0
3,E27E-M8WQ-LDT5-2XDA,q3_practice,308,16,0,1027.493,1463.979,16,True,True,104,51,89,0,3531.064575,2842.064575,14210.323,520,89.0,80.0,2.405,220.691536,user_exit,0.0
3,HGBA-XGH9-UNZJ-7YQ3,q3_practice,306,10,0,1018.005,1487.291,10,True,True,123,35,113,0,3340.02512,2562.02512,12810.126,615,113.0,50.0,1.997,334.002512,user_exit,0.0
3,JW47-AVPH-XGHH-RLLV,q3_practice,313,13,0,1018.607,1446.721,13,True,True,106,40,93,0,3389.151979,2701.151979,13505.76,530,93.0,65.0,2.146,260.703998,user_exit,0.0
3,LZDX-68DJ-XM4P-A6XL,q3_practice,312,10,0,1011.801,1441.227,10,True,True,117,30,105,0,2954.389763,2214.389763,11071.949,585,105.0,50.0,1.784,295.438976,user_exit,0.0
3,MT3R-68KH-CNY5-6X73,q3_practice,307,13,0,1021.012,1457.861,13,True,True,112,44,94,0,3527.712508,2808.712508,14043.563,560,94.0,65.0,2.15,271.362501,user_exit,0.0
3,MULB-4YTU-VQYG-YAS6,q3_practice,303,10,0,1016.095,1460.312,10,True,True,129,32,119,0,3064.020357,2250.020357,11250.102,645,119.0,50.0,1.636,306.402036,user_exit,0.0
3,NMRC-85ZV-3FFE-GBXY,q3_practice,304,13,0,1010.416,1489.87,13,True,True,101,39,96,0,2860.706202,2194.706202,10973.531,505,96.0,65.0,1.858,220.054323,user_exit,0.0
3,QS6H-CC29-PDY3-ZA54,q3_practice,314,16,0,1025.989,1475.263,16,True,True,85,55,74,0,2987.384891,2408.384891,12041.924,425,74.0,80.0,2.052,186.711556,user_exit,0.0
3,R2N7-QMLC-26QX-RXYT,q3_practice,311,16,0,1007.2,1491.089,16,True,True,106,57,95,0,3556.396565,2851.396565,14256.983,530,95.0,80.0,1.991,222.274785,user_exit,0.0
3,VE37-SRPD-NCFR-4EQF,q3_practice,300,10,0,1009.526,1433.065,10,True,True,124,34,115,0,3240.473597,2455.473597,12277.368,620,115.0,50.0,2.106,324.04736,user_exit,0.0
4,2M5J-QWHS-BYGB-MPYF,q4_practice,404,13,6,1070.641,1495.106,13,True,True,343,43,296,0,9191.436652,7115.436652,35577.183,1715,296.0,65.0,5.04,707.033589,user_exit,0.0
4,2Y25-4LD6-5A4P-64CV,q4_practice,411,16,8,1004.93,1458.657,16,True,True,226,57,182,0,6685.801927,5293.801927,26469.01,1130,182.0,80.0,5.26,417.86262,user_exit,0.0
4,3XM4-65TW-CH39-XWTZ,q4_practice,420,16,8,1004.366,1454.027,16,True,True,211,58,160,0,7327.980928,6032.980928,30164.905,1055,160.0,80.0,6.208,457.998808,user_exit,0.0
4,6LEP-YVQ6-AYP5-N359,q4_practice,432,16,8,1008.896,1463.598,16,True,True,280,61,222,0,7982.568866,6280.568866,31402.844,1400,222.0,80.0,5.584,498.910554,user_exit,0.0
4,6US3-A76N-KC8B-DMFM,q4_practice,409,10,3,1008.26,1464.117,10,True,True,371,30,331,0,8171.259372,5935.259372,29676.297,1855,331.0,50.0,3.814,817.125937,user_exit,0.0
4,7HG2-UEBM-K3D2-LZWT,q4_practice,433,10,3,1114.562,1481.86,10,True,True,377,37,333,0,8146.580541,5878.580541,29392.903,1885,333.0,50.0,4.436,814.658054,user_exit,0.0
4,96M4-SK2V-PDWN-YGHB,q4_practice,407,13,6,1020.414,1492.263,13,True,True,364,51,311,0,9593.183569,7397.183569,36985.918,1820,311.0,65.0,4.853,737.937198,user_exit,0.0
4,A5YU-4SM6-4PK7-M5H8,q4_practice,412,10,3,1139.46,1478.185,10,True,True,382,43,327,0,9854.818271,7567.818271,37839.091,1910,327.0,50.0,4.675,985.481827,user_exit,0.0
4,B8HR-3HJH-2UYH-YDVB,q4_practice,416,13,6,1012.067,1489.111,13,True,True,371,58,306,0,8840.641969,6614.641969,33073.21,1855,306.0,65.0,5.794,680.049382,user_exit,0.0
4,CEL3-XQV8-M7KY-3CSW,q4_practice,426,16,8,1045.853,1497.562,16,True,True,184,54,144,0,5806.59591,4662.59591,23312.98,920,144.0,80.0,5.568,362.912244,user_exit,0.0
4,DX2D-KDF9-BGYU-XPMG,q4_practice,425,13,6,1023.466,1360.172,13,True,True,314,49,265,0,8642.080868,6742.080868,33710.404,1570,265.0,65.0,5.272,664.775451,user_exit,0.0
4,GSG4-6HTJ-AH5L-ZYGM,q4_practice,403,10,3,1001.593,1484.685,10,True,True,438,48,379,0,9488.521663,6869.521663,34347.608,2190,379.0,50.0,5.235,948.852166,user_exit,0.0
4,JDAY-2SU7-RTSJ-NQFR,q4_practice,434,13,6,1018.26,1401.084,13,True,True,351,63,283,0,9813.925336,7710.925336,38554.627,1755,283.0,65.0,5.774,754.917334,user_exit,0.0
4,JH76-MEWS-BR6Q-BQ6S,q4_practice,413,13,6,1042.824,1495.794,13,True,True,336,45,287,0,8732.841353,6700.841353,33504.207,1680,287.0,65.0,5.772,671.757027,user_exit,0.0
4,JY84-364B-45UL-AFBK,q4_practice,400,10,3,1003.128,1498.908,10,True,True,436,38,392,0,8704.596801,6082.596801,30412.984,2180,392.0,50.0,13.713,870.45968,user_exit,0.0
4,QDDJ-S89Y-7TKY-E6VJ,q4_practice,438,16,8,1018.88,1422.829,16,True,True,221,59,169,0,7316.173221,5962.173221,29810.866,1105,169.0,80.0,5.495,457.260826,user_exit,0.0
4,QL6B-KEUW-DJR2-P84B,q4_practice,435,16,8,1001.665,1490.865,16,True,True,184,60,144,0,5229.931106,4085.931106,20429.656,920,144.0,80.0,5.873,326.870694,user_exit,0.0
4,QXVN-L63H-6WLX-KLEJ,q4_practice,430,10,3,1029.996,1485.51,10,True,True,399,38,351,0,9307.230821,6911.230821,34556.154,1995,351.0,50.0,4.514,930.723082,user_exit,0.0
4,R2E7-LF5E-J5K6-BSVQ,q4_practice,419,13,6,1055.108,1474.754,13,True,True,340,56,283,0,9809.920542,7761.920542,38809.603,1700,283.0,65.0,5.223,754.609272,user_exit,0.0
4,RLK4-E82Q-UHT6-2M9M,q4_practice,418,10,3,1052.266,1496.42,10,True,True,374,36,328,0,8342.1353,6094.1353,30470.676,1870,328.0,50.0,4.395,834.21353,user_exit,0.0
4,SE9X-67GA-6YC6-QB7B,q4_practice,439,10,3,1040.839,1476.506,10,True,True,406,39,359,0,9241.602648,6802.602648,34013.013,2030,359.0,50.0,4.463,924.160265,user_exit,0.0
4,SWA3-C3FB-C5QC-FLW7,q4_practice,402,16,8,1025.798,1458.463,16,True,True,159,72,108,0,4780.431839,3797.431839,18987.159,795,108.0,80.0,6.598,298.77699,user_exit,0.0
4,T5ZL-5GEP-FDCK-3P8T,q4_practice,428,13,6,1032.374,1491.324,13,True,True,344,56,285,0,8719.661303,6649.661303,33248.306,1720,285.0,65.0,5.763,670.743177,user_exit,0.0
4,TKCY-2VT2-WYUX-2JXU,q4_practice,427,10,3,1032.206,1492.339,10,True,True,385,35,342,0,8260.712396,5943.712396,29718.562,1925,342.0,50.0,5.028,826.07124,user_exit,0.0
4,US79-DFNV-UQVW-NPLL,q4_practice,406,10,3,1107.601,1451.394,10,True,True,408,40,358,0,8795.904309,6347.904309,31739.522,2040,358.0,50.0,4.565,879.590431,user_exit,0.0
4,VJ68-ADZ6-STFT-LHYT,q4_practice,410,13,6,1016.63,1491.9,13,True,True,369,58,312,0,8783.09917,6561.09917,32805.496,1845,312.0,65.0,5.691,675.623013,user_exit,0.0
4,X3Y4-S9D2-XWR9-V6CZ,q4_practice,431,13,6,1022.115,1395.544,13,True,True,365,55,304,0,10160.134188,7966.134188,39830.671,1825,304.0,65.0,6.694,781.548784,user_exit,0.0
4,XN57-MHFY-V58F-XCMY,q4_practice,421,10,3,1022.143,1499.183,10,True,True,438,31,397,0,9188.211438,6551.211438,32756.057,2190,397.0,50.0,4.568,918.821144,user_exit,0.0
4,ZLKB-GVZT-NQDE-CU5F,q4_practice,424,10,3,1099.547,1417.736,10,True,True,395,45,343,0,8723.919348,6355.919348,31779.597,1975,343.0,50.0,4.398,872.391935,user_exit,0.0
1 problem_no case_code module seed source_total directional_total r_eff_min_m r_eff_max_m cleared_jammer_count all_cleared complete measure_accepted_count direction_measure_count channel_switch_count clear_failure_count virtual_time_s move_time_s move_distance_m measure_time_s switch_time_s clear_time_s program_run_duration_s average_clear_time_s end_reason max_recorded_gap_s
2 3 6LST-ZFVS-2HHW-TQQ4 q3_practice 310 13 0 1025.565 1479.16 13 True True 109 42 99 0 2794.211708 2085.211708 10426.059 545 99.0 65.0 2.04 214.939362 user_exit 0.0
3 3 6V2P-HJCS-GJ9A-83MA q3_practice 305 16 0 1032.735 1455.427 16 True True 103 55 88 0 3174.57833 2491.57833 12457.892 515 88.0 80.0 2.361 198.411146 user_exit 0.0
4 3 7ZXN-N53X-HAXQ-EGY4 q3_practice 309 10 0 1110.109 1403.33 10 True True 119 34 105 0 3228.653175 2478.653175 12393.266 595 105.0 50.0 2.086 322.865318 user_exit 0.0
5 3 8VRC-ADW4-XDXM-F62L q3_practice 301 13 0 1017.333 1460.77 13 True True 102 40 93 0 3407.038502 2739.038502 13695.193 510 93.0 65.0 2.024 262.079885 user_exit 0.0
6 3 ARBP-6XFZ-5YJU-STC8 q3_practice 302 16 0 1020.28 1464.97 16 True True 116 50 105 0 3867.871913 3102.871913 15514.36 580 105.0 80.0 1.952 241.741995 user_exit 0.0
7 3 E27E-M8WQ-LDT5-2XDA q3_practice 308 16 0 1027.493 1463.979 16 True True 104 51 89 0 3531.064575 2842.064575 14210.323 520 89.0 80.0 2.405 220.691536 user_exit 0.0
8 3 HGBA-XGH9-UNZJ-7YQ3 q3_practice 306 10 0 1018.005 1487.291 10 True True 123 35 113 0 3340.02512 2562.02512 12810.126 615 113.0 50.0 1.997 334.002512 user_exit 0.0
9 3 JW47-AVPH-XGHH-RLLV q3_practice 313 13 0 1018.607 1446.721 13 True True 106 40 93 0 3389.151979 2701.151979 13505.76 530 93.0 65.0 2.146 260.703998 user_exit 0.0
10 3 LZDX-68DJ-XM4P-A6XL q3_practice 312 10 0 1011.801 1441.227 10 True True 117 30 105 0 2954.389763 2214.389763 11071.949 585 105.0 50.0 1.784 295.438976 user_exit 0.0
11 3 MT3R-68KH-CNY5-6X73 q3_practice 307 13 0 1021.012 1457.861 13 True True 112 44 94 0 3527.712508 2808.712508 14043.563 560 94.0 65.0 2.15 271.362501 user_exit 0.0
12 3 MULB-4YTU-VQYG-YAS6 q3_practice 303 10 0 1016.095 1460.312 10 True True 129 32 119 0 3064.020357 2250.020357 11250.102 645 119.0 50.0 1.636 306.402036 user_exit 0.0
13 3 NMRC-85ZV-3FFE-GBXY q3_practice 304 13 0 1010.416 1489.87 13 True True 101 39 96 0 2860.706202 2194.706202 10973.531 505 96.0 65.0 1.858 220.054323 user_exit 0.0
14 3 QS6H-CC29-PDY3-ZA54 q3_practice 314 16 0 1025.989 1475.263 16 True True 85 55 74 0 2987.384891 2408.384891 12041.924 425 74.0 80.0 2.052 186.711556 user_exit 0.0
15 3 R2N7-QMLC-26QX-RXYT q3_practice 311 16 0 1007.2 1491.089 16 True True 106 57 95 0 3556.396565 2851.396565 14256.983 530 95.0 80.0 1.991 222.274785 user_exit 0.0
16 3 VE37-SRPD-NCFR-4EQF q3_practice 300 10 0 1009.526 1433.065 10 True True 124 34 115 0 3240.473597 2455.473597 12277.368 620 115.0 50.0 2.106 324.04736 user_exit 0.0
17 4 2M5J-QWHS-BYGB-MPYF q4_practice 404 13 6 1070.641 1495.106 13 True True 343 43 296 0 9191.436652 7115.436652 35577.183 1715 296.0 65.0 5.04 707.033589 user_exit 0.0
18 4 2Y25-4LD6-5A4P-64CV q4_practice 411 16 8 1004.93 1458.657 16 True True 226 57 182 0 6685.801927 5293.801927 26469.01 1130 182.0 80.0 5.26 417.86262 user_exit 0.0
19 4 3XM4-65TW-CH39-XWTZ q4_practice 420 16 8 1004.366 1454.027 16 True True 211 58 160 0 7327.980928 6032.980928 30164.905 1055 160.0 80.0 6.208 457.998808 user_exit 0.0
20 4 6LEP-YVQ6-AYP5-N359 q4_practice 432 16 8 1008.896 1463.598 16 True True 280 61 222 0 7982.568866 6280.568866 31402.844 1400 222.0 80.0 5.584 498.910554 user_exit 0.0
21 4 6US3-A76N-KC8B-DMFM q4_practice 409 10 3 1008.26 1464.117 10 True True 371 30 331 0 8171.259372 5935.259372 29676.297 1855 331.0 50.0 3.814 817.125937 user_exit 0.0
22 4 7HG2-UEBM-K3D2-LZWT q4_practice 433 10 3 1114.562 1481.86 10 True True 377 37 333 0 8146.580541 5878.580541 29392.903 1885 333.0 50.0 4.436 814.658054 user_exit 0.0
23 4 96M4-SK2V-PDWN-YGHB q4_practice 407 13 6 1020.414 1492.263 13 True True 364 51 311 0 9593.183569 7397.183569 36985.918 1820 311.0 65.0 4.853 737.937198 user_exit 0.0
24 4 A5YU-4SM6-4PK7-M5H8 q4_practice 412 10 3 1139.46 1478.185 10 True True 382 43 327 0 9854.818271 7567.818271 37839.091 1910 327.0 50.0 4.675 985.481827 user_exit 0.0
25 4 B8HR-3HJH-2UYH-YDVB q4_practice 416 13 6 1012.067 1489.111 13 True True 371 58 306 0 8840.641969 6614.641969 33073.21 1855 306.0 65.0 5.794 680.049382 user_exit 0.0
26 4 CEL3-XQV8-M7KY-3CSW q4_practice 426 16 8 1045.853 1497.562 16 True True 184 54 144 0 5806.59591 4662.59591 23312.98 920 144.0 80.0 5.568 362.912244 user_exit 0.0
27 4 DX2D-KDF9-BGYU-XPMG q4_practice 425 13 6 1023.466 1360.172 13 True True 314 49 265 0 8642.080868 6742.080868 33710.404 1570 265.0 65.0 5.272 664.775451 user_exit 0.0
28 4 GSG4-6HTJ-AH5L-ZYGM q4_practice 403 10 3 1001.593 1484.685 10 True True 438 48 379 0 9488.521663 6869.521663 34347.608 2190 379.0 50.0 5.235 948.852166 user_exit 0.0
29 4 JDAY-2SU7-RTSJ-NQFR q4_practice 434 13 6 1018.26 1401.084 13 True True 351 63 283 0 9813.925336 7710.925336 38554.627 1755 283.0 65.0 5.774 754.917334 user_exit 0.0
30 4 JH76-MEWS-BR6Q-BQ6S q4_practice 413 13 6 1042.824 1495.794 13 True True 336 45 287 0 8732.841353 6700.841353 33504.207 1680 287.0 65.0 5.772 671.757027 user_exit 0.0
31 4 JY84-364B-45UL-AFBK q4_practice 400 10 3 1003.128 1498.908 10 True True 436 38 392 0 8704.596801 6082.596801 30412.984 2180 392.0 50.0 13.713 870.45968 user_exit 0.0
32 4 QDDJ-S89Y-7TKY-E6VJ q4_practice 438 16 8 1018.88 1422.829 16 True True 221 59 169 0 7316.173221 5962.173221 29810.866 1105 169.0 80.0 5.495 457.260826 user_exit 0.0
33 4 QL6B-KEUW-DJR2-P84B q4_practice 435 16 8 1001.665 1490.865 16 True True 184 60 144 0 5229.931106 4085.931106 20429.656 920 144.0 80.0 5.873 326.870694 user_exit 0.0
34 4 QXVN-L63H-6WLX-KLEJ q4_practice 430 10 3 1029.996 1485.51 10 True True 399 38 351 0 9307.230821 6911.230821 34556.154 1995 351.0 50.0 4.514 930.723082 user_exit 0.0
35 4 R2E7-LF5E-J5K6-BSVQ q4_practice 419 13 6 1055.108 1474.754 13 True True 340 56 283 0 9809.920542 7761.920542 38809.603 1700 283.0 65.0 5.223 754.609272 user_exit 0.0
36 4 RLK4-E82Q-UHT6-2M9M q4_practice 418 10 3 1052.266 1496.42 10 True True 374 36 328 0 8342.1353 6094.1353 30470.676 1870 328.0 50.0 4.395 834.21353 user_exit 0.0
37 4 SE9X-67GA-6YC6-QB7B q4_practice 439 10 3 1040.839 1476.506 10 True True 406 39 359 0 9241.602648 6802.602648 34013.013 2030 359.0 50.0 4.463 924.160265 user_exit 0.0
38 4 SWA3-C3FB-C5QC-FLW7 q4_practice 402 16 8 1025.798 1458.463 16 True True 159 72 108 0 4780.431839 3797.431839 18987.159 795 108.0 80.0 6.598 298.77699 user_exit 0.0
39 4 T5ZL-5GEP-FDCK-3P8T q4_practice 428 13 6 1032.374 1491.324 13 True True 344 56 285 0 8719.661303 6649.661303 33248.306 1720 285.0 65.0 5.763 670.743177 user_exit 0.0
40 4 TKCY-2VT2-WYUX-2JXU q4_practice 427 10 3 1032.206 1492.339 10 True True 385 35 342 0 8260.712396 5943.712396 29718.562 1925 342.0 50.0 5.028 826.07124 user_exit 0.0
41 4 US79-DFNV-UQVW-NPLL q4_practice 406 10 3 1107.601 1451.394 10 True True 408 40 358 0 8795.904309 6347.904309 31739.522 2040 358.0 50.0 4.565 879.590431 user_exit 0.0
42 4 VJ68-ADZ6-STFT-LHYT q4_practice 410 13 6 1016.63 1491.9 13 True True 369 58 312 0 8783.09917 6561.09917 32805.496 1845 312.0 65.0 5.691 675.623013 user_exit 0.0
43 4 X3Y4-S9D2-XWR9-V6CZ q4_practice 431 13 6 1022.115 1395.544 13 True True 365 55 304 0 10160.134188 7966.134188 39830.671 1825 304.0 65.0 6.694 781.548784 user_exit 0.0
44 4 XN57-MHFY-V58F-XCMY q4_practice 421 10 3 1022.143 1499.183 10 True True 438 31 397 0 9188.211438 6551.211438 32756.057 2190 397.0 50.0 4.568 918.821144 user_exit 0.0
45 4 ZLKB-GVZT-NQDE-CU5F q4_practice 424 10 3 1099.547 1417.736 10 True True 395 45 343 0 8723.919348 6355.919348 31779.597 1975 343.0 50.0 4.398 872.391935 user_exit 0.0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。 本文件是完整 table 环境,供正文直接 \input。
\begin{table}[H]
\centering\footnotesize
\caption{演练局汇总(仅统计全清且无失败清除的局)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrr}
\toprule
问题 & 全清局数 & 累计清除 & 平均总虚拟时间/s & 时间区间/s & 平均定位清除时间/(s/个) & 移动占比\\
\midrule
问题三(全向源) & 15 & 195 & 3261.579 & 2794.212--3867.872 & 258.782 & 78.1\% \\
问题四(混合源) & 29 & 362 & 8401.445 & 4780.432--10160.134 & 708.350 & 75.8\% \\
\bottomrule
\end{tabular}
\label{tab:practicesummary}
\end{table}
@@ -1,6 +0,0 @@
% 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 \\
@@ -1,6 +0,0 @@
% 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 & 是 & 是 & 是 \\
不相容观测 & -- & -- & -- & -- & -- & -- & -- \\
-5
View File
@@ -1,5 +0,0 @@
% 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
@@ -1,4 +0,0 @@
% 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 \\
+17
View File
@@ -0,0 +1,17 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
\begin{table}[htbp]
\centering\small
\caption{问题三:三种策略在 18 个全向源场景上的对比(时间:s;三种策略的缺席误判均为 0)}
\setlength{\tabcolsep}{2pt}
\begin{tabular}{lrrrrrrrr}
\toprule
策略 & 有效局 & 全清率 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值 & 位移/km & 后备清除\\
\midrule
bayes(默认策略) & 18/18 & 100.0\% & 3188.734 & 3111.256 & 3663.707 & 111.1 & 12.338 & 0.0 \\ \\
seven(七点批量) & 18/18 & 100.0\% & 5361.707 & 5367.455 & 6462.351 & 100.8 & 23.508 & 0.0 \\ \\
grid(49 点网格) & 18/18 & 100.0\% & 14935.659 & 15275.057 & 16691.406 & 430.6 & 61.550 & 0.0 \\ \\
\bottomrule
\end{tabular}
\label{tab:q3compare}
\end{table}
-4
View File
@@ -1,4 +0,0 @@
% 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 \\
+17
View File
@@ -0,0 +1,17 @@
% Generated by scripts/build_paper_support.py from project/results/*.json.
\begin{table}[htbp]
\centering\small
\caption{问题四:三种策略在 11 个混合源场景上的对比(时间:s)}
\setlength{\tabcolsep}{4pt}
\begin{tabular}{lrrrrrrr}
\toprule
策略 & 有效局 & 全清率 & 缺席误判 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值\\
\midrule
directional(默认策略) & 11/11 & 100.0\% & 0 & 9069.871 & 8803.261 & 12081.148 & 355.6 \\
grid(问题三网格) & 0/11 & 66.6\% & 0 & 18179.173 & 18345.164 & 20457.858 & 573.7 \\
bayes(问题三策略) & 0/11 & 77.0\% & 24 & 3890.801 & 3646.178 & 5054.382 & 132.0 \\
\bottomrule
\end{tabular}
\label{tab:q4compare}
\end{table}
File diff suppressed because it is too large Load Diff

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