1
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""P5 定向扩展端到端验收:朝外边界源不漏、真源不误删、格数与预算检查。
|
||||
|
||||
对应 docs/B题建模分析与实施计划.md P5 行验收门槛(自建部分):
|
||||
- 混合源(全向+定向)场景全部清除并正常退出;16 源触发数量上界停止;
|
||||
- 朝向背离圆心的近边界定向源仍被 49 点覆盖发现并清除;
|
||||
- no_signal 联合约束基线不删除空间候选(真源保留性);
|
||||
- 后备清除 <=20m 覆盖无空洞、格数有上界、格数/现实预算不足时不盲扫;
|
||||
- 虚拟时刻逐步复算与服务器响应一致。
|
||||
"""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from itertools import count
|
||||
|
||||
from mcm26b.belief import BeliefState, CandidateRegion
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.evaluation import recompute_virtual_time
|
||||
from mcm26b.planner import (DirectionalExtension, fallback_cell_bound,
|
||||
fallback_clear_cells, safe_disk_radius)
|
||||
from mcm26b.protocol.client import SimulatorClient
|
||||
from mcm26b.protocol.rules import DIRECTION, NO_SIGNAL
|
||||
from mcm26b.sim import LocalSimTransport, LocalSimulator, Source
|
||||
from mcm26b.sim.scenes import mixed_scene
|
||||
|
||||
CONFIG = replace(ProblemConfig(),
|
||||
search=replace(ProblemConfig().search,
|
||||
bearing_rounding_margin_deg=0.005))
|
||||
|
||||
|
||||
def _run_scene(sources, config=CONFIG, clock=None):
|
||||
simulator = LocalSimulator(sources, robot_id="T001", config=config)
|
||||
client = SimulatorClient("T001", config=config,
|
||||
transport=LocalSimTransport(simulator),
|
||||
**({"clock": clock} if clock else {}))
|
||||
loop = DirectionalExtension(client, config)
|
||||
summary = loop.run()
|
||||
report = recompute_virtual_time(client.log.action_events())
|
||||
return summary, report, simulator, client, loop
|
||||
|
||||
|
||||
class FallbackClearCellsTests(unittest.TestCase):
|
||||
def test_cells_cover_every_polygon_witness(self):
|
||||
"""覆盖无空洞:多边形边界采样点到最近格心 <= 10*sqrt(2)。"""
|
||||
region = CandidateRegion.disk((0.0, 0.0), 200.0, 192)
|
||||
cells = fallback_clear_cells(region.polygon, CONFIG)
|
||||
self.assertTrue(cells)
|
||||
limit = 10.0 * 2 ** 0.5 + 1e-6
|
||||
for witness in region.witnesses(40.0):
|
||||
nearest = min(math.dist(witness, cell) for cell in cells)
|
||||
self.assertLessEqual(nearest, limit,
|
||||
f"格心覆盖在 {witness} 附近出现空洞")
|
||||
|
||||
def test_grid_line_polygon_keeps_both_sides(self):
|
||||
"""多边形横跨格线时两侧格子都保留(宁可多测)。"""
|
||||
square = [(10.0, -10.0), (30.0, -10.0), (30.0, 10.0), (10.0, 10.0)]
|
||||
cells = fallback_clear_cells(square, CONFIG)
|
||||
columns = {cell[0] for cell in cells}
|
||||
self.assertIn(10.0, columns) # 格线 x=20 左侧
|
||||
self.assertIn(30.0, columns) # 右侧
|
||||
|
||||
def test_degenerate_polygon_single_cell(self):
|
||||
"""点与线段候选集:覆盖格数最小化且无空洞。"""
|
||||
point = [(500.0, 500.0)]
|
||||
self.assertEqual(len(fallback_clear_cells(point, CONFIG)), 1)
|
||||
segment = [(490.0, 500.0), (510.0, 500.0)]
|
||||
cells = fallback_clear_cells(segment, CONFIG)
|
||||
self.assertTrue(cells)
|
||||
limit = 10.0 * 2 ** 0.5 + 1e-6
|
||||
for step in range(21):
|
||||
sample = (490.0 + step, 500.0)
|
||||
nearest = min(math.dist(sample, cell) for cell in cells)
|
||||
self.assertLessEqual(nearest, limit)
|
||||
|
||||
def test_count_within_plan_bound(self):
|
||||
region = CandidateRegion.disk((0.0, 0.0), 200.0, 192)
|
||||
xs = [p[0] for p in region.polygon]
|
||||
ys = [p[1] for p in region.polygon]
|
||||
bound = fallback_cell_bound(max(xs) - min(xs), max(ys) - min(ys), CONFIG)
|
||||
cells = fallback_clear_cells(region.polygon, CONFIG)
|
||||
self.assertLessEqual(len(cells), bound)
|
||||
|
||||
|
||||
class MixedLoopAcceptanceTests(unittest.TestCase):
|
||||
def test_mixed_ten_sources_all_cleared(self):
|
||||
sources = mixed_scene(seed=5, count=10, directional=3)
|
||||
summary, report, simulator, _, loop = _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(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")
|
||||
# 观测到的定向频道必须来自真定向源(观测推理不误标)
|
||||
truth_directional = sorted(s.channel for s in sources if s.directional)
|
||||
self.assertTrue(summary["directional_observed_channels"])
|
||||
self.assertTrue(set(summary["directional_observed_channels"])
|
||||
<= set(truth_directional))
|
||||
|
||||
def test_mixed_sixteen_sources_stop_by_count_bound(self):
|
||||
sources = mixed_scene(seed=11, count=16, directional=6)
|
||||
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(sorted(simulator.cleared), truth)
|
||||
self.assertEqual(report["mismatches"], [])
|
||||
|
||||
def test_outward_boundary_directional_source_not_missed(self):
|
||||
"""朝向背离圆心、r_eff=1000 的近边界源:覆盖保证仍发现并清除。"""
|
||||
source = Source(channel=7, x=1790.0, y=0.0, r_eff_m=1000.0,
|
||||
directional=True, heading_deg=0.0)
|
||||
summary, _, simulator, _, _ = _run_scene([source])
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(summary["cleared_channels"], [7])
|
||||
self.assertEqual(sorted(simulator.cleared), [7])
|
||||
self.assertEqual(summary["directional_observed_channels"], [7])
|
||||
|
||||
def test_mixed_batch_seeds_never_degrade(self):
|
||||
"""固定批量种子回归:任何种子下不得全清失败或破坏守恒性。"""
|
||||
for seed in range(5, 10):
|
||||
sources = mixed_scene(seed=seed, count=10, directional=4)
|
||||
summary, report, simulator, _, _ = _run_scene(sources)
|
||||
truth = sorted(s.channel for s in sources)
|
||||
self.assertTrue(summary["completed"], f"seed={seed}")
|
||||
self.assertEqual(sorted(summary["cleared_channels"]), truth,
|
||||
f"seed={seed}")
|
||||
self.assertEqual(summary["anomalies"], [], f"seed={seed}")
|
||||
self.assertEqual(report["mismatches"], [], f"seed={seed}")
|
||||
|
||||
|
||||
class JointConstraintBaselineTests(unittest.TestCase):
|
||||
def test_no_signal_keeps_candidates_and_true_source(self):
|
||||
"""联合约束基线:保证接收盘内 no_signal 不删空间候选(真源不误删)。"""
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.update_measure(7, (0.0, 0.0), DIRECTION, 30.0)
|
||||
region = belief.candidate_set(7)
|
||||
snapshot = list(region.polygon)
|
||||
self.assertGreater(safe_disk_radius(region, CONFIG), 0.0)
|
||||
center, _ = region.mec()
|
||||
belief.update_measure(7, tuple(center), NO_SIGNAL, None)
|
||||
self.assertEqual(region.polygon, snapshot, "no_signal 不得收缩候选集")
|
||||
# 候选集中任取的代表点(含生成读数的角域内部点)仍被保留
|
||||
for witness in region.witnesses(300.0):
|
||||
self.assertTrue(region.contains(witness, tol_m=0.05))
|
||||
|
||||
def test_directional_source_retained_through_blocked_probes(self):
|
||||
"""端到端保留性:定向源被遮挡若干次后仍被清除,不因 no_signal 误删。"""
|
||||
source = Source(channel=3, x=600.0, y=800.0, r_eff_m=1500.0,
|
||||
directional=True, heading_deg=200.0)
|
||||
summary, _, simulator, _, _ = _run_scene([source])
|
||||
self.assertTrue(summary["completed"])
|
||||
self.assertEqual(summary["cleared_channels"], [3])
|
||||
self.assertEqual(sorted(simulator.cleared), [3])
|
||||
|
||||
|
||||
class FallbackDisciplineTests(unittest.TestCase):
|
||||
def _stall_config(self, **search_updates):
|
||||
"""迫使定位立即停滞进入后备清除的最小配置。"""
|
||||
return replace(CONFIG,
|
||||
search=replace(CONFIG.search,
|
||||
max_localize_steps=1,
|
||||
max_blocked_probes=0,
|
||||
**search_updates))
|
||||
|
||||
def test_cell_cap_skips_instead_of_blind_scan(self):
|
||||
"""格数超硬顶:后备清除全部记录 skipped_cell_cap,频道保持未解决。"""
|
||||
config = self._stall_config(fallback_max_cells=5)
|
||||
sources = mixed_scene(seed=5, count=6, directional=2)
|
||||
summary, report, _, _, _ = _run_scene(sources, config=config)
|
||||
self.assertFalse(summary["completed"])
|
||||
self.assertEqual(summary["stop_reason"], "unresolved_channels")
|
||||
self.assertTrue(summary["fallback_clears"])
|
||||
self.assertTrue(all(entry["outcome"] == "skipped_cell_cap"
|
||||
for entry in summary["fallback_clears"]))
|
||||
self.assertEqual(report["mismatches"], [])
|
||||
|
||||
def test_real_budget_check_skips_fallback(self):
|
||||
"""现实预算核对:N*p95 + 退出余量超过剩余时不启动后备清除。"""
|
||||
config = replace(self._stall_config(),
|
||||
session=replace(CONFIG.session, real_rtt_p95_s=600.0))
|
||||
sources = mixed_scene(seed=6, count=6, directional=2)
|
||||
summary, _, _, _, _ = _run_scene(sources, config=config)
|
||||
self.assertFalse(summary["completed"])
|
||||
self.assertTrue(summary["fallback_clears"])
|
||||
self.assertTrue(all(entry["outcome"] == "skipped_budget"
|
||||
for entry in summary["fallback_clears"]))
|
||||
self.assertTrue(summary["unresolved_channels"])
|
||||
|
||||
def test_fallback_clear_succeeds_on_bounded_candidate(self):
|
||||
"""后备清除正路径:有界候选集逐格 /clear 命中真源。"""
|
||||
source = Source(channel=7, x=500.0, y=500.0, r_eff_m=1500.0,
|
||||
directional=True, heading_deg=180.0)
|
||||
simulator = LocalSimulator([source], robot_id="T001", config=CONFIG)
|
||||
client = SimulatorClient("T001", config=CONFIG,
|
||||
transport=LocalSimTransport(simulator))
|
||||
loop = DirectionalExtension(client, CONFIG)
|
||||
client.enter()
|
||||
region = CandidateRegion.disk((500.0, 500.0), 30.0, 192)
|
||||
self.assertTrue(loop._fallback_clear(7, region))
|
||||
self.assertEqual(loop.cleared, [7])
|
||||
self.assertEqual(simulator.cleared, {7})
|
||||
entry = loop.fallback_log[0]
|
||||
self.assertEqual(entry["outcome"], "cleared")
|
||||
self.assertLessEqual(entry["attempts"], entry["cells"])
|
||||
cells = fallback_clear_cells(region.polygon, CONFIG)
|
||||
self.assertLessEqual(len(cells), CONFIG.search.fallback_max_cells)
|
||||
|
||||
def test_fallback_exhaustion_records_anomaly_without_deletion(self):
|
||||
"""后备全失败(空域):守恒性异常留痕,且不删除空间候选。"""
|
||||
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
|
||||
client = SimulatorClient("T001", config=CONFIG,
|
||||
transport=LocalSimTransport(simulator))
|
||||
loop = DirectionalExtension(client, CONFIG)
|
||||
client.enter()
|
||||
region = CandidateRegion.disk((0.0, 0.0), 30.0, 192)
|
||||
snapshot = list(region.polygon)
|
||||
self.assertFalse(loop._fallback_clear(7, region))
|
||||
entry = loop.fallback_log[0]
|
||||
self.assertEqual(entry["outcome"], "exhausted")
|
||||
kinds = [item["kind"] for item in loop.anomalies]
|
||||
self.assertIn("fallback_exhausted_without_success", kinds)
|
||||
self.assertEqual(region.polygon, snapshot, "清除失败不得删除候选")
|
||||
|
||||
|
||||
class RealBudgetExitTests(unittest.TestCase):
|
||||
def test_budget_exhaustion_exits_incomplete(self):
|
||||
"""混合场景现实预算耗尽:立即退出且记为未证明完成。"""
|
||||
ticks = count(0.0, 100.0) # 每次查询前进 100s,约 12 个动作内耗尽预算
|
||||
sources = mixed_scene(seed=7, count=8, directional=2)
|
||||
summary, _, _, _, _ = _run_scene(sources, config=CONFIG,
|
||||
clock=lambda: next(ticks))
|
||||
self.assertFalse(summary["completed"])
|
||||
self.assertEqual(summary["stop_reason"], "real_budget_exhausted")
|
||||
self.assertEqual(summary["exit_reason"], "user_exit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user