feat(q3): 贝叶斯网格粒子滤波闭环(PF负观测+信息增益选点+2-opt调度)

- particles.py: 网格幸存集滤波,Voronoi 裕量保证真源保留(缺席证明/网格清除证书/跳测规则)
- planner/bayes_loop.py: 统一任务循环(站点/定位/清除滚动 2-opt 排序)、期望后验半径选点
- planner/seven.py: 七点覆盖策略族提升为库代码
- cli: mcm26b run --strategy {grid,seven,bayes}
- BayesConfig 参数组;tests +24 项(真源保留/证书/端到端),129 全绿
This commit is contained in:
Horldsence
2026-09-11 09:02:28 +08:00
parent 7930eb65bf
commit e0665c73fb
9 changed files with 1335 additions and 67 deletions
+154
View File
@@ -0,0 +1,154 @@
"""P4b 贝叶斯闭环端到端验收:全清、PF 缺席证明、预算与故障。
对齐 test_loop_q3.py 的验收口径(docs/algo-3-bayes.md):
- 10/16 源场景全部清除且守恒性零异常(凸证书 + 网格证书均不得失效);
- PF 缺席证明(幸存集为空)与覆盖账本口径一致,不误判有源频道;
- 无源场景全部频道证明缺席后正常退出(persistent 关闭时 all_resolved);
- 近圆域边界源(r_eff=1000)不漏搜;
- 持续复扫(persistent 默认开):找全后复扫到虚拟安全余量,
accounted_virtual_time_s 记录首次证明找全时刻;
- 网络故障幂等重试不污染闭环结果;
- 虚拟时刻逐步复算一致。
"""
import math
import unittest
from dataclasses import replace
from mcm26b.config import ProblemConfig
from mcm26b.evaluation import recompute_virtual_time
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.protocol.client import SimulatorClient
from mcm26b.sim import (LocalSimTransport, LocalSimulator,
ScriptedFaultTransport, Source)
from mcm26b.sim.scenes import random_omni_scene
CONFIG = replace(ProblemConfig(),
search=replace(ProblemConfig().search,
bearing_rounding_margin_deg=0.005))
SMALL_CAP = replace(CONFIG,
field=replace(CONFIG.field, max_virtual_duration_s=60_000.0))
NO_PERSIST = replace(CONFIG, search=replace(CONFIG.search,
persistent_search=False))
def _run_scene(sources, fault_script=None, config=SMALL_CAP):
simulator = LocalSimulator(sources, robot_id="T001", config=config)
transport = LocalSimTransport(simulator)
if fault_script is not None:
transport = ScriptedFaultTransport(transport, fault_script)
client = SimulatorClient("T001", config=config, transport=transport)
loop = BayesLoop(client, config)
summary = loop.run()
report = recompute_virtual_time(client.log.action_events())
return summary, report, simulator
class BayesLoopAcceptanceTests(unittest.TestCase):
def test_ten_sources_all_cleared(self):
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
summary, report, simulator = _run_scene(sources)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertEqual(summary["unresolved_channels"], [])
self.assertIsNotNone(summary["accounted_virtual_time_s"])
self.assertEqual(summary["anomalies"], [], "守恒性或证书不应被打破")
self.assertEqual(report["mismatches"], [])
self.assertEqual(sorted(simulator.cleared), truth)
self.assertEqual(summary["exit_reason"], "user_exit")
self.assertEqual(summary["strategy"], "bayes")
def test_sixteen_sources_stop_by_count_bound(self):
sources = random_omni_scene(seed=6, count=16, config=CONFIG)
summary, report, simulator = _run_scene(sources)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "sixteen_cleared")
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertEqual(report["mismatches"], [])
self.assertEqual(summary["anomalies"], [])
def test_empty_arena_all_absent(self):
"""0 源:每个频道都必须由 PF 排除盘证明缺席后正常退出。"""
summary, report, simulator = _run_scene([], config=NO_PERSIST)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "all_resolved")
self.assertEqual(sorted(summary["absent_channels"]),
list(range(1, 21)))
self.assertEqual(summary["pf_absent_channels"], list(range(1, 21)))
self.assertEqual(summary["clears"], 0)
self.assertEqual(summary["anomalies"], [])
self.assertEqual(report["mismatches"], [])
def test_boundary_sources_detected_and_cleared(self):
"""近圆域边界源(r_eff=1000):七站覆盖半径 968.9m 保证发现。"""
sources = [Source(c + 1, 1800.0 * math.cos(math.pi / 6 + 2 * math.pi * c / 4),
1800.0 * math.sin(math.pi / 6 + 2 * math.pi * c / 4),
1000.0) for c in range(4)]
summary, report, simulator = _run_scene(sources, config=NO_PERSIST)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertEqual(summary["anomalies"], [])
self.assertEqual(report["mismatches"], [])
def test_persistent_phase_resweeps_until_cap(self):
"""找全后持续复扫到虚拟安全余量 [用户决策 2026-09-11]。"""
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
summary, _report, _simulator = _run_scene(sources, config=SMALL_CAP)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "virtual_time_cap")
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertGreater(summary["measures"], 7 * 20,
"找全后应继续复扫未清除频道")
def test_transport_fault_retries_keep_session_clean(self):
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
script = [("drop_before", 1)] # 首个请求丢弃一次:幂等重试路径
summary, report, simulator = _run_scene(sources, fault_script=script)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertEqual(report["mismatches"], [])
def test_skip_rule_never_skips_detectable_channel(self):
"""跳测规则只允许跳过"必 no_signal"的 (站,频道) 对。"""
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
simulator = LocalSimulator(sources, robot_id="T001", config=CONFIG)
client = SimulatorClient("T001", config=CONFIG,
transport=LocalSimTransport(simulator))
loop = BayesLoop(client, CONFIG)
loop.run()
from mcm26b.belief import ChannelStatus
for index, point in enumerate(loop.ledger.points):
for channel, belief in loop.belief.channels.items():
if belief.status is not ChannelStatus.UNKNOWN:
continue
skipped = loop._skip_station_channel(channel, index, point)
if skipped and len(loop.particles[channel]):
nearest = loop.particles[channel].nearest_alive_distance(
point, loop.particles[channel].r_eff_max_m
+ loop.particles[channel].margin_m)
self.assertIsNone(
nearest, f"频道 {channel} 站点 {index} 被跳过但可能检出")
def test_cli_strategy_registry(self):
"""CLI --strategy 注册表:bayes/seven 用七站,grid 用 49 点网格。"""
import mcm26b.cli as cli
from mcm26b.coverage import coverage_points
from mcm26b.planner.seven import SevenBatch, seven_points
self.assertIs(cli.Q3_STRATEGIES["bayes"], BayesLoop)
for name, loop_cls in cli.Q3_STRATEGIES.items():
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
client = SimulatorClient("T001", config=CONFIG,
transport=LocalSimTransport(simulator))
loop = loop_cls(client, CONFIG)
expected = seven_points() if name in ("bayes", "seven") \
else coverage_points(CONFIG)
self.assertEqual(loop.ledger.points, expected)
if __name__ == "__main__":
unittest.main()