1
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""P3 信念模块验收:保守性(真源保留)与角域/圆盘约束的几何正确性。
|
||||
|
||||
对应 docs/B题建模分析与实施计划.md 验收标准"自建已知真值案例中真源
|
||||
始终保留;跨角度边界不出错"。
|
||||
"""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from random import Random
|
||||
from unittest import TestCase
|
||||
|
||||
from mcm26b.belief import (BeliefState, CandidateRegion, ChannelStatus,
|
||||
circumscribed_polygon, clip_halfplane)
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.protocol.rules import DIRECTION, NEAR, NO_SIGNAL
|
||||
|
||||
CONFIG = replace(ProblemConfig(),
|
||||
search=replace(ProblemConfig().search,
|
||||
bearing_rounding_margin_deg=0.005))
|
||||
|
||||
|
||||
class ClipGeometryTests(TestCase):
|
||||
def test_circumscribed_polygon_contains_whole_disk(self):
|
||||
"""外切多边形必须包含圆盘内任意点(保守外包)。"""
|
||||
region = CandidateRegion(circumscribed_polygon((100, -50), 800, 192))
|
||||
rng = Random(1)
|
||||
for _ in range(200):
|
||||
angle = rng.uniform(0, 2 * math.pi)
|
||||
radius = 800 * math.sqrt(rng.random())
|
||||
point = (100 + radius * math.cos(angle),
|
||||
-50 + radius * math.sin(angle))
|
||||
self.assertTrue(region.contains(point), f"圆盘内点 {point} 被外包排除")
|
||||
|
||||
def test_clip_halfplane_keeps_inside_drops_outside(self):
|
||||
square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
|
||||
clipped = clip_halfplane(square, 1.0, 0.0, 6.0)
|
||||
inside = CandidateRegion(clipped)
|
||||
self.assertTrue(inside.contains((5.0, 5.0)))
|
||||
self.assertFalse(inside.contains((8.0, 5.0), tol_m=0.001))
|
||||
|
||||
def test_wedge_clip_handles_0_360_wraparound(self):
|
||||
region = CandidateRegion.disk((0.0, 0.0), 1500, 192)
|
||||
region.clip_wedge((0.0, 0.0), 359.99, 1.005)
|
||||
inside = (800 * math.cos(math.radians(0.001)),
|
||||
800 * math.sin(math.radians(0.001)))
|
||||
self.assertTrue(region.contains(inside), "跨 0° 边界附近的真点不得被剪掉")
|
||||
outside = (800 * math.cos(math.radians(5.0)),
|
||||
800 * math.sin(math.radians(5.0)))
|
||||
self.assertFalse(region.contains(outside, tol_m=1.0))
|
||||
|
||||
def test_clip_away_everything_raises(self):
|
||||
region = CandidateRegion.disk((0.0, 0.0), 100, 32)
|
||||
with self.assertRaises(Exception):
|
||||
# 与区域完全分离的半平面会把多边形剪空:必须显式报错而非静默
|
||||
for _ in range(4):
|
||||
region._clip(1.0, 0.0, -1000.0)
|
||||
|
||||
|
||||
class BeliefDirectionTests(TestCase):
|
||||
def test_direction_region_retains_true_source(self):
|
||||
"""随机场景:构造合法 direction 读数后真源必须仍在候选集内。"""
|
||||
rng = Random(7)
|
||||
cases = 0
|
||||
while cases < 40:
|
||||
source = (rng.uniform(-1790, 1790), rng.uniform(-1790, 1790))
|
||||
if math.hypot(*source) > 1790:
|
||||
continue
|
||||
station = (rng.uniform(-2100, 2100), rng.uniform(-2100, 2100))
|
||||
if math.dist(station, source) > CONFIG.field.r_eff_max_m:
|
||||
continue
|
||||
cases += 1
|
||||
true_bearing = math.degrees(math.atan2(
|
||||
source[1] - station[1], source[0] - station[0])) % 360.0
|
||||
svd = round(true_bearing + rng.uniform(-1.0, 1.0), 2) % 360.0
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.update_measure(3, station, DIRECTION, svd)
|
||||
region = belief.candidate_set(3)
|
||||
self.assertIsNotNone(region)
|
||||
self.assertEqual(belief.channels[3].status, ChannelStatus.DETECTED)
|
||||
self.assertTrue(region.contains(source),
|
||||
f"真源 {source} 被读数 {svd}@{station} 剪掉")
|
||||
|
||||
def test_second_direction_shrinks_and_retains(self):
|
||||
source = (600.0, 400.0)
|
||||
|
||||
def svd_from(station):
|
||||
bearing = math.degrees(math.atan2(source[1] - station[1],
|
||||
source[0] - station[0])) % 360.0
|
||||
return round(bearing + 0.7, 2) % 360.0
|
||||
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.update_measure(1, (0.0, 0.0), DIRECTION, svd_from((0.0, 0.0)))
|
||||
radius_first = belief.candidate_set(1).mec()[1]
|
||||
belief.update_measure(1, (-200.0, 900.0), DIRECTION, svd_from((-200.0, 900.0)))
|
||||
region = belief.candidate_set(1)
|
||||
self.assertTrue(region.contains(source))
|
||||
self.assertLess(region.mec()[1], radius_first - 100.0,
|
||||
"第二次测向后候选集应显著收缩")
|
||||
|
||||
def test_rounding_margin_keeps_edge_reading(self):
|
||||
"""误差取满 1° 且舍入再偏 0.005°:有裕量的配置保留真源。
|
||||
|
||||
svd=2.01 的读数角域在有 0.005° 裕量时下边界恰为 1.005°,
|
||||
与真源方位重合;无裕量配置会把它剪掉——这就是裕量存在的理由。
|
||||
"""
|
||||
source = (1200 * math.cos(math.radians(1.005)),
|
||||
1200 * math.sin(math.radians(1.005)))
|
||||
with_margin = BeliefState(CONFIG)
|
||||
with_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
||||
self.assertTrue(with_margin.candidate_set(1).contains(source, tol_m=0.5))
|
||||
no_margin = BeliefState(ProblemConfig())
|
||||
no_margin.update_measure(1, (0.0, 0.0), DIRECTION, 2.01)
|
||||
self.assertFalse(no_margin.candidate_set(1).contains(source, tol_m=0.5))
|
||||
|
||||
def test_near_shrinks_to_five_meters(self):
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.update_measure(2, (100.0, 100.0), NEAR, None)
|
||||
center, radius = belief.candidate_set(2).mec()
|
||||
self.assertLessEqual(radius, 5.1)
|
||||
self.assertTrue(belief.candidate_set(2).contains((101.0, 101.0)))
|
||||
self.assertEqual(belief.channels[2].status, ChannelStatus.DETECTED)
|
||||
|
||||
def test_no_signal_keeps_region_unchanged(self):
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.update_measure(4, (0.0, 0.0), DIRECTION, 45.0)
|
||||
before = list(belief.candidate_set(4).polygon)
|
||||
belief.update_measure(4, (300.0, 0.0), NO_SIGNAL, None)
|
||||
self.assertEqual(belief.candidate_set(4).polygon, before)
|
||||
self.assertEqual(belief.channels[4].status, ChannelStatus.DETECTED)
|
||||
|
||||
def test_invalid_readings_rejected(self):
|
||||
belief = BeliefState(CONFIG)
|
||||
with self.assertRaises(ValueError):
|
||||
belief.update_measure(1, (0.0, 0.0), DIRECTION, None)
|
||||
with self.assertRaises(ValueError):
|
||||
belief.update_measure(1, (0.0, 0.0), "garbage", None)
|
||||
|
||||
def test_absent_and_cleared_transitions(self):
|
||||
belief = BeliefState(CONFIG)
|
||||
belief.mark_absent(7)
|
||||
belief.mark_cleared(9)
|
||||
self.assertEqual(belief.channels_by_status(ChannelStatus.ABSENT), [7])
|
||||
self.assertEqual(belief.channels_by_status(ChannelStatus.CLEARED), [9])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""P3 选点器验收:保证接收、后验收敛、清除证书阈值、与解析证书衔接。"""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest import TestCase
|
||||
|
||||
from mcm26b.belief import CandidateRegion
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.geometry.localization import Measurement
|
||||
from mcm26b.geometry.planning import first_bearing_candidate_disk
|
||||
from mcm26b.planner import (bearing_deg, clear_certificate, select_measurement_point,
|
||||
select_second_point)
|
||||
from mcm26b.protocol.client import SimulatorClient
|
||||
from mcm26b.sim import LocalSimTransport, LocalSimulator
|
||||
|
||||
CONFIG = replace(ProblemConfig(),
|
||||
search=replace(ProblemConfig().search,
|
||||
bearing_rounding_margin_deg=0.005))
|
||||
FIRST_STATION = (0.0, 0.0)
|
||||
FIRST_BEARING = 30.0
|
||||
|
||||
|
||||
def _k1_region() -> CandidateRegion:
|
||||
region = CandidateRegion.initial(FIRST_STATION, CONFIG)
|
||||
region.clip_wedge(FIRST_STATION, FIRST_BEARING,
|
||||
CONFIG.field.bearing_error_half_deg
|
||||
+ CONFIG.search.bearing_rounding_margin_deg)
|
||||
return region
|
||||
|
||||
|
||||
class SelectionGuaranteeTests(TestCase):
|
||||
def test_selected_point_receives_every_sector_candidate(self):
|
||||
"""安全盘内选点对解析角域 K1 内任意候选源的距离 <= 1000m。
|
||||
|
||||
直接对截断角域 K1 = Ω ∩ W(s1, θ±δ) ∩ B(s1,1500) 稠密采样验证,
|
||||
不依赖外包多边形本身。
|
||||
"""
|
||||
region = _k1_region()
|
||||
plan = select_measurement_point(region, FIRST_STATION, CONFIG)
|
||||
half = 1.005
|
||||
for step_phi in range(41):
|
||||
phi = math.radians(FIRST_BEARING - half + 2 * half * step_phi / 40)
|
||||
for step_t in range(60):
|
||||
t = 5.0 + (1500.0 - 5.0) * step_t / 59
|
||||
g = (t * math.cos(phi), t * math.sin(phi))
|
||||
self.assertLessEqual(
|
||||
math.dist(plan.position, g), 1000.0 + 1e-6,
|
||||
f"候选源 {g} 距选点超过最小接收半径,将浪费一次 no_signal")
|
||||
|
||||
def test_worst_posterior_shrinks_region(self):
|
||||
region = _k1_region()
|
||||
plan = select_measurement_point(region, FIRST_STATION, CONFIG)
|
||||
self.assertLess(plan.worst_posterior_radius_m, region.mec()[1],
|
||||
"最坏后验半径必须优于当前候选集,否则选点无进展")
|
||||
self.assertGreater(plan.worst_posterior_radius_m, 0.0)
|
||||
|
||||
def test_posterior_probe_retains_witness(self):
|
||||
"""后验探针(读数取区间端点)仍包含生成它的边界点:保守性抽查。"""
|
||||
region = _k1_region()
|
||||
plan = select_measurement_point(region, FIRST_STATION, CONFIG)
|
||||
half = CONFIG.field.bearing_error_half_deg \
|
||||
+ CONFIG.search.bearing_rounding_margin_deg
|
||||
witnesses = region.witnesses(300.0)
|
||||
self.assertGreater(len(witnesses), 4)
|
||||
for g in witnesses:
|
||||
phi = bearing_deg(plan.position, g)
|
||||
for reading in (phi - half, phi + half):
|
||||
probe = CandidateRegion(list(region.polygon), region.slack_m)
|
||||
probe.clip_wedge(plan.position, reading, half)
|
||||
self.assertTrue(probe.polygon and probe.contains(g, tol_m=0.05),
|
||||
f"后验剪掉了生成读数的边界点 {g}")
|
||||
|
||||
def test_move_cost_recorded_and_reason_explains(self):
|
||||
region = _k1_region()
|
||||
plan = select_measurement_point(region, (500.0, 500.0), CONFIG)
|
||||
expected = math.dist((500.0, 500.0), plan.position) / CONFIG.cost.speed_mps
|
||||
self.assertAlmostEqual(plan.move_cost_s, expected, places=6)
|
||||
self.assertIn("安全盘", plan.reason)
|
||||
|
||||
|
||||
class ClearCertificateTests(TestCase):
|
||||
def test_near_region_is_certified(self):
|
||||
region = CandidateRegion.disk((500.0, 500.0), 5.0, 192)
|
||||
cert = clear_certificate(region, 7, CONFIG)
|
||||
self.assertTrue(cert.certified)
|
||||
self.assertLess(cert.limit_m, CONFIG.field.clear_radius_m)
|
||||
self.assertGreater(cert.limit_m, 19.0)
|
||||
|
||||
def test_first_bearing_region_is_not_certified(self):
|
||||
cert = clear_certificate(_k1_region(), 7, CONFIG)
|
||||
self.assertFalse(cert.certified)
|
||||
self.assertGreater(cert.radius_m, cert.limit_m)
|
||||
|
||||
|
||||
class SelectSecondPointTests(TestCase):
|
||||
def test_plan_lies_in_analytic_safe_disk_or_is_equivalent(self):
|
||||
"""P3 入口输出与 geometry 解析证书一致(或提供同等保证)。"""
|
||||
simulator = LocalSimulator([], robot_id="T001")
|
||||
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
|
||||
client.enter()
|
||||
plan = select_second_point(client, FIRST_STATION, FIRST_BEARING, CONFIG)
|
||||
# 逐点保证:对解析角域采样,接收距离全部成立(测试见上);此处只
|
||||
# 核对输出位置确实落在解析证书安全盘的合理邻域内(两者中心口径不同,
|
||||
# 允许差一个外包裕量)。
|
||||
cert = first_bearing_candidate_disk(
|
||||
Measurement(FIRST_STATION[0], FIRST_STATION[1], FIRST_BEARING,
|
||||
1.0 + CONFIG.search.bearing_rounding_margin_deg))
|
||||
disk = cert["sufficient_candidate_disk"]
|
||||
self.assertLessEqual(math.dist(plan.position, tuple(disk["center"])),
|
||||
disk["radius_m"] + 10.0)
|
||||
|
||||
def test_exploration_fallback_when_rho_over_1000(self):
|
||||
"""ρ>1000 的一般候选集退化为探索 MEC 圆心,并如实标注可能 no_signal。"""
|
||||
wide = CandidateRegion.disk((0.0, 0.0), 1200.0, 192)
|
||||
plan = select_measurement_point(wide, (0.0, 0.0), CONFIG)
|
||||
self.assertAlmostEqual(plan.position[0], 0.0, places=6)
|
||||
self.assertAlmostEqual(plan.position[1], 0.0, places=6)
|
||||
self.assertIn("探索", plan.reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user