1
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""P4 全向闭环端到端验收:10/16/0 源、边界源、预算耗尽与故障注入。
|
||||
|
||||
对应 docs/B题建模分析与实施计划.md P4 行验收门槛(自建部分):
|
||||
- 10/16 源场景正确退出,全清或数量上界证明;
|
||||
- 近圆域边界源不漏搜、可清除;
|
||||
- 虚拟时刻逐步复算与服务器响应一致;
|
||||
- 现实预算耗尽退出记为未完成,不与成功混记;
|
||||
- 网络故障重试(幂等回放)不污染闭环结果。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from itertools import count
|
||||
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.coverage import coverage_points
|
||||
from mcm26b.evaluation import recompute_virtual_time
|
||||
from mcm26b.planner import OmnidirectionalLoop
|
||||
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))
|
||||
|
||||
|
||||
def _run_scene(sources, fault_script=None, clock=None):
|
||||
simulator = LocalSimulator(sources, robot_id="T001", config=CONFIG)
|
||||
transport = LocalSimTransport(simulator)
|
||||
if fault_script is not None:
|
||||
transport = ScriptedFaultTransport(transport, fault_script)
|
||||
kwargs = {"clock": clock} if clock is not None else {}
|
||||
client = SimulatorClient("T001", config=CONFIG, transport=transport, **kwargs)
|
||||
loop = OmnidirectionalLoop(client, CONFIG)
|
||||
summary = loop.run()
|
||||
report = recompute_virtual_time(client.log.action_events())
|
||||
return summary, report, simulator
|
||||
|
||||
|
||||
class LoopAcceptanceTests(unittest.TestCase):
|
||||
def test_ten_sources_all_cleared(self):
|
||||
sources = random_omni_scene(seed=5, count=10)
|
||||
summary, report, simulator = _run_scene(sources)
|
||||
truth = sorted(s.channel for s in sources)
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(summary["stop_reason"], "all_resolved")
|
||||
self.assertEqual(sorted(summary["cleared_channels"]), truth)
|
||||
self.assertEqual(len(summary["absent_channels"]), 10)
|
||||
self.assertEqual(summary["unresolved_channels"], [])
|
||||
self.assertEqual(summary["anomalies"], [], "守恒性或证书不应被打破")
|
||||
self.assertEqual(report["mismatches"], [])
|
||||
self.assertEqual(summary["virtual_recompute_mismatches"], 0)
|
||||
self.assertEqual(sorted(simulator.cleared), truth)
|
||||
self.assertEqual(summary["exit_reason"], "user_exit")
|
||||
self.assertLess(summary["virtual_time_s"],
|
||||
CONFIG.field.max_virtual_duration_s)
|
||||
|
||||
def test_sixteen_sources_stop_by_count_bound(self):
|
||||
sources = random_omni_scene(seed=6, count=16)
|
||||
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(sorted(simulator.cleared), truth)
|
||||
|
||||
def test_empty_field_proves_absence_everywhere(self):
|
||||
summary, report, _ = _run_scene([])
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(summary["stop_reason"], "all_resolved")
|
||||
self.assertEqual(summary["cleared_channels"], [])
|
||||
self.assertEqual(summary["absent_channels"], list(range(1, 21)))
|
||||
self.assertEqual(summary["measures"], 49 * 20, "缺席证明要求全网格逐频道检测")
|
||||
self.assertEqual(report["mismatches"], [])
|
||||
|
||||
def test_boundary_source_close_to_domain_edge(self):
|
||||
"""半径 1790m、接收半径 1000m 的源:覆盖保证的最近网格点必须发现它。"""
|
||||
source = Source(channel=13, x=1790.0, y=0.0, r_eff_m=1000.0)
|
||||
summary, _, simulator = _run_scene([source])
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(sorted(summary["cleared_channels"]), [13])
|
||||
self.assertEqual(sorted(simulator.cleared), [13])
|
||||
|
||||
def test_source_on_grid_vertex(self):
|
||||
source = Source(channel=20, x=-1400.0, y=700.0, r_eff_m=1500.0)
|
||||
summary, _, _ = _run_scene([source])
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(sorted(summary["cleared_channels"]), [20])
|
||||
|
||||
def test_real_budget_exhaustion_exits_cleanly(self):
|
||||
"""现实预算耗尽:立即退出并记为未证明完成。"""
|
||||
ticks = count(0.0, 200.0) # 每次查询时钟前进 200s,迅速耗尽 1200s 预算
|
||||
sources = random_omni_scene(seed=8, count=4)
|
||||
summary, _, _ = _run_scene(sources, clock=lambda: next(ticks))
|
||||
self.assertFalse(summary["completed"])
|
||||
self.assertEqual(summary["stop_reason"], "real_budget_exhausted")
|
||||
self.assertEqual(summary["exit_reason"], "user_exit")
|
||||
|
||||
def test_fault_injection_during_coverage(self):
|
||||
"""覆盖中途注入读超时与断连:幂等回放保证闭环结果不变。"""
|
||||
sources = random_omni_scene(seed=9, count=3)
|
||||
script = [None, None, None, "timeout_after", None,
|
||||
"drop_before", None, ("status", 500), None, None]
|
||||
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"], [])
|
||||
self.assertEqual(sorted(simulator.cleared), truth)
|
||||
|
||||
|
||||
class CoverageLedgerIntegrationTests(unittest.TestCase):
|
||||
def test_grid_matches_plan_derivation(self):
|
||||
points = coverage_points(CONFIG)
|
||||
self.assertEqual(len(points), 49)
|
||||
self.assertEqual(min(p[0] for p in points), -2100.0)
|
||||
self.assertEqual(max(p[1] for p in points), 2100.0)
|
||||
# 单元内任意点到最近网格点 <= 700*sqrt(2) <= 1000 的覆盖保证
|
||||
worst = 0.0
|
||||
for gx in range(-20, 21):
|
||||
for gy in range(-20, 21):
|
||||
g = (gx * 90.0, gy * 90.0)
|
||||
if (g[0] ** 2 + g[1] ** 2) ** 0.5 <= 1800.0:
|
||||
nearest = min(math_dist(g, p) for p in points)
|
||||
worst = max(worst, nearest)
|
||||
self.assertLessEqual(worst, 700.0 * 2 ** 0.5 + 1e-9)
|
||||
|
||||
|
||||
def math_dist(a, b):
|
||||
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user