"""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_config = replace(ProblemConfig(), search=replace(ProblemConfig().search, bearing_rounding_margin_deg=0.0)) no_margin = BeliefState(no_margin_config) 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_default_config_carries_rounding_margin(self): """库默认配置必须自带 0.005° 舍入裕量,与 CLI run 默认一致。 在线 API 若按默认配置消费官方读数,"加误差再舍入"规则下 合法真源可能被裁掉;默认口径必须保守。 """ self.assertEqual(ProblemConfig().search.bearing_rounding_margin_deg, 0.005) default_belief = BeliefState(ProblemConfig()) source = (1200 * math.cos(math.radians(1.005)), 1200 * math.sin(math.radians(1.005))) default_belief.update_measure(1, (0.0, 0.0), DIRECTION, 2.01) self.assertTrue(default_belief.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) _, 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()