Files
mcm-26-b/mcm26b-核心代码.ipynb
2026-09-13 18:43:25 +08:00

5841 lines
323 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# CUMCM 2026 B 题 · 无线电干扰源的快速自动定位与清除\n",
"## 单文件核心代码(问题一 ~ 问题四)\n",
"\n",
"本 notebook 是四问**生产实现**的自包含整理版:按求解流程顺序排列的算法内核,\n",
"不含评测机、命令行外壳、采集/基准/绘图脚本、单元测试,也不含未被采用的方案。\n",
"\n",
"### 1. 求解主线\n",
"\n",
"| 问 | 任务 | 本文档中的实现 |\n",
"| --- | --- | --- |\n",
"| 一 | 由 ±1° 有界误差示向度交会定位,求定位区域、直径、直径圆覆盖判定、最小包围圆与 20 m 清除证书(并给出直径圆不覆盖区域的等边三角形反例) | `Measurement` / `halfplanes` / `convex_hull` / `diameter` / `smallest_enclosing_circle` / `solve` |\n",
"| 二 | 选择第二个检测点,使目标必被接收且交会角最优 | `first_bearing_candidate_disk` / `CandidateRegion` / `safe_disk_radius` / `select_second_point` |\n",
"| 三 | 全向源:发现全部干扰源、逐一定位清除,最小化虚拟总时间 | `SevenBatch`(对照)与 `BayesLoop`(默认:网格粒子滤波 + 负观测 + 信息增益选点 + 滚动 2-opt) |\n",
"| 四 | 含定向发射源(半盘覆盖):不可见的静默必须与\"距离不足\"区分 | `DirectionalExtension`(定向覆盖最小命中集 + (位置×朝向) 联合信念 + ≤20 m 后备清除) |\n",
"\n",
"### 2. 公共层(四问共用)\n",
"\n",
"- **参数中心**(`ProblemConfig`):题面常量与工程参数的唯一字面量来源;\n",
"- **协议层**:字段表/结果码(`rules`)、纯计时模型(`timing`)、本地状态机(`state`)、\n",
" 传输抽象(`transport`)、会话日志(`log`)、串行幂等客户端(`SimulatorClient`);\n",
"- **信念层**:逐频道保守凸候选集(`CandidateRegion`)、网格幸存集(`ChannelParticles`)、\n",
" (位置×朝向) 联合可行集(`JointBelief`);\n",
"- **保证层**:覆盖账本(`CoverageLedger`)、覆盖计划与证书(`CoveragePlan`);\n",
"- **调度层**:开放路径排序(`nearest_neighbor_order` / `two_opt_open` / `improve_open_path`)。\n",
"\n",
"### 3. 三条硬约束(贯穿全部代码)\n",
"\n",
"1. **保守性**:真源(第四问含朝向)永远不被观测更新删掉——候选集用外切多边形外包圆盘、\n",
" 半平面裁剪带正容差、网格幸存集按 Voronoi 裕量内缩后再删格;\n",
"2. **幂等串行**:一个动作未决不发起下一个;网络失败时原样重发同一 `request_id` 与请求体;\n",
" `accepted=false` 不更新任何状态,其 `virtual_time_s=0` 不是当前时刻;\n",
"3. **计时一致**:预测(客户端)—执行(模拟器)—复算(日志)三方用同一计时模型,\n",
" 按整数微秒累计。\n",
"\n",
"### 4. 与仓库原文件的关系\n",
"\n",
"各代码单元逐字取自 `src/mcm26b/**`,仅做机械化改写(去包内相对导入、去文件 docstring、\n",
"去掉覆盖计划的 JSON 文件读取改为内嵌字面量、去掉未被生产路径调用的函数)。\n",
"本文件可独立运行:**零第三方依赖**(`numpy` 仅作可选加速,缺失时自动回退纯 Python;\n",
"`requests` 仅在真正发起 HTTP 时才惰性导入)。最后的总自检单元会复算四问的核心结论,\n",
"15 项断言全部通过才算整理成功。\n",
"\n",
"自检覆盖:问1 角域交会有界/直径圆反例、问2 保证接收与后验收缩、问3 七站解析覆盖证书与\n",
"Voronoi 真源保留、问4 定向最小命中集证书与 (位置×朝向) 联合信念保守性。\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 0. 环境与依赖\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"import math\n",
"import os\n",
"import uuid\n",
"from dataclasses import dataclass, field, fields, replace\n",
"from enum import Enum\n",
"from functools import lru_cache\n",
"from itertools import combinations\n",
"from math import cos, dist, hypot, isfinite, radians, sin\n",
"from pathlib import Path\n",
"from random import Random\n",
"from time import monotonic, sleep\n",
"\n",
"try: # numpy 仅作可选加速后端;缺失时全部回退纯 Python\n",
" import numpy as _np\n",
"except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支\n",
" _np = None\n",
"\n",
"print(\"numpy 加速后端:\", \"启用\" if _np is not None else \"缺失(纯 Python 回退)\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 第一部分 · 公共层\n",
"\n",
"## 1. 参数中心(`config.py`)\n",
"\n",
"题面常量标 `[题面]`,工程参数标 `[工程]`;后者可依演练实测调整,不代表官方规定。\n",
"任何魔法数字只在这里出现一次,其余模块通过 `ProblemConfig` 读取。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"Vec2 = tuple[float, float]\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class FieldConfig:\n",
" \"\"\"题目物理世界与协议硬限制。[题面]\"\"\"\n",
"\n",
" omega_radius_m: float = 1800.0 # 目标区域半径,圆心为原点\n",
" channel_min: int = 1 # 有效频道范围\n",
" channel_max: int = 20\n",
" source_count_min: int = 10 # 一局源总数范围(接口不直接告知)\n",
" source_count_max: int = 16\n",
" r_eff_min_m: float = 1000.0 # 有效接收半径下界:no_signal 只能排除该圆盘\n",
" r_eff_max_m: float = 1500.0 # 有效接收半径上界:direction 隐含距离上界\n",
" near_radius_m: float = 5.0 # \"距离过近\"阈值,不返回示向度\n",
" clear_radius_m: float = 20.0 # 清除半径;与定向朝向无关\n",
" bearing_error_half_deg: float = 1.0 # 示向度误差幅度(题设模型)\n",
" bearing_decimals: int = 2 # 返回示向度小数位\n",
" coord_abs_limit_m: float = 2_000_000.0 # 提交坐标的绝对值上限(超出返回 HTTP 400)\n",
" directional_half_angle_deg: float = 90.0 # 定向源朝向两侧各90度覆盖(含边界)\n",
" max_virtual_duration_s: float = 360_000.0 # 虚拟世界限时(100小时)\n",
" max_real_duration_s: float = 1200.0 # /enter 成功后程序运行上限(20分钟)\n",
" test_window_s: float = 1500.0 # 确认开始后的测试窗口(25分钟)\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class CostConfig:\n",
" \"\"\"虚拟世界动作耗时模型。[题面] 附件1表1、附件2第4节。\"\"\"\n",
"\n",
" speed_mps: float = 5.0 # 移动速度;耗时=直线距离/speed\n",
" measure_s: float = 5.0 # 每次检测动作耗时(无论结果)\n",
" switch_s: float = 1.0 # 任意两频道间切换耗时,仅由合法 /measure 触发\n",
" clear_success_s: float = 5.0 # 清除成功耗时 = 光学定位3s + 激光清除2s\n",
" clear_fail_s: float = 3.0 # 未发现可清除目标 = 仅光学定位3s\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class SessionConfig:\n",
" \"\"\"会话与网络策略。arena/robot/request 规则为 [题面],其余为 [工程]。\"\"\"\n",
"\n",
" arena_id: str = \"default\" # [题面] 固定值\n",
" base_url: str = \"http://127.0.0.1:2026\" # [题面] 模拟器默认接口地址\n",
" robot_id_max_bytes: int = 64 # [题面] UTF-8 长度 1..64\n",
" request_id_max_bytes: int = 128 # [题面] UTF-8 长度 1..128\n",
" request_body_max_bytes: int = 65_536 # [题面] 超出返回 HTTP 413\n",
" connect_timeout_s: float = 5.0 # [工程] 连接超时\n",
" read_timeout_s: float = 15.0 # [工程] 读超时;覆盖\"服务器已处理但响应丢失\"\n",
" max_attempts: int = 3 # [工程] 单动作最多尝试次数(含首次)\n",
" retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大\n",
" reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数\n",
" real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用\n",
" # [工程] 自检/演练实机预算:题面正式测试程序运行上限 1200s 不可延长;\n",
" # 自检经 --real-budget-s 覆盖 LocalSimulator 返回的 remaining_real_duration_s,\n",
" # 默认 1800s(30 分钟实机),用于验证\"限时内持续运行直到找全\"的行为。\n",
" self_test_real_budget_s: float = 1800.0\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class SearchConfig:\n",
" \"\"\"搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。\"\"\"\n",
"\n",
" grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000\n",
" grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω\n",
" fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20\n",
" # 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];\n",
" # 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。\n",
" bearing_rounding_margin_deg: float = 0.005\n",
" # ---- P3/P4 在线定位与闭环参数 [工程] ----\n",
" polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m\n",
" witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核\n",
" candidate_rings: tuple = (0.0, 0.4, 0.75, 1.0) # 安全接收盘内候选点的半径比例\n",
" candidate_angles: int = 12 # 每环候选方向数\n",
" clear_margin_m: float = 0.5 # 清除证书的额外工程余量(多边形近似误差之外)\n",
" max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限\n",
" max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏\n",
" # ---- P5 定向扩展参数 [工程] ----\n",
" max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算\n",
" # (首次选点+对跖复核+2 次环探;达限转后备清除)\n",
" fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)\n",
" # ---- 覆盖计划(保证层)[工程,2026-09-12 架构优化 P0-2 / S2 硬化] ----\n",
" # 第四问发现点集:\n",
" # square49 旧的充分条件表(700m 方格,巡回 36.4km);\n",
" # q4_mincover_v1 75m/24 档贪心最小集(31 点,巡回 22.9km)——细网格复检\n",
" # 在 25m/48 档有 125 个 (p,h) 缺口,是采样过拟合解;\n",
" # q4_mincover_v2 **设计裕量**最小集(31 点,r_design=965m、前向角裕量 5°,\n",
" # 搜索分辨率 50m/48 档 + 证书分辨率精细剪枝),\n",
" # 15m/72 档(325.6 万 (p,h))缺口 0,最坏命中 990m,巡回 23.9km。\n",
" # 运行期按同一采样语义复核,未通过即拒绝启动。\n",
" q4_coverage_plan: str = \"q4_mincover_v2\"\n",
" verify_coverage_plan: bool = True # 运行期抽样校验(进程内缓存一次)\n",
" # ---- 第四问定向源联合信念 [工程,2026-09-12 架构优化 P0-3] ----\n",
" # 为已发现的定向频道维护 (位置 × 朝向) 联合可行集:用可见性概率选点,\n",
" # 取代固定 45° 阶梯探点;联合集空即给出缺席证明。安全性仍由凸/网格\n",
" # 清除证书与覆盖证明负责,本开关只影响选点顺序。\n",
" directional_joint_belief: bool = True\n",
" # ---- 第四问定向源探点值函数 [工程,2026-09-12 图论与 DP 方案 S3/D1] ----\n",
" # 旧评分 ``prob_detect/(移动+6)`` 只问\"这点看不看得见源\",不问\"测完剩多少\n",
" # 不确定度\",会反复选中同一个近处被遮挡点。D1 口径:先按可见概率过滤\n",
" # (只保留 prob_detect ≥ ratio×最大值的候选),再最小化\n",
" # E[后验 MEC 半径](q) + λ·(当前位置→q→MEC 圆心 的移动秒数 + 检测)\n",
" # 9 场景 core 配对(修正联合信念朝向规则之后):\n",
" # λ=0.05:后备 6.8 次 / T 9740 s(值函数主导,为信息多绕路)\n",
" # λ=5 :后备 4.3 次 / T 8604 s(移动项主导,可见性过滤 + 就近抵近)\n",
" # 仅移动项(nearest_visible):后备 4.3 次 / T 8602 s,墙钟最低\n",
" # 相对旧 visibility 口径(后备 125.6 次、T 9225 s)三者都更好,且 λ 越大\n",
" # 越优说明本题\"看得见 + 少走路\"比\"信息量最大\"更重要,故默认取极限口径。\n",
" probe_score_mode: str = \"nearest_visible\" # visibility | expected_radius | nearest_visible\n",
" probe_lambda_move_s: float = 5.0 # expected_radius 口径下的移动折算\n",
" # 可见概率过滤:只保留 prob_detect ≥ ratio×最大值的候选再比期望半径,\n",
" # 避免\"期望上有点信息、实际必被朝向遮挡\"的探点。\n",
" probe_visibility_ratio: float = 0.7\n",
" probe_exclude_blocked: bool = True # 已测过且被遮挡的探点不再重复选取\n",
" approach_fractions: tuple = () # D1 抵近点插值比例(0=圆心);默认关闭\n",
" # ---- 后备清除路径优化 [工程,2026-09-12 S4/D3] ----\n",
" # 实测反例:后备清除在命中真源时立即结束,\"整条走格路径最短\"并不是正确\n",
" # 目标——Or-opt 会把真源推到序列更后面。9 场景配对:最近邻 125.7 次失败\n",
" # vs Or-opt 161.4 次(T 8676 → 8940 s)。默认关闭,保留代码与开关。\n",
" fallback_optimize_tour: bool = False\n",
" # ---- 持续搜索 [工程,2026-09-11 用户决策修订] ----\n",
" # 虚拟时间即评测成绩口径(表1:平均定位清除时间):找全证明达成后\n",
" # 立即 /exit,不复扫。复扫保险模式(防缺席证明假设偏差)保留,经\n",
" # `mcm26b run --persistent` 显式开启:只复扫未清除频道,直到清除数\n",
" # 达 16、实机预算耗尽或虚拟安全余量不足。\n",
" persistent_search: bool = False\n",
" virtual_reserve_s: float = 10_000.0 # 虚拟时长安全余量:剩余不足时停止发起动作,\n",
" # 避免触及 100 小时上限被模拟器强制结束\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class BayesConfig:\n",
" \"\"\"P4b 贝叶斯网格滤波策略参数(问题3新策略 bayes)。[工程]\n",
"\n",
" 网格粒子滤波的严格性依赖 Voronoi 裕量:真实源所在格心与源的距离\n",
" <= sqrt(2)/2 * particle_spacing_m,所有 kill 判定都内缩该裕量,\n",
" 因此幸存集永远包含真源(定理见 docs/algo-3-bayes.md §2)。\n",
" \"\"\"\n",
"\n",
" particle_spacing_m: float = 14.65 # 网格间距;Voronoi 裕量 ≈ 10.35m;\n",
" # 全域约 4.7 万格/频道,查询走粗格索引\n",
" # ---- 信息增益评分 [工程,Optuna TPE 调参回填 2026-09-11] ----\n",
" # 原调参记录见 output/q3/bayes-tuning/;当前代码的配对验收记录见\n",
" # output/q3/optimization-20260911/,随机分布与信息评分仅为工程假设。\n",
" score_mode: str = \"entropy\" # entropy=期望 log 幸存占比(信息增益)|\n",
" # radius=期望后验半径(直指证书)\n",
" score_subsample: int = 800 # 期望幸存熵估计的粒子子采样规模\n",
" mc_representatives: int = 7 # 假设真值代表点数(均匀抽取幸存格)\n",
" lambda_move_s: float = 0.05 # 选点评分中移动时间的折算系数(分值/秒);\n",
" # 2026-09-12 配对复跑:0.05 优于 0.0105(20 场均值 −2.8%),\n",
" # entropy 口径下信息项为 O(1) 对数值,λ须小型但不可过小\n",
" # ---- 路线感知探点 [工程,2026-09-12 P2] ----\n",
" # 旧评分只看 current->q + q->MEC,容易选到“信息增益略高但要多绕几百米”\n",
" # 的探点。开启后,在候选评分中加入 q 替换当前位置后对当前任务路线代价\n",
" # 的增量;A/B 以冻结基准集配对验收。\n",
" # 2026-09-12 冻结基准集复跑(18 场景):关闭 3193.1 s vs 开启 3210.3 s,\n",
" # 移动 12339.8 m vs 12446.8 m —— 权重 1e-3 的路线项未带来净收益,默认关闭。\n",
" route_aware_probe: bool = False\n",
" route_aware_insert_weight: float = 0.001 # 路线代价增量的折算系数(小权重,仅微调)\n",
" route_aware_clear_weight: float = 0.0 # 额外 q->MEC 清除接近项(基础移动项已含)\n",
" wide_rings: tuple = (1.0, 1.5, 2.0) # 无保证接收盘时的探索环(× PF MEC 半径)\n",
" # ---- 站点与覆盖 [工程] ----\n",
" station_ring_radius_m: float = 1159.7 # 七点环半径;构造时检验全向覆盖解析上界\n",
" # ---- 发现-定位融合巡回 [工程,2026-09-12 P0-1,实测无效,默认关闭] ----\n",
" # 设想:站点扫频顺带取第二/第三条方位线,取代专用探点。\n",
" # 实测(output/suite/q3-fuse-ab*,20 场景配对,2026-09-12):\n",
" # 仅追加站点方位线:3454.6s vs 3426.9s(+0.8%,检测 +10.8 次,位移仅 −188m);\n",
" # 追加 + 抑制专用探点:4989.0s vs 3426.9s(+45.6%,20/20 场景更慢)。\n",
" # 原因:七站环上只有 2–3 个站能对同一源取得有效方位线,且站间交会角\n",
" # 不足以保证 20m 证书,专用信息增益探点仍然必要;抑制探点只会白等巡回。\n",
" # 代码保留以便复现与后续研究,成绩口径必须保持 False。\n",
" fuse_localize_into_tour: bool = False\n",
" # ---- 任务排序 [工程] ----\n",
" two_opt_enabled: bool = True # 开放路径 2-opt 改进(最近邻打底)\n",
" two_opt_passes: int = 4 # 2-opt 最大改进轮数\n",
" # ---- 精确任务序(Held-Karp,含换频状态)[工程,2026-09-12 S5/T1] ----\n",
" # 任务表典型 6–12 项:|T| ≤ exact_order_limit 时用精确 DP(状态=已访问集合\n",
" # × 结束时测向频道),换频代价精确计入;否则回退 NN + 2-opt。0 表示禁用。\n",
" # 实测(18 场景):精确 DP 3203.2 s vs 2-opt 3193.1 s —— 逐位最优的\"当前\n",
" # 任务表\"在滚动时域下反而略差(贪心排序的次优性起到探索作用),默认 0;\n",
" # 代码与对拍测试保留,可随时开启复核。\n",
" exact_order_limit: int = 0\n",
" # ---- PF 清除证书 [工程] ----\n",
" grid_cert_enabled: bool = True # 允许用幸存集 MEC 颁发清除证书(凸证书之外)\n",
" opportunistic_measure: bool = True # 已停靠位置顺路交会;仍逐频道计费\n",
" # 预测后验半径至少减少 (1−ratio) 才补测;0.5 优于 0.65(18 场景 3205.4 vs 3210.3 s)\n",
" opportunistic_radius_ratio: float = 0.5\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class ProblemConfig:\n",
" \"\"\"聚合配置。各模块函数的第一个默认参数,便于单测替换。\"\"\"\n",
"\n",
" field: FieldConfig = FieldConfig()\n",
" cost: CostConfig = CostConfig()\n",
" session: SessionConfig = SessionConfig()\n",
" search: SearchConfig = SearchConfig()\n",
" bayes: BayesConfig = BayesConfig()\n",
"\n",
"\n",
"DEFAULT_CONFIG = ProblemConfig()\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"@dataclass(frozen=True)\n",
"class ProblemConfig:\n",
" \"\"\"聚合配置。各模块函数的第一个默认参数,便于单测替换。\"\"\"\n",
"\n",
" field: FieldConfig = FieldConfig()\n",
" cost: CostConfig = CostConfig()\n",
" session: SessionConfig = SessionConfig()\n",
" search: SearchConfig = SearchConfig()\n",
" bayes: BayesConfig = BayesConfig()\n",
"\n",
"\n",
"DEFAULT_CONFIG = ProblemConfig()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. 协议层(`protocol/`)\n",
"\n",
"### 2.1 规则表与字段表(`rules.py`)\n",
"\n",
"四条指令的字段约束、结果码与 HTTP 错误语义,全部落成可执行数据:\n",
"客户端用它构造并预校验请求,模拟器用同一张表校验并拒绝未知字段。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 结果码 [题面] ----------------------------------------------------------\n",
"\n",
"NO_SIGNAL = \"no_signal\" # 收不到信号:无未清除源 / 超接收半径 / 定向覆盖角外\n",
"NEAR = \"near\" # 距离 <=5m 且在覆盖角内;不返回 svd_deg\n",
"DIRECTION = \"direction\" # 有信号;附 svd_deg(含 ±1° 误差,保留两位小数)\n",
"MEASURE_RESULTS = (NO_SIGNAL, NEAR, DIRECTION)\n",
"\n",
"CLEAR_SUCCESS = \"success\" # 20m 内发现并清除;耗时 5s\n",
"CLEAR_NO_TARGET = \"no_target_in_range\" # 未发现(无源/已清除/超20m);耗时 3s\n",
"CLEAR_RESULTS = (CLEAR_SUCCESS, CLEAR_NO_TARGET)\n",
"\n",
"EXIT_USER = \"user_exit\" # /exit 成功响应中的固定退出原因\n",
"\n",
"# accepted=false 的响应只含这 3 个字段;virtual_time_s 恒为 0,不是当前时刻 [题面]\n",
"BASE_RESPONSE_FIELDS = (\"accepted\", \"real_timestamp_ms\", \"virtual_time_s\")\n",
"\n",
"\n",
"# ---- 请求字段表 --------------------------------------------------------------\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class FieldSpec:\n",
" \"\"\"单个请求字段的约束。[题面] 附件2 5.1 节与各指令小节。\"\"\"\n",
"\n",
" kind: type # str / dict;数值字段由 sim 按整型/浮点细则单独校验\n",
" required: bool = True\n",
" max_bytes: int | None = None # 字符串字段的 UTF-8 长度上限\n",
"\n",
"\n",
"COMMON_FIELDS: dict[str, FieldSpec] = {\n",
" \"arena_id\": FieldSpec(str), # 必须为 \"default\"\n",
" \"robot_id\": FieldSpec(str, max_bytes=64), # 逐字节等于登录参赛队号\n",
" \"request_id\": FieldSpec(str, max_bytes=128), # 会话内幂等键,新动作用新 ID\n",
"}\n",
"\n",
"POSITION_FIELDS: dict[str, FieldSpec] = {\n",
" \"x\": FieldSpec(float), # 有限 JSON number,|x| <= 2_000_000\n",
" \"y\": FieldSpec(float),\n",
"}\n",
"\n",
"# 路径 -> 除公共字段外的动作字段;/enter 与 /exit 无附加字段\n",
"ACTION_FIELDS: dict[str, dict[str, FieldSpec]] = {\n",
" \"/enter\": {},\n",
" \"/measure\": {\"position\": FieldSpec(dict), \"channel\": FieldSpec(float)},\n",
" \"/clear\": {\"position\": FieldSpec(dict), \"channel\": FieldSpec(float)},\n",
" \"/exit\": {},\n",
"}\n",
"ACTION_PATHS = tuple(ACTION_FIELDS)\n",
"\n",
"# channel 写成 JSON number 1.0 且恰为整数也接受;1.5 返回 HTTP 400 [题面]\n",
"\n",
"\n",
"# ---- 响应字段表(accepted=true 时的附加字段) --------------------------------\n",
"\n",
"RESPONSE_FIELDS: dict[str, tuple[str, ...]] = {\n",
" \"/enter\": (\"max_virtual_duration_s\", \"max_real_duration_s\", \"remaining_real_duration_s\"),\n",
" \"/measure\": (\"measure_result\",), # direction 时另有 svd_deg\n",
" \"/clear\": (\"clear_result\",),\n",
" \"/exit\": (\"exit_reason\",),\n",
"}\n",
"\n",
"\n",
"# ---- 错误语义 [题面] 附件2 5.3 节 --------------------------------------------\n",
"\n",
"STATUS_OK = 200\n",
"STATUS_BAD_REQUEST = 400 # JSON 语法/重复键/缺字段/类型/频道或坐标范围\n",
"STATUS_CONFLICT = 409 # 同 request_id 对应不同动作,或并发不同动作\n",
"STATUS_RETRYABLE = (429, 500) # 流量保护/幂等记录上限、服务器内部错误:可原样重试\n",
"\n",
"\n",
"def http_status_meaning(status: int) -> str:\n",
" table = {\n",
" 200: \"动作已执行,或 JSON 合法但被业务拒绝(看 accepted)\",\n",
" 400: \"请求结构错误;不占用 request_id,修正后可复用该 ID\",\n",
" 404: \"路径未知或不精确(不接受尾随斜线与查询参数)\",\n",
" 405: \"已知路径使用了非 POST 方法\",\n",
" 409: \"request_id 幂等冲突或并发发送不同动作\",\n",
" 413: \"请求体超过 65536 字节\",\n",
" 415: \"Content-Type 或 Content-Encoding 不受支持\",\n",
" 429: \"连接/流量保护触发,或本局幂等记录达到上限\",\n",
" 500: \"模拟器内部错误\",\n",
" }\n",
" return table.get(status, \"未知状态码\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.2 纯计时模型(`timing.py`)\n",
"\n",
"附件示例(105 / 111 / 194 / 199 秒)是唯一权威锚点:\n",
"一次合法动作 = 移动(直线距离 / 5 m/s)+ 换频道(仅检测,1 s)+ 动作本身(检测 5 s、\n",
"清除成功 5 s / 未发现 3 s),逐段量化后**按整数微秒累计**。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"@dataclass(frozen=True)\n",
"class CostBreakdown:\n",
" \"\"\"一次合法动作的三段耗时 [题面]:移动 + 换频道(仅检测)+ 动作本身。\"\"\"\n",
"\n",
" move_s: float\n",
" switch_s: float\n",
" action_s: float\n",
"\n",
" @property\n",
" def total_s(self) -> float:\n",
" return self.total_us / 1_000_000\n",
"\n",
" @property\n",
" def total_us(self) -> int:\n",
" \"\"\"逐段量化后按整数微秒累计,与官方虚拟时钟一致。\"\"\"\n",
" return sum(round(value * 1_000_000)\n",
" for value in (self.move_s, self.switch_s, self.action_s))\n",
"\n",
"\n",
"def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,\n",
" cost: CostConfig = CostConfig()) -> CostBreakdown:\n",
" \"\"\"合法 /measure 的耗时。移动按直线距离;换频道只看前后频道是否不同。\"\"\"\n",
" return CostBreakdown(\n",
" move_s=dist(prev_pos, pos) / cost.speed_mps,\n",
" switch_s=0.0 if channel == prev_channel else cost.switch_s,\n",
" action_s=cost.measure_s,\n",
" )\n",
"\n",
"\n",
"def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool,\n",
" cost: CostConfig = CostConfig()) -> CostBreakdown:\n",
" \"\"\"合法 /clear 的耗时。\n",
"\n",
" /clear 的 channel 是目标干扰源频道,不切换测向机频道,也不产生换频道耗时 [题面]。\n",
" \"\"\"\n",
" return CostBreakdown(\n",
" move_s=dist(prev_pos, pos) / cost.speed_mps,\n",
" switch_s=0.0,\n",
" action_s=cost.clear_success_s if success else cost.clear_fail_s,\n",
" )\n",
"\n",
"\n",
"def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float:\n",
" \"\"\"任意两点间的移动耗时(规划用)。\"\"\"\n",
" return dist(a, b) / cost.speed_mps\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.3 本地状态机(`state.py`)\n",
"\n",
"位置 / 测向频道 / 虚拟时刻的唯一权威是最近一次 `accepted=true` 的响应;\n",
"`/clear` 不切换测向机频道,只有合法 `/measure` 才更新频道。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class StateError(RuntimeError):\n",
" \"\"\"状态机被以非法顺序驱动(例如未 enter 就 apply_measure)。\"\"\"\n",
"\n",
"\n",
"@dataclass\n",
"class RobotState:\n",
" position: Vec2 = (0.0, 0.0) # /enter 成功后的初始位置 [题面]\n",
" channel: int = 1 # /enter 成功后的初始频道 [题面]\n",
" virtual_time_s: float = 0.0\n",
" entered: bool = False\n",
" exited: bool = False\n",
" action_count: int = 0\n",
" # 预测与实测的偏差记录;strict 模式下出现偏差会直接抛错。\n",
" mismatches: list = field(default_factory=list)\n",
"\n",
" # -------------------------------------------------------------- enter/exit\n",
" def apply_enter(self, response: dict, *, strict: bool = False) -> None:\n",
" self._require_accepted(response)\n",
" if strict and (self.entered or self.exited):\n",
" raise StateError(\"重复 /enter 或已退出后再 enter\")\n",
" self.entered = True\n",
" self.virtual_time_s = response[\"virtual_time_s\"]\n",
"\n",
" def apply_exit(self, response: dict) -> None:\n",
" self._require_accepted(response)\n",
" self.exited = True\n",
" self.virtual_time_s = response[\"virtual_time_s\"]\n",
"\n",
" # ----------------------------------------------------------------- measure\n",
" def apply_measure(self, request: dict, response: dict, *,\n",
" cost: CostConfig = CostConfig(),\n",
" strict: bool = False) -> CostBreakdown:\n",
" self._require_accepted(response)\n",
" if strict and (not self.entered or self.exited):\n",
" raise StateError(\"/measure 要求已 enter 且未退出\")\n",
" pos = (request[\"position\"][\"x\"], request[\"position\"][\"y\"])\n",
" channel = request[\"channel\"]\n",
" predicted = measure_cost(self.position, pos, self.channel, channel, cost)\n",
" predicted = self._advance(pos, channel, response, predicted, strict)\n",
" return predicted\n",
"\n",
" # ------------------------------------------------------------------- clear\n",
" def apply_clear(self, request: dict, response: dict, *,\n",
" cost: CostConfig = CostConfig(),\n",
" strict: bool = False) -> CostBreakdown:\n",
" self._require_accepted(response)\n",
" if strict and (not self.entered or self.exited):\n",
" raise StateError(\"/clear 要求已 enter 且未退出\")\n",
" pos = (request[\"position\"][\"x\"], request[\"position\"][\"y\"])\n",
" success = response[\"clear_result\"] == CLEAR_SUCCESS\n",
" predicted = clear_cost(self.position, pos, success, cost)\n",
" # 频道保持不变:/clear 不切换测向机频道 [题面]\n",
" return self._advance(pos, self.channel, response, predicted, strict)\n",
"\n",
" # ------------------------------------------------------------------ 共用\n",
" def _advance(self, pos: Vec2, channel: int, response: dict,\n",
" predicted: CostBreakdown, strict: bool) -> CostBreakdown:\n",
" # 先完成全部校验再落子:strict 模式下失败时不留半更新状态。\n",
" actual_delta = response[\"virtual_time_s\"] - self.virtual_time_s\n",
" if abs(actual_delta - predicted.total_s) > 1e-6:\n",
" message = (f\"耗时预测不一致: 预测 {predicted.total_s:.6f}s, \"\n",
" f\"实测 {actual_delta:.6f}s ({predicted})\")\n",
" if strict:\n",
" raise StateError(message)\n",
" # 非严格模式:状态仍以响应为准,偏差记入日志供复盘。\n",
" self.mismatches.append(\n",
" {\"action\": self.action_count + 1, \"predicted_s\": predicted.total_s,\n",
" \"actual_s\": actual_delta, \"message\": message})\n",
" self.position = pos\n",
" self.channel = channel\n",
" self.virtual_time_s = response[\"virtual_time_s\"]\n",
" self.action_count += 1\n",
" return predicted\n",
"\n",
" @staticmethod\n",
" def _require_accepted(response: dict) -> None:\n",
" if response.get(\"accepted\") is not True:\n",
" raise StateError(\"accepted=false 的响应不允许更新状态\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.4 传输抽象与会话日志(`transport.py` / `log.py`)\n",
"\n",
"HTTP 细节与协议客户端解耦:正式运行用 `RequestsTransport`,\n",
"本地评测 / 故障注入 / 日志回放各自替换 `Transport` 实现即可,客户端代码零改动。\n",
"会话日志逐条追加 JSONL(题面要求机器狗程序自行留档指令与响应)。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class TransportError(Exception):\n",
" \"\"\"网络层失败:连接不上、读写超时。动作可能未送达,也可能结果未知。\"\"\"\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class TransportResponse:\n",
" status: int\n",
" body: bytes\n",
"\n",
"\n",
"class Transport:\n",
" \"\"\"最小 POST 接口。同步阻塞,一次一个请求(协议要求串行)。\"\"\"\n",
"\n",
" def post(self, url: str, body: bytes, headers: dict[str, str],\n",
" timeout: tuple[float, float]) -> TransportResponse:\n",
" raise NotImplementedError\n",
"\n",
"\n",
"class RequestsTransport(Transport):\n",
" \"\"\"基于 requests 的默认实现。timeout=(连接, 读取)。\"\"\"\n",
"\n",
" def post(self, url, body, headers, timeout):\n",
" import requests # 惰性导入:仅真正发起 HTTP 时才需要该依赖\n",
"\n",
" try:\n",
" response = requests.post(url, data=body, headers=headers, timeout=timeout)\n",
" except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:\n",
" raise TransportError(str(exc)) from exc\n",
" except requests.exceptions.RequestException as exc: # 其它请求级异常同样按网络失败处理\n",
" raise TransportError(str(exc)) from exc\n",
" return TransportResponse(response.status_code, response.content)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"@dataclass\n",
"class SessionLog:\n",
" path: Path | None = None\n",
" events: list = field(default_factory=list)\n",
"\n",
" def __post_init__(self):\n",
" if self.path is not None:\n",
" self.path.parent.mkdir(parents=True, exist_ok=True)\n",
"\n",
" def append(self, event: dict) -> None:\n",
" event = {**event, \"logged_at_unix_s\": time()}\n",
" self.events.append(event)\n",
" if self.path is not None:\n",
" with self.path.open(\"a\", encoding=\"utf-8\") as handle:\n",
" handle.write(json.dumps(event, ensure_ascii=False, allow_nan=False) + \"\\n\")\n",
"\n",
" def action_events(self) -> list[dict]:\n",
" \"\"\"仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。\"\"\"\n",
" return [event for event in self.events if event.get(\"response\") is not None\n",
" and event[\"response\"].get(\"accepted\") is True]\n",
"\n",
" def step_events(self) -> list[dict]:\n",
" \"\"\"每步计时事件(客户端 _log_step 写入,按发生顺序)。\"\"\"\n",
" return [event for event in self.events if event.get(\"event\") == \"step\"]\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.5 串行幂等客户端(`client.py`)\n",
"\n",
"- 每个新动作使用新的 `request_id`;仅在网络失败后重试**完全相同的动作**时复用原 ID 与\n",
" 原请求体(本类一次性序列化出 bytes 并原样重发);\n",
"- 必须同时检查 HTTP 状态与 `accepted`;`accepted=false` 不更新状态;\n",
"- 逐次等待响应、不得并发;本类完全同步;\n",
"- 现实截止用 `/enter` 的 `remaining_real_duration_s` 建立本地单调时钟并预留退出余量。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class ProtocolError(RuntimeError):\n",
" \"\"\"协议层失败,含 HTTP 状态与响应体;默认不重试(确定性错误)。\"\"\"\n",
"\n",
" def __init__(self, message: str, status: int | None = None, body=None):\n",
" super().__init__(message)\n",
" self.status = status\n",
" self.body = body\n",
"\n",
"\n",
"class ActionRejected(ProtocolError):\n",
" \"\"\"HTTP 200 但 accepted=false:动作未生效,状态不得更新。\"\"\"\n",
"\n",
" def __init__(self, message: str, body=None):\n",
" super().__init__(message, status=STATUS_OK, body=body)\n",
"\n",
"\n",
"class ProtocolViolation(ProtocolError):\n",
" \"\"\"响应与协议约定不符(字段缺失、值非法、虚拟时刻回退等)。\"\"\"\n",
"\n",
"\n",
"class DeadlineExceeded(RuntimeError):\n",
" \"\"\"现实预算不足:预留退出余量后剩余时间不够执行新动作。\"\"\"\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class MeasureOutcome:\n",
" result: str\n",
" svd_deg: float | None\n",
" virtual_time_s: float\n",
" predicted: CostBreakdown\n",
" raw: dict\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class ClearOutcome:\n",
" result: str\n",
" virtual_time_s: float\n",
" predicted: CostBreakdown\n",
" raw: dict\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class ExitInfo:\n",
" reason: str\n",
" virtual_time_s: float\n",
" raw: dict\n",
"\n",
"\n",
"def _validate_identifier(name: str, value: str, max_bytes: int) -> None:\n",
" if not isinstance(value, str) or not value:\n",
" raise ValueError(f\"{name} 必须是非空字符串\")\n",
" encoded = value.encode(\"utf-8\")\n",
" if not 1 <= len(encoded) <= max_bytes:\n",
" raise ValueError(f\"{name} 的 UTF-8 长度须在 1..{max_bytes} 字节\")\n",
" if any(code < 0x20 or code == 0x7F for code in encoded):\n",
" raise ValueError(f\"{name} 不能包含控制字符\")\n",
"\n",
"\n",
"def _validate_position(position: dict, limit: float) -> None:\n",
" if set(position) != set(POSITION_FIELDS):\n",
" raise ValueError(\"position 字段必须是且只能是 x、y\")\n",
" for key in (\"x\", \"y\"):\n",
" value = position[key]\n",
" if isinstance(value, bool) or not isinstance(value, (int, float)):\n",
" raise ValueError(f\"position.{key} 必须是数值\")\n",
" if not math.isfinite(value):\n",
" raise ValueError(f\"position.{key} 必须是有限数值\")\n",
" if abs(value) > limit:\n",
" raise ValueError(f\"position.{key} 绝对值不得超过 {limit} 米\")\n",
"\n",
"\n",
"def _validate_channel(channel) -> int:\n",
" # 写成 JSON number 1.0 且恰为整数也接受 [题面];1.5 拒绝。\n",
" if isinstance(channel, bool) or not isinstance(channel, (int, float)):\n",
" raise ValueError(\"channel 必须是数值\")\n",
" if isinstance(channel, float) and not channel.is_integer():\n",
" raise ValueError(\"channel 必须是整数\")\n",
" channel = int(channel)\n",
" if not 1 <= channel <= 20:\n",
" raise ValueError(\"channel 必须在 1..20 范围内\")\n",
" return channel\n",
"\n",
"\n",
"class SimulatorClient:\n",
" \"\"\"四条指令的同步客户端。状态只在 accepted=true 后推进。\"\"\"\n",
"\n",
" def __init__(self, robot_id: str, *,\n",
" config: ProblemConfig = ProblemConfig(),\n",
" transport: Transport | None = None,\n",
" log: SessionLog | None = None,\n",
" clock=monotonic):\n",
" _validate_identifier(\"robot_id\", robot_id, config.session.robot_id_max_bytes)\n",
" self.robot_id = robot_id\n",
" self.config = config\n",
" self.session = config.session\n",
" self.transport = transport if transport is not None else RequestsTransport()\n",
" self.log = log if log is not None else SessionLog()\n",
" self.clock = clock\n",
" self.state = RobotState()\n",
" self._prefix = uuid.uuid4().hex[:12]\n",
" self._seq = 0\n",
" self._deadline: float | None = None # 本地单调时钟截止\n",
" self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对\n",
" self._enter_monotonic: float | None = None # 实机计时起点(/enter 成功)\n",
"\n",
" # ------------------------------------------------------------ 公共动作 API\n",
" def enter(self) -> dict:\n",
" started = self.clock()\n",
" payload = self._payload(\"/enter\")\n",
" response = self._call(\"/enter\", payload)\n",
" self.state.apply_enter(response)\n",
" remaining = response[\"remaining_real_duration_s\"]\n",
" if not isinstance(remaining, (int, float)) or remaining < 0:\n",
" raise ProtocolViolation(\"remaining_real_duration_s 缺失或非法\", body=response)\n",
" self._deadline = self.clock() + remaining\n",
" self._server_deadline_ms = (response[\"real_timestamp_ms\"]\n",
" + remaining * 1000.0)\n",
" self._enter_monotonic = self.clock()\n",
" self._log_step(\"/enter\", payload, response, started,\n",
" predicted=CostBreakdown(0.0, 0.0, 0.0))\n",
" return response\n",
"\n",
" def measure(self, position: Vec2, channel: int) -> MeasureOutcome:\n",
" started = self.clock()\n",
" payload = self._payload(\"/measure\", position=position, channel=channel)\n",
" response = self._call(\"/measure\", payload)\n",
" predicted = self.state.apply_measure(payload, response,\n",
" cost=self.config.cost)\n",
" result = response.get(\"measure_result\")\n",
" if result not in MEASURE_RESULTS:\n",
" raise ProtocolViolation(f\"measure_result 非法: {result!r}\", body=response)\n",
" svd = response.get(\"svd_deg\")\n",
" if result == DIRECTION:\n",
" if not isinstance(svd, (int, float)) or isinstance(svd, bool):\n",
" raise ProtocolViolation(\"direction 响应缺少数值 svd_deg\", body=response)\n",
" self._log_step(\"/measure\", payload, response, started, result=result,\n",
" predicted=predicted, channel=payload[\"channel\"])\n",
" return MeasureOutcome(result, float(svd) if svd is not None else None,\n",
" response[\"virtual_time_s\"], predicted, response)\n",
"\n",
" def clear(self, position: Vec2, channel: int) -> ClearOutcome:\n",
" started = self.clock()\n",
" payload = self._payload(\"/clear\", position=position, channel=channel)\n",
" response = self._call(\"/clear\", payload)\n",
" predicted = self.state.apply_clear(payload, response,\n",
" cost=self.config.cost)\n",
" result = response.get(\"clear_result\")\n",
" if result not in CLEAR_RESULTS:\n",
" raise ProtocolViolation(f\"clear_result 非法: {result!r}\", body=response)\n",
" self._log_step(\"/clear\", payload, response, started, result=result,\n",
" predicted=predicted, channel=payload[\"channel\"])\n",
" return ClearOutcome(result, response[\"virtual_time_s\"], predicted, response)\n",
"\n",
" def exit(self) -> ExitInfo:\n",
" started = self.clock()\n",
" payload = self._payload(\"/exit\")\n",
" response = self._call(\"/exit\", payload)\n",
" self.state.apply_exit(response)\n",
" reason = response.get(\"exit_reason\")\n",
" if reason != EXIT_USER:\n",
" raise ProtocolViolation(f\"exit_reason 非法: {reason!r}\", body=response)\n",
" self._log_step(\"/exit\", payload, response, started,\n",
" predicted=CostBreakdown(0.0, 0.0, 0.0))\n",
" return ExitInfo(reason, response[\"virtual_time_s\"], response)\n",
"\n",
" # ---------------------------------------------------------------- 预算控制\n",
" @property\n",
" def real_remaining_s(self) -> float | None:\n",
" \"\"\"按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。\"\"\"\n",
" return None if self._deadline is None else self._deadline - self.clock()\n",
"\n",
" def _log_step(self, path: str, payload: dict, response: dict,\n",
" started: float, *, result: str | None = None,\n",
" predicted: CostBreakdown, channel: int | None = None) -> None:\n",
" \"\"\"每步计时留档 [用户决策 2026-09-11]:实机墙钟耗时 + 比赛虚拟耗时分项\n",
" (移动=直线距离/5、切频道 1s、检测 5s、清除成功 5s/未发现 3s)。\n",
"\n",
" step 事件不含 response 字段,不会混入 action_events / 虚拟时刻复算。\n",
" \"\"\"\n",
" now = self.clock()\n",
" entry: dict = {\"event\": \"step\", \"path\": path,\n",
" \"request_id\": payload[\"request_id\"],\n",
" \"real_elapsed_s\": round(now - started, 6),\n",
" \"virtual_time_s\": response[\"virtual_time_s\"],\n",
" \"virtual_cost\": {\"move_s\": round(predicted.move_s, 6),\n",
" \"switch_s\": round(predicted.switch_s, 6),\n",
" \"action_s\": round(predicted.action_s, 6),\n",
" \"total_s\": round(predicted.total_s, 6)}}\n",
" if self._enter_monotonic is not None:\n",
" entry[\"real_since_enter_s\"] = round(now - self._enter_monotonic, 6)\n",
" if \"position\" in payload:\n",
" entry[\"position\"] = {\"x\": payload[\"position\"][\"x\"],\n",
" \"y\": payload[\"position\"][\"y\"]}\n",
" if channel is not None:\n",
" entry[\"channel\"] = channel\n",
" if result is not None:\n",
" entry[\"result\"] = result\n",
" self.log.append(entry)\n",
"\n",
" def _check_budget(self, path: str) -> None:\n",
" if path == \"/exit\" or self._deadline is None:\n",
" return\n",
" margin = self.session.reserve_exit_s\n",
" if self.real_remaining_s <= margin:\n",
" raise DeadlineExceeded(\n",
" f\"剩余现实时间 {self.real_remaining_s:.1f}s 不足以预留 {margin:.1f}s 退出余量\")\n",
"\n",
" # ---------------------------------------------------------------- 内部机制\n",
" def _payload(self, _path: str, *, position: Vec2 | None = None,\n",
" channel: int | None = None) -> dict:\n",
" payload = {\"arena_id\": self.session.arena_id,\n",
" \"robot_id\": self.robot_id,\n",
" \"request_id\": self._next_request_id()}\n",
" if position is not None:\n",
" x, y = float(position[0]), float(position[1])\n",
" _validate_position({\"x\": x, \"y\": y}, self.config.field.coord_abs_limit_m)\n",
" payload[\"position\"] = {\"x\": x, \"y\": y}\n",
" if channel is not None:\n",
" payload[\"channel\"] = _validate_channel(channel)\n",
" return payload\n",
"\n",
" def _next_request_id(self) -> str:\n",
" self._seq += 1\n",
" request_id = f\"{self._prefix}-{self._seq:04d}\"\n",
" _validate_identifier(\"request_id\", request_id,\n",
" self.session.request_id_max_bytes)\n",
" return request_id\n",
"\n",
" def _call(self, path: str, payload: dict) -> dict:\n",
" \"\"\"串行执行一个动作;网络失败原样重发,HTTP 确定性错误立即抛出。\"\"\"\n",
" self._check_budget(path)\n",
" body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode(\"utf-8\")\n",
" if len(body) > self.session.request_body_max_bytes:\n",
" raise ValueError(\"请求体超过 65536 字节\")\n",
" headers = {\"Content-Type\": \"application/json\"}\n",
" url = self.session.base_url + path\n",
" attempts, last_error = 0, None\n",
" while attempts < self.session.max_attempts:\n",
" attempts += 1\n",
" started = self.clock()\n",
" self.log.append({\"event\": \"attempt\", \"path\": path,\n",
" \"request_id\": payload[\"request_id\"],\n",
" \"attempt\": attempts, \"request\": payload})\n",
" try:\n",
" result = self.transport.post(\n",
" url, body, headers,\n",
" timeout=(self.session.connect_timeout_s,\n",
" self.session.read_timeout_s))\n",
" except TransportError as exc:\n",
" last_error = exc\n",
" self.log.append({\"event\": \"transport_error\", \"path\": path,\n",
" \"request_id\": payload[\"request_id\"],\n",
" \"attempt\": attempts, \"error\": str(exc),\n",
" \"elapsed_s\": self.clock() - started})\n",
" if attempts < self.session.max_attempts:\n",
" sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))\n",
" continue\n",
" if result.status in STATUS_RETRYABLE:\n",
" self.log.append({\"event\": \"retryable_status\", \"path\": path,\n",
" \"request_id\": payload[\"request_id\"],\n",
" \"attempt\": attempts, \"status\": result.status})\n",
" if attempts < self.session.max_attempts:\n",
" sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))\n",
" continue\n",
" if result.status != STATUS_OK:\n",
" raise ProtocolError(\n",
" f\"{path} 返回 HTTP {result.status}\", status=result.status,\n",
" body=self._safe_json(result.body))\n",
" response = self._safe_json(result.body)\n",
" if response is None or response.get(\"accepted\") is not True:\n",
" raise ActionRejected(\n",
" f\"{path} 未被接受: {response}\", body=response)\n",
" self._verify_clock(response)\n",
" self.log.append({\"event\": \"accepted\", \"path\": path,\n",
" \"request_id\": payload[\"request_id\"],\n",
" \"request\": payload, \"response\": response,\n",
" \"attempts\": attempts,\n",
" \"elapsed_s\": self.clock() - started})\n",
" return response\n",
" raise TransportError(\n",
" f\"{path} 重试 {attempts} 次仍失败: {last_error}\") from last_error\n",
"\n",
" def _verify_clock(self, response: dict) -> None:\n",
" \"\"\"accepted=true 的虚拟时刻必须单调不减;现实时间戳用于漂移核对。\"\"\"\n",
" virtual = response.get(\"virtual_time_s\")\n",
" if not isinstance(virtual, (int, float)) or isinstance(virtual, bool):\n",
" raise ProtocolViolation(\"accepted 响应缺少 virtual_time_s\", body=response)\n",
" if virtual < self.state.virtual_time_s - 1e-9:\n",
" raise ProtocolViolation(\n",
" f\"虚拟时刻回退: {self.state.virtual_time_s} -> {virtual}\",\n",
" body=response)\n",
" if self._server_deadline_ms is not None:\n",
" drift = (self._server_deadline_ms - response[\"real_timestamp_ms\"]) / 1000.0\n",
" if drift < self.session.reserve_exit_s / 2:\n",
" # 服务器视角余量告急(本地时钟漂移或请求排队);仅记录,交由上层决策。\n",
" self.log.append({\"event\": \"server_time_warning\",\n",
" \"server_remaining_s\": drift})\n",
"\n",
" @staticmethod\n",
" def _safe_json(body: bytes):\n",
" try:\n",
" return json.loads(body.decode(\"utf-8\"))\n",
" except (UnicodeDecodeError, json.JSONDecodeError):\n",
" return None\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. 几何内核(`geometry/`)\n",
"\n",
"### 3.1 角域交会定位(`localization.py`)\n",
"\n",
"每条示向度 = 三个半平面:角域下边界、上边界,以及\"只能在前向\"约束\n",
"(半角 < 90° 时交集是尖锥而非整条直线)。定位区域 = 全部半平面之交。\n",
"\n",
"对偶枚举任意两个半平面的交点即得全部候选顶点,再按可行性筛选——\n",
"有界性由\"是否存在非零衰退方向\"判定,因此**不需要人为加包围盒**:\n",
"\n",
"- 无交点 ⇒ 空集(数据矛盾或频道选错);\n",
"- 有非零衰退方向 ⇒ 无界,直径为 ∞;\n",
"- 否则顶点凸包即定位多边形,直径由顶点对枚举给出,最小包围圆用 Welzl 随机增量算法。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"Point = tuple[float, float]\n",
"EPS = 1e-9\n",
"PARALLEL_EPS = 1e-12\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class Measurement:\n",
" x: float\n",
" y: float\n",
" bearing_deg: float\n",
" half_angle_deg: float = 1.0\n",
"\n",
" def __post_init__(self):\n",
" values = (self.x, self.y, self.bearing_deg, self.half_angle_deg)\n",
" if any(isinstance(v, bool) or not isinstance(v, (int, float))\n",
" or not isfinite(v) for v in values):\n",
" raise ValueError(\"坐标、示向度和误差半角必须是有限数值\")\n",
" if not 0 <= self.half_angle_deg < 90:\n",
" raise ValueError(\"误差半角必须在 [0, 90) 度;题设为1度\")\n",
"\n",
"\n",
"def cross(a: Point, b: Point) -> float:\n",
" return a[0] * b[1] - a[1] * b[0]\n",
"\n",
"\n",
"def distance(a: Point, b: Point) -> float:\n",
" return hypot(a[0] - b[0], a[1] - b[1])\n",
"\n",
"\n",
"def halfplanes(m: Measurement) -> list[tuple[float, float, float]]:\n",
" \"\"\"Return a*x+b*y<=c with unit normals, including the forward constraint.\"\"\"\n",
" theta = m.bearing_deg % 360\n",
" lower, upper = radians(theta - m.half_angle_deg), radians(theta + m.half_angle_deg)\n",
" middle = radians(theta)\n",
" normals = [(sin(lower), -cos(lower)), (-sin(upper), cos(upper)),\n",
" (-cos(middle), -sin(middle))]\n",
" return [(a, b, a * m.x + b * m.y) for a, b in normals]\n",
"\n",
"\n",
"def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]:\n",
" \"\"\"Monotone chain, CCW, no repeated final vertex; retain segment endpoints.\"\"\"\n",
" ordered = sorted(points)\n",
" unique = []\n",
" for p in ordered:\n",
" if not unique or distance(p, unique[-1]) > eps:\n",
" unique.append(p)\n",
" if len(unique) <= 2:\n",
" return unique\n",
"\n",
" def chain(seq):\n",
" out = []\n",
" for p in seq:\n",
" while len(out) >= 2:\n",
" a = (out[-1][0] - out[-2][0], out[-1][1] - out[-2][1])\n",
" b = (p[0] - out[-1][0], p[1] - out[-1][1])\n",
" # Distance-to-line tolerance, not an unscaled area tolerance.\n",
" if cross(a, b) > eps * max(hypot(*a), hypot(*b), eps):\n",
" break\n",
" out.pop()\n",
" out.append(p)\n",
" return out\n",
"\n",
" return chain(unique)[:-1] + chain(reversed(unique))[:-1]\n",
"\n",
"\n",
"def diameter(points: list[Point]):\n",
" if not points:\n",
" raise ValueError(\"空点集没有本程序定义下的直径\")\n",
" a = b = points[0]\n",
" value = 0.0\n",
" for p, q in combinations(points, 2):\n",
" d = distance(p, q)\n",
" if d > value:\n",
" value, a, b = d, p, q\n",
" return value, (a, b)\n",
"\n",
"\n",
"def _circumcircle(a: Point, b: Point, c: Point):\n",
" u = (b[0] - a[0], b[1] - a[1])\n",
" v = (c[0] - a[0], c[1] - a[1])\n",
" det = 2 * cross(u, v)\n",
" if abs(det) <= PARALLEL_EPS * max(hypot(*u) * hypot(*v), 1e-300):\n",
" return None\n",
" uu, vv = u[0] ** 2 + u[1] ** 2, v[0] ** 2 + v[1] ** 2\n",
" center = (a[0] + (uu * v[1] - vv * u[1]) / det,\n",
" a[1] + (u[0] * vv - v[0] * uu) / det)\n",
" return center, distance(center, a)\n",
"\n",
"\n",
"def _enclosing_circle_reference(points: list[Point], eps: float = EPS):\n",
" \"\"\"Enumerate all 1/2/3-point supports; O(m^4), appropriate for small m.\n",
"\n",
" Radius is recomputed against every vertex to preserve numerical containment.\n",
" Coordinates should be normalized when there are very large offsets.\n",
" \"\"\"\n",
" if not points:\n",
" raise ValueError(\"空集不计算最小包围圆\")\n",
" xs, ys = [p[0] for p in points], [p[1] for p in points]\n",
" best_c = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)\n",
" best_r = max(distance(best_c, p) for p in points)\n",
"\n",
" def consider(center, radius):\n",
" nonlocal best_c, best_r\n",
" if radius > best_r + eps:\n",
" return\n",
" actual = max(distance(center, p) for p in points)\n",
" if actual <= radius + eps and actual < best_r:\n",
" best_c, best_r = center, actual\n",
"\n",
" for p in points:\n",
" consider(p, 0.0)\n",
" for a, b in combinations(points, 2):\n",
" consider(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), distance(a, b) / 2)\n",
" for a, b, c in combinations(points, 3):\n",
" circle = _circumcircle(a, b, c)\n",
" if circle is not None:\n",
" consider(*circle)\n",
" return best_c, best_r\n",
"\n",
"\n",
"def _circle_covers(circle, point: Point, tolerance: float) -> bool:\n",
" \"\"\"Whether the circle already covers point; None means no circle yet.\"\"\"\n",
" if circle is None:\n",
" return False\n",
" return distance(circle[0], point) <= circle[1] + tolerance\n",
"\n",
"\n",
"def smallest_enclosing_circle(points: list[Point], eps: float = EPS):\n",
" \"\"\"Welzl's randomized incremental algorithm, using an explicit call stack.\n",
"\n",
" The fixed shuffle makes results reproducible. Expected linear work refers\n",
" to randomized input order, not a worst-case guarantee for this fixed seed.\n",
" Normalize internally so this public helper also handles translated inputs.\n",
" The exhaustive reference is retained for independent regression comparisons.\n",
" \"\"\"\n",
" if not points:\n",
" raise ValueError(\"空集不计算最小包围圆\")\n",
" if not isfinite(eps) or eps <= 0:\n",
" raise ValueError(\"eps必须为有限正数\")\n",
" if any(len(p) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float))\n",
" or not isfinite(x) for x in p) for p in points):\n",
" raise ValueError(\"点集必须由有限二维坐标组成\")\n",
" origin = points[0]\n",
" scale = max(distance(origin, p) for p in points)\n",
" if scale == 0:\n",
" return tuple(origin), 0.0\n",
" local = [((p[0]-origin[0])/scale, (p[1]-origin[1])/scale) for p in points]\n",
" tolerance = min(eps/scale, EPS)\n",
" Random(20260910).shuffle(local)\n",
" # Frame = (remaining prefix length, forced boundary, returning from child).\n",
" stack = [(len(local), (), False)]\n",
" circle = None\n",
" while stack:\n",
" n, boundary, returning = stack.pop()\n",
" if n == 0 or len(boundary) == 3:\n",
" # Forced support points must lie ON the boundary. For three points\n",
" # use their circumcircle, even when their unconstrained MEC is smaller.\n",
" circle = _circumcircle(*boundary) if len(boundary) == 3 else None\n",
" if circle is None and boundary:\n",
" circle = _enclosing_circle_reference(list(boundary), tolerance)\n",
" elif not returning:\n",
" stack.append((n, boundary, True))\n",
" stack.append((n-1, boundary, False))\n",
" elif not _circle_covers(circle, local[n-1], tolerance):\n",
" stack.append((n-1, boundary + (local[n-1],), False))\n",
" assert circle is not None\n",
" center = (origin[0]+circle[0][0]*scale, origin[1]+circle[0][1]*scale)\n",
" # Recompute after transforming back: returned circle contains all input points\n",
" # to distance() precision, including any roundoff from the large translation.\n",
" return center, max(distance(center, p) for p in points)\n",
"\n",
"\n",
"def solve(measurements: list[Measurement], eps: float = EPS) -> dict:\n",
" \"\"\"Solve Q1's pure angular intersection (no disk/range prior added).\n",
"\n",
" Nonempty intersections of <180-degree forward wedges are pointed polyhedra,\n",
" so they possess a vertex, even if unbounded. Feasibility follows from vertex\n",
" enumeration; unboundedness follows from a nonzero recession direction.\n",
" Empty diameter is null, unbounded diameter is null with diameter_infinite=true.\n",
"\n",
" Near-parallel plane pairs are intersected rather than skipped: their\n",
" intersection can be a legitimate vertex of a very long thin region\n",
" (e.g. stations 1cm apart with bearings 1 and 358.99999999999 deg give a\n",
" vertex ~5.7e10 m away). Only exactly-parallel pairs (det == 0) have no\n",
" intersection. The feasibility tolerance scales with the vertex magnitude,\n",
" since absolute residuals grow with coordinates.\n",
" \"\"\"\n",
" if not isfinite(eps) or eps <= 0:\n",
" raise ValueError(\"eps必须为有限正数\")\n",
" if not measurements:\n",
" return {\"status\": \"unbounded\", \"vertices\": [], \"diameter_m\": None,\n",
" \"diameter_infinite\": True, \"recession_direction\": [1.0, 0.0],\n",
" \"message\": \"没有观测,整个平面均可行\"}\n",
" if len(measurements) > 100:\n",
" raise ValueError(\"参考实现最多接收100个检测点;更大规模应使用半平面交算法\")\n",
" ox = sum(m.x for m in measurements) / len(measurements)\n",
" oy = sum(m.y for m in measurements) / len(measurements)\n",
" scale = max(1.0, max(hypot(m.x - ox, m.y - oy) for m in measurements))\n",
" local = [Measurement((m.x - ox) / scale, (m.y - oy) / scale,\n",
" m.bearing_deg, m.half_angle_deg) for m in measurements]\n",
" planes = [h for m in local for h in halfplanes(m)]\n",
"\n",
" def inside(p):\n",
" tol = eps * max(1.0, abs(p[0]), abs(p[1]))\n",
" return all(a * p[0] + b * p[1] <= c + tol for a, b, c in planes)\n",
"\n",
" feasible_vertices = []\n",
" for (a, b, c), (d, e, f) in combinations(planes, 2):\n",
" det = a * e - b * d\n",
" if det == 0.0:\n",
" continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)\n",
" p = ((c * e - b * f) / det, (a * f - c * d) / det)\n",
" if isfinite(p[0]) and isfinite(p[1]) and inside(p):\n",
" feasible_vertices.append(p)\n",
"\n",
" if not feasible_vertices:\n",
" return {\"status\": \"empty\", \"vertices\": [], \"diameter_m\": None,\n",
" \"diameter_infinite\": False, \"tolerance_m\": eps * scale,\n",
" \"message\": \"测向角域没有公共点;检查频道、数据及误差界\"}\n",
"\n",
" # A nontrivial 2D recession cone has a ray tangent to an input halfplane.\n",
" # Use a much tighter angular threshold than the positional feasibility epsilon.\n",
" ray = None\n",
" for a, b, _ in planes:\n",
" for direction in ((b, -a), (-b, a)):\n",
" if all(d * direction[0] + e * direction[1] <= 2e-14\n",
" for d, e, _ in planes):\n",
" ray = direction\n",
" break\n",
" if ray is not None:\n",
" break\n",
"\n",
" hull = convex_hull(feasible_vertices, eps)\n",
"\n",
" def world(p):\n",
" return [ox + p[0] * scale, oy + p[1] * scale]\n",
"\n",
" result = {\"status\": \"unbounded\" if ray else\n",
" (\"point\" if len(hull) == 1 else \"segment\" if len(hull) == 2 else \"polygon\"),\n",
" \"vertices\": [world(p) for p in hull], \"tolerance_m\": eps * scale,\n",
" \"diameter_infinite\": ray is not None}\n",
" if ray is not None:\n",
" result.update(diameter_m=None, recession_direction=list(ray),\n",
" message=\"存在非零衰退方向,区域无界,直径为无穷大\")\n",
" return result\n",
"\n",
" d, pair = diameter(hull)\n",
" midpoint = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2)\n",
" distances = [distance(midpoint, p) for p in hull]\n",
" witness_index = max(range(len(hull)), key=distances.__getitem__)\n",
" max_distance = distances[witness_index]\n",
" center, radius = smallest_enclosing_circle(hull, eps)\n",
" area = abs(sum(cross(hull[i], hull[(i + 1) % len(hull)])\n",
" for i in range(len(hull)))) / 2\n",
" result.update(diameter_m=d * scale, diameter_pair=[world(p) for p in pair],\n",
" area_m2=area * scale ** 2,\n",
" diameter_circle={\"center\": world(midpoint), \"radius_m\": d * scale / 2,\n",
" \"max_vertex_distance_m\": max_distance * scale,\n",
" \"covers\": max_distance <= d / 2 + eps * 4,\n",
" \"coverage_tolerance_m\": 4 * eps * scale,\n",
" \"max_excess_m\": (max_distance-d/2) * scale,\n",
" \"outside_vertex_indices\": [i for i, value in enumerate(distances)\n",
" if value > d/2 + 4*eps],\n",
" \"witness_vertex\": world(hull[witness_index])\n",
" if max_distance > d/2 + 4*eps else None},\n",
" minimum_enclosing_circle={\"center\": world(center), \"radius_m\": radius * scale},\n",
" guaranteed_clearable_at_mec_center=radius * scale <= 20 - eps * scale * 4,\n",
" clearance_certificate={\"model\": \"angular_intersection_only\",\n",
" \"clear_radius_m\": 20.0,\n",
" \"numerical_margin_m\": 4*eps*scale,\n",
" \"radius_with_margin_m\": (radius+4*eps)*scale,\n",
" \"remaining_margin_m\": 20-(radius+4*eps)*scale,\n",
" \"center\": world(center)})\n",
" return result\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 3.2 问题二的安全候选盘证书(`planning.py`)\n",
"\n",
"首次示向度只给出\"目标在角域 ∩ 距离 ≤ 1500 m 的扇形内\"。该扇形的**最小包围圆**\n",
"半径 a = R / (2 cos δ);再按保证接收半径(≥ 1000 m)内缩 s = 1000 − a 得到\n",
"保证接收盘 B(圆心, s)——盘内任意点到真源的距离 ≤ s + a = 1000 m,\n",
"因此第二点必收到 `direction`(或 `near`),不会浪费一次 `no_signal`。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def first_bearing_candidate_disk(measurement: Measurement,\n",
" max_range_m: float = 1500,\n",
" guaranteed_range_m: float = 1000) -> dict:\n",
" \"\"\"Enclose the entire truncated first-bearing sector, then erode its disk.\n",
"\n",
" For half-angle <=45 degrees the sector MEC has center distance and radius\n",
" a=R/(2*cos(delta)). Intersecting with Omega only shrinks the source set.\n",
" A missing sufficient disk is NOT proof that Q is empty. The certificate\n",
" guarantees distance only: directional transmitters may still be invisible.\n",
" \"\"\"\n",
" if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v)\n",
" or v <= 0 for v in (max_range_m, guaranteed_range_m)):\n",
" raise ValueError(\"接收半径必须为有限正数\")\n",
" if measurement.half_angle_deg > 45:\n",
" raise ValueError(\"此窄角域证书仅支持半角不超过45度;题设为1度\")\n",
" delta, theta = radians(measurement.half_angle_deg), radians(measurement.bearing_deg % 360)\n",
" a = max_range_m/(2*cos(delta))\n",
" center = [measurement.x+a*cos(theta), measurement.y+a*sin(theta)]\n",
" available = guaranteed_range_m-a\n",
" return {\"source_model\": \"first_bearing_sector_with_range_upper_bound\",\n",
" \"sector_enclosing_circle\": {\"center\": center, \"radius_m\": a},\n",
" \"sufficient_candidate_disk\": {\"center\": center, \"radius_m\": available}\n",
" if available >= 0 else None,\n",
" \"candidate_nonempty_certified\": available >= 0,\n",
" \"distance_only\": True,\n",
" \"max_range_m\": max_range_m, \"guaranteed_range_m\": guaranteed_range_m}\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. 信念层与保证层\n",
"\n",
"### 4.1 逐频道保守凸候选集(`belief/__init__.py`)\n",
"\n",
"候选集 K_c 用**圆盘外切多边形 ∩ 测向角域半平面**表示。两条不变式:\n",
"\n",
"- 外切多边形严格包含圆盘、半平面裁剪带正容差 ⇒ 更新只会放大不会缩小真实候选集;\n",
"- `direction` 读数给出 K_c ⊆ Ω ∩ W(s, θ±δ_eff) ∩ B(s, 1500),δ_eff 含示向度两位小数的\n",
" 舍入裕量 0.005°;\n",
"- `no_signal` **不删空间候选**(对定向源它只说明被遮挡,见第四问),\n",
" \"该频道无源\"的证明由覆盖账本或网格幸存集给出;\n",
"- `near` 读数把候选集收缩到 B(s, 5),可直接产生清除证书。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 半平面裁剪的正容差(米)。坐标量级 ~2100,浮点误差远小于它;\n",
"# 容差只向\"保留更多候选\"的方向倾斜,不破坏保守性。\n",
"_CLIP_TOL_M = 1e-6\n",
"_DEDUPE_TOL_M = 1e-7\n",
"\n",
"\n",
"class InconsistentBelief(RuntimeError):\n",
" \"\"\"读数把候选集剪空等违背保守性的情形;须停机核查而非继续跑。\"\"\"\n",
"\n",
"\n",
"class ChannelStatus(Enum):\n",
" UNKNOWN = \"unknown\" # 尚无有效证据\n",
" DETECTED = \"detected\" # 收到过 direction/near,待定位\n",
" LOCATED = \"located\" # 候选集包围圆半径 <= 20,可清除\n",
" CLEARED = \"cleared\" # 清除成功\n",
" ABSENT = \"absent\" # 覆盖证明不存在(P4)\n",
"\n",
"\n",
"def circumscribed_polygon(center: Vec2, radius_m: float, sides: int) -> list[Vec2]:\n",
" \"\"\"圆的外切正多边形顶点(外接半径 r/cos(π/n)),严格包含圆盘。\n",
"\n",
" 近似误差(外接圆超出圆盘的部分)为 r(1/cos(π/n) - 1) ≤ r(1-cos(π/n))·sec²,\n",
" 调用方用 (1-cos(π/n)) 口径记账即可(略保守偏大)。\n",
" \"\"\"\n",
" if sides < 8:\n",
" raise ValueError(\"外切多边形边数过少,近似误差不可控\")\n",
" circumradius = radius_m / math.cos(math.pi / sides)\n",
" step = 2 * math.pi / sides\n",
" return [(center[0] + circumradius * math.cos(step * i + step / 2),\n",
" center[1] + circumradius * math.sin(step * i + step / 2))\n",
" for i in range(sides)]\n",
"\n",
"\n",
"def polygon_halfplanes(polygon: list[Vec2]) -> list[tuple[float, float, float]]:\n",
" \"\"\"CCW 多边形 -> 外法线半平面列表 (a, b, c),含义 a·x + b·y <= c。\"\"\"\n",
" planes = []\n",
" count = len(polygon)\n",
" for index in range(count):\n",
" p, q = polygon[index], polygon[(index + 1) % count]\n",
" dx, dy = q[0] - p[0], q[1] - p[1]\n",
" norm = math.hypot(dx, dy)\n",
" if norm <= 1e-12:\n",
" continue\n",
" a, b = dy / norm, -dx / norm # CCW 的右法线朝外\n",
" planes.append((a, b, a * p[0] + b * p[1]))\n",
" return planes\n",
"\n",
"\n",
"def clip_halfplane(polygon: list[Vec2], a: float, b: float, c: float,\n",
" tol: float = _CLIP_TOL_M) -> list[Vec2]:\n",
" \"\"\"Sutherland–Hodgman 凸多边形半平面裁剪,保留 a·x + b·y <= c + tol。\n",
"\n",
" 边界交点取在无容差直线上;进入判定带正容差,保证\"贴边\"的真点不丢。\n",
" \"\"\"\n",
" if not polygon:\n",
" return []\n",
" output: list[Vec2] = []\n",
" count = len(polygon)\n",
" for index in range(count):\n",
" p, q = polygon[index], polygon[(index + 1) % count]\n",
" fp = a * p[0] + b * p[1] - c\n",
" fq = a * q[0] + b * q[1] - c\n",
" p_in, q_in = fp <= tol, fq <= tol\n",
" if p_in:\n",
" output.append(p)\n",
" if p_in != q_in:\n",
" ratio = fp / (fp - fq)\n",
" output.append((p[0] + ratio * (q[0] - p[0]),\n",
" p[1] + ratio * (q[1] - p[1])))\n",
" return _dedupe(output)\n",
"\n",
"\n",
"def _dedupe(points: list[Vec2], tol: float = _DEDUPE_TOL_M) -> list[Vec2]:\n",
" unique: list[Vec2] = []\n",
" for point in points:\n",
" if not unique or math.dist(point, unique[-1]) > tol:\n",
" unique.append(point)\n",
" if len(unique) > 1 and math.dist(unique[0], unique[-1]) <= tol:\n",
" unique.pop()\n",
" return unique\n",
"\n",
"\n",
"class CandidateRegion:\n",
" \"\"\"某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。\"\"\"\n",
"\n",
" def __init__(self, polygon: list[Vec2], slack_m: float = 0.0):\n",
" self.polygon = list(polygon)\n",
" self.slack_m = float(slack_m) # 外包近似引入的半径向误差记账\n",
" self._mec: tuple[Vec2, float] | None = None\n",
"\n",
" # ------------------------------------------------------------ 构造\n",
" @classmethod\n",
" def disk(cls, center: Vec2, radius_m: float, sides: int,\n",
" slack_m: float | None = None) -> \"CandidateRegion\":\n",
" if slack_m is None:\n",
" slack_m = radius_m * (1 - math.cos(math.pi / sides))\n",
" return cls(circumscribed_polygon(center, radius_m, sides), slack_m)\n",
"\n",
" @classmethod\n",
" def initial(cls, station: Vec2, config: ProblemConfig) -> \"CandidateRegion\":\n",
" \"\"\"首个 direction 后的基础集:Ω ∩ B(station, r_max) 的保守外包。\"\"\"\n",
" field_cfg, sides = config.field, config.search.polygon_sides\n",
" slack = field_cfg.omega_radius_m * (1 - math.cos(math.pi / sides))\n",
" region = cls.disk((0.0, 0.0), field_cfg.omega_radius_m, sides, slack)\n",
" region.clip_disk(station, field_cfg.r_eff_max_m, sides)\n",
" return region\n",
"\n",
" # ------------------------------------------------------------ 收缩\n",
" def _clip(self, a: float, b: float, c: float) -> None:\n",
" polygon = clip_halfplane(self.polygon, a, b, c)\n",
" if not polygon:\n",
" raise InconsistentBelief(\n",
" f\"候选区域被半平面 ({a:.6f},{b:.6f},{c:.6f}) 剪空:读数与保守集矛盾\")\n",
" self.polygon = polygon\n",
" self._mec = None\n",
"\n",
" def clip_disk(self, center: Vec2, radius_m: float, sides: int) -> None:\n",
" \"\"\"与圆盘 B(center, radius) 的外切多边形求交(近似误差入账)。\"\"\"\n",
" for a, b, c in polygon_halfplanes(circumscribed_polygon(center, radius_m, sides)):\n",
" self._clip(a, b, c)\n",
" self.slack_m += radius_m * (1 - math.cos(math.pi / sides))\n",
"\n",
" def clip_wedge(self, station: Vec2, bearing_deg: float,\n",
" half_angle_deg: float) -> None:\n",
" \"\"\"与测向角域 W(station, bearing±half_angle) 求交(半角须 <90°)。\n",
"\n",
" 两个边界半平面与 localization.halfplanes 同口径,0°/360° 跨界由\n",
" sin/cos 直接处理,无需分支。\n",
" \"\"\"\n",
" if not 0 < half_angle_deg < 90:\n",
" raise ValueError(\"角域半角须在 (0, 90) 度\")\n",
" lower = math.radians(bearing_deg - half_angle_deg)\n",
" upper = math.radians(bearing_deg + half_angle_deg)\n",
" sx, sy = float(station[0]), float(station[1])\n",
" self._clip(math.sin(lower), -math.cos(lower),\n",
" math.sin(lower) * sx - math.cos(lower) * sy)\n",
" self._clip(-math.sin(upper), math.cos(upper),\n",
" -math.sin(upper) * sx + math.cos(upper) * sy)\n",
"\n",
" # ------------------------------------------------------------ 查询\n",
" def mec(self) -> tuple[Vec2, float]:\n",
" \"\"\"最小包围圆(圆心, 半径)。半径是真实候选集 ρ 的上界。\"\"\"\n",
" if self._mec is None:\n",
" if not self.polygon:\n",
" raise InconsistentBelief(\"候选区域为空\")\n",
" self._mec = smallest_enclosing_circle(self.polygon)\n",
" return self._mec\n",
"\n",
" def witnesses(self, spacing_m: float) -> list[Vec2]:\n",
" \"\"\"顶点 + 边界按间距细分的采样点(最坏后验评估用)。\"\"\"\n",
" if len(self.polygon) < 3:\n",
" return list(self.polygon)\n",
" points = list(self.polygon)\n",
" count = len(self.polygon)\n",
" for index in range(count):\n",
" p, q = self.polygon[index], self.polygon[(index + 1) % count]\n",
" length = math.dist(p, q)\n",
" steps = int(length // spacing_m)\n",
" for k in range(1, steps + 1):\n",
" ratio = k / (steps + 1)\n",
" points.append((p[0] + ratio * (q[0] - p[0]),\n",
" p[1] + ratio * (q[1] - p[1])))\n",
" return _dedupe(points, tol=1e-9)\n",
"\n",
" def contains(self, point: Vec2, tol_m: float = 0.05) -> bool:\n",
" \"\"\"点是否在候选集内(带容差;用于真源保留性检验,不做删点依据)。\"\"\"\n",
" if not self.polygon:\n",
" return False\n",
" if len(self.polygon) == 1:\n",
" return math.dist(point, self.polygon[0]) <= tol_m + self.slack_m\n",
" if len(self.polygon) == 2:\n",
" p, q = self.polygon\n",
" return _point_segment_distance(point, p, q) <= tol_m + self.slack_m\n",
" for index, p in enumerate(self.polygon):\n",
" q = self.polygon[(index + 1) % len(self.polygon)]\n",
" if (q[0] - p[0]) * (point[1] - p[1]) - (q[1] - p[1]) * (point[0] - p[0]) \\\n",
" < -tol_m - self.slack_m:\n",
" return False\n",
" return True\n",
"\n",
"\n",
"def _point_segment_distance(point: Vec2, p: Vec2, q: Vec2) -> float:\n",
" vx, vy = q[0] - p[0], q[1] - p[1]\n",
" length_sq = vx * vx + vy * vy\n",
" if length_sq <= 1e-18:\n",
" return math.dist(point, p)\n",
" t = max(0.0, min(1.0, ((point[0] - p[0]) * vx + (point[1] - p[1]) * vy) / length_sq))\n",
" return math.dist(point, (p[0] + t * vx, p[1] + t * vy))\n",
"\n",
"\n",
"@dataclass\n",
"class ChannelBelief:\n",
" channel: int\n",
" status: ChannelStatus = ChannelStatus.UNKNOWN\n",
" region: CandidateRegion | None = None\n",
" measurements: list = field(default_factory=list) # 逐步观测记录(审计用)\n",
"\n",
"\n",
"class BeliefState:\n",
" \"\"\"全部 20 个频道的信念集合。策略端唯一的事实来源。\"\"\"\n",
"\n",
" def __init__(self, config: ProblemConfig = ProblemConfig()):\n",
" self.config = config\n",
" self.channels = {channel: ChannelBelief(channel)\n",
" for channel in range(config.field.channel_min,\n",
" config.field.channel_max + 1)}\n",
"\n",
" @property\n",
" def effective_half_angle_deg(self) -> float:\n",
" \"\"\"示向度误差半角 + 舍入裕量:服务器保留两位小数引入 ≤0.005° 偏差。\"\"\"\n",
" return (self.config.field.bearing_error_half_deg\n",
" + self.config.search.bearing_rounding_margin_deg)\n",
"\n",
" def update_measure(self, channel: int, position: Vec2, result: str,\n",
" svd_deg: float | None) -> None:\n",
" \"\"\"按读数收敛候选集合;真源保留性由保守表示保证。\"\"\"\n",
" belief = self.channels[channel]\n",
" belief.measurements.append({\"position\": tuple(position), \"result\": result,\n",
" \"svd_deg\": svd_deg})\n",
" if result == DIRECTION:\n",
" if svd_deg is None:\n",
" raise ValueError(\"direction 读数必须携带 svd_deg\")\n",
" sides = self.config.search.polygon_sides\n",
" if belief.region is None:\n",
" belief.region = CandidateRegion.initial(position, self.config)\n",
" else:\n",
" # 后续 direction 同样隐含距离 <= r_max [题面]\n",
" belief.region.clip_disk(position, self.config.field.r_eff_max_m, sides)\n",
" belief.region.clip_wedge(position, svd_deg, self.effective_half_angle_deg)\n",
" belief.status = ChannelStatus.DETECTED\n",
" elif result == NEAR:\n",
" sides = self.config.search.polygon_sides\n",
" if belief.region is None:\n",
" belief.region = CandidateRegion.disk(\n",
" tuple(position), self.config.field.near_radius_m, sides)\n",
" else:\n",
" belief.region.clip_disk(position, self.config.field.near_radius_m, sides)\n",
" belief.status = ChannelStatus.DETECTED\n",
" elif result == NO_SIGNAL:\n",
" # P3/P4 基线:不据此删空间候选(缺席证明由覆盖账本给出)。\n",
" pass\n",
" else:\n",
" raise ValueError(f\"未知测向结果: {result!r}\")\n",
"\n",
" def candidate_set(self, channel: int) -> CandidateRegion | None:\n",
" \"\"\"该频道当前候选集合(保守外包形式);尚无 direction/near 时为 None。\"\"\"\n",
" return self.channels[channel].region\n",
"\n",
" def mark_cleared(self, channel: int) -> None:\n",
" self.channels[channel].status = ChannelStatus.CLEARED\n",
"\n",
" def mark_absent(self, channel: int) -> None:\n",
" self.channels[channel].status = ChannelStatus.ABSENT\n",
"\n",
" def channels_by_status(self, *statuses: ChannelStatus) -> list[int]:\n",
" return sorted(channel for channel, belief in self.channels.items()\n",
" if belief.status in statuses)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.2 逐频道覆盖账本(`coverage/__init__.py`)\n",
"\n",
"\"路过某点\"不算\"测过某频道\":覆盖必须按 (频道, 点) 二维记账,\n",
"这是\"全点皆 `no_signal` ⇒ 该频道不存在源\"这一缺席证明的唯一依据。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def coverage_points(config: ProblemConfig = ProblemConfig()) -> list[Vec2]:\n",
" \"\"\"49 点覆盖网格生成(纯几何,可独立测试)。\"\"\"\n",
" step = config.search.grid_step_m\n",
" half = config.search.grid_half_cells\n",
" return [(step * i, step * j)\n",
" for j in range(half, -half - 1, -1)\n",
" for i in range(-half, half + 1)]\n",
"\n",
"\n",
"@dataclass\n",
"class CoverageLedger:\n",
" \"\"\"逐频道覆盖账本:(频道, 网格点) -> 是否已测。\"\"\"\n",
"\n",
" config: ProblemConfig = field(default_factory=ProblemConfig)\n",
" points: list[Vec2] = field(default_factory=coverage_points)\n",
" measured: set = field(default_factory=set) # {(channel, point_index)}\n",
" skipped: dict = field(default_factory=dict) # channel -> 原因\n",
"\n",
" def mark_measured(self, channel: int, point_index: int) -> None:\n",
" self.measured.add((channel, point_index))\n",
"\n",
" def measured_at(self, channel: int, point_index: int) -> bool:\n",
" return (channel, point_index) in self.measured\n",
"\n",
" def skip_channel(self, channel: int, reason: str) -> None:\n",
" \"\"\"记录整频道跳过原因(如 already_cleared / located_pending_clear)。\"\"\"\n",
" self.skipped[channel] = reason\n",
"\n",
" def pending_points(self, channel: int) -> list[int]:\n",
" \"\"\"该频道尚未测量的网格点下标。扫描调度策略未实现(P4)。\"\"\"\n",
" return [index for index in range(len(self.points))\n",
" if not self.measured_at(channel, index)]\n",
"\n",
" def coverage_complete(self, channel: int) -> bool:\n",
" \"\"\"该频道覆盖是否完成(含显式跳过)。\"\"\"\n",
" return channel in self.skipped or not self.pending_points(channel)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.3 覆盖计划与证书(`guarantee/cover.py`)\n",
"\n",
"两类覆盖语义(题面约束不同,绝不可混用):\n",
"\n",
"| 语义 | 适用 | 缺席条件 |\n",
"| --- | --- | --- |\n",
"| `full_disk` | 第三问全向源 | ∪ B(c, 1000) ⊇ Ω,即 Ω 内每点都落在某个被访点的最小接收圆盘内 |\n",
"| `forward_half_disk` | 第四问定向源 | ∀(p, h) ∈ Ω × S¹,存在被访点 c 使 \\|c−p\\| ≤ 1000 且 (c−p)·u(h) ≥ 0 |\n",
"\n",
"**保证 = 离线搜索 + 运行期校验**:点表与证书一起落盘并校验采样语义、缺口数、\n",
"最坏命中距离与生成参数哈希,运行期用同一套采样语义重新校核,未通过即拒绝启动。\n",
"证书硬化把\"采样格覆盖\"提升为\"连续空间覆盖\":设计裕量 r_eff + step·√2/2 ≤ 1000\n",
"且角裕量 ≥ 180/headings/2。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"R_EFF_MIN_M = 1000.0 # [题面] 最小有效接收半径\n",
"OMEGA_RADIUS_M = 1800.0 # [题面] 目标圆域半径\n",
"SPEED_MPS = 5.0 # [题面] 用于把巡回长度折算成虚拟移动秒数\n",
"\n",
"# 采样分辨率。**校核必须与证书同分辨率**:更粗的采样会放过\"删掉一个关键点\n",
"# 仍然看似覆盖\"的点集,使校核退化为装饰(实测 100 m/12 档即漏判)。\n",
"# 75 m / 24 档下一次全域校核约 1 s,且按计划名缓存,每进程只付一次。\n",
"CERTIFY_STEP_M = 75.0\n",
"CERTIFY_HEADINGS = 24\n",
"VERIFY_STEP_M = CERTIFY_STEP_M\n",
"VERIFY_HEADINGS = CERTIFY_HEADINGS\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class CoverageCertificate:\n",
" \"\"\"覆盖证书:采样语义 + 缺口 + 最坏命中距离。\"\"\"\n",
"\n",
" kind: str # \"full_disk\" | \"forward_half_disk\"\n",
" target_step_m: float\n",
" headings: int\n",
" r_eff_min_m: float\n",
" targets: int\n",
" uncovered: int\n",
" worst_hit_distance_m: float\n",
" nn_tour_m: float\n",
" generator_sha256: str = \"\"\n",
" # 证书硬化:校核时使用的前向角裕量(度)。与 r_eff_min_m 的设计裕量\n",
" # 一起把\"采样格覆盖\"提升为\"连续空间覆盖\",见 verify_forward_half_disk。\n",
" verify_heading_margin_deg: float = 0.0\n",
"\n",
" @property\n",
" def valid(self) -> bool:\n",
" return self.uncovered == 0 and self.targets > 0\n",
"\n",
" def as_dict(self) -> dict:\n",
" return {\"kind\": self.kind, \"target_step_m\": self.target_step_m,\n",
" \"headings\": self.headings, \"r_eff_min_m\": self.r_eff_min_m,\n",
" \"targets\": self.targets, \"uncovered\": self.uncovered,\n",
" \"worst_hit_distance_m\": round(self.worst_hit_distance_m, 3),\n",
" \"nn_tour_m\": round(self.nn_tour_m, 1),\n",
" \"nn_tour_virtual_s\": round(self.nn_tour_m / SPEED_MPS, 1),\n",
" \"generator_sha256\": self.generator_sha256,\n",
" \"verify_heading_margin_deg\": self.verify_heading_margin_deg}\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class CoveragePlan:\n",
" \"\"\"一份覆盖计划:点集 + 语义 + 证书(可为解析证明)。\"\"\"\n",
"\n",
" name: str\n",
" problem: int\n",
" points: tuple[Vec2, ...]\n",
" kind: str # \"analytic\" | \"certified\"\n",
" coverage: str # \"full_disk\" | \"forward_half_disk\"\n",
" note: str = \"\"\n",
" certificate: CoverageCertificate | None = None\n",
" generator: dict = field(default_factory=dict)\n",
"\n",
" def __len__(self) -> int:\n",
" return len(self.points)\n",
"\n",
" def as_dict(self) -> dict:\n",
" return {\"name\": self.name, \"problem\": self.problem, \"kind\": self.kind,\n",
" \"coverage\": self.coverage, \"note\": self.note,\n",
" \"generator\": self.generator,\n",
" \"certificate\": (self.certificate.as_dict()\n",
" if self.certificate is not None else None),\n",
" \"points\": [[p[0], p[1]] for p in self.points]}\n",
"\n",
"\n",
"# --------------------------------------------------------------------- 采样\n",
"def omega_positions(step_m: float) -> list[Vec2]:\n",
" \"\"\"Ω 内均匀方格位置采样。\"\"\"\n",
" span = int(OMEGA_RADIUS_M / step_m) + 1\n",
" return [(step_m * i, step_m * j)\n",
" for i in range(-span, span + 1) for j in range(-span, span + 1)\n",
" if (step_m * i) ** 2 + (step_m * j) ** 2 <= OMEGA_RADIUS_M ** 2]\n",
"\n",
"\n",
"def forward_targets(step_m: float, headings: int) -> list[tuple[float, float, float]]:\n",
" \"\"\"(p, h) 目标采样:位置在 Ω 内,朝向均分整圆。\"\"\"\n",
" return [(x, y, 2.0 * math.pi * k / headings)\n",
" for (x, y) in omega_positions(step_m) for k in range(headings)]\n",
"\n",
"\n",
"def candidate_lattice(spacing_m: float, radius_m: float) -> list[Vec2]:\n",
" \"\"\"半径 radius_m 圆盘内的方格候选点(离线搜索用)。\"\"\"\n",
" span = int(radius_m / spacing_m) + 1\n",
" return [(spacing_m * i, spacing_m * j)\n",
" for i in range(-span, span + 1) for j in range(-span, span + 1)\n",
" if (spacing_m * i) ** 2 + (spacing_m * j) ** 2 <= radius_m ** 2]\n",
"\n",
"\n",
"def _bucket(points: list[Vec2], cell: float) -> dict[tuple[int, int], list[int]]:\n",
" table: dict[tuple[int, int], list[int]] = {}\n",
" for index, (x, y) in enumerate(points):\n",
" table.setdefault((int(x // cell), int(y // cell)), []).append(index)\n",
" return table\n",
"\n",
"\n",
"def coverage_masks(targets, candidates: list[Vec2],\n",
" r_eff: float = R_EFF_MIN_M,\n",
" heading_margin_deg: float = 0.0) -> list[int]:\n",
" \"\"\"每个候选点覆盖的目标位掩码(Python 大整数,按位)。\n",
"\n",
" 只在与候选点空间桶相邻的格内做距离/前向判定,避免 |T|·|C| 笛卡尔积。\n",
"\n",
" ``r_eff`` / ``heading_margin_deg`` 是**设计裕量**:离线搜索时把\"命中\"\n",
" 定义得比物理要求更严(距离 ≤ r_eff、前向夹角 ≤ 90°−margin),使采样格\n",
" 上的覆盖自动蕴含相邻未采样 (p,h) 也被覆盖(S2 证书硬化)。\n",
" \"\"\"\n",
" table = _bucket(candidates, r_eff)\n",
" masks = [0] * len(candidates)\n",
" sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))\n",
" for t_index, (px, py, heading) in enumerate(targets):\n",
" ux, uy = math.cos(heading), math.sin(heading)\n",
" ci, cj = int(px // r_eff), int(py // r_eff)\n",
" bit = 1 << t_index\n",
" for di in (-1, 0, 1):\n",
" for dj in (-1, 0, 1):\n",
" for c_index in table.get((ci + di, cj + dj), ()):\n",
" cx, cy = candidates[c_index]\n",
" dx, dy = cx - px, cy - py\n",
" distance_sq = dx * dx + dy * dy\n",
" if distance_sq > r_eff * r_eff:\n",
" continue\n",
" if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:\n",
" continue\n",
" masks[c_index] |= bit\n",
" return masks\n",
"\n",
"\n",
"def greedy_cover(masks: list[int], n_targets: int) -> list[int]:\n",
" \"\"\"贪心集合覆盖:每次取\"新增覆盖目标数\"最大的候选,返回索引表。\"\"\"\n",
" full = (1 << n_targets) - 1\n",
" covered = 0\n",
" chosen: list[int] = []\n",
" remaining = list(range(len(masks)))\n",
" while covered != full and remaining:\n",
" best_index, best_gain = -1, 0\n",
" for c_index in remaining:\n",
" gain = (masks[c_index] & ~covered).bit_count()\n",
" if gain > best_gain:\n",
" best_index, best_gain = c_index, gain\n",
" if best_index < 0:\n",
" break\n",
" chosen.append(best_index)\n",
" covered |= masks[best_index]\n",
" remaining.remove(best_index)\n",
" return chosen\n",
"\n",
"\n",
"def nn_tour_length(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:\n",
" \"\"\"最近邻开放路径长度(巡回长度上界)。\"\"\"\n",
" remaining = list(points)\n",
" position = start\n",
" total = 0.0\n",
" while remaining:\n",
" nxt = min(remaining, key=lambda q: (q[0] - position[0]) ** 2\n",
" + (q[1] - position[1]) ** 2)\n",
" remaining.remove(nxt)\n",
" total += math.dist(position, nxt)\n",
" position = nxt\n",
" return total\n",
"\n",
"\n",
"# --------------------------------------------------------------------- 校验\n",
"def _hit_distance(points: list[Vec2], position: Vec2, heading: float,\n",
" r_eff: float) -> float | None:\n",
" \"\"\"最近的前向命中距离;没有任何前向命中点则返回 None。\"\"\"\n",
" ux, uy = math.cos(heading), math.sin(heading)\n",
" best = None\n",
" for (cx, cy) in points:\n",
" dx, dy = cx - position[0], cy - position[1]\n",
" if dx * dx + dy * dy > r_eff * r_eff:\n",
" continue\n",
" if dx * ux + dy * uy < -1e-9:\n",
" continue\n",
" distance = math.hypot(dx, dy)\n",
" if best is None or distance < best:\n",
" best = distance\n",
" return best\n",
"\n",
"\n",
"def _nearest_distance(points: list[Vec2], position: Vec2,\n",
" r_eff: float) -> float | None:\n",
" \"\"\"最近距离(不要求前向);超过 r_eff 视作未命中。\"\"\"\n",
" best = None\n",
" for (cx, cy) in points:\n",
" distance = math.hypot(cx - position[0], cy - position[1])\n",
" if distance <= r_eff and (best is None or distance < best):\n",
" best = distance\n",
" return best\n",
"\n",
"\n",
"def verify_forward_half_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,\n",
" headings: int = VERIFY_HEADINGS,\n",
" r_eff: float = R_EFF_MIN_M,\n",
" heading_margin_deg: float = 0.0) -> CoverageCertificate:\n",
" \"\"\"第四问语义:∀(p,h) 存在前向半盘内的被访点。\n",
"\n",
" ``heading_margin_deg`` 是**证书硬化**用的前向角裕量:把\"命中\"收紧为\n",
" \"前向夹角 ≤ 90° − margin\"。与 ``r_eff`` 的设计裕量合用时,采样格上的\n",
" 覆盖即可推出**连续空间**覆盖:真实 (p',h') 距采样格 ≤ step·√2/2、\n",
" 方位差 ≤ 180/headings 度,只要\n",
"\n",
" r_eff + step·√2/2 ≤ 1000 且 margin ≥ 180/headings/2\n",
"\n",
" 就能保证 |c−p'| ≤ 1000 且 (c−p')·u(h') ≥ 0。这正是 S2 取代\"粗采样\n",
" 假通过\"的方式。默认 0 表示按物理条件校核。\n",
" \"\"\"\n",
" points = list(points)\n",
" targets = forward_targets(step_m, headings)\n",
" table = _bucket(points, r_eff)\n",
" missing, worst = 0, 0.0\n",
" sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))\n",
" for (px, py, heading) in targets:\n",
" ux, uy = math.cos(heading), math.sin(heading)\n",
" ci, cj = int(px // r_eff), int(py // r_eff)\n",
" best = None\n",
" for di in (-1, 0, 1):\n",
" for dj in (-1, 0, 1):\n",
" for c_index in table.get((ci + di, cj + dj), ()):\n",
" cx, cy = points[c_index]\n",
" dx, dy = cx - px, cy - py\n",
" distance_sq = dx * dx + dy * dy\n",
" if distance_sq > r_eff * r_eff:\n",
" continue\n",
" if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:\n",
" continue\n",
" distance = math.sqrt(distance_sq)\n",
" if best is None or distance < best:\n",
" best = distance\n",
" if best is None:\n",
" missing += 1\n",
" else:\n",
" worst = max(worst, best)\n",
" return CoverageCertificate(\"forward_half_disk\", step_m, headings, r_eff,\n",
" len(targets), missing, worst,\n",
" nn_tour_length(points))\n",
"\n",
"\n",
"def verify_full_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,\n",
" r_eff: float = R_EFF_MIN_M) -> CoverageCertificate:\n",
" \"\"\"第三问语义:Ω 内每点都在某个被访点的 r_eff 圆盘内。\"\"\"\n",
" points = list(points)\n",
" positions = omega_positions(step_m)\n",
" table = _bucket(points, r_eff)\n",
" missing, worst = 0, 0.0\n",
" for (px, py) in positions:\n",
" ci, cj = int(px // r_eff), int(py // r_eff)\n",
" best = None\n",
" for di in (-1, 0, 1):\n",
" for dj in (-1, 0, 1):\n",
" for c_index in table.get((ci + di, cj + dj), ()):\n",
" cx, cy = points[c_index]\n",
" distance = math.hypot(cx - px, cy - py)\n",
" if distance <= r_eff and (best is None or distance < best):\n",
" best = distance\n",
" if best is None:\n",
" missing += 1\n",
" else:\n",
" worst = max(worst, best)\n",
" return CoverageCertificate(\"full_disk\", step_m, 1, r_eff, len(positions),\n",
" missing, worst, nn_tour_length(points))\n",
"\n",
"\n",
"def verify_plan(plan: CoveragePlan, step_m: float | None = None,\n",
" headings: int | None = None) -> CoverageCertificate:\n",
" \"\"\"按计划声明的覆盖语义校核。\n",
"\n",
" 默认使用**证书自带的采样分辨率**(离线 75 m / 24 档):更粗的运行期采样\n",
" 会放过\"删掉一个关键点仍看似覆盖\"的点集,使校核失去意义。校核按计划名\n",
" 缓存(``load_plan`` 的 lru_cache),每进程只付一次成本。\n",
" \"\"\"\n",
" certificate = plan.certificate\n",
" if step_m is None:\n",
" step_m = certificate.target_step_m if certificate else VERIFY_STEP_M\n",
" if headings is None:\n",
" headings = (certificate.headings if certificate\n",
" else VERIFY_HEADINGS)\n",
" if plan.coverage == \"forward_half_disk\":\n",
" # 硬化证书:按证书自带的距离/角度设计裕量重新校核(旧证书默认\n",
" # r_eff=1000、margin=0,与历史口径逐位一致)。\n",
" return verify_forward_half_disk(\n",
" plan.points, step_m, headings,\n",
" r_eff=(certificate.r_eff_min_m if certificate else R_EFF_MIN_M),\n",
" heading_margin_deg=(certificate.verify_heading_margin_deg\n",
" if certificate else 0.0))\n",
" return verify_full_disk(plan.points, step_m, certificate.r_eff_min_m\n",
" if certificate else R_EFF_MIN_M)\n",
"\n",
"\n",
"# --------------------------------------------------------------------- 计划\n",
"def seven_points(ring_radius_m: float) -> list[Vec2]:\n",
" \"\"\"解析七站:原点 + 环上 6 等分点。\"\"\"\n",
" return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n",
" ring_radius_m * math.sin(k * math.pi / 3))\n",
" for k in range(6)]\n",
"\n",
"\n",
"def seven_plan(ring_radius_m: float) -> CoveragePlan:\n",
" \"\"\"第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。\"\"\"\n",
" return CoveragePlan(\n",
" name=f\"seven-r{ring_radius_m:.1f}\", problem=3, kind=\"analytic\",\n",
" coverage=\"full_disk\",\n",
" note=(\"解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;\"\n",
" \"r∈[1122.9, 1732] 时成立,巡检半径越小巡回越短\"),\n",
" points=tuple(seven_points(ring_radius_m)))\n",
"\n",
"\n",
"def square_49_plan() -> CoveragePlan:\n",
" \"\"\"第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。\"\"\"\n",
" return CoveragePlan(\n",
" name=\"square49\", problem=4, kind=\"certified\",\n",
" coverage=\"forward_half_disk\",\n",
" note=\"单元对角 700√2≈989.95≤1000 的充分条件;点数为最小命中集的 1.6 倍\",\n",
" # 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史\n",
" # 基线 grid49_immediate 的逐位可复现。\n",
" points=tuple((700.0 * i, 700.0 * j)\n",
" for j in range(3, -4, -1) for i in range(-3, 4)))\n",
"\n",
"\n",
"def plan_instance(name: str, verify: bool = True) -> CoveragePlan:\n",
" \"\"\"统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。\"\"\"\n",
" if name == \"square49\":\n",
" return square_49_plan()\n",
" if name == \"seven\":\n",
" return seven_plan(1200.0)\n",
" if name not in EMBEDDED_PLANS:\n",
" raise FileNotFoundError(f\"未找到覆盖计划 {name!r};现有:{plan_names()}\")\n",
" plan = embedded_plan(name)\n",
" if verify and plan.kind == \"certified\":\n",
" certificate = verify_plan(plan)\n",
" if not certificate.valid:\n",
" raise ValueError(\n",
" f\"覆盖计划 {name!r} 运行期校验失败:\"\n",
" f\"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖\")\n",
" plan = CoveragePlan(\n",
" name=plan.name, problem=plan.problem, kind=plan.kind,\n",
" coverage=plan.coverage, note=plan.note, generator=plan.generator,\n",
" certificate=CoverageCertificate(\n",
" kind=certificate.kind, target_step_m=certificate.target_step_m,\n",
" headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,\n",
" targets=certificate.targets, uncovered=certificate.uncovered,\n",
" worst_hit_distance_m=certificate.worst_hit_distance_m,\n",
" nn_tour_m=certificate.nn_tour_m,\n",
" generator_sha256=(plan.certificate.generator_sha256\n",
" if plan.certificate else \"\")),\n",
" points=plan.points)\n",
" return plan\n",
"\n",
"\n",
"def plan_names() -> list[str]:\n",
" \"\"\"全部可加载的计划名(内置解析计划 + 内嵌证书计划)。\"\"\"\n",
" return [\"square49\", \"seven\"] + sorted(EMBEDDED_PLANS)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.4 第四问最小命中集(内嵌计划)\n",
"\n",
"现役计划 `q4_mincover_v2`:31 点(700 m 方格 49 点的充分条件可压到 31 点),\n",
"设计裕量 r_design = 965 m、前向角裕量 5°,按 15 m / 72 档共 3 256 200 个 (p, h)\n",
"复核缺口为 0、最坏命中 990.05 m、最近邻巡回 23.88 km。\n",
"`q4_mincover_v1` 是同点数的粗采样(75 m / 24 档)解,细网格复检有 125 个缺口——\n",
"保留它正是\"粗采样假通过\"的反例。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def seven_points(ring_radius_m: float) -> list[Vec2]:\n",
" \"\"\"解析七站:原点 + 环上 6 等分点。\"\"\"\n",
" return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n",
" ring_radius_m * math.sin(k * math.pi / 3))\n",
" for k in range(6)]\n",
"\n",
"\n",
"def seven_plan(ring_radius_m: float) -> CoveragePlan:\n",
" \"\"\"第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。\"\"\"\n",
" return CoveragePlan(\n",
" name=f\"seven-r{ring_radius_m:.1f}\", problem=3, kind=\"analytic\",\n",
" coverage=\"full_disk\",\n",
" note=(\"解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;\"\n",
" \"r∈[1122.9, 1732] 时成立,巡检半径越小巡回越短\"),\n",
" points=tuple(seven_points(ring_radius_m)))\n",
"\n",
"\n",
"def square_49_plan() -> CoveragePlan:\n",
" \"\"\"第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。\"\"\"\n",
" return CoveragePlan(\n",
" name=\"square49\", problem=4, kind=\"certified\",\n",
" coverage=\"forward_half_disk\",\n",
" note=\"单元对角 700√2≈989.95≤1000 的充分条件;点数为最小命中集的 1.6 倍\",\n",
" # 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史\n",
" # 基线 grid49_immediate 的逐位可复现。\n",
" points=tuple((700.0 * i, 700.0 * j)\n",
" for j in range(3, -4, -1) for i in range(-3, 4)))\n",
"\n",
"\n",
"def plan_instance(name: str, verify: bool = True) -> CoveragePlan:\n",
" \"\"\"统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。\"\"\"\n",
" if name == \"square49\":\n",
" return square_49_plan()\n",
" if name == \"seven\":\n",
" return seven_plan(1200.0)\n",
" if name not in EMBEDDED_PLANS:\n",
" raise FileNotFoundError(f\"未找到覆盖计划 {name!r};现有:{plan_names()}\")\n",
" plan = embedded_plan(name)\n",
" if verify and plan.kind == \"certified\":\n",
" certificate = verify_plan(plan)\n",
" if not certificate.valid:\n",
" raise ValueError(\n",
" f\"覆盖计划 {name!r} 运行期校验失败:\"\n",
" f\"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖\")\n",
" plan = CoveragePlan(\n",
" name=plan.name, problem=plan.problem, kind=plan.kind,\n",
" coverage=plan.coverage, note=plan.note, generator=plan.generator,\n",
" certificate=CoverageCertificate(\n",
" kind=certificate.kind, target_step_m=certificate.target_step_m,\n",
" headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,\n",
" targets=certificate.targets, uncovered=certificate.uncovered,\n",
" worst_hit_distance_m=certificate.worst_hit_distance_m,\n",
" nn_tour_m=certificate.nn_tour_m,\n",
" generator_sha256=(plan.certificate.generator_sha256\n",
" if plan.certificate else \"\")),\n",
" points=plan.points)\n",
" return plan\n",
"\n",
"\n",
"def plan_names() -> list[str]:\n",
" \"\"\"全部可加载的计划名(内置解析计划 + 内嵌证书计划)。\"\"\"\n",
" return [\"square49\", \"seven\"] + sorted(EMBEDDED_PLANS)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 覆盖计划以字面量内嵌(原实现从 guarantee/plans/*.json 读取;数值逐位一致)。\n",
"# v2 是现役计划:31 点、设计裕量 r_design=965 m / 前向角裕量 5°,\n",
"# 证书按 15 m / 72 档复核 3 256 200 个 (p, h) 目标,缺口 0、最坏命中 990.05 m。\n",
"# v1(75 m/24 档采样过拟合解)保留作对照,说明\"粗采样假通过\"的风险。\n",
"Q4_MINCOVER_V2 = {\n",
" \"name\": \"q4_mincover_v2\",\n",
" \"problem\": 4,\n",
" \"kind\": \"certified\",\n",
" \"coverage\": \"forward_half_disk\",\n",
" \"note\": \"设计裕量最小命中集:31 点,r_design=965 m / 前向角裕量 5.0°;证书按 15 m/72 档、r_verify=1000 m、角裕量 0.0° 校核缺口 0(同一证书口径复核,含精细剪枝);最坏命中 990 m,NN 巡回 23.9 km\",\n",
" \"generator\": {\n",
" \"script\": \"scripts/verify_q4_cover.py\",\n",
" \"spacing_m\": 100.0,\n",
" \"radius_m\": 2850.0,\n",
" \"target_step_m\": 50.0,\n",
" \"headings\": 48,\n",
" \"design_r_eff_m\": 965.0,\n",
" \"heading_margin_deg\": 5.0,\n",
" \"pruned\": True,\n",
" \"verify_step_m\": 15.0,\n",
" \"verify_headings\": 72,\n",
" \"verify_r_eff_m\": 1000.0,\n",
" \"verify_heading_margin_deg\": 0.0,\n",
" \"code_sha256\": \"adf4fe6e9853c47314fac2c630fc6846ea6922102c2e83b507e5d397434c0e1d\"\n",
" },\n",
" \"certificate\": {\n",
" \"kind\": \"forward_half_disk\",\n",
" \"target_step_m\": 15.0,\n",
" \"headings\": 72,\n",
" \"r_eff_min_m\": 1000.0,\n",
" \"targets\": 3256200,\n",
" \"uncovered\": 0,\n",
" \"worst_hit_distance_m\": 990.051,\n",
" \"nn_tour_m\": 23883.8,\n",
" \"nn_tour_virtual_s\": 4776.8,\n",
" \"generator_sha256\": \"adf4fe6e9853c47314fac2c630fc6846ea6922102c2e83b507e5d397434c0e1d\",\n",
" \"verify_heading_margin_deg\": 0.0\n",
" },\n",
" \"points\": [\n",
" (-800, -200), (800, 200), (-200, 900), (200, -900), (-1200, 700), (1200, -700),\n",
" (800, 1200), (-1800, -300), (1800, 300), (-400, 1800), (0, 0), (1400, -1400),\n",
" (-1500, -1300), (1500, 1300), (-400, -1800), (400, 1800), (-1800, 400), (1800, -400),\n",
" (-500, 700), (-700, -700), (700, 700), (1300, 1100), (-1300, 1500), (500, -1800),\n",
" (-900, -2100), (600, 1900), (-2500, 500), (1900, -900), (-1700, -500), (-700, -1200),\n",
" (900, -700),\n",
" ],\n",
"}\n",
"Q4_MINCOVER_V1 = {\n",
" \"name\": \"q4_mincover_v1\",\n",
" \"problem\": 4,\n",
" \"kind\": \"certified\",\n",
" \"coverage\": \"forward_half_disk\",\n",
" \"note\": \"贪心击中集等价覆盖:31 点 vs 现役 49 点,NN 巡回 22.9 km vs 36.4 km\",\n",
" \"generator\": {\n",
" \"script\": \"scripts/verify_q4_cover.py\",\n",
" \"spacing_m\": 200.0,\n",
" \"radius_m\": 2850.0,\n",
" \"target_step_m\": 75.0,\n",
" \"headings\": 24,\n",
" \"code_sha256\": \"dce9f0e189439bbea26f6bcbebf7e7f7bb99021a9035fca0c8d67bb67a3a529a\"\n",
" },\n",
" \"certificate\": None,\n",
" \"points\": [\n",
" (-800, 0), (800, 0), (0, -1000), (0, 1000), (-1200, -1000), (-1000, 1000),\n",
" (1000, -1000), (1000, 1000), (1800, 0), (-1800, 200), (-400, -1800), (-200, 1800),\n",
" (0, 0), (800, -1800), (1200, 1600), (-1400, 1400), (1800, -800), (-1800, -800),\n",
" (1800, 600), (-600, -1000), (400, 1800), (-1400, -1800), (-1200, 800), (-1200, 1800),\n",
" (400, -800), (-200, 400), (600, -1400), (-2200, 600), (1000, -1600), (-2000, -400),\n",
" (-200, -2200),\n",
" ],\n",
"}\n",
"\n",
"EMBEDDED_PLANS = {\"q4_mincover_v1\": Q4_MINCOVER_V1, \"q4_mincover_v2\": Q4_MINCOVER_V2}\n",
"\n",
"\n",
"def embedded_plan(name: str) -> CoveragePlan:\n",
" \"\"\"把内嵌计划字典还原成 :class:`CoveragePlan`(证书字段按数据类取值)。\"\"\"\n",
" payload = EMBEDDED_PLANS[name]\n",
" raw_cert = payload.get(\"certificate\") or {}\n",
" known = {item.name for item in fields(CoverageCertificate)}\n",
" certificate = (CoverageCertificate(**{key: value for key, value in raw_cert.items()\n",
" if key in known}) if raw_cert else None)\n",
" return CoveragePlan(\n",
" name=payload[\"name\"], problem=int(payload[\"problem\"]),\n",
" kind=payload.get(\"kind\", \"certified\"),\n",
" coverage=payload.get(\"coverage\", \"forward_half_disk\"),\n",
" note=payload.get(\"note\", \"\"), certificate=certificate,\n",
" generator=payload.get(\"generator\", {}),\n",
" points=tuple((float(x), float(y)) for x, y in payload[\"points\"]))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.5 第三问解析七站计划(`planner/seven.py` 覆盖部分)\n",
"\n",
"原点 + 半径 r 的 6 等分环:域半径 R、环半径 r 时,域内任一点到最近站的距离\n",
"≤ max(r/√3, √(R² + r² − √3Rr)),只要该上界 < 1000 m(最小有效接收半径),\n",
"\"7 站全 `no_signal`\"就是严格的缺席证明。r ∈ [1122.9, 1732] 可行,巡检半径越小巡回越短。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"RING_RADIUS_M = 1200.0 # [工程] 解析证明见 docs/algo-3.md §5 与 geometry_check\n",
"\n",
"\n",
"def seven_points(ring_radius_m: float = RING_RADIUS_M) -> list[tuple[float, float]]:\n",
" \"\"\"原点 + 环上 6 等分点的七站覆盖集。\"\"\"\n",
" return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n",
" ring_radius_m * math.sin(k * math.pi / 3)) for k in range(6)]\n",
"\n",
"\n",
"def validate_seven_coverage(ring_radius_m: float, config: ProblemConfig) -> float:\n",
" \"\"\"六个60°扇区内的最近站最远距离上界;仅证明全向源覆盖。\n",
"\n",
" 原点/环站Voronoi交点给出r/√3,目标圆边界的扇区接缝给出另一项。\n",
" 环半径满足0<r<=√3R时这两项覆盖全部极值。\n",
" \"\"\"\n",
" radius = config.field.omega_radius_m\n",
" if not 0 < ring_radius_m <= math.sqrt(3)*radius:\n",
" raise ValueError(\"七站环半径不满足全域覆盖证明的前提\")\n",
" bound = max(ring_radius_m / math.sqrt(3), math.sqrt(\n",
" radius**2 + ring_radius_m**2 - math.sqrt(3)*radius*ring_radius_m))\n",
" if bound > config.field.r_eff_min_m - 1e-6:\n",
" raise ValueError(\"七站未覆盖整个目标域,不能据此证明频道缺席\")\n",
" return bound\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.6 网格幸存集粒子滤波(`particles.py`)\n",
"\n",
"保守凸外包无法表达\"角域 ∩ 距离盘 ∩ 全部 no_signal 排除盘\"这类**非凸(挖洞)**可行集。\n",
"网格幸存集可以,并额外买到三件事:\n",
"\n",
"1. **缺席证明**:幸存集为空 ⇒ 可行集为空 ⇒ 该频道无源(严格,不必等覆盖账本跑完);\n",
"2. **跳测规则**:幸存格到测点的最小距离 > r_max ⇒ 该次测量必 `no_signal` 且不产生新排除,\n",
" 直接跳过(省 5 s 检测 + 1 s 换频);\n",
"3. **负观测更新**:`no_signal` 直接删掉 B(s, 1000) 内的整格。\n",
"\n",
"严格性由 **Voronoi 裕量**保证:格距 h 的裕量 m = √2/2·h 是格心到格内任一点的最大距离,\n",
"所有删除判定都内缩 m(\"整格都在可行集外才删\"),因此真源所在格永不被删。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"_ANG_EPS_DEG = 1e-9 # 角向 kill 比较的浮点余量\n",
"_DIST_EPS_M = 1e-9\n",
"_COARSE_FACTOR = 4 # 粗格每边聚合的细格数\n",
"\n",
"try: # numpy 仅作**可选**加速后端;缺失时全部回退纯 Python\n",
" import numpy as _np\n",
"except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支\n",
" _np = None\n",
"if os.environ.get(\"MCM26B_NO_NUMPY\"): # 强制纯 Python:对拍与回退验证用\n",
" _np = None\n",
"\n",
"\n",
"def angular_gap(a_deg: float, b_deg: float) -> float:\n",
" \"\"\"两个方位角(度)的最小夹角。\"\"\"\n",
" return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)\n",
"\n",
"\n",
"def as_sample_array(samples):\n",
" \"\"\"把 [(x,y), ...] 采样转成 (n,2) float64 数组;numpy 缺失时原样返回。\n",
"\n",
" 评分为 O(代表点 × 子采样) 的向量运算,是单局 CPU 的主要开销\n",
" (实测占 72%)。调用方在每次选点前转换一次,评分函数按类型分派,\n",
" 两条实现(numpy / 纯 Python)语义逐位对齐,见 tests/test_particles.py。\n",
" \"\"\"\n",
" if _np is None or isinstance(samples, _np.ndarray) or len(samples) == 0:\n",
" return samples\n",
" return _np.asarray(samples, dtype=float)\n",
"\n",
"\n",
"def _branch_radius_array(members) -> float:\n",
" \"\"\"分支后验半径的数组版:到质心的最大距离。\"\"\"\n",
" centroid = members.mean(axis=0)\n",
" return float(_np.max(_np.hypot(members[:, 0] - centroid[0],\n",
" members[:, 1] - centroid[1])))\n",
"\n",
"\n",
"class ChannelParticles:\n",
" \"\"\"单个频道的网格幸存集。\n",
"\n",
" 网格为覆盖 Ω 外扩一格的轴对齐方格(边长 h),索引 ``i*W + j``;\n",
" ``alive`` 是幸存格索引集合。初始集 = 格心距原点 <= Ω 半径 + 裕量的\n",
" 全部格子,因此真源(|p| <= 1800 [题面])所在格必在初始集内。\n",
" ``version`` 随每次删除自增,调用方可据此缓存派生量(MEC、跳测判定)。\n",
" \"\"\"\n",
"\n",
" def __init__(self, config: ProblemConfig = ProblemConfig(),\n",
" spacing_m: float | None = None):\n",
" field = config.field\n",
" self.config = config\n",
" self.spacing_m = float(spacing_m if spacing_m is not None\n",
" else config.bayes.particle_spacing_m)\n",
" if not 1.0 <= self.spacing_m <= 200.0:\n",
" raise ValueError(\"网格间距须在 [1, 200] 米\")\n",
" self.margin_m = self.spacing_m * math.sqrt(2) / 2 # Voronoi 裕量\n",
" self.r_eff_min_m = field.r_eff_min_m\n",
" self.r_eff_max_m = field.r_eff_max_m\n",
" self.near_radius_m = field.near_radius_m\n",
" self.half_angle_deg = (field.bearing_error_half_deg\n",
" + config.search.bearing_rounding_margin_deg)\n",
" # 覆盖 [-R-m, R+m]² 的格点阵列;格 (i,j) 的中心在\n",
" # (x0 + (i+0.5)h, x0 + (j+0.5)h),中心到格内任一点 <= h·√2/2 = 裕量\n",
" span = field.omega_radius_m + self.margin_m\n",
" self.x0 = -span\n",
" self.width = int(math.ceil(2 * span / self.spacing_m)) + 1\n",
" limit_sq = (span + 1e-9) ** 2\n",
" self.alive: set[int] = set()\n",
" self._coarse: dict[int, int] = {} # 粗格索引 -> 幸存细格数\n",
" coarse_w = self._coarse_width()\n",
" for i in range(self.width):\n",
" x = self.x0 + (i + 0.5) * self.spacing_m\n",
" for j in range(self.width):\n",
" if x * x + (self.x0 + (j + 0.5) * self.spacing_m) ** 2 <= limit_sq:\n",
" index = i * self.width + j\n",
" self.alive.add(index)\n",
" key = (i // _COARSE_FACTOR) * coarse_w + j // _COARSE_FACTOR\n",
" self._coarse[key] = self._coarse.get(key, 0) + 1\n",
" self.version = 0\n",
" self._mec: tuple[Vec2, float] | None = None\n",
"\n",
" # ------------------------------------------------------------ 索引与坐标\n",
" def _coarse_width(self) -> int:\n",
" return (self.width + _COARSE_FACTOR - 1) // _COARSE_FACTOR\n",
"\n",
" def cell_center(self, index: int) -> Vec2:\n",
" i, j = divmod(index, self.width)\n",
" return (self.x0 + (i + 0.5) * self.spacing_m,\n",
" self.x0 + (j + 0.5) * self.spacing_m)\n",
"\n",
" def discard(self, indices: list[int]) -> None:\n",
" \"\"\"公开删除入口:联合信念把\"朝向不可能\"的格一并删除时使用。\"\"\"\n",
" self._discard(indices)\n",
"\n",
" def _discard(self, indices: list[int]) -> None:\n",
" if not indices:\n",
" return\n",
" coarse_w = self._coarse_width()\n",
" for index in indices:\n",
" if index not in self.alive:\n",
" continue\n",
" self.alive.discard(index)\n",
" key = (index // self.width // _COARSE_FACTOR) * coarse_w \\\n",
" + (index % self.width) // _COARSE_FACTOR\n",
" count = self._coarse.get(key, 0) - 1\n",
" if count <= 0:\n",
" self._coarse.pop(key, None)\n",
" else:\n",
" self._coarse[key] = count\n",
" self.version += 1\n",
" self._mec = None\n",
"\n",
" # ------------------------------------------------------------ 观测更新\n",
" def _kill_disk(self, center: Vec2, radius_m: float) -> None:\n",
" \"\"\"删除格心距 center <= radius_m 的全部幸存格(整格判据见模块注释)。\"\"\"\n",
" if not self.alive:\n",
" return\n",
" reach = radius_m + _DIST_EPS_M\n",
" ilo = max(0, int(math.floor((center[0] - reach - self.x0) / self.spacing_m)))\n",
" ihi = min(self.width - 1,\n",
" int(math.ceil((center[0] + reach - self.x0) / self.spacing_m)))\n",
" jlo = max(0, int(math.floor((center[1] - reach - self.x0) / self.spacing_m)))\n",
" jhi = min(self.width - 1,\n",
" int(math.ceil((center[1] + reach - self.x0) / self.spacing_m)))\n",
" limit_sq = reach * reach\n",
" doomed = []\n",
" for i in range(ilo, ihi + 1):\n",
" dx = self.x0 + (i + 0.5) * self.spacing_m - center[0]\n",
" base = i * self.width\n",
" for j in range(jlo, jhi + 1):\n",
" index = base + j\n",
" if index not in self.alive:\n",
" continue\n",
" dy = self.x0 + (j + 0.5) * self.spacing_m - center[1]\n",
" if dx * dx + dy * dy <= limit_sq:\n",
" doomed.append(index)\n",
" self._discard(doomed)\n",
"\n",
" def _kill_outside_disk(self, center: Vec2, radius_m: float) -> None:\n",
" \"\"\"删除格心距 center > radius_m 的全部幸存格(保留盘内)。\"\"\"\n",
" if not self.alive:\n",
" return\n",
" reach = radius_m + _DIST_EPS_M\n",
" doomed = [index for index in self.alive\n",
" if math.dist(self.cell_center(index), center) > reach]\n",
" self._discard(doomed)\n",
"\n",
" def _kill_outside_wedge(self, station: Vec2, reading_deg: float) -> None:\n",
" \"\"\"删除角向偏离读数超出 δ_eff + 角向格裕量的幸存格。\n",
"\n",
" 角向格裕量 = asin(m / max(dist−m, 1)):格内任一点相对格心的方位\n",
" 偏差不超过它(点距测点还可再近 m,故分母再内缩 m)。\n",
" \"\"\"\n",
" if not self.alive:\n",
" return\n",
" sx, sy = float(station[0]), float(station[1])\n",
" doomed = []\n",
" for index in self.alive:\n",
" gx, gy = self.cell_center(index)\n",
" dist = math.hypot(gx - sx, gy - sy)\n",
" if dist <= self.margin_m + 1.0:\n",
" continue # 格贴测点:格内方位可指向任意方向,不可删\n",
" angular_margin = math.degrees(\n",
" math.asin(min(0.9999, self.margin_m / (dist - self.margin_m))))\n",
" gap = angular_gap(math.degrees(math.atan2(gy - sy, gx - sx)), reading_deg)\n",
" if gap > self.half_angle_deg + angular_margin + _ANG_EPS_DEG:\n",
" doomed.append(index)\n",
" self._discard(doomed)\n",
"\n",
" def apply_measure(self, station: Vec2, result: str,\n",
" svd_deg: float | None = None, *,\n",
" directional: bool = False) -> None:\n",
" \"\"\"按读数更新幸存集。真源保留性由 Voronoi 裕量保证(模块注释)。\n",
"\n",
" ``directional=True``(第四问):no_signal **不删空间候选**——定向源\n",
" 的静默还可能来自朝向遮挡,单独删空间候选会误删真源;朝向维度由\n",
" :class:`~mcm26b.belief.joint.JointBelief` 维护。\n",
" \"\"\"\n",
" if result == NO_SIGNAL:\n",
" if directional:\n",
" return\n",
" # no_signal ⟹ dist > r_eff >= 1000:整格落入 B(s, 1000−m) 的删。\n",
" self._kill_disk(station, self.r_eff_min_m - self.margin_m)\n",
" elif result == NEAR:\n",
" self._kill_outside_disk(station, self.near_radius_m + self.margin_m)\n",
" elif result == DIRECTION:\n",
" if svd_deg is None:\n",
" raise ValueError(\"direction 读数必须携带 svd_deg\")\n",
" # direction 隐含 dist <= r_max [题面]\n",
" self._kill_outside_disk(station, self.r_eff_max_m + self.margin_m)\n",
" self._kill_outside_wedge(station, svd_deg)\n",
" else:\n",
" raise ValueError(f\"未知测向结果: {result!r}\")\n",
"\n",
" # ------------------------------------------------------------ 查询\n",
" def __len__(self) -> int:\n",
" return len(self.alive)\n",
"\n",
" def alive_points(self, limit: int | None = None) -> list[Vec2]:\n",
" sorted_alive = sorted(self.alive)\n",
" if limit is not None and len(sorted_alive) > limit:\n",
" step = len(sorted_alive) / limit\n",
" indices = {sorted_alive[int(k * step) % len(sorted_alive)]\n",
" for k in range(limit)}\n",
" indices = sorted(indices)\n",
" else:\n",
" indices = sorted_alive\n",
" return [self.cell_center(index) for index in indices]\n",
"\n",
" def nearest_alive_distance(self, point: Vec2, limit_m: float) -> float | None:\n",
" \"\"\"最近幸存格心距离;> limit_m 时返回 None(粗格环扫 + 下界剪枝)。\n",
"\n",
" 返回值语义:恰为最近幸存格心距离(若 <= limit_m);None 表示所有\n",
" 幸存格心都超出 limit_m。粗格聚合半径已计入扫描范围,不漏判。\n",
" \"\"\"\n",
" if not self.alive:\n",
" return None\n",
" coarse_cell_m = self.spacing_m * _COARSE_FACTOR\n",
" coarse_reach = coarse_cell_m * math.sqrt(2) / 2\n",
" cw = self._coarse_width()\n",
" ci_base = int(math.floor((point[0] - self.x0) / coarse_cell_m))\n",
" cj_base = int(math.floor((point[1] - self.x0) / coarse_cell_m))\n",
" ring_max = int(math.ceil((limit_m + coarse_reach) / coarse_cell_m)) + 1\n",
" best: float | None = None\n",
" for ring in range(ring_max + 1):\n",
" lower_bound = max(0.0, ring - 1) * coarse_cell_m - coarse_reach\n",
" if best is not None and lower_bound >= best:\n",
" break # 更外环的所有格心都 >= 下界 >= 当前最优\n",
" for ci in range(max(0, ci_base - ring), min(cw - 1, ci_base + ring) + 1):\n",
" for cj in range(max(0, cj_base - ring), min(cw - 1, cj_base + ring) + 1):\n",
" if max(abs(ci - ci_base), abs(cj - cj_base)) != ring:\n",
" continue # 只扫当前环(Chebyshev 环)\n",
" if (ci * cw + cj) not in self._coarse:\n",
" continue\n",
" ccx = self.x0 + (ci + 0.5) * coarse_cell_m\n",
" ccy = self.x0 + (cj + 0.5) * coarse_cell_m\n",
" if math.hypot(ccx - point[0], ccy - point[1]) \\\n",
" > limit_m + coarse_reach:\n",
" continue\n",
" i0, j0 = ci * _COARSE_FACTOR, cj * _COARSE_FACTOR\n",
" for i in range(i0, min(i0 + _COARSE_FACTOR, self.width)):\n",
" base = i * self.width\n",
" for j in range(j0, min(j0 + _COARSE_FACTOR, self.width)):\n",
" index = base + j\n",
" if index not in self.alive:\n",
" continue\n",
" dist = math.dist(self.cell_center(index), point)\n",
" if dist <= limit_m and (best is None or dist < best):\n",
" best = dist\n",
" return best\n",
"\n",
" def guaranteed_no_signal(self, station: Vec2) -> bool:\n",
" \"\"\"真源与该测点距离必 > r_max(>= r_eff)⟹ 必 no_signal,测了也无信息。\"\"\"\n",
" return self.nearest_alive_distance(\n",
" station, self.r_eff_max_m + self.margin_m) is None\n",
"\n",
" def mec(self) -> tuple[Vec2, float]:\n",
" \"\"\"幸存集最小包围圆(圆心, 半径);按 version 缓存。\"\"\"\n",
" if self._mec is None:\n",
" if not self.alive:\n",
" raise ValueError(\"幸存集为空(该频道已可证明缺席)\")\n",
" points = [self.cell_center(index) for index in self.alive]\n",
" self._mec = smallest_enclosing_circle(points)\n",
" return self._mec\n",
"\n",
" # ------------------------------------------------------------ 信息增益\n",
" def subsample(self, size: int) -> list[Vec2]:\n",
" \"\"\"按索引序均匀抽取的子采样(索引序≈空间序,代表点空间铺开)。\"\"\"\n",
" return self.alive_points(limit=size)\n",
"\n",
" # ---------------------------------------------------- 向量化评分(可选)\n",
" def _expected_survival_vector(self, point: Vec2, sub, representatives) -> float:\n",
" \"\"\"``expected_survival`` 的向量化实现(与纯 Python 版逐分支同语义)。\"\"\"\n",
" count = len(sub)\n",
" eps = 1.0 / count\n",
" dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]\n",
" dist = _np.hypot(dx, dy)\n",
" within = int(_np.count_nonzero(\n",
" dist <= self.r_eff_min_m - self.margin_m + _DIST_EPS_M))\n",
" log_keep_ns = math.log(max(1.0 - within / count, eps))\n",
" bearing = _np.degrees(_np.arctan2(dy, dx))\n",
" in_range = dist <= self.r_eff_max_m + self.margin_m\n",
" rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]\n",
" rdist = _np.hypot(rdx, rdy)\n",
" rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n",
" gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n",
" survivors = _np.count_nonzero(\n",
" (gaps <= self.half_angle_deg + _ANG_EPS_DEG) & in_range[None, :], axis=1)\n",
" p_ns = _np.clip((rdist - self.r_eff_min_m)\n",
" / (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)\n",
" keep_dir = _np.maximum(survivors / count, eps)\n",
" values = p_ns * log_keep_ns + (1.0 - p_ns) * _np.log(keep_dir)\n",
" near = rdist <= self.near_radius_m + self.margin_m\n",
" values = _np.where(near, math.log(eps), values)\n",
" return float(values.mean())\n",
"\n",
" def _expected_posterior_radius_vector(self, point: Vec2, sub,\n",
" representatives) -> float:\n",
" \"\"\"``expected_posterior_radius`` 的向量化实现。\"\"\"\n",
" eps_radius = self.spacing_m / 2.0\n",
" dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]\n",
" dist = _np.hypot(dx, dy)\n",
" bearing = _np.degrees(_np.arctan2(dy, dx))\n",
" keep_ns = dist > self.r_eff_min_m - self.margin_m\n",
" r_ns = (_branch_radius_array(sub[keep_ns]) if keep_ns.any()\n",
" else eps_radius)\n",
" rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]\n",
" rdist = _np.hypot(rdx, rdy)\n",
" rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n",
" gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n",
" dir_mask = ((gaps <= self.half_angle_deg + _ANG_EPS_DEG)\n",
" & (dist[None, :] <= self.r_eff_max_m + self.margin_m))\n",
" r_dir = _np.array([_branch_radius_array(sub[row]) if row.any()\n",
" else eps_radius for row in dir_mask])\n",
" p_ns = _np.clip((rdist - self.r_eff_min_m)\n",
" / (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)\n",
" near = rdist <= self.near_radius_m + self.margin_m\n",
" values = _np.where(near, self.near_radius_m + self.margin_m,\n",
" p_ns * r_ns + (1.0 - p_ns) * r_dir)\n",
" return float(values.mean())\n",
"\n",
" def expected_survival(self, point: Vec2, subsample,\n",
" representatives) -> float:\n",
" \"\"\"E[log 幸存占比]:越小该测点的期望信息增益越大。\n",
"\n",
" r_eff 不确定性解析积分:dist∈(1000,1500] 时按 r_eff~U(1000,1500)\n",
" 取 p_ns=(dist−1000)/500 为 no_signal 分支概率。\n",
" - no_signal 分支:删 B(point, 1000−m),幸存 = 子采样中盘外比例;\n",
" - direction 分支:以代表点方位为期望读数,角域 ∩ B(point,1500+m) 内幸存;\n",
" - near 分支(dist <= 5+m):可行集收缩到 5m 盘,幸存占比趋近 0。\n",
" \"\"\"\n",
" if not self.alive or len(subsample) == 0 or len(representatives) == 0:\n",
" return 0.0\n",
" if _np is not None and isinstance(subsample, _np.ndarray) \\\n",
" and isinstance(representatives, _np.ndarray):\n",
" return self._expected_survival_vector(point, subsample, representatives)\n",
" eps = 1.0 / len(subsample)\n",
" keep_ns = max(1.0 - self.fraction_within(subsample, point,\n",
" self.r_eff_min_m - self.margin_m),\n",
" eps)\n",
" log_keep_ns = math.log(keep_ns)\n",
" total = 0.0\n",
" for rep in representatives:\n",
" dist = math.hypot(rep[0] - point[0], rep[1] - point[1])\n",
" if dist <= self.near_radius_m + self.margin_m:\n",
" total += math.log(eps)\n",
" continue\n",
" p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)\n",
" / (self.r_eff_max_m - self.r_eff_min_m)))\n",
" reading = math.atan2(rep[1] - point[1], rep[0] - point[0])\n",
" survivors = 0\n",
" for p in subsample:\n",
" if math.dist(p, point) > self.r_eff_max_m + self.margin_m:\n",
" continue\n",
" gap = angular_gap(math.degrees(\n",
" math.atan2(p[1] - point[1], p[0] - point[0])),\n",
" math.degrees(reading))\n",
" if gap <= self.half_angle_deg + _ANG_EPS_DEG:\n",
" survivors += 1\n",
" keep_dir = max(survivors / len(subsample), eps)\n",
" total += p_ns * log_keep_ns + (1.0 - p_ns) * math.log(keep_dir)\n",
" return total / max(len(representatives), 1)\n",
"\n",
" def fraction_within(self, samples, point: Vec2, radius_m: float) -> float:\n",
" if len(samples) == 0:\n",
" return 0.0\n",
" reach = radius_m + _DIST_EPS_M\n",
" if _np is not None and isinstance(samples, _np.ndarray):\n",
" distance = _np.hypot(samples[:, 0] - point[0], samples[:, 1] - point[1])\n",
" return float(_np.count_nonzero(distance <= reach)) / len(samples)\n",
" count = sum(1 for p in samples if math.dist(p, point) <= reach)\n",
" return count / len(samples)\n",
"\n",
" def _branch_radius(self, members) -> float:\n",
" \"\"\"分支后验的半径代理:幸存子采样到其质心的最大距离(O(n),免 Welzl)。\"\"\"\n",
" if len(members) == 0:\n",
" return 0.0\n",
" if _np is not None and isinstance(members, _np.ndarray):\n",
" return _branch_radius_array(members)\n",
" mx = sum(p[0] for p in members) / len(members)\n",
" my = sum(p[1] for p in members) / len(members)\n",
" return max(math.hypot(p[0] - mx, p[1] - my) for p in members)\n",
"\n",
" def expected_posterior_radius(self, point: Vec2, subsample,\n",
" representatives) -> float:\n",
" \"\"\"一次测量后期望后验半径(米):选点主指标,直指清除证书阈值。\n",
"\n",
" 对每个代表假设(真值候选)按 r_eff~U(1000,1500) 解析积分\n",
" no_signal/direction 分支,near 分支(dist <= 5+m)后验为 5m 盘;\n",
" 各分支半径用幸存子采样的质心最大距离近似(偏差 << 格距,排序用)。\n",
" 与几何版 worst-posterior 同族,但作用于含负观测的非凸幸存集,\n",
" 且以期望代替最坏(代表点覆盖幸存集主轴)。\n",
" \"\"\"\n",
" if not self.alive or len(subsample) == 0 or len(representatives) == 0:\n",
" return 0.0\n",
" if _np is not None and isinstance(subsample, _np.ndarray) \\\n",
" and isinstance(representatives, _np.ndarray):\n",
" return self._expected_posterior_radius_vector(\n",
" point, subsample, representatives)\n",
" eps_radius = self.spacing_m / 2.0\n",
" total = 0.0\n",
" for rep in representatives:\n",
" dist = math.hypot(rep[0] - point[0], rep[1] - point[1])\n",
" if dist <= self.near_radius_m + self.margin_m:\n",
" total += self.near_radius_m + self.margin_m\n",
" continue\n",
" p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)\n",
" / (self.r_eff_max_m - self.r_eff_min_m)))\n",
" members_ns = [p for p in subsample\n",
" if math.dist(p, point) > self.r_eff_min_m - self.margin_m]\n",
" reading = math.atan2(rep[1] - point[1], rep[0] - point[0])\n",
" members_dir = []\n",
" for p in subsample:\n",
" if math.dist(p, point) > self.r_eff_max_m + self.margin_m:\n",
" continue\n",
" gap = angular_gap(math.degrees(\n",
" math.atan2(p[1] - point[1], p[0] - point[0])),\n",
" math.degrees(reading))\n",
" if gap <= self.half_angle_deg + _ANG_EPS_DEG:\n",
" members_dir.append(p)\n",
" r_ns = self._branch_radius(members_ns) if members_ns else eps_radius\n",
" r_dir = self._branch_radius(members_dir) if members_dir else eps_radius\n",
" total += p_ns * r_ns + (1.0 - p_ns) * r_dir\n",
" return total / max(len(representatives), 1)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.7 (位置 × 朝向) 联合可行集(`belief/joint.py`)\n",
"\n",
"第四问的失败全部来自定向源:策略知道\"源可能在哪个空间区域\",却不知道\"它朝着哪边\"。\n",
"本模块给每个幸存格附一个 24 档(15°)朝向可行性掩码,把\"看不看得见\"变成可推理的量。\n",
"更新规则全部取保守侧并计入**格内角裕量** `asin(margin / max(dist − margin, 1))`\n",
"(格内任一点相对格心方位偏差的上界):\n",
"\n",
"| 观测 | 位置 | 朝向 |\n",
"| --- | --- | --- |\n",
"| `direction` @ s | 距离 ≤ 1500、角向 \\|gap\\| ≤ δ_eff + 格裕量 | 必须**可能**覆盖 s |\n",
"| `near` @ s | 距离 ≤ 5 | 必须**可能**覆盖 s(near 同样要求可见) |\n",
"| `no_signal` @ s | **不删**(可能是遮挡,也可能是距离不足) | 仅在整格必落在 1000 m 内时,删去\"必然看得见\"的档 |\n",
"\n",
"由此得到二维真源保留定理:真源 (位置, 朝向) 永不被删。`prob_detect` 用可见性概率选点,\n",
"取代固定的 45° 阶梯试探;联合集为空同样就是缺席证明。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"HEADING_BINS = 24 # 15° 一档\n",
"_BIN_HALF_DEG = 180.0 / HEADING_BINS # 7.5°\n",
"_FULL_MASK = (1 << HEADING_BINS) - 1\n",
"_GRID_DEG = 3.0 # 角度查表分辨率\n",
"_GRID_N = int(round(360.0 / _GRID_DEG))\n",
"\n",
"# numpy 仅作可选加速后端(与 particles 同一开关:无 numpy 时走纯 Python 分支)\n",
"_np = _np\n",
"\n",
"\n",
"def heading_gap(a_deg: float, b_deg: float) -> float:\n",
" \"\"\"两个朝向角的最小夹角(度)。\"\"\"\n",
" return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)\n",
"\n",
"\n",
"def _cover_mask(bearing_deg: float, half_deg: float) -> int:\n",
" \"\"\"与该方位夹角不超过 ``half_deg`` 的朝向档掩码。\"\"\"\n",
" mask = 0\n",
" for k in range(HEADING_BINS):\n",
" if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) <= half_deg:\n",
" mask |= 1 << k\n",
" return mask\n",
"\n",
"\n",
"# 查表:以 bearing 为轴、半角 90° 的可见半平面。\n",
"# - COVER:**可能**覆盖该方位(含半档与查表舍入的保守侧);\n",
"# - SILENT:**确定**覆盖该方位(内缩半档与舍入)——离散对照表,供审计与\n",
"# 文档比对;实际裁剪走 _cover_mask / _far_mask,因为还必须逐格计入\n",
"# \"格内角裕量\"(格心到格内任一点的方位偏差上界),无法预先制表。\n",
"_COVER_TABLE = tuple(\n",
" _cover_mask(_GRID_DEG * i, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2)\n",
" for i in range(_GRID_N))\n",
"_SILENT_TABLE = tuple(\n",
" _cover_mask(_GRID_DEG * i, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2)\n",
" for i in range(_GRID_N))\n",
"\n",
"\n",
"def _far_mask(bearing_deg: float, exclude_half_deg: float) -> int:\n",
" \"\"\"与方位夹角**大于** ``exclude_half_deg`` 的朝向档掩码(no_signal 用)。\n",
"\n",
" 与 :func:`_cover_mask` 互补:no_signal 只允许保留\"确定看不见\"的朝向档。\n",
" \"\"\"\n",
" mask = 0\n",
" for k in range(HEADING_BINS):\n",
" if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) > exclude_half_deg:\n",
" mask |= 1 << k\n",
" return mask\n",
"\n",
"if _np is not None: # 12 位半字 popcount 查表(朝向掩码 24 位)与半平面查表数组\n",
" _POP16 = _np.array([bin(value).count(\"1\") for value in range(1 << 12)],\n",
" dtype=_np.int64)\n",
" _COVER_ARR = _np.array(_COVER_TABLE, dtype=_np.int64)\n",
"else: # pragma: no cover - 无 numpy 分支\n",
" _POP16 = _COVER_ARR = None\n",
"\n",
"\n",
"def _popcount(mask: int) -> int:\n",
" return bin(mask).count(\"1\")\n",
"\n",
"\n",
"def _table_index(bearing_deg: float) -> int:\n",
" return int(round((bearing_deg % 360.0) / _GRID_DEG)) % _GRID_N\n",
"\n",
"\n",
"class JointBelief:\n",
" \"\"\"单频道 (位置 × 朝向) 联合可行集。\"\"\"\n",
"\n",
" def __init__(self, config: ProblemConfig = ProblemConfig()):\n",
" self.config = config\n",
" self.field = config.field\n",
" self.position = ChannelParticles(config)\n",
" self.masks: dict[int, int] = {} # 格索引 -> 朝向掩码(缺省为全档)\n",
" self._sample_cache: tuple[int, list[int]] | None = None\n",
"\n",
" # ------------------------------------------------------------ 查询\n",
" def __len__(self) -> int:\n",
" return len(self.position)\n",
"\n",
" def is_absent(self) -> bool:\n",
" \"\"\"联合集空 ⟹ 该频道不存在源(真源保留定理的二维推广)。\"\"\"\n",
" return not self.position.alive\n",
"\n",
" def _mask(self, index: int) -> int:\n",
" return self.masks.get(index, _FULL_MASK)\n",
"\n",
" def sample(self, size: int = 256) -> list[int]:\n",
" \"\"\"均匀抽样幸存格(评分用;证书始终用完整集合)。\"\"\"\n",
" cached = self._sample_cache\n",
" if cached is not None and cached[0] == self.position.version:\n",
" return cached[1]\n",
" alive = sorted(self.position.alive)\n",
" if len(alive) > size:\n",
" step = len(alive) / size\n",
" alive = [alive[int(k * step) % len(alive)] for k in range(size)]\n",
" self._sample_cache = (self.position.version, alive)\n",
" return alive\n",
"\n",
" def _mask_popcount(self, index: int) -> int:\n",
" return _popcount(self._mask(index))\n",
"\n",
" def expected_posterior_radius(self, reader: Vec2, size: int | None = None) -> float:\n",
" \"\"\"一次测量后的期望后验 MEC 半径(米);D1 值函数。\n",
"\n",
" 与 :meth:`ChannelParticles.expected_posterior_radius` 同族,差别只在\n",
" no_signal 分支概率:定向源不仅会因距离 > r_eff 静默,还会因朝向遮挡\n",
" 静默,因此\n",
"\n",
" p_vis(p) = P(r_eff ≥ |p−q|) × cover_ratio(p, q)\n",
"\n",
" 其中 ``cover_ratio`` 是该格朝向掩码中\"可能覆盖 q 方位\"的档位占比。\n",
" 分支半径语义:\n",
"\n",
" - direction(可见,非 near):读数为 q 处的方位 ±δ,后验收缩到该方位\n",
" 楔形与当前可行集之交,半径取幸存子采样的质心最大距离;\n",
" - no_signal:定向源的静默**不删空间候选**,位置后验半径保持当前值;\n",
" - near:后验为 5m 盘。\n",
"\n",
" 返回值只用于**选点排序**,不参与任何安全判定;证书仍由凸/网格口径给出。\n",
" \"\"\"\n",
" pf = self.position\n",
" if not pf.alive:\n",
" return 0.0\n",
" if size is None:\n",
" size = self.config.bayes.score_subsample\n",
" indices = self.sample(max(2, size))\n",
" if not indices:\n",
" return 0.0\n",
" stride = max(len(indices) // max(self.config.bayes.mc_representatives, 1), 1)\n",
" representatives = indices[::stride]\n",
" if _np is not None:\n",
" return self._epr_vector(reader, indices, representatives)\n",
" return self._epr_python(reader, indices, representatives) # pragma: no cover\n",
"\n",
" def _epr_vector(self, reader: Vec2, indices: list[int],\n",
" representatives: list[int]) -> float:\n",
" pf = self.position\n",
" points = _np.array([pf.cell_center(index) for index in indices], dtype=float)\n",
" masks = _np.array([self._mask(index) for index in indices], dtype=_np.int64)\n",
" reps = _np.array([pf.cell_center(index) for index in representatives],\n",
" dtype=float)\n",
" rep_masks = _np.array([self._mask(index) for index in representatives],\n",
" dtype=_np.int64)\n",
" dx, dy = points[:, 0] - reader[0], points[:, 1] - reader[1]\n",
" dist = _np.hypot(dx, dy)\n",
" bearing = _np.degrees(_np.arctan2(dy, dx))\n",
" rdx, rdy = reps[:, 0] - reader[0], reps[:, 1] - reader[1]\n",
" rdist = _np.hypot(rdx, rdy)\n",
" rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n",
" cover = _COVER_ARR[_np.round(_np.mod(rbearing, 360.0) / _GRID_DEG)\n",
" .astype(_np.int64) % _GRID_N]\n",
" visible_mask = rep_masks & cover\n",
" bits = (_POP16[visible_mask & 0xFFF]\n",
" + _POP16[(visible_mask >> 12) & 0xFFF])\n",
" cover_ratio = bits / float(HEADING_BINS)\n",
" p_silent_range = _np.clip((rdist - self.field.r_eff_min_m)\n",
" / (self.field.r_eff_max_m\n",
" - self.field.r_eff_min_m), 0.0, 1.0)\n",
" p_vis = (1.0 - p_silent_range) * cover_ratio\n",
" p_ns = 1.0 - p_vis\n",
" gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n",
" in_wedge = ((gaps <= self.config.field.bearing_error_half_deg\n",
" + self.config.search.bearing_rounding_margin_deg + 1e-9)\n",
" & (dist[None, :] <= self.field.r_eff_max_m + pf.margin_m))\n",
" eps_radius = pf.spacing_m / 2.0\n",
" radii = []\n",
" for row in in_wedge:\n",
" member = points[row]\n",
" radii.append(_branch_radius_array(member)\n",
" if len(member) else eps_radius)\n",
" radii = _np.array(radii, dtype=float)\n",
" current = pf.mec()[1]\n",
" near = rdist <= self.field.near_radius_m + pf.margin_m\n",
" values = _np.where(near, self.field.near_radius_m + pf.margin_m,\n",
" p_ns * current + p_vis * radii)\n",
" return float(values.mean())\n",
"\n",
" def _epr_python(self, reader: Vec2, indices: list[int],\n",
" representatives: list[int]) -> float: # pragma: no cover\n",
" \"\"\"纯 Python 回退(与向量版逐分支同语义,供无 numpy 环境与对拍)。\"\"\"\n",
" pf = self.position\n",
" points = [pf.cell_center(index) for index in indices]\n",
" masks = [self._mask(index) for index in indices]\n",
" eps_radius = pf.spacing_m / 2.0\n",
" current = pf.mec()[1]\n",
" half_angle = (self.config.field.bearing_error_half_deg\n",
" + self.config.search.bearing_rounding_margin_deg)\n",
" total = 0.0\n",
" for rep_index in representatives:\n",
" rx, ry = pf.cell_center(rep_index)\n",
" distance = math.hypot(rx - reader[0], ry - reader[1])\n",
" if distance <= self.field.near_radius_m + pf.margin_m:\n",
" total += self.field.near_radius_m + pf.margin_m\n",
" continue\n",
" reading = math.degrees(math.atan2(ry - reader[1], rx - reader[0]))\n",
" cover = _COVER_TABLE[_table_index(reading)]\n",
" bits = _popcount(self._mask(rep_index) & cover)\n",
" ratio = bits / float(HEADING_BINS)\n",
" if distance <= self.field.r_eff_min_m:\n",
" p_range = 1.0\n",
" elif distance >= self.field.r_eff_max_m:\n",
" p_range = 0.0\n",
" else:\n",
" p_range = ((self.field.r_eff_max_m - distance)\n",
" / (self.field.r_eff_max_m - self.field.r_eff_min_m))\n",
" p_vis = p_range * ratio\n",
" members = []\n",
" for point, mask in zip(points, masks):\n",
" if math.dist(point, reader) > self.field.r_eff_max_m + pf.margin_m:\n",
" continue\n",
" bearing = math.degrees(math.atan2(point[1] - reader[1],\n",
" point[0] - reader[0]))\n",
" if heading_gap(bearing, reading) <= half_angle + 1e-9:\n",
" members.append(point)\n",
" if members:\n",
" mx = sum(p[0] for p in members) / len(members)\n",
" my = sum(p[1] for p in members) / len(members)\n",
" radius = max(math.hypot(p[0] - mx, p[1] - my) for p in members)\n",
" else:\n",
" radius = eps_radius\n",
" total += (1.0 - p_vis) * current + p_vis * radius\n",
" return total / max(len(representatives), 1)\n",
"\n",
"\n",
" def prob_detect(self, reader: Vec2, size: int = 256) -> float:\n",
" \"\"\"在 ``reader`` 处能看见任一可行 (位置, 朝向) 的概率质量(启发式)。\n",
"\n",
" r_eff ~ U[1000, 1500] 解析积分;朝向按可行档位占比计权。\n",
" 只用于选点排序,不参与任何安全判定。\n",
" \"\"\"\n",
" cells = self.sample(size)\n",
" if not cells:\n",
" return 0.0\n",
" total = 0.0\n",
" span = self.field.r_eff_max_m - self.field.r_eff_min_m\n",
" for index in cells:\n",
" gx, gy = self.position.cell_center(index)\n",
" distance = math.hypot(reader[0] - gx, reader[1] - gy)\n",
" if distance > self.field.r_eff_max_m:\n",
" continue\n",
" if distance <= self.field.r_eff_min_m:\n",
" p_range = 1.0\n",
" else:\n",
" p_range = (self.field.r_eff_max_m - distance) / span\n",
" if p_range <= 0.0:\n",
" continue\n",
" bearing = math.degrees(math.atan2(reader[1] - gy, reader[0] - gx))\n",
" cover = _COVER_TABLE[_table_index(bearing)]\n",
" bits = bin(self._mask(index) & cover).count(\"1\")\n",
" if bits:\n",
" total += p_range * bits / HEADING_BINS\n",
" return total / len(cells)\n",
"\n",
" # ------------------------------------------------------------ 观测更新\n",
" def _apply_heading(self, station: Vec2, keep: bool,\n",
" within_m: float | None = None) -> None:\n",
" \"\"\"按可见性约束裁剪朝向档(保守:只删\"必然不可能\"的档)。\n",
"\n",
" ``keep=True``(direction / near)⟹ 朝向必须**可能**覆盖 station:\n",
" 保留与方位夹角 ≤ 90° + 半档 + 查表舍入 + 格内角裕量的档。\n",
"\n",
" ``keep=False``(no_signal 且整格必在 r_eff_min 内)⟹ 朝向必须\n",
" **确定不覆盖** station:只保留与方位夹角 > 90° − 半档 − 查表舍入\n",
" − 格内角裕量的档。\n",
"\n",
" ``格内角裕量 = asin(margin / max(dist − margin, 1))``:格内任一点\n",
" 相对格心的方位偏差上界。两个方向都必须计入它,否则会误删真源的\n",
" 朝向档(keep=True 漏计)或保留必然可见的档(keep=False 漏计)。\n",
" \"\"\"\n",
" margin_m = self.position.margin_m\n",
" doomed: list[int] = []\n",
" for index in self.position.alive:\n",
" gx, gy = self.position.cell_center(index)\n",
" dx, dy = station[0] - gx, station[1] - gy\n",
" distance = math.hypot(dx, dy)\n",
" if within_m is not None and distance > within_m:\n",
" continue\n",
" bearing = math.degrees(math.atan2(dy, dx))\n",
" if distance <= margin_m + 1.0:\n",
" # 格贴测点:格内方位可指向任意方向,任何一侧都不可据此裁剪\n",
" continue\n",
" angular_margin = math.degrees(\n",
" math.asin(min(0.9999, margin_m / (distance - margin_m))))\n",
" if keep:\n",
" updated = self._mask(index) & _cover_mask(\n",
" bearing, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2 + angular_margin)\n",
" else:\n",
" updated = self._mask(index) & _far_mask(\n",
" bearing, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2 - angular_margin)\n",
" if updated == 0:\n",
" doomed.append(index)\n",
" self.masks.pop(index, None)\n",
" elif updated != _FULL_MASK:\n",
" self.masks[index] = updated\n",
" if doomed:\n",
" self.position.discard(doomed)\n",
"\n",
" def apply(self, station: Vec2, result: str,\n",
" svd_deg: float | None = None) -> None:\n",
" \"\"\"按读数更新联合可行集(保守,真源 (位置, 朝向) 永不被删)。\"\"\"\n",
" if result == DIRECTION:\n",
" self.position.apply_measure(station, DIRECTION, svd_deg,\n",
" directional=True)\n",
" self._apply_heading(station, keep=True)\n",
" elif result == NEAR:\n",
" # near 同样要求可见(评测机先判朝向再判距离)\n",
" self.position.apply_measure(station, NEAR, directional=True)\n",
" self._apply_heading(station, keep=True)\n",
" elif result == NO_SIGNAL:\n",
" # 距离阈值必须取**保守侧**:只有\"整格都必然落在 r_eff_min 圆盘内\"\n",
" # 时,no_signal 才一定由朝向遮挡解释。格心到格内任一点 ≤ margin,\n",
" # 故阈值取 r_eff_min − margin;取 + margin 会把 1000~1010m 的模糊带\n",
" # 也当成\"必须不覆盖\",从而可能删掉真源的朝向档(破坏真源保留性)。\n",
" self._apply_heading(station, keep=False,\n",
" within_m=max(\n",
" 0.0, self.field.r_eff_min_m\n",
" - self.position.margin_m))\n",
" else:\n",
" raise ValueError(f\"未知测向结果: {result!r}\")\n",
"\n",
" # ------------------------------------------------------------ 诊断\n",
" def summary(self) -> dict:\n",
" \"\"\"联合集规模快照(写入策略 summary,供审计与论文复算)。\"\"\"\n",
" cells = self.sample(256)\n",
" bins = [bin(self._mask(index)).count(\"1\") for index in cells]\n",
" return {\"cells\": len(self.position),\n",
" \"sampled_mean_heading_bins\": (round(sum(bins) / len(bins), 2)\n",
" if bins else 0.0),\n",
" \"heading_bins_total\": HEADING_BINS}\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. 调度层:开放路径排序(`routing.py`)\n",
"\n",
"本题的真实图问题是**开放路径**:起点固定在机器人当前位置、终点自由,\n",
"代价 = Σ 移动时间 + Σ 动作耗时。后备清除的走格顺序用最近邻打底 + 2-opt 改进\n",
"(O(1) 增量代价评估,终点自由故只反转内部片段、不做首尾相接)。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import math\n",
"\n",
"Vec2 = tuple[float, float]\n",
"\n",
"\n",
"def path_length(order: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:\n",
" \"\"\"开放路径长度(起点 start,终点自由)。\"\"\"\n",
" total = 0.0\n",
" position = start\n",
" for point in order:\n",
" total += math.dist(position, point)\n",
" position = point\n",
" return total\n",
"\n",
"\n",
"def nearest_neighbor_order(points: list[Vec2],\n",
" start: Vec2 = (0.0, 0.0)) -> list[Vec2]:\n",
" \"\"\"最近邻开放路径(O(n²),启发式上界)。\"\"\"\n",
" remaining = list(points)\n",
" order: list[Vec2] = []\n",
" position = start\n",
" while remaining:\n",
" nxt = min(remaining, key=lambda q: math.dist(position, q))\n",
" remaining.remove(nxt)\n",
" order.append(nxt)\n",
" position = nxt\n",
" return order\n",
"\n",
"\n",
"def two_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),\n",
" max_passes: int = 8) -> list[Vec2]:\n",
" \"\"\"开放路径 2-opt(终点自由:只反转内部片段,不做首尾相接)。\n",
"\n",
" 增量代价 O(1):反转 [i..j] 只影响 (prev, i) 与 (j, next) 两条边。\n",
" \"\"\"\n",
" best = list(order)\n",
" n = len(best)\n",
" if n < 3:\n",
" return best\n",
" for _ in range(max_passes):\n",
" improved = False\n",
" for i in range(n - 1):\n",
" prev = best[i - 1] if i > 0 else start\n",
" pi = best[i]\n",
" d_prev_i = math.dist(prev, pi)\n",
" for j in range(i + 1, n):\n",
" pj = best[j]\n",
" nxt = best[j + 1] if j + 1 < n else None\n",
" d_prev_j = math.dist(prev, pj)\n",
" removed = d_prev_i\n",
" added = d_prev_j\n",
" if nxt is not None:\n",
" removed += math.dist(pj, nxt)\n",
" added += math.dist(pi, nxt)\n",
" if added < removed - 1e-9:\n",
" best[i:j + 1] = best[i:j + 1][::-1]\n",
" improved = True\n",
" pi = best[i]\n",
" d_prev_i = math.dist(prev, pi)\n",
" if not improved:\n",
" break\n",
" return best\n",
"\n",
"\n",
"def improve_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),\n",
" max_passes: int = 8) -> list[Vec2]:\n",
" \"\"\"最近邻打底 + 2-opt 改进的开放路径(后备清除走格顺序用)。\"\"\"\n",
" order = nearest_neighbor_order(points, start)\n",
" return two_opt_open(order, start, max_passes)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 第二部分 · 策略层(`planner/`)\n",
"\n",
"## 6. 问题一/二/三共用的选点器与全向闭环\n",
"\n",
"- **问题一**在几何内核里直接求解(第三部分给出算例);\n",
"- **问题二**用 `select_second_point`:在保证接收盘内环形采样,以\"最坏后验包围圆半径\"\n",
" 为主指标、移动代价为次指标;\n",
"- **问题三**用 `OmnidirectionalLoop` 骨架:覆盖 → 定位 → 清除 → 收尾,\n",
" 子类只替换覆盖计划与调度细节。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def bearing_deg(a: Vec2, b: Vec2) -> float:\n",
" \"\"\"从 a 指向 b 的方位角(0-360,0 为 x 正方向,逆时针)。\"\"\"\n",
" return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0\n",
"\n",
"\n",
"def _error_brief(exc: BaseException) -> dict:\n",
" \"\"\"异常摘要(summary 留档用):类型名 + 截断消息。\"\"\"\n",
" return {\"type\": type(exc).__name__, \"message\": str(exc)[:500]}\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class SecondPointPlan:\n",
" \"\"\"下一检测点的候选与依据(P3 输出)。\"\"\"\n",
"\n",
" position: Vec2\n",
" worst_posterior_radius_m: float\n",
" move_cost_s: float\n",
" reason: str\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class ClearCertificate:\n",
" \"\"\"清除证书:候选集外包半径(含近似误差)不超过 20m 阈值。\"\"\"\n",
"\n",
" channel: int\n",
" center: Vec2\n",
" radius_m: float\n",
" limit_m: float\n",
" certified: bool\n",
"\n",
"\n",
"def clear_certificate(region: CandidateRegion, channel: int,\n",
" config: ProblemConfig = ProblemConfig()) -> ClearCertificate:\n",
" \"\"\"ρ_poly <= 20 - 外包近似误差 - 工程余量 才允许发起清除。\"\"\"\n",
" center, radius = region.mec()\n",
" limit = (config.field.clear_radius_m - region.slack_m\n",
" - config.search.clear_margin_m)\n",
" return ClearCertificate(channel, center, radius, limit, radius <= limit)\n",
"\n",
"\n",
"def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) -> list[Vec2]:\n",
" \"\"\"保证接收盘内的候选点:盘心 + 各比例半径 × 均匀方向。\"\"\"\n",
" points = [tuple(center)]\n",
" for fraction in rings:\n",
" radius = fraction * safe_radius_m\n",
" if radius <= 1e-9:\n",
" continue\n",
" step = 2 * math.pi / angles\n",
" for k in range(angles):\n",
" angle = step * k + step / 2\n",
" points.append((center[0] + radius * math.cos(angle),\n",
" center[1] + radius * math.sin(angle)))\n",
" return points\n",
"\n",
"\n",
"def _worst_posterior_radius(region: CandidateRegion, q: Vec2,\n",
" witnesses: list[Vec2], half_angle_deg: float,\n",
" near_radius_m: float, disk_sides: int) -> float:\n",
" \"\"\"对候选点 q,遍历\"目标边界点 × 读数分支\"的最坏后验包围半径。\n",
"\n",
" 读数分支:direction 的区间端点 φ(g)±δ(端点是每个 g 的最不利读数),\n",
" 以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)∩候选集。\n",
" 保证接收盘内 no_signal 不可能发生,故不枚举该分支。\n",
" 这是启发式近似,不构成最优性证明 [工程]。\n",
" \"\"\"\n",
" posterior_radii = []\n",
" for g in witnesses:\n",
" phi = bearing_deg(q, g)\n",
" for reading in (phi - half_angle_deg, phi + half_angle_deg):\n",
" probe = CandidateRegion(list(region.polygon), region.slack_m)\n",
" probe.clip_wedge(q, reading, half_angle_deg)\n",
" if probe.polygon:\n",
" posterior_radii.append(probe.mec()[1])\n",
" near_probe = CandidateRegion(list(region.polygon), region.slack_m)\n",
" try:\n",
" near_probe.clip_disk(q, near_radius_m, disk_sides)\n",
" except InconsistentBelief:\n",
" pass # B(q, near) 与候选集不相交:near 分支不可能发生,跳过\n",
" else:\n",
" if near_probe.polygon:\n",
" posterior_radii.append(near_probe.mec()[1])\n",
" if not posterior_radii:\n",
" return region.mec()[1] # 无有效采样点时退回当前半径\n",
" return max(posterior_radii)\n",
"\n",
"\n",
"def safe_disk_radius(region: CandidateRegion,\n",
" config: ProblemConfig = ProblemConfig()) -> float:\n",
" \"\"\"保证接收盘 B(mec, safe) 的半径;<0 表示无保证(ρ 超过最小接收半径)。\n",
"\n",
" 盘内任意点到候选集每点距离 <= safe + ρ + 1e-3 < 1000 <= 最小接收半径,\n",
" 故必然收到 direction/near。1mm 数值余量避免浮点贴边被 r_eff=1000 的源\n",
" 判为 no_signal。\n",
" \"\"\"\n",
" return config.field.r_eff_min_m - region.mec()[1] - 1e-3\n",
"\n",
"\n",
"def select_measurement_point(region: CandidateRegion, from_position: Vec2,\n",
" config: ProblemConfig = ProblemConfig()) -> SecondPointPlan:\n",
" \"\"\"在保证接收盘内选择下一检测点(P3 核心;对任意候选集通用)。\n",
"\n",
" 保证接收盘 B(mec_center, 1000-ρ):盘内任意点到候选集每点距离\n",
" <= (1000-ρ) + ρ = 1000 <= 最小接收半径,故必然收到 direction/near,\n",
" 不会浪费一次 no_signal。ρ>1000 时退化为探索 MEC 圆心(可能 no_signal)。\n",
" \"\"\"\n",
" field, search = config.field, config.search\n",
" center, radius = region.mec()\n",
" half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg\n",
"\n",
" def move_cost(point: Vec2) -> float:\n",
" return move_time(from_position, point, config.cost)\n",
"\n",
" # 1mm 数值余量在 safe_disk_radius 内记账。\n",
" safe_radius = safe_disk_radius(region, config)\n",
" if safe_radius < 0:\n",
" return SecondPointPlan(tuple(center), radius, move_cost(center),\n",
" \"无保证接收盘(ρ>1000):探索 MEC 圆心,可能 no_signal\")\n",
" coarse = region.witnesses(search.witness_spacing_m)\n",
" samples = candidate_points(center, safe_radius,\n",
" search.candidate_rings, search.candidate_angles)\n",
" near_radius, sides = field.near_radius_m, search.polygon_sides\n",
" best_key, best_point, best_worst = None, None, None\n",
" for point in samples:\n",
" worst = _worst_posterior_radius(region, point, coarse, half_angle,\n",
" near_radius, sides)\n",
" key = (round(worst, 6), round(move_cost(point), 6))\n",
" if best_key is None or key < best_key:\n",
" best_key, best_point, best_worst = key, point, worst\n",
" dense = _worst_posterior_radius(region, best_point,\n",
" region.witnesses(search.witness_spacing_m / 4),\n",
" half_angle, near_radius, sides)\n",
" reason = (f\"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;\"\n",
" f\"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m\")\n",
" return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)\n",
"\n",
"\n",
"def select_second_point(client: SimulatorClient, first_position: Vec2,\n",
" first_bearing_deg: float,\n",
" config: ProblemConfig = ProblemConfig()) -> SecondPointPlan:\n",
" \"\"\"P3 入口:由首次 direction 构造 K1,返回保证接收的第二检测点方案。\"\"\"\n",
" region = CandidateRegion.initial(first_position, config)\n",
" half_angle = config.field.bearing_error_half_deg \\\n",
" + config.search.bearing_rounding_margin_deg\n",
" region.clip_wedge(first_position, first_bearing_deg, half_angle)\n",
" return select_measurement_point(region, client.state.position, config)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.1 全向闭环骨架(`OmnidirectionalLoop`)\n",
"\n",
"发现(覆盖账本)→ 定位(候选集收敛到 20 m 内)→ 清除 → 收尾 `/exit`。\n",
"任何异常都必须尽力 `/exit` 并产出 summary,不得静默丢会话:\n",
"断连、协议错误、守恒性破坏(候选集被剪空 / 保证接收盘内 `no_signal` /\n",
"清除证书失效)都会记入 `stop_reason` 与异常表。\n",
"`completed` 表示任务在证明意义上完成(每频道 CLEARED 或 ABSENT),\n",
"`accounted_virtual_time_s` 记录首次\"找全证明\"的虚拟时刻。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class OmnidirectionalLoop:\n",
" \"\"\"问题3全向\"发现—定位—清除—持续复扫\"闭环(P4 基线 + 持续搜索)。\n",
"\n",
" 串行驱动 SimulatorClient:覆盖阶段在 49 点网格上对每个 UNKNOWN 频道\n",
" 检测;发现 direction 即进入该频道的定位子循环(选点→测量→收敛→\n",
" 证书清除),near 直接清除。全部频道\"已清除或证明不存在\"后按\n",
" ``search.persistent_search``(默认关闭)可选择只复扫\n",
" 未清除频道,直到清除数达 16、实机预算耗尽或虚拟安全余量不足。\n",
" stop_reason 记录循环结束原因;``completed`` 表示任务证明完成\n",
" (结束时每频道均 CLEARED/ABSENT 且无中断),与\"复扫到限时才停\"\n",
" 相互独立:accounted_virtual_time_s 记录首次证明找全的虚拟时刻。\n",
" \"\"\"\n",
"\n",
" def __init__(self, client: SimulatorClient,\n",
" config: ProblemConfig = ProblemConfig()):\n",
" self.client = client\n",
" self.config = config\n",
" self.belief = BeliefState(config)\n",
" # 覆盖计划是\"可离线搜索、可在运行期校验\"的一等对象;基类默认保持\n",
" # 历史 49 点表(grid49_immediate 基线口径不变),各问题策略自行覆盖。\n",
" self.coverage_plan = square_49_plan()\n",
" self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points))\n",
" self.cleared: list[int] = []\n",
" self.anomalies: list[dict] = []\n",
" self.stop_reason: str | None = None\n",
" # 逐频道缺席证据来源(pf_empty / coverage),供审计与论文逐条追溯。\n",
" self.absence_evidence: dict[int, str] = {}\n",
" # \"同一 0.5m 测点 × 同一频道\"的重复检测计数:历史上一次官方演练\n",
" # 有 98.84% 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。\n",
" self.redundant_rescan_measures = 0\n",
" # 首次达成\"每频道均已清除或证明不存在\"的虚拟时刻(找全证明)。\n",
" self.accounted_virtual_time_s: float | None = None\n",
" # 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上\n",
" # 反复消耗 3 秒/次的失败清除。\n",
" self._may_clear: set[int] = set()\n",
" # 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。\n",
" self._abort_detail: BaseException | None = None\n",
"\n",
" # ------------------------------------------------------------ 主流程\n",
" def run(self) -> dict:\n",
" \"\"\"主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。\"\"\"\n",
" try:\n",
" self.client.enter()\n",
" self._coverage_phase()\n",
" if self.stop_reason is None:\n",
" self._mark_absent_after_coverage()\n",
" self._resolve_detected()\n",
" self._record_accounted()\n",
" if self.config.search.persistent_search:\n",
" self._persistent_phase()\n",
" if self.stop_reason is None:\n",
" if len(self.cleared) >= self.config.field.source_count_max:\n",
" self.stop_reason = \"sixteen_cleared\"\n",
" elif self._all_accounted():\n",
" self.stop_reason = \"all_resolved\"\n",
" else:\n",
" self.stop_reason = \"unresolved_channels\"\n",
" except DeadlineExceeded:\n",
" self.stop_reason = \"real_budget_exhausted\"\n",
" except (TransportError, ProtocolError, InconsistentBelief) as exc:\n",
" self._abort_detail = exc\n",
" if isinstance(exc, TransportError):\n",
" self.stop_reason = \"transport_failure\"\n",
" elif isinstance(exc, InconsistentBelief):\n",
" self.stop_reason = \"inconsistent_belief\"\n",
" else:\n",
" self.stop_reason = \"protocol_error\"\n",
" except Exception as exc: # 意外异常同样收尾,不阻断 /exit 与结果汇总\n",
" self._abort_detail = exc\n",
" self.stop_reason = \"unexpected_error\"\n",
" info = None\n",
" exit_error = None\n",
" try:\n",
" info = self.client.exit()\n",
" except Exception as exc: # 接口不可用:记录后仍产出未完成状态\n",
" exit_error = exc\n",
" summary = self._summary(info)\n",
" if exit_error is not None:\n",
" summary[\"exit_error\"] = _error_brief(exit_error)\n",
" return summary\n",
"\n",
" # ------------------------------------------------------------ 预算与证明\n",
" def _min_virtual_reserve(self) -> float:\n",
" \"\"\"单动作耗时上界的保守估计,用于虚拟安全余量。\n",
"\n",
" 上界 = 2 × 最远站点距离 / 速度 + 检测 + 换频(机器人不会跑到覆盖\n",
" 计划包围区之外执行任务)。配置值通常更宽松;本函数保证即使配置被\n",
" 调小,余量也足以完成最后一个动作而不触及 100 小时上限。\n",
" \"\"\"\n",
" reach = max((math.dist((0.0, 0.0), point) for point in self.ledger.points),\n",
" default=0.0)\n",
" action_bound = (2.0 * reach / self.config.cost.speed_mps\n",
" + self.config.cost.measure_s + self.config.cost.switch_s)\n",
" return max(self.config.search.virtual_reserve_s, action_bound)\n",
"\n",
" def _virtual_cap_reached(self) -> bool:\n",
" \"\"\"虚拟安全余量不足:置 stop_reason 并返回 True,调用方立即收尾。\n",
"\n",
" 余量须大于单次动作耗时上界(见 :meth:`_min_virtual_reserve`),\n",
" `search.virtual_reserve_s=10000s` [工程] 提供充分裕度,避免触及\n",
" 附件2 §4.5 的 100 小时上限被模拟器强制结束。\n",
" \"\"\"\n",
" if (self.client.state.virtual_time_s + self._min_virtual_reserve()\n",
" < self.config.field.max_virtual_duration_s):\n",
" return False\n",
" self.stop_reason = \"virtual_time_cap\"\n",
" return True\n",
"\n",
" def _all_accounted(self) -> bool:\n",
" return all(belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)\n",
" for belief in self.belief.channels.values())\n",
"\n",
" def _record_accounted(self) -> None:\n",
" \"\"\"首次\"每频道均已清除或证明不存在\"的时刻留档(找全证明)。\"\"\"\n",
" if self.accounted_virtual_time_s is None and self._all_accounted():\n",
" self.accounted_virtual_time_s = self.client.state.virtual_time_s\n",
"\n",
" # ------------------------------------------------------------ 覆盖阶段\n",
" def _pending_channels_at(self, index: int) -> list[int]:\n",
" return [channel for channel, belief in self.belief.channels.items()\n",
" if belief.status is ChannelStatus.UNKNOWN\n",
" and not self.ledger.measured_at(channel, index)]\n",
"\n",
" def _next_point_index(self) -> int | None:\n",
" \"\"\"尚有 UNKNOWN 频道待测的最近网格点(贪心最近邻 [工程])。\"\"\"\n",
" position = self.client.state.position\n",
" best_index, best_distance = None, 0.0\n",
" for index, point in enumerate(self.ledger.points):\n",
" if not self._pending_channels_at(index):\n",
" continue\n",
" distance = math.dist(position, point)\n",
" if best_index is None or distance < best_distance:\n",
" best_index, best_distance = index, distance\n",
" return best_index\n",
"\n",
" def _coverage_phase(self) -> None:\n",
" while True:\n",
" if self._count_limit_reached():\n",
" return\n",
" index = self._next_point_index()\n",
" if index is None:\n",
" return\n",
" point = self.ledger.points[index]\n",
" for channel in self._pending_channels_at(index):\n",
" if self._virtual_cap_reached():\n",
" return\n",
" outcome = self.client.measure(point, channel)\n",
" self.ledger.mark_measured(channel, index)\n",
" self.belief.update_measure(channel, point, outcome.result,\n",
" outcome.svd_deg)\n",
" if outcome.result == DIRECTION:\n",
" self._localize_and_clear(channel)\n",
" elif outcome.result == NEAR:\n",
" region = self.belief.candidate_set(channel)\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified:\n",
" self._attempt_clear(channel, cert.center, cert)\n",
" else: # 理论上不发生(near 候选半径 5m);保守记录\n",
" self._anomaly(\"near_without_certificate\", channel,\n",
" position=point)\n",
" if self.stop_reason is not None or self._count_limit_reached():\n",
" return\n",
"\n",
" # ------------------------------------------------------------ 持续复扫\n",
" def _persistent_phase(self) -> None:\n",
" \"\"\"全部频道已清除/证明不存在后的持续复扫 [用户决策 2026-09-11]。\n",
"\n",
" 只扫未清除频道(DETECTED 继续定位,ABSENT 全网格复测,防模拟器\n",
" 或覆盖证明假设偏差),直到清除数达 16 或虚拟安全余量不足;\n",
" 实机预算由客户端 DeadlineExceeded 兜底(扫到限时为止)。\n",
" \"\"\"\n",
" while self.stop_reason is None:\n",
" if self._count_limit_reached():\n",
" return\n",
" if self._virtual_cap_reached():\n",
" return\n",
" self._persistent_round()\n",
"\n",
" def _persistent_round(self) -> None:\n",
" \"\"\"一轮全网格复扫:网格点最近邻序,逐点测量全部未清除频道。\"\"\"\n",
" measured: set[tuple[int, int]] = set()\n",
" while self.stop_reason is None:\n",
" index = self._next_resweep_point(measured)\n",
" if index is None:\n",
" return\n",
" point = self.ledger.points[index]\n",
" for channel in self._resweep_channels_at(index, measured):\n",
" if self._virtual_cap_reached():\n",
" return\n",
" measured.add((channel, index))\n",
" was_absent = (self.belief.channels[channel].status\n",
" is ChannelStatus.ABSENT)\n",
" outcome = self.client.measure(point, channel)\n",
" self.belief.update_measure(channel, point, outcome.result,\n",
" outcome.svd_deg)\n",
" if was_absent and outcome.result in (DIRECTION, NEAR):\n",
" # 复测推翻了覆盖缺席证明:守恒性留痕,按新证据定位清除。\n",
" self._anomaly(\"absent_detected_on_resweep\", channel,\n",
" position=point, result=outcome.result)\n",
" if outcome.result == DIRECTION:\n",
" self._localize_and_clear(channel)\n",
" elif outcome.result == NEAR:\n",
" region = self.belief.candidate_set(channel)\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified:\n",
" self._attempt_clear(channel, cert.center, cert)\n",
" else:\n",
" self._anomaly(\"near_without_certificate\", channel,\n",
" position=point)\n",
" if self.stop_reason is not None:\n",
" return\n",
"\n",
" def _next_resweep_point(self, measured: set[tuple[int, int]]) -> int | None:\n",
" \"\"\"尚有未清除频道待复测的最近网格点(贪心最近邻 [工程])。\"\"\"\n",
" position = self.client.state.position\n",
" best_index, best_distance = None, 0.0\n",
" for index, point in enumerate(self.ledger.points):\n",
" if not self._resweep_channels_at(index, measured):\n",
" continue\n",
" distance = math.dist(position, point)\n",
" if best_index is None or distance < best_distance:\n",
" best_index, best_distance = index, distance\n",
" return best_index\n",
"\n",
" def _resweep_channels_at(self, index: int,\n",
" measured: set[tuple[int, int]]) -> list[int]:\n",
" return [channel for channel, belief in self.belief.channels.items()\n",
" if belief.status is not ChannelStatus.CLEARED\n",
" and (channel, index) not in measured]\n",
"\n",
" def _count_limit_reached(self) -> bool:\n",
" if len(self.cleared) >= self.config.field.source_count_max:\n",
" self.stop_reason = \"sixteen_cleared\"\n",
" return True\n",
" return False\n",
"\n",
" # ------------------------------------------------------------ 定位与清除\n",
" def _localize_and_clear(self, channel: int) -> bool:\n",
" \"\"\"定位子循环:选点测量收敛候选集,拿到清除证书后清除。\n",
"\n",
" 返回是否已清除;步数耗尽仍未收敛则保持 DETECTED 留待二轮。\n",
" 进入本函数前该频道刚有一次 direction 读数,允许立即清除。\n",
" \"\"\"\n",
" search = self.config.search\n",
" self._may_clear.add(channel)\n",
" for _ in range(search.max_localize_steps):\n",
" if self._virtual_cap_reached():\n",
" return False\n",
" region = self.belief.candidate_set(channel)\n",
" if region is None:\n",
" return False\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified and channel in self._may_clear:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" return True\n",
" self._may_clear.discard(channel)\n",
" continue\n",
" plan = select_measurement_point(region, self.client.state.position,\n",
" self.config)\n",
" outcome = self.client.measure(plan.position, channel)\n",
" self.belief.update_measure(channel, plan.position, outcome.result,\n",
" outcome.svd_deg)\n",
" self._may_clear.add(channel)\n",
" if outcome.result == NO_SIGNAL:\n",
" self._anomaly(\"no_signal_in_safe_disk\", channel,\n",
" position=plan.position, detail=plan.reason)\n",
" return self.belief.channels[channel].status is ChannelStatus.CLEARED\n",
"\n",
" def _attempt_clear(self, channel: int, target: Vec2,\n",
" cert: ClearCertificate) -> bool:\n",
" outcome = self.client.clear(target, channel)\n",
" if outcome.result == CLEAR_SUCCESS:\n",
" self.belief.mark_cleared(channel)\n",
" self.cleared.append(channel)\n",
" return True\n",
" if cert.certified:\n",
" self._anomaly(\"clear_failed_with_certificate\", channel, position=target,\n",
" radius_m=round(cert.radius_m, 3), limit_m=cert.limit_m)\n",
" return False\n",
"\n",
" def _anomaly(self, kind: str, channel: int, **detail) -> None:\n",
" \"\"\"异常只在守恒性被打破或证书失效时出现;每频道限量记录防刷屏。\"\"\"\n",
" recorded = sum(1 for item in self.anomalies if item[\"channel\"] == channel)\n",
" if recorded >= self.config.search.max_anomalies_per_channel:\n",
" return\n",
" self.anomalies.append({\"kind\": kind, \"channel\": channel, **detail})\n",
"\n",
" # ------------------------------------------------------------ 收尾\n",
" def _mark_absent_after_coverage(self) -> None:\n",
" \"\"\"全向源缺席证明:全网格无非 no_signal 读数的频道不存在源 [题面]。\"\"\"\n",
" for channel, belief in self.belief.channels.items():\n",
" if belief.status is ChannelStatus.UNKNOWN \\\n",
" and self.ledger.coverage_complete(channel):\n",
" self.belief.mark_absent(channel)\n",
" self.absence_evidence[channel] = \"coverage\"\n",
"\n",
" def _resolve_detected(self) -> None:\n",
" \"\"\"覆盖完成后,对仍未清除的 DETECTED 频道做第二轮定位。\n",
"\n",
" 不再在此决定停止:持续搜索模式下由 _persistent_phase 接力,\n",
" 非持续模式由 run() 依据 _all_accounted 判定 all_resolved。\n",
" \"\"\"\n",
" for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n",
" if self._count_limit_reached() or self._virtual_cap_reached():\n",
" return\n",
" self._localize_and_clear(channel)\n",
" if self.stop_reason is not None:\n",
" return\n",
"\n",
" def _summary(self, info) -> dict:\n",
" \"\"\"结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。\n",
"\n",
" completed = 无中断且结束时每频道均 CLEARED/ABSENT(任务证明完成);\n",
" stop_reason 另行记录循环结束原因(持续复扫模式下通常是\n",
" virtual_time_cap / real_budget_exhausted / sixteen_cleared)。\n",
" \"\"\"\n",
" events = self.client.log.action_events()\n",
" stats = summarize(events)\n",
" report = recompute_virtual_time(events)\n",
" steps = self._step_report()\n",
" cleared = self.belief.channels_by_status(ChannelStatus.CLEARED)\n",
" absent = self.belief.channels_by_status(ChannelStatus.ABSENT)\n",
" resolved = set(cleared) | set(absent)\n",
" unresolved = [channel for channel in sorted(self.belief.channels)\n",
" if channel not in resolved]\n",
" virtual_time_s = round((info.virtual_time_s if info is not None\n",
" else self.client.state.virtual_time_s), 3)\n",
" # 清除数达 16 即数量上界证明(其余频道无论状态如何都不可能有源);\n",
" # 否则要求每频道均 CLEARED/ABSENT。\n",
" completed = (self._abort_detail is None\n",
" and (len(self.cleared) >= self.config.field.source_count_max\n",
" or not unresolved))\n",
" # 完成依据必须显式区分\"数量上界\"与\"逐频道缺席/清除证明\",\n",
" # 否则论文与审计会把 16 源截断误读为逐频道证明。\n",
" if self._abort_detail is not None:\n",
" completion_basis = \"incomplete\"\n",
" elif len(self.cleared) >= self.config.field.source_count_max:\n",
" completion_basis = \"count_cap\"\n",
" elif not unresolved:\n",
" completion_basis = \"per_channel_proof\"\n",
" else:\n",
" completion_basis = \"incomplete\"\n",
" summary = {\n",
" \"completed\": completed,\n",
" \"completion_basis\": completion_basis,\n",
" \"stop_reason\": self.stop_reason,\n",
" \"exit_reason\": \"exit_unreachable\" if info is None else info.reason,\n",
" \"virtual_time_s\": virtual_time_s,\n",
" \"accounted_virtual_time_s\": (round(self.accounted_virtual_time_s, 3)\n",
" if self.accounted_virtual_time_s is not None\n",
" else None),\n",
" \"persistent_search\": self.config.search.persistent_search,\n",
" \"virtual_reserve_s\": round(self._min_virtual_reserve(), 3),\n",
" \"coverage_plan\": self.coverage_plan.name,\n",
" \"coverage_points\": len(self.coverage_plan.points),\n",
" \"coverage_plan_points\": [[p[0], p[1]] for p in self.coverage_plan.points],\n",
" \"coverage_plan_certificate\": (\n",
" self.coverage_plan.certificate.as_dict()\n",
" if self.coverage_plan.certificate is not None else None),\n",
" \"cleared_channels\": cleared,\n",
" \"absent_channels\": absent,\n",
" \"unresolved_channels\": unresolved,\n",
" \"absence_evidence\": {str(channel): self.absence_evidence[channel]\n",
" for channel in sorted(absent)},\n",
" \"redundant_rescan_measures\": self.redundant_rescan_measures,\n",
" \"measures\": stats[\"measures\"],\n",
" \"clears\": stats[\"clears\"],\n",
" \"clear_success\": stats[\"cleared\"],\n",
" \"total_requests\": stats[\"measures\"] + stats[\"clears\"],\n",
" \"move_distance_m\": round(stats[\"distance_m\"], 1),\n",
" # 题面口径:定位清除总时间(全程虚拟时长)/ 被清除干扰源个数。\n",
" \"avg_locate_clear_time_s\": (round(virtual_time_s / len(cleared), 3)\n",
" if cleared else None),\n",
" \"step_totals\": steps[\"totals\"],\n",
" \"steps\": steps[\"rows\"],\n",
" \"anomalies\": self.anomalies,\n",
" \"virtual_recompute_mismatches\": len(report[\"mismatches\"]),\n",
" \"channel_status\": self._channel_status_snapshot(),\n",
" }\n",
" if self._abort_detail is not None:\n",
" summary[\"abort_detail\"] = _error_brief(self._abort_detail)\n",
" return summary\n",
"\n",
" def _step_report(self) -> dict:\n",
" \"\"\"每步计时汇总 [用户决策 2026-09-11]:实机墙钟 + 比赛虚拟耗时分项。\"\"\"\n",
" totals = {\"count\": 0, \"real_elapsed_total_s\": 0.0,\n",
" \"real_since_enter_total_s\": 0.0,\n",
" \"virtual_move_s\": 0.0, \"virtual_switch_s\": 0.0,\n",
" \"virtual_measure_action_s\": 0.0, \"virtual_clear_action_s\": 0.0,\n",
" \"virtual_total_s\": 0.0}\n",
" rows = []\n",
" for index, step in enumerate(self.client.log.step_events()):\n",
" cost = step[\"virtual_cost\"]\n",
" totals[\"count\"] += 1\n",
" totals[\"real_elapsed_total_s\"] += step[\"real_elapsed_s\"]\n",
" totals[\"real_since_enter_total_s\"] = step.get(\n",
" \"real_since_enter_s\", totals[\"real_since_enter_total_s\"])\n",
" totals[\"virtual_move_s\"] += cost[\"move_s\"]\n",
" totals[\"virtual_switch_s\"] += cost[\"switch_s\"]\n",
" if step[\"path\"] == \"/measure\":\n",
" totals[\"virtual_measure_action_s\"] += cost[\"action_s\"]\n",
" elif step[\"path\"] == \"/clear\":\n",
" totals[\"virtual_clear_action_s\"] += cost[\"action_s\"]\n",
" totals[\"virtual_total_s\"] += cost[\"total_s\"]\n",
" rows.append({\"index\": index, \"action\": step[\"path\"].lstrip(\"/\"),\n",
" \"result\": step.get(\"result\"),\n",
" \"channel\": step.get(\"channel\"),\n",
" \"position\": step.get(\"position\"),\n",
" \"real_elapsed_ms\": round(step[\"real_elapsed_s\"] * 1000.0, 3),\n",
" \"real_since_enter_s\": step.get(\"real_since_enter_s\"),\n",
" \"move_s\": round(cost[\"move_s\"], 3),\n",
" \"switch_s\": round(cost[\"switch_s\"], 3),\n",
" \"action_s\": round(cost[\"action_s\"], 3),\n",
" \"step_virtual_s\": round(cost[\"total_s\"], 3),\n",
" \"virtual_time_s\": step[\"virtual_time_s\"]})\n",
" for key, value in totals.items():\n",
" if isinstance(value, float):\n",
" totals[key] = round(value, 3)\n",
" return {\"totals\": totals, \"rows\": rows}\n",
"\n",
" def _channel_status_snapshot(self) -> dict:\n",
" \"\"\"逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。\"\"\"\n",
" snapshot = {}\n",
" for channel, belief in sorted(self.belief.channels.items()):\n",
" entry = {\"status\": belief.status.value}\n",
" if belief.region is not None:\n",
" entry[\"posterior_radius_m\"] = round(belief.region.mec()[1], 3)\n",
" snapshot[str(channel)] = entry\n",
" return snapshot\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.2 第四问定向扩展(`DirectionalExtension`)\n",
"\n",
"与第三问的三点差异:\n",
"\n",
"1. 覆盖阶段换成定向覆盖最小命中集(`q4_mincover_v2`),发现保证对定向源同样成立;\n",
"2. 保证接收盘内的 `no_signal` 只能由**朝向遮挡**解释:该频道记为\"观测到定向\",\n",
" 对 MEC 圆心取对跖探点复核,并用联合信念按可见概率选点;\n",
"3. 定位停滞时对**有界**候选集构造 ≤20 m 有限圆盘覆盖,逐格 `/clear`\n",
" (清除与朝向无关,是完备后备);格数超硬顶或预算不足即跳过并留痕,绝不盲扫。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class DirectionalExtension(OmnidirectionalLoop):\n",
" \"\"\"问题4混合源闭环(P5):全向基线 + 定向观测处理 + 后备清除。\n",
"\n",
" 与 P4 的三点差异(协议衔接见 docs/协议规则表与状态机.md §7):\n",
" - 49 点覆盖的发现保证对定向源同样成立,覆盖阶段不区分源类型;\n",
" - 保证接收盘内的 no_signal 只能由朝向遮挡解释 [观测推理]:该频道\n",
" 记为\"观测到定向\",随后对 MEC 圆心取对跖探点复核;联合约束基线\n",
" 不据此删除空间候选(真源不误删);\n",
" - 定位停滞(候选集有界但未达清除证书)时构造 <=20m 有限圆盘覆盖,\n",
" 预算核对后逐格 /clear:清除与朝向无关 [题面],是定向源不依赖\n",
" 可见性的完备后备。格数超硬顶或现实预算不足则跳过并留痕,绝不盲扫。\n",
" \"\"\"\n",
"\n",
" def __init__(self, client: SimulatorClient,\n",
" config: ProblemConfig = ProblemConfig()):\n",
" super().__init__(client, config)\n",
" # P0-2:第四问切换到同一保证下的最小命中集(默认 q4_mincover_v1)。\n",
" self.coverage_plan = plan_instance(config.search.q4_coverage_plan,\n",
" verify=config.search.verify_coverage_plan)\n",
" self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points))\n",
" self.directional_observed: list[int] = [] # 观测到定向性的频道\n",
" self.blocked_probes: dict[int, list[Vec2]] = {}\n",
" self.fallback_log: list[dict] = [] # 后备清除逐次留痕\n",
" # P0-3:(位置 × 朝向) 联合可行集,仅在频道首次被看见时惰性建立,\n",
" # 使其初始化与更新成本只落在\"确实存在定向源\"的频道上。\n",
" self.joint: dict[int, JointBelief] = {}\n",
" self.joint_absent_channels: list[int] = []\n",
" self.visibility_probes = 0 # 由联合信念选出的探点数\n",
"\n",
" def _coverage_phase(self) -> None:\n",
" \"\"\"49站的定向覆盖证明不变;每站先串行扫频,再就近服务已发现源。\n",
"\n",
" 避免检测一个源后远行清除,再返回同站检测下一个频道。\n",
" no_signal不用于删空间候选,也不使用第三问的七站缺席证明。\n",
" \"\"\"\n",
" while not self._count_limit_reached() and self.stop_reason is None:\n",
" index = self._next_point_index()\n",
" if index is None:\n",
" return\n",
" point = self.ledger.points[index]\n",
" pending = self._pending_channels_at(index)\n",
" pending.sort(key=lambda c: (c != self.client.state.channel, c))\n",
" for channel in pending:\n",
" if self._virtual_cap_reached():\n",
" return\n",
" outcome = self.client.measure(point, channel)\n",
" self.ledger.mark_measured(channel, index)\n",
" self._observe(channel, point, outcome.result, outcome.svd_deg)\n",
" if outcome.result == NEAR:\n",
" cert = clear_certificate(\n",
" self.belief.candidate_set(channel), channel, self.config)\n",
" if cert.certified:\n",
" self._attempt_clear(channel, point, cert)\n",
" if self._count_limit_reached():\n",
" return\n",
" queue = self.belief.channels_by_status(ChannelStatus.DETECTED)\n",
" while queue and self.stop_reason is None:\n",
" channel = min(queue, key=lambda c: (\n",
" math.dist(self.client.state.position,\n",
" self.belief.candidate_set(c).mec()[0]), c))\n",
" queue.remove(channel)\n",
" self._localize_and_clear(channel)\n",
" if self._count_limit_reached():\n",
" return\n",
"\n",
" # ------------------------------------------------------------ 定位与清除\n",
" def _localize_and_clear(self, channel: int) -> bool:\n",
" \"\"\"定位子循环:选点测量收敛候选集,拿到清除证书后清除。\n",
"\n",
" 与 P4 的差异:保证接收盘内的 no_signal 说明探点被朝向遮挡\n",
" [观测推理],走\"对跖复核 -> 环探\"升级梯子(不计入定位步数,\n",
" 由 max_blocked_probes 限流),之后仍未收敛再转后备清除。\n",
" \"\"\"\n",
" search = self.config.search\n",
" self._may_clear.add(channel)\n",
" steps = 0\n",
" while steps < search.max_localize_steps:\n",
" if self._virtual_cap_reached():\n",
" return False\n",
" region = self.belief.candidate_set(channel)\n",
" if region is None:\n",
" return False\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified and channel in self._may_clear:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" return True\n",
" self._may_clear.discard(channel) # 证书失败:补测后才可重试\n",
" continue\n",
" if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes:\n",
" break # 梯子用尽仍被遮挡:定位停滞,转后备清除\n",
" point = self._next_probe(region, channel)\n",
" within_range = (math.dist(point, region.mec()[0])\n",
" <= safe_disk_radius(region, self.config))\n",
" outcome = self.client.measure(point, channel)\n",
" self._observe(channel, point, outcome.result, outcome.svd_deg)\n",
" self._may_clear.add(channel)\n",
" if outcome.result == NO_SIGNAL:\n",
" if within_range and channel not in self.directional_observed:\n",
" self.directional_observed.append(channel)\n",
" self.blocked_probes.setdefault(channel, []).append(point)\n",
" continue # 盘外无信号也可能是距离不足,不据此断言定向\n",
" self.blocked_probes.pop(channel, None)\n",
" steps += 1\n",
" if self.belief.channels[channel].status is ChannelStatus.CLEARED:\n",
" return True\n",
" region = self.belief.candidate_set(channel)\n",
" if region is None:\n",
" return False\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified and channel in self._may_clear:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" return True\n",
" self._may_clear.discard(channel)\n",
" return self._fallback_clear(channel, region)\n",
"\n",
" def _observe(self, channel: int, point: Vec2, result: str,\n",
" svd_deg: float | None) -> None:\n",
" \"\"\"同步更新凸候选集与 (位置 × 朝向) 联合可行集。\n",
"\n",
" 联合集只在频道首次被看见(direction/near)时建立:从未被看见的\n",
" 频道由覆盖证明负责缺席,不需要朝向维度。联合集一旦为空,直接给出\n",
" 该频道的缺席证明(严格,与覆盖证明同一条件),可提前停止对其检测。\n",
" \"\"\"\n",
" self.belief.update_measure(channel, point, result, svd_deg)\n",
" if not self.config.search.directional_joint_belief:\n",
" return\n",
" joint = self.joint.get(channel)\n",
" if joint is None:\n",
" if result not in (DIRECTION, NEAR):\n",
" return\n",
" joint = JointBelief(self.config)\n",
" self.joint[channel] = joint\n",
" joint.apply(point, result, svd_deg)\n",
" if joint.is_absent() and self.belief.channels[channel].status \\\n",
" is not ChannelStatus.CLEARED:\n",
" self.belief.mark_absent(channel)\n",
" self.absence_evidence[channel] = \"joint_empty\"\n",
" if channel not in self.joint_absent_channels:\n",
" self.joint_absent_channels.append(channel)\n",
"\n",
" def _probe_candidates(self, region: CandidateRegion, channel: int) -> list[Vec2]:\n",
" \"\"\"可见性选点的候选点族:保证接收点 + 对跖点 + 环探 + **抵近点**。\n",
"\n",
" D1 关键结构性改动:旧点族全部围绕 MEC 圆心固定环采样,远距离不收敛时\n",
" 没有\"主动靠近\"这一动作,只能退化到 20m 网格盲清。现在补上\n",
" ``center + α·(当前位置 − center)``(α∈approach_fractions,α=0 即圆心)\n",
" 的抵近序列,使策略可以在保持可见的前提下逐次逼近真源,最终以正常\n",
" 角域证书或 near 收尾。\n",
" \"\"\"\n",
" center, radius = region.mec()\n",
" position = self.client.state.position\n",
" candidates = [select_measurement_point(\n",
" region, position, self.config).position]\n",
" safe = safe_disk_radius(region, self.config)\n",
" blocked = self.blocked_probes.get(channel, [])\n",
" if blocked and safe >= 0:\n",
" dx, dy = center[0] - blocked[0][0], center[1] - blocked[0][1]\n",
" norm = math.hypot(dx, dy)\n",
" if norm > 1e-9:\n",
" scale = min(1.0, safe / norm)\n",
" candidates.append((center[0] + dx * scale, center[1] + dy * scale))\n",
" # 抵近点:从 MEC 圆心到当前点的插值序列(含圆心本身)。\n",
" for fraction in self.config.search.approach_fractions:\n",
" candidates.append((center[0] + (position[0] - center[0]) * fraction,\n",
" center[1] + (position[1] - center[1]) * fraction))\n",
" for factor in (0.5, 1.0, 1.5):\n",
" ring = max(radius * factor, 2.0 * self.config.field.near_radius_m)\n",
" for step in range(8):\n",
" angle = math.pi * step / 4.0\n",
" candidates.append((center[0] + ring * math.cos(angle),\n",
" center[1] + ring * math.sin(angle)))\n",
" unique: list[Vec2] = []\n",
" seen: set[tuple[int, int]] = set()\n",
" blocked_keys = {(int(round(point[0] * 2)), int(round(point[1] * 2)))\n",
" for point in blocked} \\\n",
" if self.config.search.probe_exclude_blocked else set()\n",
" for point in candidates:\n",
" key = (int(round(point[0] * 2)), int(round(point[1] * 2)))\n",
" if key in seen or key in blocked_keys:\n",
" continue # 已测过且被遮挡的点:再测不会有任何新信息\n",
" seen.add(key)\n",
" unique.append(point)\n",
" return unique\n",
"\n",
" def _visibility_probe(self, region: CandidateRegion, channel: int) -> Vec2 | None:\n",
" \"\"\"按联合信念选探点;联合信念不可用时返回 None(退回梯子)。\n",
"\n",
" 三种口径(只影响选点顺序,不承担任何安全责任):\n",
"\n",
" - ``nearest_visible``(默认):先按可见概率过滤(只保留\n",
" ``prob_detect ≥ ratio × max`` 的候选,避免选中\"实际必然被朝向遮挡\"的\n",
" 点),再取 ``当前位置→q→MEC 圆心`` 移动最省者。9 场景 core 实测:\n",
" 后备盲清 125.7 → 4.3 次/局、T 9225 → 8602 s、墙钟最低。\n",
" - ``expected_radius``:在同样过滤后最小化\n",
" ``E[后验 MEC 半径](q) + λ·移动``。λ=5 时与上面几乎同分(8604 s),\n",
" λ 很小时(0.05)值函数主导,为信息多绕路(9740 s)——本题\n",
" \"看得见且少走路\"比\"单步信息量最大\"更重要。\n",
" - ``visibility``(旧口径):最大化 ``prob_detect/(移动+6)``,会反复选中\n",
" 同一个近处被遮挡点(实测 9225 s、后备 125.6 次)。\n",
" \"\"\"\n",
" joint = self.joint.get(channel)\n",
" if joint is None or joint.is_absent():\n",
" return None\n",
" position = self.client.state.position\n",
" service_s = self.config.cost.measure_s + self.config.cost.switch_s\n",
" candidates = self._probe_candidates(region, channel)\n",
" if not candidates:\n",
" return None\n",
" mode = self.config.search.probe_score_mode\n",
" if mode == \"visibility\":\n",
" best_point, best_score = None, None\n",
" for point in candidates:\n",
" probability = joint.prob_detect(point)\n",
" if probability <= 0.0:\n",
" continue\n",
" score = -(probability / (move_time(position, point, self.config.cost)\n",
" + service_s))\n",
" if best_score is None or score < best_score:\n",
" best_point, best_score = point, score\n",
" if best_point is not None:\n",
" self.visibility_probes += 1\n",
" return best_point\n",
" scored = [(joint.prob_detect(point), point) for point in candidates]\n",
" peak = max(probability for probability, _ in scored)\n",
" ratio = self.config.search.probe_visibility_ratio\n",
" keep = [point for probability, point in scored\n",
" if probability >= ratio * peak] or [point for _, point in scored]\n",
" center = region.mec()[0]\n",
" weight = self.config.search.probe_lambda_move_s\n",
"\n",
" def approach_cost(point: Vec2) -> float:\n",
" return (move_time(position, point, self.config.cost)\n",
" + move_time(point, center, self.config.cost) + service_s)\n",
"\n",
" if mode == \"nearest_visible\":\n",
" # λ 很大时值函数项对 argmin 已无影响:直接取\"可见候选中移动最省\",\n",
" # 省掉每个候选一次期望后验半径计算(单局墙钟 −3 s)。\n",
" best = min(keep, key=lambda point: (approach_cost(point), point))\n",
" self.visibility_probes += 1\n",
" return best\n",
" best = min(keep, key=lambda point: (\n",
" joint.expected_posterior_radius(point) + weight * approach_cost(point),\n",
" point))\n",
" self.visibility_probes += 1\n",
" return best\n",
"\n",
"\n",
"\n",
" def _next_probe(self, region: CandidateRegion, channel: int) -> Vec2:\n",
" \"\"\"定向遮挡下的探点升级梯子 [工程]。\n",
"\n",
" - 未被遮挡过:worst-posterior 选点器(保证接收盘内);\n",
" - 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘;\n",
" - 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起\n",
" 每次 +45°。几何边界必须如实陈述:环上可见弧没有下界——\n",
" 源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个\n",
" 切点,固定步进环探可以全部漏过。梯子只提高提前命中概率,\n",
" 不构成接收或命中保证;完备性完全由后备清除兜底。\n",
" \"\"\"\n",
" visibility = self._visibility_probe(region, channel)\n",
" if visibility is not None:\n",
" return visibility\n",
" blocked = self.blocked_probes.get(channel, [])\n",
" center, radius = region.mec()\n",
" if not blocked:\n",
" return select_measurement_point(region, self.client.state.position,\n",
" self.config).position\n",
" safe = safe_disk_radius(region, self.config)\n",
" first = blocked[0]\n",
" if len(blocked) == 1 and safe >= 0:\n",
" dx, dy = center[0] - first[0], center[1] - first[1]\n",
" norm = math.hypot(dx, dy)\n",
" if norm <= 1e-9:\n",
" # 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。\n",
" return (center[0] + safe, center[1])\n",
" scale = min(1.0, safe / norm)\n",
" # 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。\n",
" return (center[0] + dx * scale, center[1] + dy * scale)\n",
" base = math.atan2(first[1] - center[1], first[0] - center[0])\n",
" azimuth = base + math.radians(45.0 * (len(blocked) - 1))\n",
" return (center[0] + radius * math.cos(azimuth),\n",
" center[1] + radius * math.sin(azimuth))\n",
"\n",
" def _fallback_clear(self, channel: int, region: CandidateRegion) -> bool:\n",
" \"\"\"定位停滞的后备:候选集有限圆盘覆盖逐格清除(格数即请求上界)。\"\"\"\n",
" search, session = self.config.search, self.config.session\n",
" width = max(p[0] for p in region.polygon) - min(p[0] for p in region.polygon)\n",
" height = max(p[1] for p in region.polygon) - min(p[1] for p in region.polygon)\n",
" cells = fallback_clear_cells(region.polygon, self.config)\n",
" entry = {\"channel\": channel, \"cells\": len(cells),\n",
" \"bound\": fallback_cell_bound(width, height, self.config),\n",
" \"posterior_radius_m\": round(region.mec()[1], 3)}\n",
" self.fallback_log.append(entry)\n",
" if len(cells) > search.fallback_max_cells:\n",
" # 宽候选集盲扫违背预算纪律:留待定位收敛,不得硬扫 [计划§4]。\n",
" entry[\"outcome\"] = \"skipped_cell_cap\"\n",
" self._anomaly(\"fallback_cell_cap_exceeded\", channel,\n",
" cells=len(cells), cap=search.fallback_max_cells)\n",
" return False\n",
" # 现实预算核对:N 次请求 × 单请求高分位估计 + 退出余量,不足不启动。\n",
" remaining = self.client.real_remaining_s\n",
" if remaining is None or len(cells) * session.real_rtt_p95_s \\\n",
" + session.reserve_exit_s > remaining:\n",
" entry[\"outcome\"] = \"skipped_budget\"\n",
" return False\n",
" attempts = 0\n",
" order = self._fallback_order(cells)\n",
" for cell in order:\n",
" if self._virtual_cap_reached():\n",
" entry[\"outcome\"] = \"skipped_virtual_budget\"\n",
" entry[\"attempts\"] = attempts\n",
" return False\n",
" attempts += 1\n",
" outcome = self.client.clear(cell, channel)\n",
" if outcome.result == CLEAR_SUCCESS:\n",
" self.belief.mark_cleared(channel)\n",
" self.cleared.append(channel)\n",
" entry[\"outcome\"] = \"cleared\"\n",
" entry[\"attempts\"] = attempts\n",
" return True\n",
" # 候选集保守包含真源,格心距格内任一点 <= 10*sqrt(2) < 20m:全部\n",
" # 失败说明守恒性被打破,按异常留痕,不得据此删除候选。\n",
" entry[\"outcome\"] = \"exhausted\"\n",
" entry[\"attempts\"] = attempts\n",
" self._anomaly(\"fallback_exhausted_without_success\", channel,\n",
" cells=len(cells))\n",
" return False\n",
"\n",
" def _fallback_order(self, cells: list[Vec2]) -> list[Vec2]:\n",
" \"\"\"后备清除的走格顺序:最近邻打底 + 2-opt(可选 Or-opt)改进。\n",
"\n",
" 旧实现每步重新 ``min(remaining, key=dist)``,等价于最近邻开放路径\n",
" (典型为最优的 1.2–1.3 倍)且每次重扫 O(k²)。这里一次性求解改进序\n",
" (D3),把格间移动压到接近最优;格数不变,故完备性不变。\n",
" \"\"\"\n",
" if not cells:\n",
" return []\n",
" if not self.config.search.fallback_optimize_tour or len(cells) < 4:\n",
" position = self.client.state.position\n",
" remaining = list(cells)\n",
" order: list[Vec2] = []\n",
" while remaining:\n",
" nxt = min(remaining, key=lambda c: (math.dist(position, c), c))\n",
" remaining.remove(nxt)\n",
" order.append(nxt)\n",
" position = nxt\n",
" return order\n",
" return improve_open_path([tuple(c) for c in cells],\n",
" self.client.state.position)\n",
"\n",
" # ------------------------------------------------------------ 收尾\n",
" def _summary(self, info) -> dict:\n",
" report = super()._summary(info)\n",
" report[\"directional_observed_channels\"] = list(self.directional_observed)\n",
" report[\"fallback_clears\"] = self.fallback_log\n",
" # 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。\n",
" report[\"fallback_clear_attempts\"] = sum(\n",
" entry.get(\"attempts\", 0) for entry in self.fallback_log)\n",
" report[\"visibility_probes\"] = self.visibility_probes\n",
" report[\"joint_absent_channels\"] = list(self.joint_absent_channels)\n",
" report[\"joint_final\"] = {str(channel): belief.summary()\n",
" for channel, belief in sorted(self.joint.items())}\n",
" return report\n",
"\n",
"\n",
"def fallback_cell_bound(width_m: float, height_m: float,\n",
" config: ProblemConfig = ProblemConfig()) -> int:\n",
" \"\"\"外包框枚举的格数上界 (⌈w/cell⌉+2)(⌈h/cell⌉+2)(计划§4 规模检查)。\"\"\"\n",
" cell = config.search.fallback_clear_grid_m\n",
" return (math.ceil(width_m / cell) + 2) * (math.ceil(height_m / cell) + 2)\n",
"\n",
"\n",
"def fallback_clear_cells(polygon: list[Vec2],\n",
" config: ProblemConfig = ProblemConfig()) -> list[Vec2]:\n",
" \"\"\"候选外包多边形的 <=20m 有限圆盘覆盖格心列表(P5 后备清除)。\n",
"\n",
" 全局半开 20m 网格索引;多边形与格子的相交判定带正容差(宁可多测),\n",
" 多边形边界恰在格线上时两侧格子都保留,因此格内任意点距某格心\n",
" <= 10*sqrt(2) < 20m,逐格 /clear 至少一次命中。列表长度即 /clear\n",
" 请求预算上界;先核对 fallback_cell_bound,超预算不得逐格盲扫。\n",
" \"\"\"\n",
" if not polygon:\n",
" return []\n",
" cell = config.search.fallback_clear_grid_m\n",
" xs = [p[0] for p in polygon]\n",
" ys = [p[1] for p in polygon]\n",
" i0, i1 = math.floor(min(xs) / cell), math.floor(max(xs) / cell)\n",
" j0, j1 = math.floor(min(ys) / cell), math.floor(max(ys) / cell)\n",
" eps = 1e-9\n",
" centers: list[Vec2] = []\n",
" for i in range(i0, i1 + 1):\n",
" for j in range(j0, j1 + 1):\n",
" clipped = list(polygon)\n",
" for a, b, c in ((1.0, 0.0, (i + 1) * cell + eps),\n",
" (-1.0, 0.0, -(i * cell - eps)),\n",
" (0.0, 1.0, (j + 1) * cell + eps),\n",
" (0.0, -1.0, -(j * cell - eps))):\n",
" clipped = clip_halfplane(clipped, a, b, c, tol=eps)\n",
" if not clipped:\n",
" break\n",
" if clipped:\n",
" centers.append(((i + 0.5) * cell, (j + 0.5) * cell))\n",
" return centers\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.3 第三问默认策略:贝叶斯网格滤波闭环(`planner/bayes_loop.py`)\n",
"\n",
"三层重构:\n",
"\n",
"1. **感知层**:每频道一个网格幸存集,全部读数(含 `no_signal` 负观测)即时更新,\n",
" 幸存集为空即缺席证明;\n",
"2. **决策层**:站点扫频、定位测量、清除三类任务合成统一任务表,\n",
" 开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP);\n",
"3. **选点层**:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)内\n",
" 按\"期望 log 幸存占比 + λ·移动时间\"评分(信息增益贪心)。\n",
"\n",
"相对基线的增益来自负观测跳站/跳测、PF 缺席证明提前证毕、全局任务排序。\n",
"清除证书仍以凸证书为主,PF 网格证书只在负观测挖洞使幸存集更紧时提前触发。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import math\n",
"from dataclasses import dataclass\n",
"\n",
"\n",
"\n",
"@dataclass(frozen=True)\n",
"class Task:\n",
" \"\"\"统一任务表的一项。service_s 为不含移动的固有耗时(检测+换频/清除)。\"\"\"\n",
"\n",
" kind: str # \"station\" | \"locate\" | \"clear\"\n",
" position: Vec2\n",
" service_s: float\n",
" channel: int | None = None\n",
" station_index: int | None = None\n",
" station_channels: tuple[int, ...] = ()\n",
"\n",
"\n",
"def _point_key(point: Vec2) -> tuple[int, int]:\n",
" \"\"\"候选点去重键:0.5m 网格取整(同一物理点不重复测量)。\"\"\"\n",
" return (int(round(point[0] * 2)), int(round(point[1] * 2)))\n",
"\n",
"\n",
"class BayesLoop(OmnidirectionalLoop):\n",
" \"\"\"问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。\"\"\"\n",
"\n",
" def __init__(self, client: SimulatorClient,\n",
" config: ProblemConfig = ProblemConfig()):\n",
" super().__init__(client, config)\n",
" validate_seven_coverage(config.bayes.station_ring_radius_m, config)\n",
" self.coverage_plan = seven_plan(config.bayes.station_ring_radius_m)\n",
" self.ledger = CoverageLedger(\n",
" config, points=list(self.coverage_plan.points))\n",
" self.particles = {channel: ChannelParticles(config)\n",
" for channel in self.belief.channels}\n",
" self._measured_points: dict[int, set[tuple[int, int]]] = {}\n",
" self._clear_failed: set[int] = set() # 证书清除失败后须先补测再重试\n",
" self._stall: dict[int, int] = {} # 连续无收缩计数(异常留痕用)\n",
" self._skip_cache: dict[tuple[int, int], tuple[int, bool]] = {}\n",
" self.skipped_measures = 0 # 跳测规则省下的检测次数\n",
" self.no_shrink_measures = 0 # 未收缩幸存集的定位检测(诊断用)\n",
" self.pf_absent_channels: list[int] = [] # 由幸存集为空证明缺席的频道\n",
" self.opportunistic_measures = 0\n",
"\n",
" # ------------------------------------------------------------ 主循环\n",
" def _coverage_phase(self) -> None:\n",
" \"\"\"统一任务循环:发现 + 定位 + 清除滚动调度,直到任务表为空。\"\"\"\n",
" while self.stop_reason is None:\n",
" if self._count_limit_reached() or self._virtual_cap_reached():\n",
" return\n",
" if not self._step_once():\n",
" return\n",
"\n",
" def _step_once(self) -> bool:\n",
" \"\"\"构建任务表、排序并执行首任务;无可执行任务返回 False。\"\"\"\n",
" if self.config.bayes.opportunistic_measure:\n",
" self._measure_at_stop()\n",
" tasks = self._build_tasks()\n",
" if not tasks:\n",
" return False\n",
" tasks = self._refine_locate_tasks(tasks)\n",
" order = self._order_tasks(tasks)\n",
" self._execute_task(order[0])\n",
" return True\n",
"\n",
" def _measure_at_stop(self) -> None:\n",
" \"\"\"利用已停靠位置补交会方位;评分只作选测,证书仍用完整角域。\n",
"\n",
" 只测尚未在此处测过、有望显著收缩且保证距离可接收的已发现频道。\n",
" 逐频道调用measure,换频与5秒检测照常计费;不在移动中检测。\n",
" 此距离保证仅适用于第三问,不能用于未知定向源。\n",
" \"\"\"\n",
" point = self.client.state.position\n",
" channels = self.belief.channels_by_status(ChannelStatus.DETECTED)\n",
" channels.sort(key=lambda c: (c != self.client.state.channel, c))\n",
" for channel in channels:\n",
" if self._virtual_cap_reached():\n",
" return\n",
" if _point_key(point) in self._measured_points.get(channel, set()):\n",
" continue\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" continue\n",
" region = self.belief.candidate_set(channel)\n",
" farthest = max(math.dist(point, v) for v in region.polygon)\n",
" if farthest > self.config.field.r_eff_min_m - 1e-3:\n",
" continue\n",
" radius = region.mec()[1]\n",
" vertices = region.polygon\n",
" stride = max(1, len(vertices) // self.config.bayes.mc_representatives)\n",
" radii = []\n",
" delta = (self.config.field.bearing_error_half_deg\n",
" + self.config.search.bearing_rounding_margin_deg)\n",
" for target in vertices[::stride]:\n",
" theta = math.degrees(math.atan2(target[1]-point[1], target[0]-point[0]))\n",
" probe = CandidateRegion(list(vertices), region.slack_m)\n",
" probe.clip_wedge(point, theta, delta)\n",
" radii.append(probe.mec()[1] if probe.polygon else radius)\n",
" if sum(radii) / len(radii) > radius * self.config.bayes.opportunistic_radius_ratio:\n",
" continue\n",
" outcome = self.client.measure(point, channel)\n",
" self._record_measure(channel, point, outcome)\n",
" self._may_clear.add(channel)\n",
" self.opportunistic_measures += 1\n",
"\n",
" # ------------------------------------------------------------ 任务构建\n",
" def _station_pending(self, index: int) -> list[int]:\n",
" \"\"\"站点待测频道:UNKNOWN、未测过、非\"必 no_signal\"且未证缺席。\"\"\"\n",
" point = self.ledger.points[index]\n",
" pending = []\n",
" for channel, belief in self.belief.channels.items():\n",
" if belief.status is not ChannelStatus.UNKNOWN:\n",
" continue\n",
" if self.ledger.measured_at(channel, index):\n",
" continue\n",
" if self._skip_station_channel(channel, index, point):\n",
" continue\n",
" pending.append(channel)\n",
" return pending\n",
"\n",
" def _fused_channels_at(self, index: int) -> list[int]:\n",
" \"\"\"本站要测的频道 = 未发现(UNKNOWN) + 已发现但尚未定证(DETECTED)。\n",
"\n",
" P0-1:把定位并入覆盖巡回。站点三角化可行性(方案 §1.3 F11):\n",
" 99.28% 的源位置有 ≥2 个站在 r_eff_max=1500m 内,最佳交会角中位\n",
" 122.9°、5 分位 58.5°,因此\"顺路取第二条方位线\"对绝大多数源成立。\n",
" 已有证书、已在同一 0.5m 测点测过、或该点必 no_signal 的频道跳过。\n",
"\n",
" 实测结论(2026-09-12,`output/suite/q3-fuse-ab*`):本机制**不划算**。\n",
" 七站环上通常只有 2–3 个站能对该源取得有效方位线,交会角也不足以\n",
" 直接换到 20m 证书,专用信息增益探点仍然必要;仅在站点顺带测量等于\n",
" 平白多花约 11 次检测(+0.8%),若同时抑制专用探点则 +45.6%(20/20\n",
" 场景更慢)。默认关闭,保留代码以便复现与后续研究。\n",
" \"\"\"\n",
" point = self.ledger.points[index]\n",
" channels = self._station_pending(index)\n",
" selected = set(channels)\n",
" if not self.config.bayes.fuse_localize_into_tour:\n",
" return channels\n",
" for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n",
" if channel in selected or self.ledger.measured_at(channel, index):\n",
" continue\n",
" if _point_key(point) in self._measured_points.get(channel, set()):\n",
" continue\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" continue # 已有证书:交给清除任务,不再多测\n",
" if self._skip_station_channel(channel, index, point):\n",
" continue # 幸存集都在 1500m 外:该点必 no_signal\n",
" channels.append(channel)\n",
" selected.add(channel)\n",
" return channels\n",
"\n",
" def _skip_station_channel(self, channel: int, index: int, point: Vec2) -> bool:\n",
" \"\"\"跳测规则(按 pf.version 缓存;幸存集单调收缩,判定单调)。\"\"\"\n",
" pf = self.particles[channel]\n",
" if not pf:\n",
" return True\n",
" cached = self._skip_cache.get((channel, index))\n",
" if cached is not None and cached[0] == pf.version:\n",
" return cached[1]\n",
" value = pf.guaranteed_no_signal(point)\n",
" self._skip_cache[(channel, index)] = (pf.version, value)\n",
" return value\n",
"\n",
" def _build_tasks(self) -> list[Task]:\n",
" tasks: list[Task] = []\n",
" for index, point in enumerate(self.ledger.points):\n",
" pending = self._fused_channels_at(index)\n",
" if not pending:\n",
" continue\n",
" switches = len(pending) - (1 if self.client.state.channel in pending else 0)\n",
" tasks.append(Task(\"station\", tuple(point),\n",
" self.config.cost.measure_s * len(pending)\n",
" + self.config.cost.switch_s * max(switches, 0),\n",
" station_index=index, station_channels=tuple(pending)))\n",
" for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified \\\n",
" and channel not in self._clear_failed:\n",
" tasks.append(Task(\"clear\", cert.center,\n",
" self.config.cost.clear_success_s,\n",
" channel=channel))\n",
" continue\n",
" if self._defer_to_station(channel):\n",
" continue # 下一条方位线可由\"本就要去的站点\"顺路提供\n",
" point = self._select_point(channel)\n",
" if point is None:\n",
" continue\n",
" tasks.append(Task(\"locate\", point,\n",
" self.config.cost.measure_s + self.config.cost.switch_s,\n",
" channel=channel))\n",
" return tasks\n",
"\n",
" def _defer_to_station(self, channel: int) -> bool:\n",
" \"\"\"该频道能否由\"本就要去的站点\"提供下一条方位线。\n",
"\n",
" P0-1 的关键:仅仅在站点顺带测量是不够的(那只是多做一次检测),\n",
" 必须同时**抑制专用探点任务**,否则滚动排序仍会优先飞向更近的探点。\n",
" 判据要求该站本来就有 UNKNOWN 频道待测(覆盖巡回必须去),因此不会\n",
" 为了等一条方位线而制造新的绕行,也不会出现\"无任务可做\"的死锁:\n",
" 站点扫完后 ``_station_pending`` 为空,探点任务自然恢复。\n",
" \"\"\"\n",
" if not self.config.bayes.fuse_localize_into_tour:\n",
" return False\n",
" for index, point in enumerate(self.ledger.points):\n",
" if not self._station_pending(index):\n",
" continue\n",
" if self.ledger.measured_at(channel, index):\n",
" continue\n",
" if _point_key(point) in self._measured_points.get(channel, set()):\n",
" continue\n",
" if self._skip_station_channel(channel, index, point):\n",
" continue\n",
" return True\n",
" return False\n",
"\n",
" # ------------------------------------------------------------ 任务排序\n",
" def _order_tasks(self, tasks: list[Task]) -> list[Task]:\n",
" \"\"\"开放路径排序:小任务表用精确 DP,否则最近邻 + 2-opt。\n",
"\n",
" Held-Karp(S5/T1)把\"已访问集合 × 结束时测向频道\"并入状态,因此换频\n",
" 代价是精确的,而不是 :meth:`_path_cost` 里\"按频道排序\"的贪心近似。\n",
" \"\"\"\n",
" if len(tasks) <= 2:\n",
" ordered = sorted(tasks, key=lambda t: (\n",
" move_time(self.client.state.position, t.position, self.config.cost),\n",
" t.kind, t.channel or 0))\n",
" return min((ordered, ordered[::-1]), key=self._path_cost)\n",
" limit = self.config.bayes.exact_order_limit\n",
" if 0 < limit and len(tasks) <= limit:\n",
" exact = self._exact_order(tasks)\n",
" if exact is not None:\n",
" return exact\n",
" order = self._nearest_order(tasks)\n",
" if not self.config.bayes.two_opt_enabled:\n",
" return order\n",
" return self._two_opt(order)\n",
"\n",
" def _service_and_exit(self, task: Task,\n",
" channel: int) -> tuple[float, int]:\n",
" \"\"\"任务在进入频道 ``channel`` 时的固有耗时与离开后的测向频道。\"\"\"\n",
" cost = self.config.cost\n",
" if task.kind == \"locate\":\n",
" return (cost.measure_s + (cost.switch_s if channel != task.channel else 0.0),\n",
" task.channel)\n",
" if task.kind == \"station\" and task.station_channels:\n",
" pending = sorted(task.station_channels,\n",
" key=lambda c: (c != channel, c))\n",
" switches = len(pending) - (1 if channel in pending else 0)\n",
" return (cost.measure_s * len(pending) + cost.switch_s * switches,\n",
" pending[-1])\n",
" return task.service_s, channel\n",
"\n",
" def _exact_order(self, tasks: list[Task]) -> list[Task] | None:\n",
" \"\"\"Held-Karp 精确开放路径(起点固定、终点自由,含换频状态)。\n",
"\n",
" 状态 ``(mask, c)`` = 已访问任务集 mask、离开最后一个任务后测向频道为 c;\n",
" 频道取值域只需 {当前频道} ∪ {locate 频道} ∪ {station 离开频道}。\n",
" 规模 ``2^n·n·|C|``;n 超过 :attr:`BayesConfig.exact_order_limit` 时由\n",
" 调用方回退启发式。返回 ``None`` 表示状态过大(防御性)。\n",
" \"\"\"\n",
" n = len(tasks)\n",
" if n <= 1:\n",
" return list(tasks)\n",
" start = self.client.state.position\n",
" init = self.client.state.channel\n",
" channels = {init}\n",
" for task in tasks:\n",
" if task.kind == \"locate\":\n",
" channels.add(task.channel)\n",
" elif task.kind == \"station\" and task.station_channels:\n",
" # 离开频道 = 按\"当前频道优先\"排序后的最后一个:通常是最大值;\n",
" # 进入频道恰为最大值时退化为次大值,故两者都要进状态空间。\n",
" ordered = sorted(task.station_channels)\n",
" channels.add(ordered[-1])\n",
" if len(ordered) > 1:\n",
" channels.add(ordered[-2])\n",
" channels = sorted(channels)\n",
" width = len(channels)\n",
" if width * (1 << n) * n > 4_000_000:\n",
" return None\n",
" index_of = {channel: i for i, channel in enumerate(channels)}\n",
" c_init = index_of[init]\n",
" service: list[list[float]] = []\n",
" exits: list[list[int]] = []\n",
" for task in tasks:\n",
" row_service, row_exit = [], []\n",
" for channel in channels:\n",
" value, exit_channel = self._service_and_exit(task, channel)\n",
" row_service.append(value)\n",
" row_exit.append(index_of[exit_channel])\n",
" service.append(row_service)\n",
" exits.append(row_exit)\n",
" move = [[move_time(tasks[i].position, tasks[j].position, self.config.cost)\n",
" for j in range(n)] for i in range(n)]\n",
" from_start = [move_time(start, task.position, self.config.cost)\n",
" for task in tasks]\n",
" inf = float(\"inf\")\n",
" # dp[(mask, ci)] = 最小代价;parent[(mask, ci)] = (上一 mask, 上一 ci)\n",
" dp: dict[tuple[int, int], float] = {}\n",
" last_task: dict[tuple[int, int], int] = {}\n",
" parent: dict[tuple[int, int], tuple[int, int] | None] = {}\n",
" for k in range(n):\n",
" key = (1 << k, exits[k][c_init])\n",
" value = from_start[k] + service[k][c_init]\n",
" if value < dp.get(key, inf):\n",
" dp[key] = value\n",
" last_task[key] = k\n",
" parent[key] = None\n",
" for mask in range(1, 1 << n):\n",
" for ci in range(width):\n",
" state = (mask, ci)\n",
" cost = dp.get(state)\n",
" if cost is None:\n",
" continue\n",
" for k in range(n):\n",
" if (mask >> k) & 1:\n",
" continue\n",
" nxt = (mask | (1 << k), exits[k][ci])\n",
" value = cost + move[last_task[state]][k] + service[k][ci]\n",
" if value < dp.get(nxt, inf) - 1e-12:\n",
" dp[nxt] = value\n",
" last_task[nxt] = k\n",
" parent[nxt] = state\n",
" full = (1 << n) - 1\n",
" best_key, best_cost = None, inf\n",
" for ci in range(width):\n",
" state = (full, ci)\n",
" if state in dp and dp[state] < best_cost:\n",
" best_key, best_cost = state, dp[state]\n",
" if best_key is None:\n",
" return None\n",
" order: list[Task] = []\n",
" key: tuple[int, int] | None = best_key\n",
" while key is not None:\n",
" order.append(tasks[last_task[key]])\n",
" key = parent[key]\n",
" order.reverse()\n",
" return order\n",
"\n",
"\n",
"\n",
" def _nearest_order(self, tasks: list[Task]) -> list[Task]:\n",
" remaining = list(tasks)\n",
" order = []\n",
" position = self.client.state.position\n",
" while remaining:\n",
" best = min(remaining, key=lambda t: (\n",
" move_time(position, t.position, self.config.cost),\n",
" t.kind, t.channel or 0))\n",
" remaining.remove(best)\n",
" order.append(best)\n",
" position = best.position\n",
" return order\n",
"\n",
" def _path_cost(self, order: list[Task]) -> float:\n",
" total = 0.0\n",
" position = self.client.state.position\n",
" channel = self.client.state.channel\n",
" for task in order:\n",
" total += move_time(position, task.position, self.config.cost)\n",
" if task.kind == \"locate\":\n",
" total += self.config.cost.measure_s\n",
" total += self.config.cost.switch_s * (channel != task.channel)\n",
" channel = task.channel\n",
" elif task.kind == \"station\" and task.station_channels:\n",
" pending = sorted(\n",
" task.station_channels,\n",
" key=lambda c, channel=channel: (c != channel, c))\n",
" total += self.config.cost.measure_s * len(pending)\n",
" total += self.config.cost.switch_s * (len(pending) - (channel in pending))\n",
" channel = pending[-1]\n",
" else:\n",
" total += task.service_s # 清除不改变测向频道\n",
" position = task.position\n",
" return total\n",
"\n",
" def _two_opt(self, order: list[Task]) -> list[Task]:\n",
" best = list(order)\n",
" best_cost = self._path_cost(best)\n",
" for _ in range(self.config.bayes.two_opt_passes):\n",
" improved = False\n",
" for i in range(len(best) - 1):\n",
" for j in range(i + 1, len(best)):\n",
" candidate = best[:i] + best[i:j + 1][::-1] + best[j + 1:]\n",
" cost = self._path_cost(candidate)\n",
" if cost < best_cost - 1e-6:\n",
" best, best_cost, improved = candidate, cost, True\n",
" if not improved:\n",
" break\n",
" return best\n",
"\n",
" # ------------------------------------------------------------ 任务执行\n",
" def _execute_task(self, task: Task) -> None:\n",
" if task.kind == \"station\":\n",
" self._execute_station(task.station_index)\n",
" elif task.kind == \"clear\":\n",
" cert = self._best_certificate(task.channel)\n",
" if cert is not None and cert.certified:\n",
" if not self._attempt_clear(task.channel, cert.center, cert):\n",
" self._clear_failed.add(task.channel)\n",
" self._may_clear.discard(task.channel)\n",
" else:\n",
" self._clear_failed.discard(task.channel)\n",
" elif task.kind == \"locate\":\n",
" self._execute_locate(task.channel, task.position)\n",
"\n",
" def _execute_station(self, index: int) -> None:\n",
" \"\"\"单站批量扫频:只测待测频道(跳测规则已过滤),扫完即返回重排。\n",
"\n",
" P0-1:本站同时测量\"已发现未定证\"频道,使第二/第三条方位线在巡回\n",
" 途中顺路取得;证书一旦成立即就地清除,不额外绕行。\n",
" \"\"\"\n",
" point = self.ledger.points[index]\n",
" pending = self._fused_channels_at(index)\n",
" unknown = [c for c, b in self.belief.channels.items()\n",
" if b.status is ChannelStatus.UNKNOWN]\n",
" self.skipped_measures += len(unknown) - len(\n",
" [c for c in pending if self.belief.channels[c].status\n",
" is ChannelStatus.UNKNOWN])\n",
" # 当前频道优先,减少换频;同站 near 直接清除不打断扫频\n",
" pending.sort(key=lambda c: (c != self.client.state.channel, c))\n",
" for channel in pending:\n",
" if self.stop_reason is not None or self._count_limit_reached() \\\n",
" or self._virtual_cap_reached():\n",
" return\n",
" detected_before = (self.belief.channels[channel].status\n",
" is ChannelStatus.DETECTED)\n",
" outcome = self.client.measure(point, channel)\n",
" self.ledger.mark_measured(channel, index)\n",
" self._record_measure(channel, point, outcome)\n",
" if outcome.result in (DIRECTION, NEAR):\n",
" self._may_clear.add(channel)\n",
" if outcome.result == NEAR:\n",
" region = self.belief.candidate_set(channel)\n",
" cert = clear_certificate(region, channel, self.config)\n",
" if cert.certified:\n",
" if self._attempt_clear(channel, point, cert):\n",
" self._clear_failed.discard(channel)\n",
" else:\n",
" self._clear_failed.add(channel)\n",
" self._may_clear.discard(channel)\n",
" else:\n",
" self._anomaly(\"near_without_certificate\", channel, position=point)\n",
" continue\n",
" if not detected_before:\n",
" continue\n",
" if outcome.result == NO_SIGNAL:\n",
" # 该频道已被发现:保证接收盘内不可能 no_signal,属守恒性异常\n",
" region = self.belief.candidate_set(channel)\n",
" safe = (safe_disk_radius(region, self.config)\n",
" if region is not None else -1.0)\n",
" if safe >= 0 and math.dist(point, region.mec()[0]) <= safe:\n",
" self._anomaly(\"no_signal_in_safe_disk\", channel,\n",
" position=point)\n",
" continue\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" self._clear_failed.discard(channel)\n",
" else:\n",
" self._clear_failed.add(channel)\n",
" self._may_clear.discard(channel)\n",
"\n",
" def _execute_locate(self, channel: int, point: Vec2) -> None:\n",
" \"\"\"单步定位测量:执行后立即重排(滚动时域),收敛则顺路清除。\"\"\"\n",
" pf = self.particles[channel]\n",
" before = len(pf)\n",
" outcome = self.client.measure(point, channel)\n",
" self._record_measure(channel, point, outcome)\n",
" self._may_clear.add(channel)\n",
" if outcome.result == NO_SIGNAL:\n",
" region = self.belief.candidate_set(channel)\n",
" if region is not None and safe_disk_radius(region, self.config) >= 0 \\\n",
" and math.dist(point, region.mec()[0]) \\\n",
" <= safe_disk_radius(region, self.config):\n",
" self._anomaly(\"no_signal_in_safe_disk\", channel, position=point)\n",
" after = len(pf)\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" self._clear_failed.discard(channel)\n",
" self._stall.pop(channel, None)\n",
" return\n",
" self._clear_failed.add(channel)\n",
" self._may_clear.discard(channel)\n",
" if after == before:\n",
" self._stall[channel] = self._stall.get(channel, 0) + 1\n",
" self.no_shrink_measures += 1 # 只是浪费不破坏守恒性,不进异常表\n",
" else:\n",
" self._stall[channel] = 0\n",
"\n",
" # ------------------------------------------------------------ 感知与证书\n",
" def _record_measure(self, channel: int, position: Vec2, outcome) -> None:\n",
" \"\"\"belief(凸证书)与 PF(幸存集)同步更新;PF 空即证缺席。\"\"\"\n",
" self.belief.update_measure(channel, position, outcome.result,\n",
" outcome.svd_deg)\n",
" # 同一 0.5m 测点 × 同一频道的重复检测:历史上一次官方演练有 98.84%\n",
" # 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。\n",
" key = _point_key(position)\n",
" seen = self._measured_points.setdefault(channel, set())\n",
" if key in seen:\n",
" self.redundant_rescan_measures += 1\n",
" seen.add(key)\n",
" self._clear_failed.discard(channel) # 新证据落地:允许再次尝试清除\n",
" belief = self.belief.channels[channel]\n",
" if belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):\n",
" return # 已证毕:清除/缺席后的 no_signal 与距离无关,不进 PF\n",
" pf = self.particles[channel]\n",
" pf.apply_measure(position, outcome.result, outcome.svd_deg)\n",
" if not pf:\n",
" if belief.status is ChannelStatus.UNKNOWN:\n",
" self.belief.mark_absent(channel)\n",
" self.absence_evidence[channel] = \"pf_empty\"\n",
" if channel not in self.pf_absent_channels:\n",
" self.pf_absent_channels.append(channel)\n",
" else:\n",
" self._anomaly(\"pf_empty_while_detected\", channel, position=position)\n",
" self._apply_count_bound()\n",
"\n",
" def _apply_count_bound(self) -> None:\n",
" \"\"\"存在证据达到题面源数上界时,其余 UNKNOWN 可由计数直接判缺席。\n",
"\n",
" 依据 [题面]:每频道至多一个源、全局限定 10..16 个源。一旦\n",
" DETECTED/CLEARED 的不同频道数达到 16,任何尚未出现有效信号的\n",
" 频道都不可能是源,无须等 7 站覆盖账本跑完。该判定与位置无关,\n",
" 是严格的计数证明;evidence 与 coverage/pf_empty 分开留痕。\n",
" \"\"\"\n",
" present = [channel for channel, belief in self.belief.channels.items()\n",
" if belief.status in (ChannelStatus.DETECTED,\n",
" ChannelStatus.CLEARED)]\n",
" if len(present) < self.config.field.source_count_max:\n",
" return\n",
" for channel, belief in self.belief.channels.items():\n",
" if belief.status is ChannelStatus.UNKNOWN:\n",
" belief.status = ChannelStatus.ABSENT\n",
" self.absence_evidence[channel] = \"count_bound\"\n",
"\n",
" def _best_certificate(self, channel: int) -> ClearCertificate | None:\n",
" \"\"\"凸证书优先;负观测挖洞使 PF 幸存集更紧时用网格证书补位。\"\"\"\n",
" region = self.belief.candidate_set(channel)\n",
" cert = clear_certificate(region, channel, self.config) \\\n",
" if region is not None else None\n",
" if cert is not None and cert.certified:\n",
" return self._near_clear_target(cert)\n",
" if self.config.bayes.grid_cert_enabled:\n",
" pf = self.particles[channel]\n",
" if len(pf):\n",
" center, radius = pf.mec()\n",
" limit = (self.config.field.clear_radius_m - pf.margin_m\n",
" - self.config.search.clear_margin_m)\n",
" if radius <= limit:\n",
" return self._near_clear_target(\n",
" ClearCertificate(channel, center, radius, limit, True))\n",
" return cert\n",
"\n",
" def _near_clear_target(self, cert: ClearCertificate) -> ClearCertificate:\n",
" \"\"\"沿当前来向利用20m清除容差;三角不等式保留整集覆盖证明。\"\"\"\n",
" position = self.client.state.position\n",
" distance = math.dist(position, cert.center)\n",
" shift = min(distance, max(0.0, cert.limit_m - cert.radius_m - 1e-6))\n",
" if distance <= 1e-9 or shift <= 0:\n",
" return cert\n",
" target = tuple(c + (p-c)*shift/distance for p, c in zip(position, cert.center))\n",
" return ClearCertificate(cert.channel, target, cert.radius_m+shift, cert.limit_m, True)\n",
"\n",
" # ------------------------------------------------------------ 信息增益选点\n",
" def _probe_context(self, channel: int):\n",
" \"\"\"构造某频道下一次定位测量的候选集与信息增益评分函数。\n",
"\n",
" 返回 ``(candidates, info_score, mec_center, current_position)``;\n",
" 候选为空返回 ``None``。``_select_point`` 与路线感知精修共用此逻辑,\n",
" 保证两者只差“移动代价如何折算”。\n",
" \"\"\"\n",
" bayes, search = self.config.bayes, self.config.search\n",
" pf = self.particles[channel]\n",
" if not pf:\n",
" return None\n",
" region = self.belief.candidate_set(channel)\n",
" center, radius = pf.mec()\n",
" measured = self._measured_points.get(channel, set())\n",
" position = self.client.state.position\n",
" candidates: list[Vec2] = []\n",
" safe = safe_disk_radius(region, self.config) if region is not None else -1.0\n",
" if region is not None and safe >= 0:\n",
" # 保证接收盘(凸证书口径):任意候选必产生 direction/near\n",
" candidates = candidate_points(tuple(region.mec()[0]), safe,\n",
" search.candidate_rings,\n",
" search.candidate_angles)\n",
" # 以实际来向生成对称交会候选,避免固定全局角度网格的旋转偏差。\n",
" geometric_center = region.mec()[0]\n",
" base_angle = math.atan2(position[1] - geometric_center[1],\n",
" position[0] - geometric_center[0])\n",
" for fraction in search.candidate_rings:\n",
" if fraction <= 0:\n",
" continue\n",
" for offset in (-math.pi/3, -math.pi/6, 0, math.pi/6, math.pi/3):\n",
" angle = base_angle + offset\n",
" candidates.append((geometric_center[0] + safe * fraction * math.cos(angle),\n",
" geometric_center[1] + safe * fraction * math.sin(angle)))\n",
" # 已停靠位置若本身在安全接收盘内,是零移动的合法测点;显式加入\n",
" # 可避免为了固定角度环而白白横移。\n",
" if math.dist(position, geometric_center) <= safe + 1e-9:\n",
" candidates.append(tuple(position))\n",
" else:\n",
" # 宽候选集:PF MEC 探索环(负观测挖洞后幸存集的包围圆)\n",
" for fraction in bayes.wide_rings:\n",
" ring = max(radius * fraction, 2.0 * pf.margin_m)\n",
" candidates.extend(candidate_points(center, ring, (1.0,),\n",
" search.candidate_angles))\n",
" candidates.append(tuple(center))\n",
" candidates.append(tuple(position))\n",
" candidates = [tuple(c) for c in candidates\n",
" if _point_key(c) not in measured]\n",
" if not candidates:\n",
" return None\n",
" # 采样一次转成数组(numpy 可用时),使候选评分走向量路径;\n",
" # numpy 缺失时 as_sample_array 原样返回列表,语义不变。\n",
" subsample = as_sample_array(pf.subsample(bayes.score_subsample))\n",
" stride = max(len(subsample) // bayes.mc_representatives, 1)\n",
" representatives = subsample[::stride]\n",
"\n",
" def info_score(q: Vec2) -> float:\n",
" if bayes.score_mode == \"entropy\":\n",
" return pf.expected_survival(q, subsample, representatives)\n",
" return pf.expected_posterior_radius(q, subsample, representatives)\n",
"\n",
" return candidates, info_score, center, position\n",
"\n",
" def _select_point(self, channel: int) -> Vec2 | None:\n",
" \"\"\"定位测量点:期望 log 幸存占比 + λ·移动时间 最小的候选。\"\"\"\n",
" context = self._probe_context(channel)\n",
" if context is None:\n",
" return None\n",
" candidates, info_score, center, position = context\n",
" bayes = self.config.bayes\n",
"\n",
" def score(q: Vec2) -> float:\n",
" # 一次定位后仍需接近源才能清除;仅计到检测点的移动会偏好绕行。\n",
" move = bayes.lambda_move_s * (move_time(position, q, self.config.cost)\n",
" + move_time(q, center, self.config.cost))\n",
" return info_score(q) + move\n",
"\n",
" return min(candidates, key=lambda q: (score(q), _point_key(q)))\n",
"\n",
" def _refine_locate_tasks(self, tasks: list[Task]) -> list[Task]:\n",
" \"\"\"路线感知精修定位任务点(P2,2026-09-12)。\n",
"\n",
" 旧评分只比较 ``current->q + q->MEC``,是局部代理;滚动 2-opt 的真实\n",
" 代价还包含 ``q->next_task``。这里对每个 locate 任务在候选点上计算\n",
" “替换该任务后整条当前路线代价的增量”,再与信息增益、MEC 接近项合并\n",
" 选点。只改变选点启发式,不改变覆盖/清除证明。\n",
" \"\"\"\n",
" bayes = self.config.bayes\n",
" if not bayes.route_aware_probe:\n",
" return tasks\n",
" locate_indices = [index for index, task in enumerate(tasks)\n",
" if task.kind == \"locate\"]\n",
" if not locate_indices:\n",
" return tasks\n",
" base_order = self._order_tasks(tasks)\n",
" base_cost = self._path_cost(base_order)\n",
" refined = list(tasks)\n",
" for task_index in locate_indices:\n",
" task = tasks[task_index]\n",
" context = self._probe_context(task.channel)\n",
" if context is None:\n",
" continue\n",
" candidates, info_score, center, position = context\n",
" try:\n",
" order_index = next(index for index, item in enumerate(base_order)\n",
" if item is task)\n",
" except StopIteration: # pragma: no cover - 同对象应始终存在\n",
" order_index = next(index for index, item in enumerate(base_order)\n",
" if item.kind == \"locate\"\n",
" and item.channel == task.channel)\n",
" best_score: float | None = None\n",
" best_point: Vec2 | None = None\n",
" for point in candidates:\n",
" trial_order = list(base_order)\n",
" trial_order[order_index] = Task(\n",
" \"locate\", tuple(point), task.service_s, channel=task.channel)\n",
" route_delta = self._path_cost(trial_order) - base_cost\n",
" move_proxy = bayes.lambda_move_s * (\n",
" move_time(position, point, self.config.cost)\n",
" + move_time(point, center, self.config.cost))\n",
" clear_near = move_time(point, center, self.config.cost)\n",
" value = (info_score(point)\n",
" + move_proxy\n",
" + bayes.route_aware_insert_weight * route_delta\n",
" + bayes.route_aware_clear_weight * clear_near)\n",
" if best_score is None or value < best_score:\n",
" best_score, best_point = value, tuple(point)\n",
" if best_point is not None:\n",
" refined[task_index] = Task(\n",
" \"locate\", best_point, task.service_s, channel=task.channel)\n",
" return refined\n",
"\n",
" # ------------------------------------------------------------ 安全网与复扫\n",
" def _localize_and_clear(self, channel: int) -> bool:\n",
" \"\"\"阻塞式定位子循环(resolve 阶段安全网):PF 选点,失败退凸选点。\"\"\"\n",
" search = self.config.search\n",
" self._may_clear.add(channel)\n",
" for _ in range(search.max_localize_steps):\n",
" if self._virtual_cap_reached():\n",
" return False\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified and channel in self._may_clear:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" self._clear_failed.discard(channel)\n",
" return True\n",
" self._clear_failed.add(channel)\n",
" self._may_clear.discard(channel)\n",
" continue\n",
" point = self._select_point(channel)\n",
" if point is None:\n",
" region = self.belief.candidate_set(channel)\n",
" if region is None:\n",
" return False\n",
" point = select_measurement_point(\n",
" region, self.client.state.position, self.config).position\n",
" outcome = self.client.measure(point, channel)\n",
" self._record_measure(channel, point, outcome)\n",
" self._may_clear.add(channel)\n",
" if outcome.result == NO_SIGNAL:\n",
" region = self.belief.candidate_set(channel)\n",
" if region is not None and safe_disk_radius(region, self.config) >= 0 \\\n",
" and math.dist(point, region.mec()[0]) \\\n",
" <= safe_disk_radius(region, self.config):\n",
" self._anomaly(\"no_signal_in_safe_disk\", channel, position=point)\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" if self._attempt_clear(channel, cert.center, cert):\n",
" self._clear_failed.discard(channel)\n",
" return True\n",
" self._clear_failed.add(channel)\n",
" self._may_clear.discard(channel)\n",
" return self.belief.channels[channel].status is ChannelStatus.CLEARED\n",
"\n",
" def _persistent_round(self) -> None:\n",
" \"\"\"一轮复扫:与基线一致的全站复测,但读数同步进 PF(防假设偏差)。\"\"\"\n",
" measured: set[tuple[int, int]] = set()\n",
" while self.stop_reason is None:\n",
" index = self._next_resweep_point(measured)\n",
" if index is None:\n",
" return\n",
" point = self.ledger.points[index]\n",
" for channel in self._resweep_channels_at(index, measured):\n",
" if self._virtual_cap_reached():\n",
" return\n",
" measured.add((channel, index))\n",
" was_absent = (self.belief.channels[channel].status\n",
" is ChannelStatus.ABSENT)\n",
" outcome = self.client.measure(point, channel)\n",
" self._record_measure(channel, point, outcome)\n",
" if was_absent and outcome.result in (DIRECTION, NEAR):\n",
" # 复测推翻了缺席证明:守恒性留痕,按新证据定位清除。\n",
" self._anomaly(\"absent_detected_on_resweep\", channel,\n",
" position=point, result=outcome.result)\n",
" if outcome.result == DIRECTION:\n",
" self._localize_and_clear(channel)\n",
" elif outcome.result == NEAR:\n",
" cert = self._best_certificate(channel)\n",
" if cert is not None and cert.certified:\n",
" if not self._attempt_clear(channel, cert.center, cert):\n",
" self._clear_failed.add(channel)\n",
" else:\n",
" self._anomaly(\"near_without_certificate\", channel,\n",
" position=point)\n",
" if self.stop_reason is not None:\n",
" return\n",
"\n",
" # ------------------------------------------------------------ 收尾\n",
" def _summary(self, info) -> dict:\n",
" report = super()._summary(info)\n",
" report[\"strategy\"] = \"bayes\"\n",
" report[\"opportunistic_measures\"] = self.opportunistic_measures\n",
" report[\"skipped_measures\"] = self.skipped_measures\n",
" report[\"no_shrink_measures\"] = self.no_shrink_measures\n",
" report[\"pf_absent_channels\"] = sorted(self.pf_absent_channels)\n",
" report[\"pf_alive_final\"] = {\n",
" str(channel): len(self.particles[channel])\n",
" for channel in sorted(self.particles)\n",
" if self.belief.channels[channel].status is ChannelStatus.DETECTED}\n",
" return report\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.4 第三问对照策略:七站批量扫频(`planner/seven.py`)\n",
"\n",
"同一骨架换覆盖计划:7 站解析覆盖 + 站内批量扫频 + 按\"距候选集 MEC 圆心最近\"服务队列。\n",
"它曾以官方案例 12/12 全清通过演练,是\"覆盖证明 + 批量调度\"的中间对照。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class SevenBatch(OmnidirectionalLoop):\n",
" \"\"\"站内只扫频,扫完按最近候选集圆心顺序服务检测队列(A2 主策略)。\"\"\"\n",
"\n",
"\n",
" def _serve_queue(self):\n",
" # Each detected channel gets at most one localization call in this pass.\n",
" queue = self.belief.channels_by_status(ChannelStatus.DETECTED)\n",
" while queue:\n",
" c = min(queue, key=lambda c: (math.dist(self.client.state.position,\n",
" self.belief.candidate_set(c).mec()[0]), c))\n",
" queue.remove(c)\n",
" self._localize_and_clear(c)\n",
" if self._count_limit_reached():\n",
" break\n",
"\n",
" def _coverage_phase(self):\n",
" while not self._count_limit_reached():\n",
" index = self._next_point_index()\n",
" if index is None:\n",
" return\n",
" point = self.ledger.points[index]\n",
" pending = self._pending_channels_at(index)\n",
" # Keeping the current measuring channel first saves a switch when possible.\n",
" pending.sort(key=lambda c: (c != self.client.state.channel, c))\n",
" for c in pending:\n",
" outcome = self.client.measure(point, c)\n",
" self.ledger.mark_measured(c, index)\n",
" self.belief.update_measure(c, point, outcome.result, outcome.svd_deg)\n",
" if outcome.result == NEAR:\n",
" cert = clear_certificate(self.belief.candidate_set(c), c, self.config)\n",
" # Same-station near clear; do not displace the station scan.\n",
" if cert.certified:\n",
" self._attempt_clear(c, point, cert)\n",
" if self._count_limit_reached():\n",
" return\n",
" self._serve_queue()\n",
"\n",
" def _resolve_detected(self):\n",
" self._serve_queue()\n",
" unresolved = [c for c, b in self.belief.channels.items()\n",
" if b.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)]\n",
" return \"all_resolved\" if not unresolved else \"unresolved_channels\"\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 第三部分 · 逐问算例与自检\n",
"\n",
"以下单元用**合成观测**驱动上面的求解器(合成观测的生成规则单独写明,\n",
"不依赖评测机)。每个单元末尾用断言检查结论,运行通过即说明该问的代码链路正确。\n",
"\n",
"## 7. 问题一:角域交会定位\n",
"\n",
"三个检测点、±1° 误差的示向度。输出:定位区域多边形顶点、直径、直径圆是否覆盖整个区域、\n",
"最小包围圆(MEC)半径,以及\"MEC 半径 ≤ 20 m\"的清除证书。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"Q1_EXAMPLES = {\n",
" \"normal\": { # 三点交会:区域有界、MEC 远小于 20 m\n",
" \"target\": (80.0, 90.0),\n",
" \"measurements\": [(-600.0, -250.0, 27.21505117707799),\n",
" (650.0, -100.0, 161.015051177078),\n",
" (100.0, 700.0, 268.4721225527146)],\n",
" },\n",
" \"triangle\": { # 直径圆反例:直径圆不覆盖区域\n",
" \"target\": (20.0, 11.547005383792515),\n",
" \"measurements\": [(-1000.0, 0.0, 1.0),\n",
" (540.0, -866.0254037844386, 121.0),\n",
" (520.0, 900.6664199358162, 241.0)],\n",
" },\n",
" \"poor\": { # 近共线:细长区域,MEC 远大于直径圆\n",
" \"target\": (0.0, 0.0),\n",
" \"measurements\": [(-900.0, -20.0, 1.29), (0.0, 0.0, 0.0), (900.0, 20.0, 358.73)],\n",
" },\n",
"}\n",
"\n",
"def solve_q1(case: str, rounding_margin_deg: float = 0.0) -> dict:\n",
" \"\"\"复现 `mcm26b q1 examples/q1/<case>.json` 的求解口径。\"\"\"\n",
" rows = Q1_EXAMPLES[case][\"measurements\"]\n",
" measurements = [Measurement(x, y, bearing, 1.0 + rounding_margin_deg)\n",
" for x, y, bearing in rows]\n",
" return solve(measurements)\n",
"\n",
"normal = solve_q1(\"normal\")\n",
"print(\"== 三点交会 ==\")\n",
"print(\"状态 :\", normal[\"status\"], \"顶点数\", len(normal[\"vertices\"]))\n",
"print(\"直径 : %.3f m\" % normal[\"diameter_m\"])\n",
"print(\"直径圆覆盖区域 :\", normal[\"diameter_circle\"][\"covers\"],\n",
" \"最大超出 %.3f m\" % normal[\"diameter_circle\"][\"max_excess_m\"])\n",
"print(\"最小包围圆 : 圆心 (%.3f, %.3f) 半径 %.3f m\"\n",
" % (*normal[\"minimum_enclosing_circle\"][\"center\"],\n",
" normal[\"minimum_enclosing_circle\"][\"radius_m\"]))\n",
"print(\"可原地清除 :\", normal[\"guaranteed_clearable_at_mec_center\"])\n",
"assert normal[\"status\"] == \"polygon\" and normal[\"diameter_m\"] > 0\n",
"assert normal[\"diameter_infinite\"] is False\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 反例:直径圆的圆心是\"最远顶点对\"的中点,第二个最远顶点可能落在圆外。\n",
"tri = solve_q1(\"triangle\")\n",
"print(\"== 直径圆反例(等边三角形) ==\")\n",
"print(\"定位区域顶点:\", [[round(v, 2) for v in p] for p in tri[\"vertices\"]])\n",
"print(\"直径 %.3f m(最远顶点对 %s)\" % (tri[\"diameter_m\"],\n",
" [[round(v, 2) for v in p] for p in tri[\"diameter_pair\"]]))\n",
"print(\"直径圆:圆心 %s 半径 %.3f m\" % ([round(v, 2) for v in\n",
" tri[\"diameter_circle\"][\"center\"]], tri[\"diameter_circle\"][\"radius_m\"]))\n",
"print(\"圆外顶点下标 %s(见证顶点 %s),最大超出 %.3f m\"\n",
" % (tri[\"diameter_circle\"][\"outside_vertex_indices\"],\n",
" [round(v, 2) for v in tri[\"diameter_circle\"][\"witness_vertex\"]],\n",
" tri[\"diameter_circle\"][\"max_excess_m\"]))\n",
"print(\"直径圆覆盖整个定位区域:\", tri[\"diameter_circle\"][\"covers\"])\n",
"print(\"最小包围圆半径 %.3f m(>= 直径/2 = %.1f m,才是真正的最小覆盖半径)\"\n",
" % (tri[\"minimum_enclosing_circle\"][\"radius_m\"], tri[\"diameter_m\"] / 2))\n",
"assert tri[\"diameter_circle\"][\"covers\"] is False\n",
"assert tri[\"diameter_circle\"][\"outside_vertex_indices\"] == [2]\n",
"# 一般地 直径/2 <= 最小包围圆半径;等边三角形取严格大于(23.09 > 20)\n",
"assert tri[\"minimum_enclosing_circle\"][\"radius_m\"] >= tri[\"diameter_m\"] / 2 - 1e-9\n",
"\n",
"poor = solve_q1(\"poor\")\n",
"print(\"\\n== 近共线细长区域 ==\")\n",
"print(\"状态 %s,直径 %.1f m,最小包围圆半径 %.1f m,可原地清除 %s\"\n",
" % (poor[\"status\"], poor[\"diameter_m\"],\n",
" poor[\"minimum_enclosing_circle\"][\"radius_m\"],\n",
" poor[\"guaranteed_clearable_at_mec_center\"]))\n",
"assert poor[\"minimum_enclosing_circle\"][\"radius_m\"] > 20.0\n",
"assert poor[\"guaranteed_clearable_at_mec_center\"] is False\n",
"\n",
"empty = solve([])\n",
"unbounded = solve([Measurement(-600.0, -250.0, 27.21505117707799, 1.0)])\n",
"print(\"\\n无观测 ->\", empty[\"status\"], \"| 单条示向度 ->\", unbounded[\"status\"],\n",
" \"(衰退方向 %s)\" % unbounded[\"recession_direction\"])\n",
"assert empty[\"status\"] == \"unbounded\" and unbounded[\"status\"] == \"unbounded\"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 示向度二位小数的舍入敏感性:误差半角 1.0° vs 1.005°(含 0.005° 舍入裕量)\n",
"tight = solve_q1(\"normal\", 0.0)\n",
"loose = solve_q1(\"normal\", 0.005)\n",
"print(\"舍入裕量 0.000°:直径 %.3f m,MEC 半径 %.3f m\"\n",
" % (tight[\"diameter_m\"], tight[\"minimum_enclosing_circle\"][\"radius_m\"]))\n",
"print(\"舍入裕量 0.005°:直径 %.3f m,MEC 半径 %.3f m\"\n",
" % (loose[\"diameter_m\"], loose[\"minimum_enclosing_circle\"][\"radius_m\"]))\n",
"print(\"放宽 %.3f° 使 MEC 半径增加 %.3f m —— 因此库默认带该裕量,\"\n",
" \"否则合法真源可能被角域裁掉\" % (0.005, loose[\"minimum_enclosing_circle\"][\"radius_m\"]\n",
" - tight[\"minimum_enclosing_circle\"][\"radius_m\"]))\n",
"assert loose[\"minimum_enclosing_circle\"][\"radius_m\"] >= \\\n",
" tight[\"minimum_enclosing_circle\"][\"radius_m\"] - 1e-9\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 8. 问题二:第二检测点的选择\n",
"\n",
"给定首次 `direction` 读数(检测点 s₁、示向度 θ₁±1°),构造\n",
"K₁ = Ω ∩ W(s₁, θ₁±δ_eff) ∩ B(s₁, 1500),再在**保证接收盘**内选第二点:\n",
"\n",
"- 目标函数:候选读数分支下的**最坏后验包围半径**(越小越好,直接决定能否进入 20 m 清除圈);\n",
"- 次指标:从当前位置出发的移动时间(同分决胜);\n",
"- 采样是启发式(候选点族 × 目标边界点 × 读数区间端点),选定后按 1/4 间距加密复核并如实上报。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class _StateStub:\n",
" \"\"\"最小状态替身:只提供选点器需要的当前位置与当前测向频道。\"\"\"\n",
"\n",
" def __init__(self, position=(0.0, 0.0), channel: int = 1):\n",
" self.position = position\n",
" self.channel = channel\n",
"\n",
"\n",
"class _ClientStub:\n",
" def __init__(self, position=(0.0, 0.0), channel: int = 1):\n",
" self.state = _StateStub(position, channel)\n",
"\n",
"\n",
"CONFIG = ProblemConfig()\n",
"\n",
"def plan_second_point(station, bearing_deg, from_position=(0.0, 0.0)):\n",
" \"\"\"问题二主入口:首次示向度 -> 保证接收的第二检测点方案。\"\"\"\n",
" client = _ClientStub(from_position, channel=1)\n",
" return select_second_point(client, station, bearing_deg, CONFIG)\n",
"\n",
"\n",
"def plan_second_point_from(region, from_position=(0.0, 0.0)):\n",
" \"\"\"已知候选集时的下一测点(同一选点器,供逐次追加测点演示)。\"\"\"\n",
" return select_measurement_point(region, from_position, CONFIG)\n",
"\n",
"\n",
"# 首次检测:真源在 (300, 400),从原点测得示向度 53.13°,误差半角 ±1°\n",
"station1, bearing1 = (0.0, 0.0), 53.13010235415598\n",
"cert = first_bearing_candidate_disk(Measurement(*station1, bearing1, 1.0))\n",
"print(\"首次角域扇形的包围圆:圆心 (%.1f, %.1f) 半径 %.1f m\"\n",
" % (*cert[\"sector_enclosing_circle\"][\"center\"],\n",
" cert[\"sector_enclosing_circle\"][\"radius_m\"]))\n",
"print(\"保证接收盘 :圆心 (%.1f, %.1f) 半径 %.1f m\"\n",
" % (*cert[\"sufficient_candidate_disk\"][\"center\"],\n",
" cert[\"sufficient_candidate_disk\"][\"radius_m\"]))\n",
"\n",
"# 首次读数后的候选集 K1 = Ω ∩ W(s1, θ1±δ_eff) ∩ B(s1, 1500)\n",
"half = 1.0 + CONFIG.search.bearing_rounding_margin_deg\n",
"region = CandidateRegion.initial(station1, CONFIG)\n",
"region.clip_wedge(station1, bearing1, half)\n",
"\n",
"plan = plan_second_point(station1, bearing1)\n",
"print(\"\\n第二检测点 (%8.2f, %8.2f)\" % plan.position)\n",
"print(\"最坏后验包围半径 %.2f m(当前候选集 MEC 半径 %.1f m,必须更小才叫有信息)\"\n",
" % (plan.worst_posterior_radius_m, region.mec()[1]))\n",
"print(\"移动耗时 %.1f s(次指标:同样精度下取更近的点)\" % plan.move_cost_s)\n",
"print(\"依据:\", plan.reason)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 用同一套观测语义复核这个点:无论真源落在候选集何处,第二点都必收到信号。\n",
"# 候选集边界按 1 m 加密采样做最坏情形检验。\n",
"worst_distance, worst_radius, samples = 0.0, 0.0, 0\n",
"for truth in region.witnesses(1.0):\n",
" worst_distance = max(worst_distance, math.dist(plan.position, truth))\n",
" for reading in (bearing_deg(plan.position, truth) - half,\n",
" bearing_deg(plan.position, truth) + half):\n",
" probe = CandidateRegion(list(region.polygon), region.slack_m)\n",
" probe.clip_wedge(plan.position, reading, half)\n",
" if probe.polygon:\n",
" worst_radius = max(worst_radius, probe.mec()[1])\n",
" samples += 1\n",
"print(\"候选集边界采样点 %d 个\" % samples)\n",
"print(\"真源到第二点的最远距离 %.2f m(<= 1000 m 才保证收到信号)\" % worst_distance)\n",
"print(\"对候选集最坏点、最不利读数的后验包围半径 %.2f m\" % worst_radius)\n",
"print(\"第二点相对候选集包围圆的保证接收余量:%.1f m\"\n",
" % (CONFIG.field.r_eff_min_m - region.mec()[1] - math.dist(plan.position,\n",
" region.mec()[0])))\n",
"assert worst_distance <= CONFIG.field.r_eff_min_m # 保证接收:不会浪费 no_signal\n",
"assert worst_radius < region.mec()[1] # 一定带来收缩\n",
"\n",
"# 逐次追加测点:最坏后验半径单调收缩,第三点后进入 20 m 清除圈\n",
"for step in range(2, 5):\n",
" if plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m:\n",
" break\n",
" region.clip_wedge(plan.position, bearing_deg(plan.position, (300.0, 400.0)), half)\n",
" plan = plan_second_point_from(region, (0.0, 0.0))\n",
" print(\"追加第 %d 个测点 (%8.2f, %8.2f):最坏后验半径 %.2f m\"\n",
" % (step, *plan.position, plan.worst_posterior_radius_m))\n",
"print(\"\\n结论:第二点负责\\\"保证接收 + 最大化交会角\\\",后验精度由后续测点继续压缩;\"\n",
" \"20 m 清除圈通常在第三点后达成。\")\n",
"assert plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 对照:候选集在不同首次示向度下的选择结果(目标函数与移动代价的权衡)\n",
"print(\"首次示向度 第二点位置 最坏后验半径 移动耗时\")\n",
"for bearing in (0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0):\n",
" item = plan_second_point(station1, bearing, from_position=(-1500.0, 0.0))\n",
" print(\"%7.1f° (%8.2f, %8.2f) %8.2f m %6.1f s\"\n",
" % (bearing, item.position[0], item.position[1],\n",
" item.worst_posterior_radius_m, item.move_cost_s))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 9. 问题三:全向源闭环\n",
"\n",
"第三问没有可直连的模拟器时,仍可自检全部**决策内核**:\n",
"\n",
"1. 七站覆盖计划是否真的覆盖 Ω(每点到最近站 ≤ 1000 m);\n",
"2. 网格幸存集在随机观测序列后是否始终保留真源(Voronoi 裕量定理);\n",
"3. 空幸存集、跳测规则、清除证书是否按预期给出;\n",
"4. 任务表排序(最近邻 + 2-opt)是否真的缩短开放路径。\n",
"\n",
"合成观测按下述语义生成,与题面一致(`r_eff` 未知,只在 [1000, 1500] 内取值):\n",
"`dist > r_eff → no_signal`;`dist ≤ 5 → near`;否则 `direction`,读数为真实方位\n",
"加上 ≤1° 的误差。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def observe_omni(truth, station, r_eff, bearing_noise_deg=0.0):\n",
" \"\"\"全向源的观测语义(合成用):返回 (result, svd_deg)。\"\"\"\n",
" distance = math.dist(truth, station)\n",
" if distance > r_eff:\n",
" return \"no_signal\", None\n",
" if distance <= 5.0:\n",
" return \"near\", None\n",
" return \"direction\", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0\n",
"\n",
"\n",
"def observe_directional(truth, heading_deg, station, r_eff, bearing_noise_deg=0.0):\n",
" \"\"\"定向源的观测语义:源→读点方向与朝向夹角 > 90° 时同样静默。\"\"\"\n",
" dx, dy = station[0] - truth[0], station[1] - truth[1]\n",
" distance = math.hypot(dx, dy)\n",
" if distance > r_eff:\n",
" return \"no_signal\", None\n",
" forward = dx * math.cos(math.radians(heading_deg)) \\\n",
" + dy * math.sin(math.radians(heading_deg))\n",
" if forward < 0.0:\n",
" return \"no_signal\", None # 背向发射:接收不到\n",
" if distance <= 5.0:\n",
" return \"near\", None\n",
" return \"direction\", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0\n",
"\n",
"\n",
"# ---- 9.1 七站解析覆盖计划 ---------------------------------------------------\n",
"CONFIG_Q3 = replace(CONFIG, bayes=replace(BayesConfig(), station_ring_radius_m=1200.0))\n",
"bound = validate_seven_coverage(CONFIG_Q3.bayes.station_ring_radius_m, CONFIG_Q3)\n",
"plan7 = seven_plan(CONFIG_Q3.bayes.station_ring_radius_m)\n",
"print(\"七站环半径 %.1f m,最近站最远距离解析上界 %.1f m(阈值 1000 m)\"\n",
" % (CONFIG_Q3.bayes.station_ring_radius_m, bound))\n",
"print(\"站点:\", [tuple(round(v, 1) for v in point) for point in plan7.points])\n",
"\n",
"# 数值复核必须与证书同分辨率:这里用 300 m / 12 档演示(全分辨率证书见 4.4 节)\n",
"cert7 = verify_full_disk(plan7.points, step_m=300.0, r_eff=R_EFF_MIN_M)\n",
"print(\"粗采样复核:目标点 %d 个,未覆盖 %d 个,最坏命中 %.1f m,最近邻巡回 %.1f km\"\n",
" % (cert7.targets, cert7.uncovered, cert7.worst_hit_distance_m, cert7.nn_tour_m / 1000))\n",
"assert cert7.uncovered == 0 and cert7.worst_hit_distance_m <= R_EFF_MIN_M\n",
"assert bound <= CONFIG_Q3.field.r_eff_min_m\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 9.2 网格幸存集的真源保留性(Voronoi 裕量定理的随机检验) ----------------\n",
"Random(20260913).seed(20260913)\n",
"source = (300.0, 400.0) # 真源\n",
"r_eff_true = 1200.0 # 真源的有效接收半径(策略端不可见)\n",
"stations = [(x, y) for x, y in\n",
" [(-900.0, 200.0), (0.0, 0.0), (600.0, -700.0), (1100.0, 800.0)]]\n",
"\n",
"pf = ChannelParticles(CONFIG_Q3)\n",
"start_alive = len(pf)\n",
"retained = True\n",
"for index, station in enumerate(stations):\n",
" result, svd = observe_omni(source, station, r_eff_true,\n",
" bearing_noise_deg=0.8 if index % 2 else -0.9)\n",
" pf.apply_measure(station, result, svd)\n",
" cell = (min(pf.alive, key=lambda k: math.dist(pf.cell_center(k), source))\n",
" if pf.alive else None)\n",
" truth_offset = (math.dist(pf.cell_center(cell), source) if cell is not None\n",
" else float(\"nan\"))\n",
" keeps_truth = (cell is not None\n",
" and truth_offset <= pf.margin_m + 1e-9)\n",
" retained = retained and keeps_truth\n",
" center, radius = pf.mec()\n",
" print(\"第 %d 次读数 %-9s 幸存格 %6d 格心距真源 %7.3f m(裕量 %.3f m) MEC 半径 %8.3f m\"\n",
" % (index + 1, result, len(pf), truth_offset, pf.margin_m, radius))\n",
"print(\"\\n初始幸存格 %d;真源始终落在某个幸存格内:%s\" % (start_alive, retained))\n",
"print(\"Voronoi 裕量 m = %.3f m(格心到格内任一点的最大距离)\" % pf.margin_m)\n",
"assert retained and pf.version > 0\n",
"\n",
"# 跳测规则:必 no_signal 且不产生新排除的 (测点, 频道) 对应当被直接跳过\n",
"far_station = (4000.0, 4000.0)\n",
"print(\"远端测点 %s 是否必 no_signal:%s\" % (far_station, pf.guaranteed_no_signal(far_station)))\n",
"assert pf.guaranteed_no_signal(far_station) is True\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 9.3 证书与缺席证明 -----------------------------------------------------\n",
"center, radius = pf.mec()\n",
"limit = CONFIG_Q3.field.clear_radius_m - pf.margin_m - CONFIG_Q3.search.clear_margin_m\n",
"print(\"幸存集 MEC:圆心 (%.2f, %.2f) 半径 %.3f m\" % (*center, radius))\n",
"print(\"网格清除证书阈值 %.3f m(= 20 − 裕量 %.3f − 工程余量 %.1f)→ 可清除:%s\"\n",
" % (limit, pf.margin_m, CONFIG_Q3.search.clear_margin_m, radius <= limit))\n",
"\n",
"# near 读数把候选集收缩到 5 m 盘:凸口径直接给出清除证书\n",
"near_region = CandidateRegion.disk((source[0] + 3.0, source[1]), 5.0,\n",
" CONFIG_Q3.search.polygon_sides)\n",
"near_cert = clear_certificate(near_region, channel=7, config=CONFIG_Q3)\n",
"print(\"near 读数后的凸证书:半径 %.3f m ≤ %.3f m → %s\"\n",
" % (near_cert.radius_m, near_cert.limit_m, near_cert.certified))\n",
"assert near_cert.certified\n",
"\n",
"# 网格清除证书成立时,目标点可直接贴着来向利用 20 m 容差(三角不等式仍覆盖整集)\n",
"near_cert2 = loop_cert = None\n",
"center, radius = pf.mec()\n",
"shift = max(0.0, (CONFIG_Q3.field.clear_radius_m - pf.margin_m\n",
" - CONFIG_Q3.search.clear_margin_m) - radius)\n",
"shifted = (center[0] + shift * 0.6, center[1] + shift * 0.8)\n",
"print(\"沿来向偏移后清除点距真源 %.3f m <= 半径 %.3f + 偏移 %.3f = %.3f m < 20 m\"\n",
" % (math.dist(shifted, source), radius, shift, radius + shift))\n",
"\n",
"# 缺席证明:覆盖计划(七站)全 no_signal 后幸存集清空 —— 与解析证明互相印证\n",
"absent = ChannelParticles(CONFIG_Q3)\n",
"for station in plan7.points:\n",
" absent.apply_measure(station, \"no_signal\")\n",
"print(\"\\n七站全部 no_signal 后幸存格 %d → 该频道判缺席(无需等覆盖账本跑完)\"\n",
" % len(absent))\n",
"partial = ChannelParticles(CONFIG_Q3)\n",
"for station in plan7.points[:-1]:\n",
" partial.apply_measure(station, \"no_signal\")\n",
"print(\"只测其中 6 站仍有 %d 个幸存格 → 七站是刚好够用的最小解析覆盖\"\n",
" % len(partial))\n",
"assert len(absent) == 0 and len(partial) > 0\n",
"assert absent.guaranteed_no_signal(tuple(plan7.points[0])) is True\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 9.4 统一任务表与滚动排序(信息增益选点 + 2-opt) ------------------------\n",
"class _LogStub:\n",
" def action_events(self):\n",
" return []\n",
"\n",
" def step_events(self):\n",
" return []\n",
"\n",
"\n",
"client3 = SimulatorClient(\"OFFLINE\", config=CONFIG_Q3, transport=None, log=_LogStub())\n",
"client3.state.position = (0.0, 0.0)\n",
"loop3 = BayesLoop(client3, CONFIG_Q3)\n",
"print(\"第三问策略:覆盖计划 %s(%d 站),网格间距 %.2f m\"\n",
" % (loop3.coverage_plan.name, len(loop3.coverage_plan.points),\n",
" CONFIG_Q3.bayes.particle_spacing_m))\n",
"\n",
"# 注入一次真实读数,建立该频道的信念与粒子集\n",
"loop3._record_measure(9, (0.0, 0.0), type(\"O\", (), {\n",
" \"result\": \"direction\", \"svd_deg\": bearing_deg((0.0, 0.0), (300.0, 400.0))})())\n",
"tasks = loop3._build_tasks()\n",
"print(\"任务表:%d 项(站点扫频 %d / 定位 %d / 清除 %d)\"\n",
" % (len(tasks), sum(t.kind == \"station\" for t in tasks),\n",
" sum(t.kind == \"locate\" for t in tasks), sum(t.kind == \"clear\" for t in tasks)))\n",
"order = loop3._order_tasks(tasks)\n",
"print(\"排序后前 5 项:\", [(t.kind, t.channel) for t in order[:5]])\n",
"print(\"2-opt 前后路径耗时 %.2f s -> %.2f s\"\n",
" % (loop3._path_cost(tasks), loop3._path_cost(order)))\n",
"assert loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9\n",
"\n",
"point = loop3._select_point(9)\n",
"print(\"信息增益选点:(%8.2f, %8.2f)\" % point)\n",
"assert point is not None and 9 in loop3.particles\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 9.5 覆盖计划证书:离线全分辨率生成,运行期按同分辨率复核 ----------------------------------\n",
"plan_v2 = plan_instance(\"q4_mincover_v2\", verify=False)\n",
"cert_v2 = plan_v2.certificate\n",
"print(\"第四问覆盖计划 %s:%d 点\" % (plan_v2.name, len(plan_v2.points)))\n",
"print(\"证书采样分辨率 %g m / %d 档,目标 (p,h) %d 个,缺口 %d,最坏命中 %.3f m\"\n",
" % (cert_v2.target_step_m, cert_v2.headings, cert_v2.targets,\n",
" cert_v2.uncovered, cert_v2.worst_hit_distance_m))\n",
"print(\"最近邻巡回 %.1f km(虚拟移动 %.1f s)→ 发现阶段的移动代价下界\"\n",
" % (cert_v2.nn_tour_m / 1000, cert_v2.nn_tour_m / CONFIG.cost.speed_mps))\n",
"print(\"设计裕量:r_design %.0f m、前向角裕量 %.1f°;校核用 r_eff %.0f m、角裕量 %.1f°\"\n",
" % (plan_v2.generator[\"design_r_eff_m\"], plan_v2.generator[\"heading_margin_deg\"],\n",
" cert_v2.r_eff_min_m, cert_v2.verify_heading_margin_deg))\n",
"assert cert_v2.uncovered == 0 and cert_v2.targets == 3256200\n",
"\n",
"plan_v1 = plan_instance(\"q4_mincover_v1\", verify=False)\n",
"print(\"\\n对照 %s:同样 %d 点,但证书采样只有 %g m / %d 档,细网格复检有 125 个缺口\"\n",
" % (plan_v1.name, len(plan_v1.points), plan_v1.generator[\"target_step_m\"],\n",
" plan_v1.generator[\"headings\"]))\n",
"print(\"→ 采样分辨率必须与证书一致,否则'删掉一个关键点仍看似覆盖'(校核退化为装饰)\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 10. 问题四:含定向源的闭环\n",
"\n",
"定向源的难点是**静默的歧义**:`no_signal` 既可能是距离不足,也可能是朝向遮挡。\n",
"本问的关键推理与对策:\n",
"\n",
"1. **联合可行集**:给每个幸存格附 24 档朝向掩码。`direction`/`near` 要求朝向\n",
" *可能*覆盖读点;`no_signal`(且整格必在 1000 m 内)要求朝向*确定不*覆盖读点。\n",
" 真源 (位置, 朝向) 永不被删;联合集为空即缺席证明。\n",
"2. **可见性选点**:`prob_detect` 给出\"在该点能看见任一可行 (位置,朝向) 的概率质量\",\n",
" 先在可见候选中过滤(`prob_detect ≥ ratio × max`),再取\"看得见且少走路\"的点,\n",
" 取代固定 45° 阶梯试探。\n",
"3. **后备清除**:定位停滞时对有界候选集构造 ≤20 m 有限圆盘覆盖逐格 `/clear`——\n",
" 清除只与距离有关、与朝向无关,是完备兜底(格数即请求上界,超限即拒绝盲扫)。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 10.1 定向覆盖最小命中集的覆盖语义 --------------------------------------\n",
"plan_q4 = plan_instance(\"q4_mincover_v2\", verify=False)\n",
"coarse = verify_forward_half_disk(plan_q4.points, step_m=300.0, headings=12,\n",
" r_eff=R_EFF_MIN_M, heading_margin_deg=0.0)\n",
"print(\"最小命中集 %d 点,粗采样复核 (p,h) %d 个:未覆盖 %d,最坏命中 %.1f m\"\n",
" % (len(plan_q4.points), coarse.targets, coarse.uncovered,\n",
" coarse.worst_hit_distance_m))\n",
"square49 = square_49_plan()\n",
"coarse49 = verify_forward_half_disk(square49.points, step_m=300.0, headings=12)\n",
"print(\"对照 700 m 方格 %d 点:同样未覆盖 %d,巡回 %.1f km vs %.1f km\"\n",
" % (len(square49.points), coarse49.uncovered,\n",
" coarse49.nn_tour_m / 1000, coarse.nn_tour_m / 1000))\n",
"assert coarse.uncovered == 0 and coarse.targets > 0\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 10.2 (位置 × 朝向) 联合可行集的保守性随机检验 --------------------------\n",
"truth4 = (420.0, -260.0) # 真源位置\n",
"heading4 = 200.0 # 真源朝向(背向圆心发射的边界工况)\n",
"r_eff4 = 1100.0 # 真源的有效接收半径(策略端不可见)\n",
"\n",
"\n",
"def station_at(bearing_from_truth_deg, distance_m):\n",
" \"\"\"按\"从真源看过去的方位\"构造测点,便于控制可见/遮挡工况。\"\"\"\n",
" return (truth4[0] + distance_m * math.cos(math.radians(bearing_from_truth_deg)),\n",
" truth4[1] + distance_m * math.sin(math.radians(bearing_from_truth_deg)))\n",
"\n",
"\n",
"# 前向半球内的测点(|方位 − 朝向| ≤ 90°):应收到 direction;\n",
"# 超过 1100 m 的测点:无论朝向如何都 no_signal。\n",
"CASES = [(heading4 - 85.0, 900.0), (heading4 + 85.0, 700.0), (heading4, 600.0),\n",
" (heading4 + 30.0, 1050.0), (heading4 - 60.0, 400.0), (heading4 + 130.0, 800.0),\n",
" (heading4, 1250.0)]\n",
"\n",
"joint = JointBelief(CONFIG)\n",
"half_bin = 0.5 * 360.0 / HEADING_BINS # 档位半宽 7.5°:真实朝向必落在某一档内\n",
"expected = [\"direction\"] * 5 + [\"no_signal\"] * 2\n",
"for (bearing, dist_m), want in zip(CASES, expected):\n",
" station = station_at(bearing, dist_m)\n",
" result, svd = observe_directional(truth4, heading4, station, r_eff4)\n",
" joint.apply(station, result, svd)\n",
" cell = min(joint.position.alive,\n",
" key=lambda k: math.dist(joint.position.cell_center(k), truth4))\n",
" offset = math.dist(joint.position.cell_center(cell), truth4)\n",
" mask = joint._mask(cell)\n",
" keeps = any(heading_gap(360.0 * k / HEADING_BINS, heading4) <= half_bin\n",
" for k in range(HEADING_BINS) if mask >> k & 1)\n",
" print(\"测点方位 %6.1f° 距离 %5.0f m → %-9s(预期 %-9s)幸存格 %6d \"\n",
" \"真源格偏移 %6.3f m(裕量 %.3f m)朝向档 %2d 个 含真源朝向档:%s\"\n",
" % (bearing % 360.0, dist_m, result, want, len(joint), offset,\n",
" joint.position.margin_m, bin(mask).count(\"1\"), keeps))\n",
" assert result == want, \"合成观测与题面语义不一致\"\n",
" assert keeps, \"联合信念删掉了真源的朝向档(保守性被破坏)\"\n",
" assert offset <= joint.position.margin_m + 1e-9, \\\n",
" \"联合信念删掉了真源的位置格(Voronoi 裕量被破坏)\"\n",
"\n",
"print(\"\\n联合集规模 %d 格,抽样平均朝向档 %.2f / %d\"\n",
" % (len(joint), joint.summary()[\"sampled_mean_heading_bins\"], HEADING_BINS))\n",
"assert not joint.is_absent()\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 10.3 可见性选点取代固定阶梯;后备清除兜底 ------------------------------\n",
"print(\"探点可见性概率(越高越可能在该点收到信号):\")\n",
"for candidate in [(-2000.0, 0.0), (0.0, 0.0), (420.0, -260.0), (1500.0, -900.0),\n",
" (420.0 + 800.0 * math.cos(math.radians(heading4)),\n",
" -260.0 + 800.0 * math.sin(math.radians(heading4)))]:\n",
" print(\" (%8.1f, %8.1f) prob_detect = %.4f\"\n",
" % (*candidate, joint.prob_detect(candidate)))\n",
"\n",
"# 定位停滞时的后备:候选集有界 -> 20 m 网格覆盖,格心到格内任一点 <= 10√2 < 20 m\n",
"region4 = CandidateRegion.initial((0.0, 0.0), CONFIG)\n",
"region4.clip_wedge((0.0, 0.0), 45.0, 1.0 + CONFIG.search.bearing_rounding_margin_deg)\n",
"region4.clip_disk((900.0, 900.0), 900.0, CONFIG.search.polygon_sides)\n",
"cells = fallback_clear_cells(region4.polygon, CONFIG)\n",
"width = max(p[0] for p in region4.polygon) - min(p[0] for p in region4.polygon)\n",
"height = max(p[1] for p in region4.polygon) - min(p[1] for p in region4.polygon)\n",
"print(\"\\n候选集外包框 %.0f m × %.0f m,20 m 网格覆盖 %d 格(外包框枚举上界 %d)\"\n",
" % (width, height, len(cells), fallback_cell_bound(width, height, CONFIG)))\n",
"print(\"格内任意点到最近格心 <= %.2f m < 20 m → 逐格 /clear 至少命中一次\"\n",
" % (CONFIG.search.fallback_clear_grid_m * math.sqrt(2) / 2))\n",
"assert cells and len(cells) <= CONFIG.search.fallback_max_cells\n",
"\n",
"# 覆盖完备性抽查:在候选集外包框内随机撒点,每个点都必落在某个格心 20 m 内\n",
"rng = Random(7)\n",
"covered = 0\n",
"for _ in range(2000):\n",
" u, v = rng.random(), rng.random()\n",
" sample = (min(p[0] for p in region4.polygon) + u * width,\n",
" min(p[1] for p in region4.polygon) + v * height)\n",
" if not region4.contains(sample, tol_m=0.0):\n",
" continue # 框内但在候选集外:不计入\n",
" covered += min(math.dist(sample, cell) for cell in cells) <= 20.0\n",
"print(\"外包框内落在候选集中的随机点,被某个格心 20 m 覆盖:%d 个\" % covered)\n",
"assert covered > 0\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 10.4 第四问闭环装配(不连模拟器:只检查策略对象初始化) ----------------\n",
"client4 = SimulatorClient(\"OFFLINE\", config=CONFIG, transport=None, log=_LogStub())\n",
"client4.state.position = (0.0, 0.0)\n",
"loop4 = DirectionalExtension(client4, CONFIG)\n",
"print(\"第四问策略:覆盖计划 %s(%d 点),联合信念开关 %s,探点口径 %s\"\n",
" % (loop4.coverage_plan.name, len(loop4.coverage_plan.points),\n",
" CONFIG.search.directional_joint_belief, CONFIG.search.probe_score_mode))\n",
"print(\"后备清除硬顶 %d 格,单轮遮挡探点预算 %d 次,定位步数上限 %d\"\n",
" % (CONFIG.search.fallback_max_cells, CONFIG.search.max_blocked_probes,\n",
" CONFIG.search.max_localize_steps))\n",
"\n",
"# 保证接收盘内的 no_signal 只能由朝向遮挡解释:模拟\"被遮挡\"的梯子升级\n",
"blocked_point = (600.0, 600.0)\n",
"loop4._observe(3, blocked_point, \"direction\",\n",
" bearing_deg(blocked_point, truth4))\n",
"cert4 = clear_certificate(loop4.belief.candidate_set(3), 3, CONFIG)\n",
"print(\"\\n频道 3 候选集半径 %.1f m,可清除:%s\" % (cert4.radius_m, cert4.certified))\n",
"safe = safe_disk_radius(loop4.belief.candidate_set(3), CONFIG)\n",
"print(\"保证接收盘半径 %.1f m(盘内必收到信号 -> 静默即遮挡)\" % safe)\n",
"loop4._observe(3, blocked_point, \"no_signal\", None)\n",
"next_probe = loop4._next_probe(loop4.belief.candidate_set(3), 3)\n",
"print(\"被遮挡后梯子升级到的探点:(%8.2f, %8.2f)\"\n",
" % next_probe)\n",
"assert loop4.joint.get(3) is not None and not loop4.joint[3].is_absent()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 第四部分 · 总览与自检\n",
"\n",
"## 11. 本文件包含 / 不包含的内容\n",
"\n",
"**包含**(四问生产路径):\n",
"\n",
"| 层 | 模块 | 内容 |\n",
"| --- | --- | --- |\n",
"| 参数 | `config.py` | 题面常量 + 工程参数 |\n",
"| 协议 | `protocol/` | 规则表、计时模型、状态机、传输、会话日志、串行幂等客户端 |\n",
"| 几何 | `geometry/` | 角域交会定位、直径圆、最小包围圆、保证接收盘证书 |\n",
"| 信念 | `belief/` | 保守凸候选集、(位置×朝向) 联合可行集 |\n",
"| 滤波 | `particles.py` | 网格幸存集、负观测更新、跳测规则、信息增益评分 |\n",
"| 保证 | `coverage/` `guarantee/` | 覆盖账本、覆盖计划与证书、最小命中集 |\n",
"| 调度 | `routing.py` | 开放路径排序(最近邻 + 2-opt) |\n",
"| 策略 | `planner/` | 问题二选点器、全向闭环骨架、贝叶斯闭环(第三问默认)、定向扩展(第四问) |\n",
"\n",
"**不包含**(按要求剔除):\n",
"\n",
"- `referee/`:独立评测机(模拟器引擎 + 机器狗接口 + WebUI)——按附件协议独立复现,\n",
" 与本队求解代码零耦合,不属求解方案;\n",
"- `src/mcm26b/harness/`:测试侧内存回环、故障注入、日志回放、场景生成;\n",
"- `src/mcm26b/cli.py`、`geometry/__main__.py`、`main.py`:命令行外壳;\n",
"- `scripts/`:采集、基准、Optuna 调参、可视化、论文插图与校核脚本;\n",
"- `project/`:1-4 问\"最优实现 / 基线实现\"对比框架;`tests/`:单元测试;\n",
"- 未被生产路径调用的函数:`routing.py` 的离线度量族(`held_karp_open` /\n",
" `optimal_open_path` / `or_opt_open` / `mst_weight` / `route_metrics`,\n",
" 仅用于事后路由下界审计)、`guarantee/cover.py` 的计划落盘 IO、\n",
" `planner/seven.py` 的未采用变体(`SevenImmediate` / `SevenSweep`)。\n",
" 这些在仓库中保留并有测试守护,默认关闭或不被实例化。\n",
"\n",
"## 12. 运行口径\n",
"\n",
"```bash\n",
"# 对接官方模拟器(演练/正式)\n",
"python -m mcm26b.cli run --robot-id <队号> --problem 3 # 第三问:bayes 闭环\n",
"python -m mcm26b.cli run --robot-id <队号> --problem 4 # 第四问:directional 闭环\n",
"```\n",
"\n",
"成绩口径是**虚拟时间**:`avg_locate_clear_time_s = 全程虚拟时长 / 已清除源数`,\n",
"找全证明达成后立即 `/exit` 而不是复扫到限时。\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ---- 总自检:把四问的核心结论再跑一遍,全部通过才算整理成功 ----------------\n",
"checks = []\n",
"\n",
"q1 = solve_q1(\"normal\")\n",
"checks.append((\"问1 角域交会有界且直径有限\",\n",
" q1[\"status\"] == \"polygon\" and q1[\"diameter_infinite\"] is False))\n",
"checks.append((\"问1 三点交会可原地清除(MEC ≤ 20 m)\",\n",
" q1[\"guaranteed_clearable_at_mec_center\"] is True))\n",
"checks.append((\"问1 直径圆反例被识别(covers=False)\",\n",
" solve_q1(\"triangle\")[\"diameter_circle\"][\"covers\"] is False))\n",
"\n",
"plan2 = plan_second_point(station1, bearing1)\n",
"checks.append((\"问2 第二点在保证接收盘内\",\n",
" math.dist(plan2.position, region.mec()[0])\n",
" <= safe_disk_radius(region, CONFIG)))\n",
"checks.append((\"问2 追加三点后最坏后验半径 ≤ 20 m\",\n",
" plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m))\n",
"\n",
"checks.append((\"问3 七站解析覆盖上界 < 1000 m\",\n",
" bound < CONFIG_Q3.field.r_eff_min_m))\n",
"checks.append((\"问3 七站计划粗采样无缺口\", cert7.uncovered == 0))\n",
"checks.append((\"问3 粒子滤波保留真源\", retained))\n",
"checks.append((\"问3 空幸存集给出缺席证明\", len(absent) == 0))\n",
"checks.append((\"问3 2-opt 不劣于原任务序\",\n",
" loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9))\n",
"\n",
"checks.append((\"问4 最小命中集 31 点、证书 0 缺口\",\n",
" len(plan_q4.points) == 31 and cert_v2.uncovered == 0))\n",
"checks.append((\"问4 粗采样复核定向覆盖无缺口\", coarse.uncovered == 0))\n",
"checks.append((\"问4 联合信念保留真源朝向档\", not joint.is_absent()))\n",
"checks.append((\"问4 后备清除格数在硬顶内\",\n",
" len(cells) <= CONFIG.search.fallback_max_cells))\n",
"checks.append((\"问4 闭环装配成功(定向覆盖计划已加载)\",\n",
" loop4.coverage_plan.name.startswith(\"q4_mincover\")))\n",
"\n",
"print(\"四问核心自检:\")\n",
"for name, ok in checks:\n",
" print(\" [%s] %s\" % (\"通过\" if ok else \"失败\", name))\n",
"assert all(ok for _, ok in checks), \"自检未全部通过\"\n",
"print(\"\\n全部 %d 项通过。\" % len(checks))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10",
"mimetype": "text/x-python",
"file_extension": ".py",
"pygments_lexer": "ipython3",
"nbconvert_exporter": "python",
"codemirror_mode": {
"name": "ipython",
"version": 3
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}