165 lines
7.7 KiB
Python
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()
|