Files
2026-09-11 02:32:31 +08:00

165 lines
7.7 KiB
Python

"""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)
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()