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:
@@ -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()
|
||||
Reference in New Issue
Block a user