第一问建模完成

This commit is contained in:
2026-09-10 20:46:45 +08:00
parent 86ffc8ee65
commit abf21b2cbf
77 changed files with 3892 additions and 24 deletions
+131
View File
@@ -0,0 +1,131 @@
import math
import random
import unittest
from geometry import Measurement, solve, smallest_enclosing_circle
from geometry.localization import halfplanes, distance
class GeometryTests(unittest.TestCase):
def test_equilateral_from_legal_bearings(self):
h = 20 * math.sqrt(3)
stations = [Measurement(-1000, 0, 1),
Measurement(540, -500 * math.sqrt(3), 121),
Measurement(520, h + 500 * math.sqrt(3), 241)]
result = solve(stations)
self.assertEqual(result['status'], 'polygon')
self.assertEqual(len(result['vertices']), 3)
self.assertAlmostEqual(result['diameter_m'], 40, places=7)
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'], 40/math.sqrt(3), places=7)
self.assertFalse(result['diameter_circle']['covers'])
self.assertFalse(result['guaranteed_clearable_at_mec_center'])
def test_no_observations_and_single_bearing(self):
self.assertEqual(solve([])['status'], 'unbounded')
r = solve([Measurement(20, 30, 359.8)])
self.assertEqual(r['status'], 'unbounded')
self.assertTrue(r['diameter_infinite'])
self.assertIsNone(r['diameter_m'])
def test_inconsistent(self):
result = solve([Measurement(-100, 0, 180), Measurement(100, 0, 0)])
self.assertEqual(result['status'], 'empty')
def test_exact_segment_and_point(self):
segment = [Measurement(0, 0, 0, 0), Measurement(100, 0, 180, 0)]
r = solve(segment)
self.assertEqual(r['status'], 'segment')
self.assertAlmostEqual(r['diameter_m'], 100)
self.assertTrue(r['diameter_circle']['covers'])
r = solve(segment + [Measurement(30, -30, 90, 0)])
self.assertEqual(r['status'], 'point')
self.assertAlmostEqual(r['vertices'][0][0], 30)
self.assertAlmostEqual(r['diameter_m'], 0)
def test_forward_ray_not_line(self):
r = solve([Measurement(0, 0, 0, 0), Measurement(-1, 0, 180, 0)])
self.assertEqual(r['status'], 'empty')
def test_angle_wrap_and_repeated_station(self):
a = [Measurement(-500, -1, 359.9), Measurement(0, -500, 90)]
b = [Measurement(-500, -1, -.1), Measurement(0, -500, 450)]
self.assertAlmostEqual(solve(a)['diameter_m'], solve(b)['diameter_m'])
self.assertAlmostEqual(solve(a+a)['diameter_m'], solve(a)['diameter_m'])
def test_close_parallel_remains_finite(self):
# Two nearly parallel wedges can still be bounded, very far away.
r = solve([Measurement(0, 0, 1), Measurement(0, 100, -1.000001)])
self.assertEqual(r['status'], 'polygon')
self.assertGreater(r['diameter_m'], 1e9)
def test_circle_known_shapes(self):
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
self.assertEqual(c, (1,1))
self.assertAlmostEqual(r, math.sqrt(2))
c, r = smallest_enclosing_circle([(0,0),(2,0),(.4,.2)])
self.assertAlmostEqual(r, 1)
c, r = smallest_enclosing_circle([(0,0),(1,0),(3,0)])
self.assertAlmostEqual(r, 1.5)
def test_random_bounded_truth_containment_and_monotonicity(self):
rng = random.Random(20260910)
for _ in range(100):
target = (rng.uniform(-300,300), rng.uniform(-300,300))
ms = []
for i in range(4):
ang = (i*90 + rng.uniform(-20,20))*math.pi/180
radius = rng.uniform(300,1200)
p = (target[0]+radius*math.cos(ang), target[1]+radius*math.sin(ang))
bearing = math.degrees(math.atan2(target[1]-p[1],target[0]-p[0]))
ms.append(Measurement(*p, bearing+rng.uniform(-.99,.99)))
r3, r4 = solve(ms[:3]), solve(ms)
self.assertEqual(r4['status'], 'polygon')
self.assertLessEqual(r4['diameter_m'], r3['diameter_m']+1e-5)
for m in ms:
for a,b,c in halfplanes(m):
self.assertLessEqual(a*target[0]+b*target[1], c+1e-7)
mec = r4['minimum_enclosing_circle']
self.assertLessEqual(distance(mec['center'], target), mec['radius_m']+1e-5)
for v in r4['vertices']:
self.assertLessEqual(distance(mec['center'], v), mec['radius_m']+1e-5)
def test_translation_rotation_scaling(self):
ms = [Measurement(-500,0,1), Measurement(0,-500,89), Measurement(400,400,225)]
original = solve(ms)
a=.37
changed = [Measurement(1e6+3*(m.x*math.cos(a)-m.y*math.sin(a)),
-2e6+3*(m.x*math.sin(a)+m.y*math.cos(a)),
m.bearing_deg+math.degrees(a)) for m in ms]
moved=solve(changed)
self.assertAlmostEqual(moved['diameter_m'], original['diameter_m']*3, places=6)
def test_invalid_input(self):
for args in [(float('nan'),0,0),(0,0,float('inf')),(0,0,0,90),(0,0,0,-1),(True,0,0)]:
with self.assertRaises(ValueError): Measurement(*args)
def test_independent_rectangle_clipping_reference(self):
# A second construction: clip a demonstrably inactive finite box.
# The production algorithm never uses this box.
rng=random.Random(4001)
for _ in range(60):
ms=[]
for x,y in [(-500,-300),(600,-200),(0,700)]:
ms.append(Measurement(x,y,math.degrees(math.atan2(-y,-x))+rng.uniform(-.9,.9)))
poly=[(-2000.,-2000.),(2000.,-2000.),(2000.,2000.),(-2000.,2000.)]
for m in ms:
for a,b,c in halfplanes(m):
out=[]
for p,q in zip(poly,poly[1:]+poly[:1]):
f=a*p[0]+b*p[1]-c;g=a*q[0]+b*q[1]-c
if f<=0:out.append(p)
if f*g<0:
t=f/(f-g);out.append((p[0]+t*(q[0]-p[0]),p[1]+t*(q[1]-p[1])))
poly=out
self.assertTrue(poly)
self.assertTrue(all(max(abs(x),abs(y))<1900 for x,y in poly))
expected=max(math.dist(p,q) for p in poly for q in poly)
self.assertAlmostEqual(solve(ms)['diameter_m'],expected,places=6)
if __name__ == '__main__':
unittest.main()
+107
View File
@@ -0,0 +1,107 @@
import json
import math
from pathlib import Path
import random
import subprocess
import sys
import unittest
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
from geometry.localization import _enclosing_circle_reference, distance
ROOT = Path(__file__).resolve().parents[1]
class CertificateTests(unittest.TestCase):
def test_incremental_circle_against_exhaustive_reference(self):
rng = random.Random(271828)
for _ in range(250):
points = [(rng.uniform(-20, 20), rng.uniform(-20, 20))
for _ in range(rng.randrange(1, 20))]
expected_c, expected_r = _enclosing_circle_reference(points)
center, radius = smallest_enclosing_circle(points)
self.assertAlmostEqual(radius, expected_r, places=7)
self.assertLess(distance(center, expected_c), 1e-6)
self.assertTrue(all(distance(center, p) <= radius for p in points))
def test_circle_large_cocircular_duplicate_and_collinear_sets(self):
# More than Python's recursion limit, with analytically known answers.
points = [(1e6+30*math.cos(i*math.tau/1500),
-2e6+30*math.sin(i*math.tau/1500)) for i in range(1500)]
center, radius = smallest_enclosing_circle(points+points[:100])
self.assertLess(distance(center, (1e6, -2e6)), 1e-7)
self.assertAlmostEqual(radius, 30, places=7)
c, r = smallest_enclosing_circle([(i, i*2) for i in range(1500)])
self.assertAlmostEqual(r, 1499*math.sqrt(5)/2)
self.assertEqual(c, (749.5, 1499))
self.assertEqual(smallest_enclosing_circle([(3, 4)]*10), ((3, 4), 0))
def test_witness_and_clearance_margin_from_saved_observations(self):
path = next((ROOT/'output/q1/practice').glob('*_observations.json'))
rows = json.loads(path.read_text(encoding='utf-8'))['measurements']
result = solve([Measurement(**m) for m in rows])
circle = result['diameter_circle']
self.assertFalse(circle['covers'])
self.assertIn(circle['witness_vertex'], result['vertices'])
self.assertAlmostEqual(distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
circle['max_excess_m'])
for i in circle['outside_vertex_indices']:
self.assertGreater(distance(result['vertices'][i], circle['center']),
circle['radius_m']+circle['coverage_tolerance_m'])
cert = result['clearance_certificate']
self.assertGreater(cert['remaining_margin_m'], 7.9)
self.assertTrue(result['guaranteed_clearable_at_mec_center'])
self.assertAlmostEqual(cert['remaining_margin_m']+cert['radius_with_margin_m'], 20)
def test_clearance_at_threshold_is_conservative(self):
for length, expected in [(39.999, True), (40, False), (40.001, False)]:
r = solve([Measurement(0, 0, 0, 0), Measurement(length, 0, 180, 0)])
self.assertEqual(r['guaranteed_clearable_at_mec_center'], expected)
self.assertTrue(r['diameter_circle']['covers'])
self.assertIsNone(r['diameter_circle']['witness_vertex'])
def test_first_sector_disk_covers_all_angles_and_distances(self):
for angle in [0, 359.9, -180]:
for delta in [0, 1, 1.005, 45]:
m = Measurement(-800, 1200, angle, delta)
cert = first_bearing_candidate_disk(m)
enclosing = cert['sector_enclosing_circle']
candidate = cert['sufficient_candidate_disk']
for j in range(21):
for k in range(11):
phi = math.radians(angle-delta+2*delta*j/20)
p = (m.x+150*k*math.cos(phi), m.y+150*k*math.sin(phi))
self.assertLessEqual(distance(p, enclosing['center']), enclosing['radius_m']+1e-9)
if candidate:
for t in range(8):
q = (candidate['center'][0]+candidate['radius_m']*math.cos(t*math.tau/8),
candidate['center'][1]+candidate['radius_m']*math.sin(t*math.tau/8))
self.assertLessEqual(distance(p, q), 1000+1e-9)
if delta <= 1.005:
self.assertTrue(cert['candidate_nonempty_certified'])
self.assertGreater(candidate['radius_m'], 249.88)
def test_candidate_equality_and_no_certificate(self):
equality = first_bearing_candidate_disk(Measurement(0, 0, 0, 0), max_range_m=2000)
self.assertTrue(equality['candidate_nonempty_certified'])
self.assertEqual(equality['sufficient_candidate_disk']['radius_m'], 0)
no_certificate = first_bearing_candidate_disk(Measurement(0, 0, 0), max_range_m=3000)
self.assertIsNone(no_certificate['sufficient_candidate_disk'])
self.assertNotIn('candidate_empty', no_certificate)
self.assertTrue(no_certificate['distance_only'])
with self.assertRaises(ValueError):
first_bearing_candidate_disk(Measurement(0, 0, 0, 46))
def test_cli_explicit_rounding_margin(self):
base = [sys.executable, '-m', 'geometry', 'examples/q1/triangle.json']
outputs = [json.loads(subprocess.check_output(base+args, cwd=ROOT, encoding='utf-8'))
for args in [[], ['--rounding-margin-deg', '0.005']]]
self.assertEqual(outputs[0]['error_policy']['effective_half_angles_deg'], [1, 1, 1])
self.assertEqual(outputs[1]['error_policy']['effective_half_angles_deg'], [1.005]*3)
self.assertGreater(outputs[1]['diameter_m'], outputs[0]['diameter_m'])
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT, capture_output=True)
self.assertNotEqual(bad.returncode, 0)
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""Offline verification of captured practice evidence; never contacts simulator."""
import json
import math
from pathlib import Path
import unittest
from geometry import Measurement, solve
ROOT=Path(__file__).resolve().parents[1]
class PracticeReplayTests(unittest.TestCase):
def test_action_log_and_derived_geometry(self):
folder=ROOT/'output/q1/practice'
files=list(folder.glob('*_actions.json'))
self.assertTrue(files, '缺少本次演练采集证据')
for path in files:
events=json.loads(path.read_text(encoding='utf-8'))
pos=(0,0);channel=1;virtual=0
self.assertEqual(events[0]['path'],'/enter')
self.assertEqual(events[-1]['path'],'/exit')
self.assertEqual(events[-1]['response']['exit_reason'],'user_exit')
ids=[]
for event in events:
self.assertIn(event['path'],('/enter','/measure','/exit'))
request,response=event['request'],event['response']
self.assertTrue(response['accepted'])
ids.append(request['request_id'])
if event['path']=='/measure':
new=(request['position']['x'],request['position']['y'])
virtual+=math.dist(pos,new)/5+5+(request['channel']!=channel)
pos,channel=new,request['channel']
self.assertAlmostEqual(virtual,response['virtual_time_s'],places=5)
self.assertEqual(len(ids),len(set(ids)))
observation_path=path.with_name(path.name.replace('_actions','_observations'))
data=json.loads(observation_path.read_text(encoding='utf-8'))
ms=[Measurement(**m) for m in data['measurements']]
recomputed=solve(ms)
self.assertAlmostEqual(recomputed['diameter_m'],data['result']['diameter_m'],places=9)
self.assertEqual(data['source'],'official_practice')
self.assertIsNone(data['ground_truth'])
directions=[e for e in events if e['response'].get('measure_result')=='direction']
self.assertEqual(directions[0]['request']['position'],directions[1]['request']['position'])
self.assertEqual(directions[0]['response']['svd_deg'],directions[1]['response']['svd_deg'])
if __name__=='__main__': unittest.main()