5841 lines
323 KiB
Plaintext
5841 lines
323 KiB
Plaintext
{
|
||
"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
|
||
}
|