Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b0ebb7fb | ||
|
|
b7aadaa400 | ||
|
|
2cb3dd6602 | ||
|
|
662d17e21a | ||
|
|
599eea40d6 | ||
|
|
89021eb9f8 | ||
|
|
9fb15f53f5 | ||
|
|
705c4c9b6a | ||
|
|
257d1a9bd7 |
@@ -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
|
||||||
|
|||||||
@@ -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 裕量定理 + 随机化真源保留性测试兜底
|
|
||||||
@@ -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` 记录论文各轮修订与验证过程(含数据重采与问题修复)。
|
||||||
|
|||||||
@@ -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。
|
|
||||||
@@ -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 引号混淆。
|
|
||||||
|
|||||||
@@ -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`. 在线闭环中的保守信念、负观测与联合信念差异。
|
|
||||||
@@ -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`(选点器在后续问题中的推广与边界)。
|
|
||||||
@@ -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]),不依赖
|
|
||||||
误差分布假设。
|
|
||||||
@@ -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`。
|
|
||||||
@@ -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`。
|
|
||||||
@@ -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` 现算;
|
|
||||||
问题三、问题四直接读覆盖计划与证书,不手抄数字。
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 212 KiB |
@@ -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) 为解析反例"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 101 KiB |
@@ -1,48 +1,107 @@
|
|||||||
# 论文与支撑材料
|
# 论文与支撑材料
|
||||||
|
|
||||||
论文依据 issue 题目、2026 年格式规范及所给往届参考 PDF 编写,四问算法对应 docs/algo-1.md 至 algo-4.md。版式采用项目原始 mcm26b-paper.cls。
|
论文依据赛题、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:论文、正式数据清单与完整源码附录。
|
- main.pdf:论文、演练局数据清单、正式数据清单与四问核心函数代码附录。
|
||||||
- main.tex、sections/:可编辑 LaTeX 源文件。
|
- main.tex、sections/:可编辑 LaTeX 源文件。
|
||||||
|
- data/practice-results.json、practice-results.csv:44 局演练局的结构化复算结果。
|
||||||
- data/formal-results.json、formal-results.csv、audit.md:六份正式测试数据、复算结果与校验说明。
|
- data/formal-results.json、formal-results.csv、audit.md:六份正式测试数据、复算结果与校验说明。
|
||||||
- support-materials.zip:源码、六份正式原始日志、统计快照、论文工程及逐文件哈希清单。
|
- support-materials.zip:源码、自建模拟器、演练局与正式日志、统计快照、论文工程及逐文件哈希清单。
|
||||||
- REWRITE_NOTES.md:本次修订及验证说明。
|
- REWRITE_NOTES.md:本次修订及验证说明。
|
||||||
|
|
||||||
## 正式数据
|
## 正文数据
|
||||||
|
|
||||||
数据来自 Jammers-simulator-full/test/q3 和 test/q4,两问各三次。
|
演练局问题三 15 局、问题四 29 局,源数覆盖 10 至 16,问题四定向源覆盖 3/6/8 个;
|
||||||
通过完整包 SHA-256、结果侧文件和公开票据哈希与官方统计队列核对。
|
**44 局全部清完全部源且失败清除为零**,累计清除 557 个源。
|
||||||
六次累计清除 75 个源,清除失败为零;问题三、四按清除数量加权的平均时间分别为 249.873 和 736.268 s/个。
|
问题三平均总虚拟时间 3261.579 s,问题四 8401.445 s,移动时间占比 78.1% 与 75.8%。
|
||||||
评价指标与统计扩展分别见论文实验结果和未来工作。
|
|
||||||
|
正式测试六次累计清除 75 个源,清除失败为零;仅用于附录中的效率与结论验证。
|
||||||
|
|
||||||
## 重算与编译
|
## 重算与编译
|
||||||
|
|
||||||
在项目根目录执行:
|
在项目根目录执行:
|
||||||
|
|
||||||
python scripts/audit_paper_formal.py
|
python scripts/run_practice_episodes.py --problem 3 --seeds 300-314 # 需要时重采演练局
|
||||||
python scripts/build_paper_support.py
|
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` 的第一种调用需要原电脑的官方统计队列;
|
||||||
|
快照模式重算统计并校验现有正式包的哈希,来源匹配仍需官方统计队列。
|
||||||
|
|
||||||
进入 docs/paper 执行:
|
进入 docs/paper 执行:
|
||||||
|
|
||||||
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
|
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
|
||||||
|
|
||||||
输出为 main.pdf。VS Code 默认配方为 latexmk (xelatex),自动完成交叉引用所需的多遍编译。
|
两条口令都完成交叉引用所需的多遍编译,输出均为 57 页。
|
||||||
需要 TeX Live 的 XeLaTeX、ctex、TikZ,以及原项目 docs/paper/fonts/ 目录。
|
需要 TeX Live 的 XeLaTeX、ctex、TikZ。
|
||||||
|
|
||||||
在项目根目录生成支撑包:
|
字体放在 `docs/paper/fonts/`(HeitiSC / SongtiSC / Times New Roman,约 150 MB),
|
||||||
|
该目录**已在 `.gitignore` 中排除、不入库**,`mcm26b-paper.cls` 通过 `Path = fonts/` 引用。
|
||||||
|
若克隆后缺少该目录,按 `mcm26b-paper.cls` 中的「若未携带 fonts/ 目录」注释把那几行
|
||||||
|
`\setCJKmainfont` / `\setmainfont` 换成系统字体即可照常编译。
|
||||||
|
|
||||||
|
### VS Code(LaTeX Workshop)
|
||||||
|
|
||||||
|
仓库根的 `.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
|
||||||
|
|
||||||
## 排版与复算
|
## 排版与复算
|
||||||
|
|
||||||
正文采用原模板的字体、标题、页边距及页码设置。源码附录通过 data/listing-source/ 的显示副本处理数学符号的 LaTeX 转义,支撑包保留原始可执行源码。
|
正文采用原模板的字体、标题、页边距及页码设置,图解与表题统一使用中文。
|
||||||
统计表、汇总量和耗时图由同一快照生成;几何图取正文给定参数。
|
附录代码直接取自单文件核心代码 ipynb,数学符号由导言区的 lstlisting 样式 corecode 映射。
|
||||||
正文使用集合估计、凸外包、清除充分条件、集合覆盖、信息增益和最近邻路径排序等概念描述模型。
|
正文使用集合估计、凸外包、清除充分条件、网格幸存集滤波、信息增益、前向半盘最小命中集、
|
||||||
|
位置与朝向联合信念和最近邻路径排序等概念描述模型。
|
||||||
|
附录只收录四问主线的核心函数(32 个,约 1300 行),评测机、命令行外壳、采集与绘图脚本、
|
||||||
|
测试以及未被采用的方案不进入论文;完整可执行程序见支撑包的 mcm26b-核心代码.ipynb。
|
||||||
|
|||||||
@@ -1,6 +1,225 @@
|
|||||||
# 本次修订与验证
|
# 本次修订与验证
|
||||||
|
|
||||||
## 正式数据补充
|
## 目录清理、去敏与文档合并(本轮)
|
||||||
|
|
||||||
|
### 身份信息去敏(队号改为运行期参数)
|
||||||
|
|
||||||
|
队号属身份信息,不再出现在代码、测试、示例与文档中:
|
||||||
|
|
||||||
|
- `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)。
|
||||||
|
|
||||||
|
### 清理无用文件
|
||||||
|
|
||||||
|
- 删除本机工作区 `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` 及其生成函数:
|
||||||
|
对应的四层主线图已从论文中移除,不再有插图引用它。
|
||||||
|
|
||||||
|
### 优化 .gitignore
|
||||||
|
|
||||||
|
按"只跟踪可复现结果所需的最小集合"重写,补齐 Python/构建/编辑器/操作系统条目,
|
||||||
|
新增忽略 `docs/paper/support-materials.zip`(脚本产物)、`.piptmp/`、`uv-temp/`、`pip-temp/`、
|
||||||
|
`.idea/`、`.zcode/`;保留 `referee-data/` 的版本控制并注明其身份信息已去敏。
|
||||||
|
|
||||||
|
### 文档合并到 README
|
||||||
|
|
||||||
|
论文已完整给出四问的模型、推导与结果分析,故删除 13 份算法与设计说明文档
|
||||||
|
(`docs/algo-*.md`、两份优化方案、示意图册、总体思路、执行时间、协议规则表、
|
||||||
|
最优与基线对比、论文列表),只保留复现所必需的说明:
|
||||||
|
|
||||||
|
- 根 `README.md` 重写为单一操作手册:环境 → 运行程序 → 自建模拟器 →
|
||||||
|
复现论文数据 → 复现插图 → 编译论文 → 单文件核心代码与测试 → 目录结构;
|
||||||
|
- `docs/README.md` 改为最小索引;
|
||||||
|
- `docs/paper/README.md` 与 `docs/figures/README.md` 同步更新(数据分工、重算与编译步骤、
|
||||||
|
插图清单与来源)。
|
||||||
|
- 源码/测试/脚本中原先指向被删文档的注释,统一改为指向论文对应章节,不留悬空引用。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `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。
|
||||||
|
|
||||||
|
## VS Code 配方与图 2 箭头(前轮)
|
||||||
|
|
||||||
|
- **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 页,无未解析引用、无超宽表格;绘图脚本无告警。
|
||||||
|
|
||||||
|
## 图 5 重做、删除图 11、射线加粗(前轮)
|
||||||
|
|
||||||
|
- **修复图 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 页,无未解析引用、无超宽表格;绘图脚本无告警。
|
||||||
|
|
||||||
|
## 版面精简与可读性(前轮)
|
||||||
|
|
||||||
|
- **摘要压缩**:删除"结论: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。
|
加入问题三第三次 UVUZ-B4MQ-XSUV-HQFP 和问题四第三次 K58B-V5VC-E4EF-CRTW。
|
||||||
两份文件均通过原始导出、结果侧文件和官方统计记录的对应校验。
|
两份文件均通过原始导出、结果侧文件和官方统计记录的对应校验。
|
||||||
@@ -10,11 +229,10 @@
|
|||||||
| Q3-3 | 10 | 290.686 | 2.680 |
|
| Q3-3 | 10 | 290.686 | 2.680 |
|
||||||
| Q4-3 | 14 | 614.884 | 6.051 |
|
| Q4-3 | 14 | 614.884 | 6.051 |
|
||||||
|
|
||||||
六次累计清除 75 个源。问题三合计 38 个,问题四合计 37 个;
|
六次累计清除 75 个源。问题三合计 38 个,问题四合计 37 个。
|
||||||
按清除数量加权的平均时间分别为 249.873 和 736.268 s/个。
|
正式结果表、汇总公式、动作耗时图、数据清单与支撑包统一使用六份记录。
|
||||||
摘要、正式结果表、汇总公式、动作耗时图、数据清单与支撑包统一使用六份记录。
|
|
||||||
|
|
||||||
## 数学表述
|
## 数学表述(前轮)
|
||||||
|
|
||||||
用“清除充分条件”“半径判据”替换“证书”;
|
用“清除充分条件”“半径判据”替换“证书”;
|
||||||
用“检测状态矩阵”替换“账本”;
|
用“检测状态矩阵”替换“账本”;
|
||||||
@@ -22,14 +240,15 @@
|
|||||||
采用集合估计、关联矩阵、离散集合覆盖、网格候选集等已有概念。
|
采用集合估计、关联矩阵、离散集合覆盖、网格候选集等已有概念。
|
||||||
实现扩展及评价限制集中于未来工作。数学证明中的必要条件、反例与适用假设保持明确。
|
实现扩展及评价限制集中于未来工作。数学证明中的必要条件、反例与适用假设保持明确。
|
||||||
|
|
||||||
信息增益目标中的移动代价恢复为加法项,与源码的加权求和一致。
|
|
||||||
可见性权重按位置子样本、候选朝向位数和初始 24 档定义。
|
|
||||||
算法的可执行源码保持原样,数学符号继续采用 LaTeX 转义排版。
|
|
||||||
|
|
||||||
## 模板与验证
|
## 模板与验证
|
||||||
|
|
||||||
mcm26b-paper.cls 保持与原仓库模板逐字节一致,文档沿用原模板的黑体标题及版式。
|
mcm26b-paper.cls 保持与原仓库模板逐字节一致,文档沿用原模板的黑体标题及版式。
|
||||||
使用 latexmk 自动多遍编译并检查文献、公式、图表引用。
|
使用 latexmk 自动多遍编译并检查文献、公式、图表引用。
|
||||||
正式统计按官方整数微秒和毫秒计算,支撑包提供 SHA-256 清单供复核。
|
正式统计按官方整数微秒和毫秒计算,支撑包提供 SHA-256 清单供复核。
|
||||||
|
|
||||||
本轮修订前的 PDF 和支撑包保存在 tmp/paper-rewrite/main-before-six-records.pdf 与 support-before-six-records.zip。
|
## 清理论文与源码附录
|
||||||
|
|
||||||
|
- 清除 7 个文件中已提交的 git 冲突标记,冲突块统一取 PNG 图版一侧。
|
||||||
|
- 附录不再逐字排版全部源码,改为“核心程序代码”:只列四问主线核心函数 32 个、约 1300 行,
|
||||||
|
每个附一句中文说明;数学符号在 main.tex 的 corecode 样式里统一映射。
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
\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}\\
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
\newcommand{\QThreeTotal}{9495.176084}
|
\newcommand{\QThreeTotal}{9495.176084}
|
||||||
\newcommand{\QThreeWeighted}{249.873}
|
\newcommand{\QThreeWeighted}{249.873}
|
||||||
\newcommand{\QThreeMean}{253.884}
|
\newcommand{\QThreeMean}{253.884}
|
||||||
|
\newcommand{\QThreeProgramTotal}{11.300}
|
||||||
\newcommand{\QFourCount}{37}
|
\newcommand{\QFourCount}{37}
|
||||||
\newcommand{\QFourTotal}{27241.909918}
|
\newcommand{\QFourTotal}{27241.909918}
|
||||||
\newcommand{\QFourWeighted}{736.268}
|
\newcommand{\QFourWeighted}{736.268}
|
||||||
\newcommand{\QFourMean}{754.062}
|
\newcommand{\QFourMean}{754.062}
|
||||||
|
\newcommand{\QFourProgramTotal}{19.443}
|
||||||
\newcommand{\FormalClearCount}{75}
|
\newcommand{\FormalClearCount}{75}
|
||||||
\newcommand{\FormalMovementMin}{74.47}
|
\newcommand{\FormalMovementMin}{74.47}
|
||||||
\newcommand{\FormalMovementMax}{81.38}
|
\newcommand{\FormalMovementMax}{81.38}
|
||||||
\newcommand{\FormalRunMin}{2.680}
|
\newcommand{\FormalRunMin}{2.680}
|
||||||
\newcommand{\FormalRunMax}{7.668}
|
\newcommand{\FormalRunMax}{7.668}
|
||||||
|
\newcommand{\FormalVirtualTotal}{36737.086002}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Audit only the formal exports selected in the official simulator's test folder.
|
|
||||||
|
|
||||||
Match the public activation-ticket digest to the official statistics queue,
|
|
||||||
and the complete package digest to its result sidecar. Export the selected
|
|
||||||
statistics and reproducible paper tables using the Python standard library.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import csv
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import struct
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
FIELDS = (
|
|
||||||
"entered", "end_reason", "cleared_jammer_count", "measure_accepted_count",
|
|
||||||
"virtual_time_us", "program_run_duration_ms", "channel_switch_count",
|
|
||||||
"clear_failure_count", "state",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sha(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def public_header(path: Path) -> tuple[dict, str]:
|
|
||||||
data = path.read_bytes()
|
|
||||||
if data[:8] != b"JMBFLOG1" or len(data) < 14:
|
|
||||||
raise ValueError(f"Unsupported formal envelope: {path.name}")
|
|
||||||
version, size = struct.unpack(">HI", data[8:14])
|
|
||||||
if version != 1 or not 0 < size <= len(data) - 14:
|
|
||||||
raise ValueError(f"Invalid envelope header: {path.name}")
|
|
||||||
header = json.loads(data[14:14 + size])
|
|
||||||
if header["package_type"] != "formal_behavior_log":
|
|
||||||
raise ValueError("Practice logs are not admitted")
|
|
||||||
return header, sha(data)
|
|
||||||
|
|
||||||
|
|
||||||
def fixed(value: Decimal, digits: int = 3) -> str:
|
|
||||||
return str(value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
|
|
||||||
|
|
||||||
|
|
||||||
def derive(row: dict) -> dict:
|
|
||||||
count = row["cleared_jammer_count"]
|
|
||||||
total = Decimal(row["virtual_time_us"]) / 1_000_000
|
|
||||||
movement = total - (5 * row["measure_accepted_count"]
|
|
||||||
+ row["channel_switch_count"] + 5 * count
|
|
||||||
+ 3 * row["clear_failure_count"])
|
|
||||||
if count <= 0 or movement < 0:
|
|
||||||
raise ValueError("Invalid counts or negative inferred movement time")
|
|
||||||
return {
|
|
||||||
"virtual_time_s": str(total),
|
|
||||||
"average_clear_time_s": fixed(total / count, 6),
|
|
||||||
"program_run_duration_s": fixed(Decimal(row["program_run_duration_ms"]) / 1000),
|
|
||||||
"inferred_movement_time_s": str(movement),
|
|
||||||
"inferred_distance_m": str(5 * movement),
|
|
||||||
"movement_share_percent": fixed(100 * movement / total, 6),
|
|
||||||
"source_total": None,
|
|
||||||
"clear_fraction": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def collect(sim: Path) -> list[dict]:
|
|
||||||
selected = sorted((sim / "test").rglob("*.jlog"))
|
|
||||||
if not selected:
|
|
||||||
raise ValueError("No formal exports in test/")
|
|
||||||
queue = sim / "JammersSimulatorData/formal-statistics-queue.sqlite3"
|
|
||||||
original = sim / "JammersSimulatorData/behavior-logs"
|
|
||||||
# Read the live queue including its WAL, without writing to the simulator.
|
|
||||||
with sqlite3.connect(queue.resolve().as_uri() + "?mode=ro", uri=True) as db:
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
query = "SELECT activation_ticket_sha256," + ",".join(FIELDS) + " FROM statistics_tasks"
|
|
||||||
statistics = {r["activation_ticket_sha256"]: dict(r) for r in db.execute(query)}
|
|
||||||
records = []
|
|
||||||
for path in selected:
|
|
||||||
header, digest = public_header(path)
|
|
||||||
encoded = header["server_ticket_b64"]
|
|
||||||
ticket_hash = sha(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
|
|
||||||
sidecar = json.loads((original / (path.stem + ".result.json")).read_text("utf-8"))
|
|
||||||
for field in ("problem_no", "formal_index", "case_code"):
|
|
||||||
if header[field] != sidecar[field]:
|
|
||||||
raise ValueError(f"Mismatched case: {path.name}")
|
|
||||||
if digest != sidecar["package_sha256"] or digest != sha((original / path.name).read_bytes()):
|
|
||||||
raise ValueError(f"Mismatched formal package: {path.name}")
|
|
||||||
matched = statistics[ticket_hash]
|
|
||||||
row = {name: header[name] for name in ("problem_no", "formal_index", "case_code", "client_version")}
|
|
||||||
row.update({name: matched[name] for name in FIELDS})
|
|
||||||
row.update({
|
|
||||||
"file": path.relative_to(sim).as_posix(), "size_bytes": path.stat().st_size,
|
|
||||||
"package_sha256": digest, "activation_ticket_sha256": ticket_hash,
|
|
||||||
"created_at_utc": header["created_at_utc"],
|
|
||||||
"ended_at_utc": sidecar["ended_at_utc"],
|
|
||||||
"content_encryption": header["content_encryption"],
|
|
||||||
"identity_and_ticket_content_omitted": True,
|
|
||||||
"same_as_official_original": True,
|
|
||||||
})
|
|
||||||
row["derived"] = derive(row)
|
|
||||||
records.append(row)
|
|
||||||
keys = [(r["problem_no"], r["formal_index"]) for r in records]
|
|
||||||
if len(set(keys)) != len(keys):
|
|
||||||
raise ValueError("Duplicate formal attempt")
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(rows: list[dict], out: Path) -> None:
|
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
|
||||||
snapshot = {
|
|
||||||
"schema": "paper-formal-audit-v1",
|
|
||||||
"selection": "Only Jammers-simulator-full/test/**/*.jlog",
|
|
||||||
"statistics_source": "Official formal-statistics-queue.sqlite3; matched by SHA-256 of the public activation ticket",
|
|
||||||
"coverage": {str(p): sorted(r["formal_index"] for r in rows if r["problem_no"] == p)
|
|
||||||
for p in (3, 4)},
|
|
||||||
"measurement_scope": "Per-case official action counts, virtual duration and program duration",
|
|
||||||
"limitations": [
|
|
||||||
"Source totals, source types, solver commit and trajectories require additional evidence.",
|
|
||||||
"Queue state confirmed records a statistics submission acknowledgement.",
|
|
||||||
],
|
|
||||||
"records": rows,
|
|
||||||
}
|
|
||||||
(out / "formal-results.json").write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
||||||
columns = ["problem_no", "formal_index", "case_code", *FIELDS, "package_sha256"]
|
|
||||||
with (out / "formal-results.csv").open("w", encoding="utf-8-sig", newline="") as handle:
|
|
||||||
writer = csv.DictWriter(handle, fieldnames=columns + list(rows[0]["derived"]))
|
|
||||||
writer.writeheader()
|
|
||||||
for row in rows:
|
|
||||||
writer.writerow({**{key: row[key] for key in columns}, **row["derived"]})
|
|
||||||
for problem in (3, 4):
|
|
||||||
by_index = {r["formal_index"]: r for r in rows if r["problem_no"] == problem}
|
|
||||||
lines = ["% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding."]
|
|
||||||
for index in (1, 2, 3):
|
|
||||||
row = by_index.get(index)
|
|
||||||
if row is None:
|
|
||||||
lines.append(f"测试 {index} & 未提供记录 & -- & -- & -- " + r"\\")
|
|
||||||
else:
|
|
||||||
avg = Decimal(row["virtual_time_us"]) / 1_000_000 / row["cleared_jammer_count"]
|
|
||||||
lines.append(f"测试 {index} & \\texttt{{{row['case_code']}}} & {row['cleared_jammer_count']} & {fixed(avg)} & {row['derived']['program_run_duration_s']} " + r"\\")
|
|
||||||
(out / f"formal-q{problem}-rows.tex").write_text("\n".join(lines) + "\n", "utf-8")
|
|
||||||
cost_lines, constants = [], []
|
|
||||||
for row in rows:
|
|
||||||
label = f"Q{row['problem_no']}-{row['formal_index']}"
|
|
||||||
total = Decimal(row["virtual_time_us"]) / 1_000_000
|
|
||||||
move = Decimal(row["derived"]["inferred_movement_time_s"])
|
|
||||||
cost_lines.append(f"{label} & {row['measure_accepted_count']} & {row['channel_switch_count']} & {row['clear_failure_count']} & {fixed(total)} & {fixed(move)} & {fixed(100*move/total, 2)}\\% " + r"\\")
|
|
||||||
(out / "formal-cost-rows.tex").write_text("\n".join(cost_lines) + "\n", "utf-8")
|
|
||||||
for problem, word in ((3, "Three"), (4, "Four")):
|
|
||||||
group = [r for r in rows if r["problem_no"] == problem]
|
|
||||||
count = sum(r["cleared_jammer_count"] for r in group)
|
|
||||||
total = sum(Decimal(r["virtual_time_us"]) / 1_000_000 for r in group)
|
|
||||||
mean = sum(Decimal(r["virtual_time_us"]) / 1_000_000 / r["cleared_jammer_count"] for r in group) / len(group)
|
|
||||||
constants.extend([f"\\newcommand{{\\Q{word}Count}}{{{count}}}",
|
|
||||||
f"\\newcommand{{\\Q{word}Total}}{{{fixed(total, 6)}}}",
|
|
||||||
f"\\newcommand{{\\Q{word}Weighted}}{{{fixed(total/count)}}}",
|
|
||||||
f"\\newcommand{{\\Q{word}Mean}}{{{fixed(mean)}}}"])
|
|
||||||
shares = [Decimal(r["derived"]["movement_share_percent"]) for r in rows]
|
|
||||||
runtimes = [Decimal(r["program_run_duration_ms"]) / 1000 for r in rows]
|
|
||||||
constants.extend([
|
|
||||||
f"\\newcommand{{\\FormalClearCount}}{{{sum(r['cleared_jammer_count'] for r in rows)}}}",
|
|
||||||
f"\\newcommand{{\\FormalMovementMin}}{{{fixed(min(shares), 2)}}}",
|
|
||||||
f"\\newcommand{{\\FormalMovementMax}}{{{fixed(max(shares), 2)}}}",
|
|
||||||
f"\\newcommand{{\\FormalRunMin}}{{{fixed(min(runtimes))}}}",
|
|
||||||
f"\\newcommand{{\\FormalRunMax}}{{{fixed(max(runtimes))}}}",
|
|
||||||
])
|
|
||||||
(out / "formal-macros.tex").write_text("\n".join(constants) + "\n", "utf-8")
|
|
||||||
audit = ["# 正式测试数据核对", "", f"本次汇总 `Jammers-simulator-full/test` 中的 {len(rows)} 份正式导出包。",
|
|
||||||
"利用公开头部的案例信息、包哈希及票据哈希,与原始导出、结果侧文件和官方正式统计数据库建立对应关系。", "",
|
|
||||||
"| 案例 | 正式序号 | 原件一致 | 包 SHA-256 |", "| --- | --- | --- | --- |"]
|
|
||||||
for row in rows:
|
|
||||||
audit.append(f"| {row['case_code']} | Q{row['problem_no']}-{row['formal_index']} | 是 | `{row['package_sha256']}` |")
|
|
||||||
audit += ["", "虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。",
|
|
||||||
"移动时间由总时长减去检测、换频、成功及失败清除耗时计算。",
|
|
||||||
"`confirmed` 表示统计提交确认,`user_exit` 表示主动退出。",
|
|
||||||
"实验评价采用清除数量、逐案例平均定位清除时间、程序运行时间和动作耗时分解。",
|
|
||||||
"扩展评价所需的源总数与配对场景设计见论文未来工作。", ""]
|
|
||||||
(out / "audit.md").write_text("\n".join(audit), "utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--sim-root", type=Path, default=ROOT / "Jammers-simulator-full")
|
|
||||||
parser.add_argument("--out", type=Path, default=ROOT / "docs/paper/data")
|
|
||||||
parser.add_argument("--snapshot", type=Path, help="Regenerate tables from the audited anonymous snapshot, without the simulator database")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.snapshot:
|
|
||||||
rows = json.loads(args.snapshot.read_text("utf-8"))["records"]
|
|
||||||
for row in rows:
|
|
||||||
if derive(row) != row["derived"]:
|
|
||||||
raise ValueError("Snapshot arithmetic changed")
|
|
||||||
path = args.sim_root / row["file"]
|
|
||||||
if path.exists() and public_header(path)[1] != row["package_sha256"]:
|
|
||||||
raise ValueError("Snapshot and formal export disagree")
|
|
||||||
else:
|
|
||||||
rows = collect(args.sim_root)
|
|
||||||
write_outputs(rows, args.out)
|
|
||||||
for row in rows:
|
|
||||||
print(f"Q{row['problem_no']}-{row['formal_index']} {row['case_code']}: "
|
|
||||||
f"cleared={row['cleared_jammer_count']} average={row['derived']['average_clear_time_s']} s "
|
|
||||||
f"real={row['derived']['program_run_duration_s']} s")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,393 +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)
|
|
||||||
plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8"))
|
|
||||||
lines = [
|
|
||||||
r"\begin{figure}[htbp]\centering",
|
|
||||||
r"\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]",
|
|
||||||
r"\fill[inkblue!5] (0,0) circle (1800);",
|
|
||||||
r"\draw[thick] (0,0) circle (1800);",
|
|
||||||
r"\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};",
|
|
||||||
r"\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};",
|
|
||||||
]
|
|
||||||
for x in (-2000, -1000, 1000, 2000):
|
|
||||||
lines += [fr"\draw ({x},-35)--({x},35);",
|
|
||||||
fr"\node[below,font=\scriptsize] at ({x},-50) {{{x}}};"]
|
|
||||||
for x, y in plan["points"]:
|
|
||||||
lines.append(fr"\fill[inkblue] ({x},{y}) circle (33);")
|
|
||||||
lines += [
|
|
||||||
r"\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};",
|
|
||||||
r"\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};",
|
|
||||||
r"\end{tikzpicture}",
|
|
||||||
r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}",
|
|
||||||
r"\label{fig:q4plan}\end{figure}",
|
|
||||||
]
|
|
||||||
(out / "q4-plan.tex").write_text("\n".join(lines) + "\n", "utf-8")
|
|
||||||
colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"]
|
|
||||||
bars = [
|
|
||||||
r"\begin{figure}[htbp]\centering",
|
|
||||||
r"\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]",
|
|
||||||
]
|
|
||||||
for j, row in enumerate(rows):
|
|
||||||
y = len(rows) - 1 - j
|
|
||||||
components = [Decimal(row["derived"]["inferred_movement_time_s"]),
|
|
||||||
Decimal(5 * row["measure_accepted_count"]),
|
|
||||||
Decimal(row["channel_switch_count"]),
|
|
||||||
Decimal(5 * row["cleared_jammer_count"] + 3 * row["clear_failure_count"])]
|
|
||||||
label = f"Q{row['problem_no']}-{row['formal_index']}"
|
|
||||||
bars.append(fr"\node[anchor=east] at (-100,{y + 0.3}) {{{label}}};")
|
|
||||||
current = Decimal(0)
|
|
||||||
for amount, color in zip(components, colors):
|
|
||||||
bars.append(fr"\fill[{color}] ({current},{y}) rectangle ({current + amount},{y + 0.6});")
|
|
||||||
current += amount
|
|
||||||
bars.append(fr"\node[anchor=west,font=\scriptsize] at ({current + 80},{y + 0.3}) {{{current:.1f}}};")
|
|
||||||
bars.append(r"\draw[->] (0,-0.25)--(10500,-0.25);")
|
|
||||||
for tick in range(0, 10001, 2000):
|
|
||||||
bars.append(fr"\draw ({tick},-0.25)--({tick},-0.35) node[below,font=\scriptsize] {{{tick}}};")
|
|
||||||
bars.append(r"\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};")
|
|
||||||
for x, color, label in zip((0, 2600, 5200, 7600), colors, ("移动", "检测", "换频", "清除")):
|
|
||||||
bars += [fr"\fill[{color}] ({x},-1.3) rectangle ({x + 350},-1.05);",
|
|
||||||
fr"\node[anchor=west] at ({x + 450},-1.175) {{{label}}};"]
|
|
||||||
bars += [
|
|
||||||
r"\end{tikzpicture}",
|
|
||||||
r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}",
|
|
||||||
r"\label{fig:costbars}\end{figure}",
|
|
||||||
]
|
|
||||||
(out / "formal-time-bars.tex").write_text("\n".join(bars) + "\n", "utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def fixed(value: float | None, digits: int = 3) -> str:
|
|
||||||
if value is None:
|
|
||||||
return "--"
|
|
||||||
return str(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
|
|
||||||
|
|
||||||
|
|
||||||
def percent(value: float | None, digits: int = 1) -> str:
|
|
||||||
if value is None:
|
|
||||||
return "--"
|
|
||||||
return fixed(100 * value, digits) + r"\%"
|
|
||||||
|
|
||||||
|
|
||||||
def flag(value: bool | None) -> str:
|
|
||||||
return "--" if value is None else ("是" if value else "否")
|
|
||||||
|
|
||||||
|
|
||||||
def project_tables() -> None:
|
|
||||||
"""Generate the optimal-versus-baseline comparison tables from project/results."""
|
|
||||||
out = PAPER / "data"
|
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
|
||||||
data = {name: json.loads((PROJECT_RESULTS / f"{name}.json").read_text("utf-8"))
|
|
||||||
for name in ("q1", "q2", "q3", "q4")}
|
|
||||||
examples, random = data["q1"]["aggregate_examples"], data["q1"]["aggregate_random"]
|
|
||||||
two = data["q2"]["aggregate"]
|
|
||||||
three, paired3 = data["q3"]["aggregate"]["variants"], data["q3"]["aggregate"]["paired"]
|
|
||||||
four, paired4 = data["q4"]["aggregate"]["variants"], data["q4"]["aggregate"]["paired"]
|
|
||||||
head = "% Generated by scripts/build_paper_support.py from project/results/*.json."
|
|
||||||
|
|
||||||
macros = [
|
|
||||||
("ProjQOneExampleCases", str(examples["cases"])),
|
|
||||||
("ProjQOneRandomCases", str(random["cases"])),
|
|
||||||
("ProjQOneFixedStatusBase", percent(examples["status_accuracy_baseline"])),
|
|
||||||
("ProjQOneFixedMaeBase", fixed(examples["diameter_mae_baseline_m"])),
|
|
||||||
("ProjQOneFixedMaeMaxBase", fixed(examples["diameter_max_err_baseline_m"])),
|
|
||||||
("ProjQOneFixedCoverBase", percent(examples["coverage_accuracy_baseline"])),
|
|
||||||
("ProjQOneFixedMsOpt", fixed(examples["runtime_optimal_mean_ms"])),
|
|
||||||
("ProjQOneFixedMsBase", fixed(examples["runtime_baseline_mean_ms"])),
|
|
||||||
("ProjQOneRandStatusBase", percent(random["status_accuracy_baseline"])),
|
|
||||||
("ProjQOneRandMaeBase", fixed(random["diameter_mae_baseline_m"])),
|
|
||||||
("ProjQOneRandMaeMaxBase", fixed(random["diameter_max_err_baseline_m"])),
|
|
||||||
("ProjQOneRandCoverBase", percent(random["coverage_accuracy_baseline"])),
|
|
||||||
("ProjQOneRandMsOpt", fixed(random["runtime_optimal_mean_ms"])),
|
|
||||||
("ProjQOneRandMsBase", fixed(random["runtime_baseline_mean_ms"])),
|
|
||||||
("ProjQTwoCases", str(two["optimal"]["cases"])),
|
|
||||||
("ProjQTwoInitialRadius", fixed(two["_initial_radius_mean_m"])),
|
|
||||||
("ProjQTwoGuaranteeOpt", percent(two["optimal"]["guaranteed_rate"])),
|
|
||||||
("ProjQTwoGuaranteeMec", percent(two["baseline_mec"]["guaranteed_rate"])),
|
|
||||||
("ProjQTwoGuaranteePerp", percent(two["baseline_perp"]["guaranteed_rate"])),
|
|
||||||
("ProjQTwoWorstOpt", fixed(two["optimal"]["worst_posterior_mean_m"])),
|
|
||||||
("ProjQTwoWorstRef", fixed(two["reference"]["worst_posterior_mean_m"])),
|
|
||||||
("ProjQTwoRatioOpt", fixed(two["optimal"]["final_ratio_mean"])),
|
|
||||||
("ProjQTwoRatioMec", fixed(two["baseline_mec"]["final_ratio_mean"])),
|
|
||||||
("ProjQTwoWorstPerp", fixed(two["baseline_perp"]["worst_posterior_mean_m"])),
|
|
||||||
("ProjQTwoWorstMec", fixed(two["baseline_mec"]["worst_posterior_mean_m"])),
|
|
||||||
("ProjQThreeScenes", str(three["optimal"]["episodes"])),
|
|
||||||
("ProjQThreeMeanOpt", fixed(three["optimal"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQThreeMeanSeven", fixed(three["seven"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQThreeMeanBase", fixed(three["baseline"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQThreeDropOpt", percent(-paired3["optimal_vs_baseline"]["mean_relative_change"])),
|
|
||||||
("ProjQThreeDropMedianOpt",
|
|
||||||
percent(-paired3["optimal_vs_baseline"]["median_relative_change"])),
|
|
||||||
("ProjQThreeDropSeven", percent(-paired3["seven_vs_baseline"]["mean_relative_change"])),
|
|
||||||
("ProjQThreeFasterOpt",
|
|
||||||
f"{paired3['optimal_vs_baseline']['faster_scenes']}/{paired3['optimal_vs_baseline']['compared']}"),
|
|
||||||
("ProjQThreeMeasuresOpt", fixed(three["optimal"]["measures_mean"])),
|
|
||||||
("ProjQThreeMeasuresBase", fixed(three["baseline"]["measures_mean"])),
|
|
||||||
("ProjQThreeMoveOpt", fixed(three["optimal"]["move_distance_mean_m"] / 1000)),
|
|
||||||
("ProjQThreeMoveBase", fixed(three["baseline"]["move_distance_mean_m"] / 1000)),
|
|
||||||
("ProjQThreeWallOpt", fixed(three["optimal"]["compute_wall_mean_s"])),
|
|
||||||
("ProjQFourScenes", str(four["optimal"]["episodes"])),
|
|
||||||
("ProjQFourMeanOpt", fixed(four["optimal"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQFourMeanGrid", fixed(four["baseline_grid"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQFourMeanBase", fixed(four["baseline"]["virtual_time_mean_s"])),
|
|
||||||
("ProjQFourClearOpt", percent(four["optimal"]["cleared_ratio_mean"])),
|
|
||||||
("ProjQFourClearGrid", percent(four["baseline_grid"]["cleared_ratio_mean"])),
|
|
||||||
("ProjQFourClearBase", percent(four["baseline"]["cleared_ratio_mean"])),
|
|
||||||
("ProjQFourAbsentBase", str(four["baseline"]["absent_misjudged_total"])),
|
|
||||||
("ProjQFourMeasuresOpt", fixed(four["optimal"]["measures_mean"])),
|
|
||||||
("ProjQFourMeasuresBase", fixed(four["baseline"]["measures_mean"])),
|
|
||||||
("ProjQFourFallbackOpt", fixed(four["optimal"]["fallback_clear_attempts_mean"])),
|
|
||||||
("ProjQFourValidOpt",
|
|
||||||
f"{four['optimal']['valid_episodes']}/{four['optimal']['episodes']}"),
|
|
||||||
("ProjQFourValidGrid",
|
|
||||||
f"{four['baseline_grid']['valid_episodes']}/{four['baseline_grid']['episodes']}"),
|
|
||||||
("ProjQFourRiseOpt", percent(paired4["optimal_vs_baseline"]["mean_relative_change"])),
|
|
||||||
("ProjQFourMoveOpt", fixed(four["optimal"]["move_distance_mean_m"] / 1000)),
|
|
||||||
("ProjQFourMoveBase", fixed(four["baseline"]["move_distance_mean_m"] / 1000)),
|
|
||||||
]
|
|
||||||
lines = [head] + [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
|
|
||||||
(out / "project-macros.tex").write_text("\n".join(lines) + "\n", "utf-8")
|
|
||||||
|
|
||||||
def write(stem: str, rows: list[str]) -> None:
|
|
||||||
(out / stem).write_text("\n".join([head, *rows]) + "\n", "utf-8")
|
|
||||||
|
|
||||||
write("project-overview-rows.tex", [
|
|
||||||
"问题 & 核心指标 & 最优实现 & 基线实现 " + r"\\",
|
|
||||||
f"1 & 直径平均绝对误差 / 覆盖判定准确率 & {fixed(random['diameter_mae_optimal_m'])} m / "
|
|
||||||
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['diameter_mae_baseline_m'])} m / "
|
|
||||||
f"{percent(random['coverage_accuracy_baseline'])} " + r"\\",
|
|
||||||
f"2 & 保证接收率 / 最坏后验半径均值 & {percent(two['optimal']['guaranteed_rate'])} / "
|
|
||||||
f"{fixed(two['optimal']['worst_posterior_mean_m'])} m & {percent(two['baseline_mec']['guaranteed_rate'])} / "
|
|
||||||
f"{fixed(two['baseline_mec']['worst_posterior_mean_m'])} m " + r"\\",
|
|
||||||
f"3 & 全清率 / 平均虚拟耗时 & {percent(three['optimal']['cleared_ratio_mean'])} / "
|
|
||||||
f"{fixed(three['optimal']['virtual_time_mean_s'])} s & {percent(three['baseline']['cleared_ratio_mean'])} / "
|
|
||||||
f"{fixed(three['baseline']['virtual_time_mean_s'])} s " + r"\\",
|
|
||||||
f"4 & 全清率 / 缺席误判总数 & {percent(four['optimal']['cleared_ratio_mean'])} / "
|
|
||||||
f"{four['optimal']['absent_misjudged_total']} & {percent(four['baseline']['cleared_ratio_mean'])} / "
|
|
||||||
f"{four['baseline']['absent_misjudged_total']} " + r"\\",
|
|
||||||
])
|
|
||||||
|
|
||||||
write("project-q1-example-rows.tex", [
|
|
||||||
f"{row['name']} & {fixed(row['diameter_ref_m'])} & {fixed(row['diameter_opt_m'])} & "
|
|
||||||
f"{fixed(row['diameter_base_m'])} & {fixed(row['abs_err_base_m'])} & "
|
|
||||||
f"{flag(row['covers_ref'])} & {flag(row['covers_opt'])} & {flag(row['covers_base'])} " + r"\\"
|
|
||||||
for row in data["q1"]["examples"]
|
|
||||||
])
|
|
||||||
|
|
||||||
write("project-q1-summary-rows.tex", [
|
|
||||||
f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & "
|
|
||||||
f"{fixed(examples['diameter_mae_optimal_m'])} & {fixed(examples['diameter_max_err_optimal_m'])} & "
|
|
||||||
f"{percent(examples['coverage_accuracy_optimal'])} & {fixed(examples['runtime_optimal_mean_ms'])} " + r"\\",
|
|
||||||
f"最优(随机 {random['cases']} 例) & {percent(random['status_accuracy_optimal'])} & "
|
|
||||||
f"{fixed(random['diameter_mae_optimal_m'])} & {fixed(random['diameter_max_err_optimal_m'])} & "
|
|
||||||
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['runtime_optimal_mean_ms'])} " + r"\\",
|
|
||||||
f"基线(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_baseline'])} & "
|
|
||||||
f"{fixed(examples['diameter_mae_baseline_m'])} & {fixed(examples['diameter_max_err_baseline_m'])} & "
|
|
||||||
f"{percent(examples['coverage_accuracy_baseline'])} & {fixed(examples['runtime_baseline_mean_ms'])} " + r"\\",
|
|
||||||
f"基线(随机 {random['cases']} 例) & {percent(random['status_accuracy_baseline'])} & "
|
|
||||||
f"{fixed(random['diameter_mae_baseline_m'])} & {fixed(random['diameter_max_err_baseline_m'])} & "
|
|
||||||
f"{percent(random['coverage_accuracy_baseline'])} & {fixed(random['runtime_baseline_mean_ms'])} " + r"\\",
|
|
||||||
])
|
|
||||||
|
|
||||||
write("project-q2-rows.tex", [
|
|
||||||
f"{label} & {percent(two[key]['guaranteed_rate'])} & {fixed(two[key]['worst_posterior_mean_m'])} & "
|
|
||||||
f"{fixed(two[key]['worst_posterior_median_m'])} & {fixed(two[key]['improvement_mean_m'])} & "
|
|
||||||
f"{fixed(two[key]['final_ratio_mean'])} & {fixed(two[key]['move_cost_mean_s'], 2)} " + r"\\"
|
|
||||||
for key, label in (("optimal", "算法2(默认选点)"), ("reference", "参考:盘内网格枚举"),
|
|
||||||
("baseline_perp", "基线:垂直 1000 m"), ("baseline_mec", "基线:候选圆心"))
|
|
||||||
])
|
|
||||||
|
|
||||||
def variant_rows(table: dict, mapping: list[tuple[str, str]]) -> list[str]:
|
|
||||||
return [
|
|
||||||
f"{label} & {table[key]['valid_episodes']}/{table[key]['episodes']} & "
|
|
||||||
f"{percent(table[key]['cleared_ratio_mean'])} & {table[key]['absent_misjudged_total']} & "
|
|
||||||
f"{fixed(table[key]['virtual_time_mean_s'])} & {fixed(table[key]['virtual_time_median_s'])} & "
|
|
||||||
f"{fixed(table[key]['virtual_time_max_s'])} & {fixed(table[key]['measures_mean'], 1)} & "
|
|
||||||
f"{fixed(table[key]['move_distance_mean_m'] / 1000)} & {fixed(table[key]['fallback_clear_attempts_mean'], 1)} "
|
|
||||||
+ r"\\"
|
|
||||||
for key, label in mapping
|
|
||||||
]
|
|
||||||
|
|
||||||
write("project-q3-rows.tex", variant_rows(three, [
|
|
||||||
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"), ("baseline", "grid(49 点网格)")]))
|
|
||||||
write("project-q4-rows.tex", variant_rows(four, [
|
|
||||||
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
|
|
||||||
("baseline", "bayes(问题三策略)")]))
|
|
||||||
|
|
||||||
|
|
||||||
def listings(rows: list[dict]) -> None:
|
|
||||||
data = PAPER / "data"
|
|
||||||
files = sorted((ROOT / "src/mcm26b").rglob("*.py"))
|
|
||||||
files += [ROOT / "scripts/audit_paper_formal.py",
|
|
||||||
ROOT / "scripts/build_paper_support.py"]
|
|
||||||
text = [
|
|
||||||
r"\begingroup",
|
|
||||||
r"\linespread{1.0}\selectfont",
|
|
||||||
r"\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,"
|
|
||||||
r"aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none}",
|
|
||||||
]
|
|
||||||
for path in files:
|
|
||||||
rel = path.relative_to(ROOT).as_posix()
|
|
||||||
display = data / "listing-source" / (rel + ".tex")
|
|
||||||
display.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
display.write_text(escaped_listing(path.read_text("utf-8")), "utf-8")
|
|
||||||
display_rel = display.relative_to(PAPER).as_posix()
|
|
||||||
escape_option = "escapeinside={" + ESCAPE_OPEN + "}{" + ESCAPE_CLOSE + "}"
|
|
||||||
text += [fr"\subsection*{{\pathtext{{{rel}}}}}",
|
|
||||||
fr"\lstinputlisting[{escape_option}]{{{display_rel}}}"]
|
|
||||||
text.append(r"\endgroup")
|
|
||||||
(data / "source-listings.tex").write_text("\n".join(text) + "\n", "utf-8")
|
|
||||||
table = [
|
|
||||||
r"\begin{longtable}{p{1.15cm}p{13.3cm}}",
|
|
||||||
r"\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\",
|
|
||||||
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead",
|
|
||||||
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endhead",
|
|
||||||
]
|
|
||||||
for row in rows:
|
|
||||||
h = row["package_sha256"]
|
|
||||||
label = f"Q{row['problem_no']}-{row['formal_index']}"
|
|
||||||
table += [
|
|
||||||
fr"{label} & \pathtext{{{Path(row['file']).name}}}\\",
|
|
||||||
fr"& {{\scriptsize\texttt{{{h[:32]}}}\allowbreak\texttt{{{h[32:]}}}}}\\[4pt]",
|
|
||||||
]
|
|
||||||
table += [r"\bottomrule\end{longtable}"]
|
|
||||||
(data / "formal-file-list.tex").write_text("\n".join(table) + "\n", "utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
SUPPORT_README = """# 论文支撑材料
|
|
||||||
|
|
||||||
汇总官方模拟器 test 中的六份正式导出及哈希匹配的官方统计。
|
|
||||||
问题三、四各包含三次正式测试。支撑材料保留原始文件名及文件校验值。
|
|
||||||
|
|
||||||
## 环境和运行
|
|
||||||
|
|
||||||
Python >=3.10;安装 requests,可选 numpy 加速。
|
|
||||||
在本目录使用 PowerShell:
|
|
||||||
|
|
||||||
$env:PYTHONPATH = (Resolve-Path src).Path
|
|
||||||
python -m mcm26b.cli --help
|
|
||||||
python -m mcm26b.cli run --problem 3 --strategy bayes --robot-id <模拟器登录队号>
|
|
||||||
python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号>
|
|
||||||
|
|
||||||
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
|
|
||||||
策略的自检命令另需可选 referee 包。
|
|
||||||
|
|
||||||
## 重算论文数据和图表
|
|
||||||
|
|
||||||
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
|
|
||||||
python scripts/build_paper_support.py
|
|
||||||
|
|
||||||
上述命令在本地重算统计表、图表及源码附录。统计快照来自官方正式
|
|
||||||
统计队列,通过票据哈希匹配所选包。重新验证来源匹配需原电脑的统计队列。
|
|
||||||
连续覆盖与混合类型推断的扩展见论文未来工作。
|
|
||||||
|
|
||||||
## 编译
|
|
||||||
|
|
||||||
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
|
|
||||||
|
|
||||||
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
|
|
||||||
|
|
||||||
版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回
|
|
||||||
同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。
|
|
||||||
support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def package(rows: list[dict]) -> Path:
|
|
||||||
selected = set((ROOT / "src/mcm26b").rglob("*.py"))
|
|
||||||
selected.update((ROOT / "src/mcm26b/guarantee/plans").glob("*.json"))
|
|
||||||
selected.update((PAPER / "data").rglob("*"))
|
|
||||||
selected.update((PAPER / "figures").glob("*.tex"))
|
|
||||||
selected.update((PAPER / "sections").glob("*.tex"))
|
|
||||||
selected.update((ROOT / "docs/figures").glob("*.png"))
|
|
||||||
selected.update((ROOT / "docs/figures").glob("*.json"))
|
|
||||||
selected.add(ROOT / "docs/figures/README.md")
|
|
||||||
selected.update(path for path in (ROOT / "project").rglob("*")
|
|
||||||
if path.is_file() and "__pycache__" not in path.parts)
|
|
||||||
selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
|
|
||||||
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
|
|
||||||
ROOT / "scripts/audit_paper_formal.py",
|
|
||||||
ROOT / "scripts/build_paper_support.py"])
|
|
||||||
for row in rows:
|
|
||||||
path = ROOT / "Jammers-simulator-full" / row["file"]
|
|
||||||
if digest(path.read_bytes()) != row["package_sha256"]:
|
|
||||||
raise ValueError("Original formal package changed")
|
|
||||||
selected.add(path)
|
|
||||||
blobs = {p.relative_to(ROOT).as_posix(): p.read_bytes() for p in sorted(selected) if p.is_file()}
|
|
||||||
blobs["README.md"] = SUPPORT_README.encode("utf-8")
|
|
||||||
blobs["requirements.txt"] = b"requests>=2.31\nnumpy>=1.24\n"
|
|
||||||
manifest = [{"file": name, "bytes": len(value), "sha256": digest(value)} for name, value in sorted(blobs.items())]
|
|
||||||
blobs["support-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
||||||
out = PAPER / "support-materials.zip"
|
|
||||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
||||||
for name, value in sorted(blobs.items()):
|
|
||||||
entry = zipfile.ZipInfo(name, (2026, 9, 13, 0, 0, 0))
|
|
||||||
entry.compress_type = zipfile.ZIP_DEFLATED
|
|
||||||
archive.writestr(entry, value)
|
|
||||||
if out.stat().st_size > 20_000_000:
|
|
||||||
raise ValueError("Support archive exceeds 20 MB")
|
|
||||||
print(f"Support archive: {len(blobs)} files, {out.stat().st_size} bytes")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--package", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
rows = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
|
|
||||||
figures(rows)
|
|
||||||
project_tables()
|
|
||||||
listings(rows)
|
|
||||||
if args.package:
|
|
||||||
package(rows)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
@@ -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 & 是 & 是 & 是 \\
|
|
||||||
不相容观测 & -- & -- & -- & -- & -- & -- & -- \\
|
|
||||||
@@ -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 \\
|
|
||||||
@@ -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 \\
|
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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 \\
|
|
||||||
@@ -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}
|
||||||
|
|
||||||