合并完成

This commit is contained in:
2026-09-10 21:37:28 +08:00
parent c57865edf5
commit 21e8a8bf52
14 changed files with 0 additions and 152 deletions
-56
View File
@@ -1,56 +0,0 @@
"""Offline numerical comparison; timings are local measurements, not API latency."""
from dataclasses import replace
import json
import math
from pathlib import Path
from statistics import median
import sys
from time import perf_counter
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
from geometry.localization import _enclosing_circle_reference
def timed(function, points, repeats):
durations = []
for _ in range(repeats):
start = perf_counter()
result = function(points)
durations.append(perf_counter()-start)
return result, median(durations)*1000
def main():
timings = []
for size in [4, 20, 60]:
points = [(20*math.cos(i*math.tau/size), 20*math.sin(i*math.tau/size)) for i in range(size)]
old, old_ms = timed(_enclosing_circle_reference, points, 3)
new, new_ms = timed(smallest_enclosing_circle, points, 11)
timings.append(dict(vertices=size, reference_ms=old_ms, incremental_ms=new_ms,
speedup=old_ms/new_ms, radius_difference_m=abs(old[1]-new[1])))
source = next((ROOT/'output/q1/practice').glob('*_observations.json'))
rows = json.loads(source.read_text(encoding='utf-8'))['measurements']
observations = [Measurement(**m) for m in rows]
sensitivity = []
for margin in [0, 0.005]:
ms = [replace(m, half_angle_deg=m.half_angle_deg+margin) for m in observations]
result, duration = timed(solve, ms, 31)
sensitivity.append(dict(additional_half_angle_deg=margin, solve_ms=duration,
diameter_m=result['diameter_m'],
mec_radius_m=result['minimum_enclosing_circle']['radius_m'],
diameter_circle_excess_m=result['diameter_circle']['max_excess_m'],
clearance_margin_m=result['clearance_certificate']['remaining_margin_m']))
report = dict(python=sys.version.split()[0], timing_method='median perf_counter; warm local process',
timing_scope='MEC only; not whole solver speedup or simulator/network performance',
circle_benchmarks=timings, practice_sensitivity=sensitivity,
q2_first_bearing_certificate=first_bearing_candidate_disk(Measurement(0, 0, 0, 1.005)))
output = ROOT/'output/q1/optimization_report.json'
output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)+'\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
-71
View File
@@ -1,71 +0,0 @@
"""Build reproducible examples and the interactive visualization from Python results."""
from dataclasses import asdict
import json
import math
from pathlib import Path
import sys
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
from geometry import Measurement, solve
def measured(stations, target, errors):
return [Measurement(x,y,(math.degrees(math.atan2(target[1]-y,target[0]-x))+e)%360)
for (x,y),e in zip(stations,errors)]
def scenarios():
target=(80,90)
normal=measured([(-600,-250),(650,-100),(100,700)],target,[.65,-.55,.35])
h=20*math.sqrt(3)
triangle=[Measurement(-1000,0,1),Measurement(540,-500*math.sqrt(3),121),
Measurement(520,h+500*math.sqrt(3),241)]
return [
dict(id='normal',name='三点交会',source='synthetic',target=target,measurements=normal),
dict(id='triangle',name='直径圆反例',source='synthetic',target=(20,h/3),measurements=triangle),
dict(id='poor',name='近共线检测点',source='synthetic',target=(900,50),
measurements=measured([(0,0),(100,0),(200,0)],(900,50),[0,0,0])),
dict(id='empty',name='不相容观测',source='synthetic',target=None,
measurements=[Measurement(-100,0,180),Measurement(100,0,0)]),
dict(id='segment',name='零误差退化线段',source='synthetic',target=(40,0),
measurements=[Measurement(0,0,0,0),Measurement(100,0,180,0)]),
]
def main():
cases=scenarios()
practice=ROOT/'output/q1/practice'
for path in sorted(practice.glob('*_observations.json')):
doc=json.loads(path.read_text(encoding='utf-8'))
cases.append(dict(id='practice-'+doc['case_code'],name='官方演练 '+doc['case_code'],
source='official_practice',target=None,
measurements=[Measurement(**m) for m in doc['measurements']]))
examples=ROOT/'examples/q1'
results=ROOT/'output/q1'
examples.mkdir(parents=True,exist_ok=True)
results.mkdir(parents=True,exist_ok=True)
dataset=[]
for case in cases:
ms=case['measurements']
frames=[solve(ms[:n]) for n in range(1,len(ms)+1)]
data={**case,'measurements':[asdict(m) for m in ms],'frames':frames}
dataset.append(data)
if case['source']=='synthetic':
(examples/(case['id']+'.json')).write_text(json.dumps({k:v for k,v in data.items() if k!='frames'},ensure_ascii=False,indent=2),encoding='utf-8')
(results/(case['id']+'_result.json')).write_text(json.dumps(frames[-1],ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
(results/'demo_data.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
template=(ROOT/'visualization/q1.template.html').read_text(encoding='utf-8')
fragment=template.replace('/*__Q1_DATA__*/',json.dumps(dataset,ensure_ascii=False,allow_nan=False))
(ROOT/'visualization/question1.html').write_text(fragment,encoding='utf-8')
# A standalone companion uses the same fragment and computation results.
theme=(ROOT/'visualization/standalone.css').read_text(encoding='utf-8')
page='<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>B题问题一交会定位</title><style>'+theme+'</style><body>'+fragment+'</body></html>'
(results/'question1.html').write_text(page,encoding='utf-8')
print(json.dumps({'cases':len(dataset),'fragment_bytes':len(fragment.encode('utf-8')),
'standalone':str(results/'question1.html')},ensure_ascii=False))
if __name__=='__main__':
main()
-123
View File
@@ -1,123 +0,0 @@
"""Collect a few bearings for Q1 from an ALREADY OPEN practice session.
There is deliberately no test-start API, formal mode, or /clear call here.
The documented robot API does not expose mode: verify the UI before running.
"""
import argparse
import json
import math
from pathlib import Path
import sys
import time
import uuid
from urllib.error import URLError
from urllib.request import Request, urlopen
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from geometry import Measurement, solve
class PracticeClient:
def __init__(self, robot_id, output, base_url='http://127.0.0.1:2026'):
self.robot_id = robot_id
self.output = output
self.base_url = base_url.rstrip('/')
self.prefix = uuid.uuid4().hex[:12]
self.count = 0
self.events = []
self.deadline = None
def action(self, path, **extra):
if path not in ('/enter', '/measure', '/exit'):
raise ValueError('采集器只允许进入、检测、退出')
if self.deadline is not None and path != '/exit' and time.monotonic() > self.deadline-15:
raise TimeoutError('剩余时间不足,预留退出时间')
self.count += 1
payload = dict(arena_id='default', robot_id=self.robot_id,
request_id=f'q1-{self.prefix}-{self.count}', **extra)
encoded = json.dumps(payload, allow_nan=False).encode('utf-8')
for attempt in range(3):
request = Request(self.base_url+path, data=encoded,
headers={'Content-Type':'application/json'}, method='POST')
try:
with urlopen(request, timeout=5) as response:
status = response.status
body = json.load(response)
# A retried action is recorded/consumed only once.
self.events.append(dict(path=path, request=payload, response=body, retries=attempt))
self.output.write_text(json.dumps(self.events,ensure_ascii=False,indent=2),encoding='utf-8')
if status != 200 or body.get('accepted') is not True:
raise RuntimeError(f'动作未被接受: {status}, {body}')
if path == '/enter':
self.deadline = time.monotonic()+body['remaining_real_duration_s']
return body
except URLError:
if attempt == 2:
raise
time.sleep(.25)
def measure(self, p, channel):
return self.action('/measure',position={'x':p[0],'y':p[1]},channel=channel)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--robot-id',required=True)
parser.add_argument('--practice-confirmed',action='store_true',required=True,
help='仅在界面确认为问题3演练且接口已就绪时提供')
parser.add_argument('--case-code',required=True,help='界面显示的演练案例编码')
parser.add_argument('--output',type=Path,default=Path('output/q1/practice'))
args=parser.parse_args()
args.output.mkdir(parents=True,exist_ok=True)
log_path=args.output/f'{args.case_code}_actions.json'
if log_path.exists():
parser.error('同名采集日志已存在,请使用新的输出目录,避免覆盖证据')
client=PracticeClient(args.robot_id,log_path)
observations=[]
channel=None
first=None
entered=False
repeat=None
try:
client.action('/enter')
entered=True
for p in [(0,0),(800,0),(-800,0),(0,800),(0,-800)]:
for c in range(1,21):
response=client.measure(p,c)
if response['measure_result']=='direction':
first=(p,response['svd_deg'])
observations.append(Measurement(*p,response['svd_deg']))
channel=c
break
if channel is not None:
break
if channel is None:
raise RuntimeError('预设采样点未取得direction;本次不扩展为全域搜索')
p,theta=first
repeat=client.measure(p,channel)
a=math.radians(theta)
# For an omnidirectional source seen initially at <=1500m, these
# 700m-forward, +/-200m-lateral points remain within 1000m of its wedge.
for lateral in [200,-200,300,-300]:
q=(p[0]+700*math.cos(a)-lateral*math.sin(a),
p[1]+700*math.sin(a)+lateral*math.cos(a))
response=client.measure(q,channel)
if response['measure_result']=='direction':
observations.append(Measurement(*q,response['svd_deg']))
if len(observations)>=3:
break
data={'name':'官方问题3演练观测','source':'official_practice','case_code':args.case_code,
'channel':channel,'ground_truth':None,'measurements':[m.__dict__ for m in observations],
'repeated_station_response':repeat,'result':solve(observations),
'scope':'仅采集问题一观测,不执行问题三全清除任务;没有调用clear'}
path=args.output/f'{args.case_code}_observations.json'
path.write_text(json.dumps(data,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
print(json.dumps({'observations':str(path),'count':len(observations),'result':data['result']},ensure_ascii=False,indent=2))
finally:
if entered:
client.action('/exit')
if __name__=='__main__':
main()
-94
View File
@@ -1,94 +0,0 @@
"""Rasterize our restricted SVG primitives with Pillow, without a browser.
This inspects geometry/labels at both widths/themes. It does not emulate HTML/CSS.
"""
import math
from pathlib import Path
import re
import xml.etree.ElementTree as ET
from PIL import Image,ImageDraw,ImageFont
ROOT=Path(__file__).resolve().parents[1]
S=2
FONT=ImageFont.truetype('C:/Windows/Fonts/msyh.ttc',12*S)
def render(path):
root=ET.fromstring(path.read_text(encoding='utf-8'))
width,height=map(float,(root.get('width'),root.get('height')))
bg='#191c21' if 'dark' in path.stem else '#ffffff'
image=Image.new('RGBA',(round(width*S),round(height*S)),bg)
clip=next(el for el in root.iter() if el.tag.endswith('clipPath'))[0]
clipbox=tuple(float(clip.get(k))*S for k in ('x','y','width','height'))
def color(value,opacity=1):
if value in (None,'none'): return None
value=value.lstrip('#')
return tuple(int(value[i:i+2],16) for i in (0,2,4))+(round(opacity*255),)
def draw(el,clipped=False):
nonlocal image
tag=el.tag.rsplit('}',1)[-1]
if tag in ('defs','title','desc'):return
if tag in ('svg','g'):
for child in el:draw(child,clipped or bool(el.get('clip-path')))
return
layer=Image.new('RGBA',image.size,(0,0,0,0));d=ImageDraw.Draw(layer)
num=lambda key,default=0:float(el.get(key,default))*S
fill=color(el.get('fill'),float(el.get('fill-opacity',1)))
stroke=color(el.get('stroke'),float(el.get('stroke-opacity',1)))
sw=max(1,round(num('stroke-width',1)))
if tag=='line':
xy=[(num('x1'),num('y1')),(num('x2'),num('y2'))]
dash=el.get('stroke-dasharray','none')
if dash=='none': d.line(xy,fill=stroke,width=sw)
else:
a,b=xy;dist=math.dist(a,b);unit=((b[0]-a[0])/max(dist,1e-15),(b[1]-a[1])/max(dist,1e-15))
on,off=[float(v)*S for v in dash.split()]
# Bound offscreen ray work to the finite exported viewport vicinity.
segments=min(100000,int(dist/(on+off))+1)
for i in range(segments):
lo=i*(on+off);hi=min(lo+on,dist)
d.line([(a[0]+lo*unit[0],a[1]+lo*unit[1]),(a[0]+hi*unit[0],a[1]+hi*unit[1])],fill=stroke,width=sw)
elif tag=='rect':
box=(num('x'),num('y'),num('x')+num('width'),num('y')+num('height'))
d.rectangle(box,fill=fill,outline=stroke,width=sw)
elif tag=='circle':
x,y,r=num('cx'),num('cy'),num('r');box=(x-r,y-r,x+r,y+r)
if el.get('stroke-dasharray') not in (None,'none'):
for a in range(0,360,18):d.arc(box,a,a+10,fill=stroke,width=sw)
else:d.ellipse(box,fill=fill,outline=stroke,width=sw)
elif tag=='polygon':
points=[tuple(float(v)*S for v in pair.split(',')) for pair in el.get('points').split()]
d.polygon(points,fill=fill)
if stroke:d.line(points+[points[0]],fill=stroke,width=sw,joint='curve')
elif tag=='path':
for x,y,axis,length in re.findall(r'M([\d.e+-]+),([\d.e+-]+)([hv])([\d.e+-]+)',el.get('d')):
x,y,length=float(x)*S,float(y)*S,float(length)*S
d.line([(x,y),(x+length,y) if axis=='h' else (x,y+length)],fill=stroke,width=sw)
elif tag=='text':
value=el.text or '';x,y=num('x'),num('y');anchor=el.get('text-anchor','start')
length=d.textlength(value,font=FONT)
if el.get('transform'):
tile=Image.new('RGBA',(math.ceil(length+8*S),20*S));td=ImageDraw.Draw(tile)
td.text((4*S,0),value,font=FONT,fill=fill)
tile=tile.rotate(90,expand=True)
layer.alpha_composite(tile,(round(x-tile.width/2),round(y-tile.height/2)))
else:
x-=length/2 if anchor=='middle' else length if anchor=='end' else 0
d.text((x,y),value,font=FONT,fill=fill,anchor='ls')
else:raise ValueError('unsupported SVG primitive '+tag)
if clipped:
x,y,w,h=clipbox
mask=Image.new('L',image.size);ImageDraw.Draw(mask).rectangle((x,y,x+w,y+h),fill=255)
transparent=Image.new('RGBA',image.size)
layer=Image.composite(layer,transparent,mask)
image=Image.alpha_composite(image,layer)
draw(root)
output=path.with_suffix('.png');image.convert('RGB').save(output)
return str(output.relative_to(ROOT))
if __name__=='__main__':
for path in sorted((ROOT/'output/q1/figures').glob('*.svg')):print(render(path))
-61
View File
@@ -1,61 +0,0 @@
/* Run only our generated local script in a minimal DOM, with no browser or network.
* This checks event handlers and serializes the produced SVG for static inspection.
* It is not a replacement for a native browser layout test.
*/
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert');
const rootDir=path.resolve(__dirname,'..');
const source=fs.readFileSync(path.join(rootDir,'visualization/question1.html'),'utf8');
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1];
class Element {
constructor(tag) {this.tag=tag;this.attrs={};this.children=[];this.style={};this.events={};this.value='';this.max='3';this.textContent='';}
setAttribute(k,v){this.attrs[k]=String(v);}
appendChild(x){this.children.push(x);return x;}
replaceChildren(...x){this.children=x;this.textContent='';}
addEventListener(k,f){this.events[k]=f;}
add(o){this.children.push(o);}
getBoundingClientRect(){return {width:width};}
}
const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const palette={light:{'--foreground':'#24282e','--border':'#d9dfe5','--viz-series-1':'#146e9a','--viz-series-2':'#c6503a','--viz-series-3':'#198064'},dark:{'--foreground':'#eceff3','--border':'#424a55','--viz-series-1':'#6cc8ed','--viz-series-2':'#ff947d','--viz-series-3':'#64d6ac'}};
function serialize(el,theme){
if(el.tag==='text')Object.assign(el.attrs,{fill:palette[theme]['--foreground'],'font-size':12,'font-family':'Microsoft YaHei, sans-serif'});
const attrs=Object.entries(el.attrs).map(([k,v])=>`${k}="${esc(String(v).replace(/var\(([^)]+)\)/g,(_,n)=>palette[theme][n]||n))}"`).join(' ');
return `<${el.tag} ${attrs}>${esc(el.textContent)}${el.children.map(x=>serialize(x,theme)).join('')}</${el.tag}>`;
}
function text(el){return el.textContent+el.children.map(text).join(' ');}
const elements={};
for(const key of ['case','step','view','plot','step-label','stats','status','rows'])elements[key]=new Element(key==='plot'?'svg':'div');
elements.view.value='region';
const root=new Element('div');
root.querySelector=s=>{const m=s.match(/data-q1="([^"]+)"/);assert(m&&elements[m[1]],'missing selector '+s);return elements[m[1]];};
const document={getElementById:id=>{assert.equal(id,'q1-localization');return root;},createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
let width=736;
vm.runInNewContext(script,{document,Option:function(label,value){this.label=label;this.value=String(value);},ResizeObserver:class{constructor(f){this.f=f;}observe(){}},Number,Math,String,Object,console},{timeout:5000});
const output=path.join(rootDir,'output/q1/figures');fs.mkdirSync(output,{recursive:true});
let checks=0;
for(let i=0;i<elements.case.children.length;i++) {
elements.case.value=String(i);elements.case.events.change();
for(let n=1;n<=+elements.step.max;n++) {
elements.step.value=String(n);elements.step.events.input();
assert.equal(elements['step-label'].textContent,`${n} / ${elements.step.max}`);
for(const v of ['global','region']) {
elements.view.value=v;elements.view.events.change();
const svg=serialize(elements.plot,'light');
assert(!/NaN|undefined|Infinity/.test(svg));
assert(svg.includes('data-axis="x"')&&svg.includes('data-axis="y"'));
checks++;
}
}
}
for(const w of [360,736])for(const theme of ['light','dark'])for(const index of [1,elements.case.children.length-1]) {
width=w;elements.case.value=String(index);elements.case.events.change();
elements.view.value='region';elements.view.events.change();
const height=elements.plot.attrs.viewBox.split(' ')[3];
const svg=serialize(elements.plot,theme).replace('<svg ',`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" `);
const filename=(index===1?'counterexample':'practice')+`-${w}-${theme}.svg`;
fs.writeFileSync(path.join(output,filename),svg,'utf8');
}
const report={interaction_states_checked:checks,missing_selectors:0,nonfinite_svg_values:0,browser_layout_verified:false,
note:'Browser local-file navigation blocked by browser URL policy. Event handlers checked in minimal DOM; SVG statically inspected separately.'};
fs.writeFileSync(path.join(rootDir,'output/q1/visualization_checks.json'),JSON.stringify(report,null,2));
console.log(report);