This commit is contained in:
Horldsence
2026-09-11 01:19:38 +08:00
parent 83d2e24448
commit a6308fd40c
12 changed files with 1631 additions and 87 deletions
+148
View File
@@ -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()
+137
View File
@@ -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()
+244
View File
@@ -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()
+123
View File
@@ -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()