refactor(arch): redesign the code structure

This commit is contained in:
Horldsence
2026-09-10 22:38:27 +08:00
parent 21e8a8bf52
commit 83d2e24448
42 changed files with 2514 additions and 251 deletions
+21
View File
@@ -0,0 +1,21 @@
"""mcm26b:CUMCM 2026 B题"无线电干扰源的快速自动定位与清除"求解包。
模块分层(docs/B题建模分析与实施计划.md §7 的七模块划分):
- config 全项目参数中心(题面常量与工程参数,唯一字面量来源)
- protocol P0/P1 字段表、计时模型、状态机与串行幂等客户端
- geometry P2 角域交会定位、直径、最小包围圆与 P2 证书(已完工)
- sim 自建模拟器、故障注入与演练日志回放(测试专用)
- belief P3 逐频道候选集合(骨架)
- coverage P4 49 点覆盖记录(骨架+网格生成)
- planner P3 选点 / P4 全向闭环 / P5 定向扩展(骨架)
- evaluation 日志复算与统计
"""
__version__ = "0.2.0"
from .config import DEFAULT_CONFIG, ProblemConfig
from .geometry import Measurement, first_bearing_candidate_disk, solve, \
smallest_enclosing_circle
__all__ = ["DEFAULT_CONFIG", "Measurement", "ProblemConfig", "solve",
"first_bearing_candidate_disk", "smallest_enclosing_circle"]
+58
View File
@@ -0,0 +1,58 @@
"""P3 逐频道信念状态:候选集合 K_c 维护与频道处理状态。
现状:骨架(P3 起填充)。接口与不变式先行固定,防止后续实现漂移。
核心不变式(验收标准):
- 保守性:真源永远属于维护中的候选集合;任何更新不得因误差、
圆弧离散或浮点容差把真点排除(自建真值案例中真源始终保留);
- direction 读数:K_c ⊆ Ω ∩ W(s, θ±δ) ∩ B(s, 1500)(首次测向构造见
geometry.planning.first_bearing_candidate_disk 的解析证书);
- no_signal 读数(P4,全向源):仅可排除 B(s, 1000);用于定向源时
是"位置×朝向"联合约束,单独维护空间候选会误删真源(P5 才启用);
- /clear 成功:状态转 CLEARED;失败只说明"该频道 20m 内无可清除源",
不得直接删除超出 20m 的候选。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from ..config import ProblemConfig
class ChannelStatus(Enum):
UNKNOWN = "unknown" # 尚无有效证据
DETECTED = "detected" # 收到过 direction/near,待定位
LOCATED = "located" # 候选集包围圆半径 <= 20,可清除
CLEARED = "cleared" # 清除成功
ABSENT = "absent" # 覆盖证明不存在(P4)
@dataclass
class ChannelBelief:
channel: int
status: ChannelStatus = ChannelStatus.UNKNOWN
measurements: list = field(default_factory=list) # (位置, 结果, svd_deg) 记录
class BeliefState:
"""全部 20 个频道的信念集合。策略端唯一的事实来源。"""
def __init__(self, config: ProblemConfig = ProblemConfig()):
self.config = config
self.channels = {channel: ChannelBelief(channel)
for channel in range(config.field.channel_min,
config.field.channel_max + 1)}
def update_measure(self, channel: int, position, result: str,
svd_deg: float | None) -> None:
"""按读数收敛候选集合。未实现:需要几何模块的在线圆盘角域交。"""
raise NotImplementedError("P3:接入 geometry 的圆盘约束角域交后实现")
def candidate_set(self, channel: int):
"""返回该频道当前候选集合(保守外包形式)。未实现。"""
raise NotImplementedError("P3")
def mark_cleared(self, channel: int) -> None:
self.channels[channel].status = ChannelStatus.CLEARED
+78
View File
@@ -0,0 +1,78 @@
"""统一命令行入口:mcm26b <子命令>。
- q1 问题一角域交会求解(P2 已完工)
- sim-check 用自建模拟器复现附件计时示例(P0 自检)
- run 全向闭环(P4 骨架,未实现)
"""
from __future__ import annotations
import argparse
import json
import sys
from .config import ProblemConfig
from .geometry.__main__ import main as q1_main
from .protocol.client import SimulatorClient
from .protocol.rules import EXIT_USER
from .sim import LocalSimTransport, LocalSimulator, Source
def _sim_check(args) -> int:
"""附件1表2/附件2第10节计时示例:期望 105/111/194/199 秒。"""
simulator = LocalSimulator([], robot_id=args.robot_id)
client = SimulatorClient(args.robot_id, transport=LocalSimTransport(simulator))
client.enter()
steps = []
steps.append(client.measure((300, 400), 1).virtual_time_s) # 移动500m+检测5s
steps.append(client.measure((300, 400), 2).virtual_time_s) # 换频道1s+检测5s
steps.append(client.clear((300, 0), 3).virtual_time_s) # 移动400m+未发现3s
steps.append(client.measure((300, 0), 2).virtual_time_s) # 原地检测频道2
info = client.exit()
expected = [105, 111, 194, 199]
ok = [round(value, 6) for value in steps] == expected \
and round(info.virtual_time_s, 6) == 199 and info.reason == EXIT_USER
print(json.dumps({"expected": expected, "got": steps,
"final_virtual_time_s": info.virtual_time_s,
"exit_reason": info.reason, "pass": ok},
ensure_ascii=False))
return 0 if ok else 1
def _run(args) -> int:
raise NotImplementedError(
"P4 全向闭环尚未实现;当前可用子命令:q1(问题一求解)、sim-check(协议自检)")
def main(argv=None) -> int:
parser = argparse.ArgumentParser(prog="mcm26b", description=__doc__)
parser.add_argument("--config", default="",
help="预留:覆盖 ProblemConfig 的 JSON 文件(参数化入口)")
subparsers = parser.add_subparsers(dest="command", required=True)
q1 = subparsers.add_parser("q1", help="问题一角域交会求解")
q1.add_argument("input", type=str)
q1.add_argument("-o", "--output", type=str, default=None)
q1.add_argument("--rounding-margin-deg", type=float, default=0)
q1.set_defaults(func=lambda a: q1_main([a.input] + (["-o", a.output] if a.output else [])
+ (["--rounding-margin-deg", str(a.rounding_margin_deg)]
if a.rounding_margin_deg else [])) or 0)
check = subparsers.add_parser("sim-check", help="自建模拟器复现附件计时示例")
check.add_argument("--robot-id", default="2026001")
check.set_defaults(func=_sim_check)
run = subparsers.add_parser("run", help="P4 全向闭环(骨架)")
run.add_argument("--robot-id", required=True)
run.set_defaults(func=_run)
args = parser.parse_args(argv)
try:
return args.func(args)
except NotImplementedError as exc:
print(f"未实现: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""全项目参数中心:题面常量、协议限制、会话策略与搜索规划参数。
任何魔法数字只允许在这里出现一次。各模块通过 :class:`ProblemConfig` 读取;
命令行需要覆盖时用 ``dataclasses.replace`` 派生新配置,不散落字面量。
来源标注约定:
- ``[题面]`` 出自 issue/B题.pdf、附件1《模拟器使用说明》、附件2《通信接口说明》原文;
- ``[工程]`` 本队工程参数,可依演练实测调整,不代表官方规定。
"""
from __future__ import annotations
from dataclasses import dataclass
Vec2 = tuple[float, float]
@dataclass(frozen=True)
class FieldConfig:
"""题目物理世界与协议硬限制。[题面]"""
omega_radius_m: float = 1800.0 # 目标区域半径,圆心为原点
channel_min: int = 1 # 有效频道范围
channel_max: int = 20
source_count_min: int = 10 # 一局源总数范围(接口不直接告知)
source_count_max: int = 16
r_eff_min_m: float = 1000.0 # 有效接收半径下界:no_signal 只能排除该圆盘
r_eff_max_m: float = 1500.0 # 有效接收半径上界:direction 隐含距离上界
near_radius_m: float = 5.0 # "距离过近"阈值,不返回示向度
clear_radius_m: float = 20.0 # 清除半径;与定向朝向无关
bearing_error_half_deg: float = 1.0 # 示向度误差幅度(题设模型)
bearing_decimals: int = 2 # 返回示向度小数位
coord_abs_limit_m: float = 2_000_000.0 # 提交坐标的绝对值上限(超出返回 HTTP 400)
directional_half_angle_deg: float = 90.0 # 定向源朝向两侧各90度覆盖(含边界)
max_virtual_duration_s: float = 360_000.0 # 虚拟世界限时(100小时)
max_real_duration_s: float = 1200.0 # /enter 成功后程序运行上限(20分钟)
test_window_s: float = 1500.0 # 确认开始后的测试窗口(25分钟)
@dataclass(frozen=True)
class CostConfig:
"""虚拟世界动作耗时模型。[题面] 附件1表1、附件2第4节。"""
speed_mps: float = 5.0 # 移动速度;耗时=直线距离/speed
measure_s: float = 5.0 # 每次检测动作耗时(无论结果)
switch_s: float = 1.0 # 任意两频道间切换耗时,仅由合法 /measure 触发
clear_success_s: float = 5.0 # 清除成功耗时 = 光学定位3s + 激光清除2s
clear_fail_s: float = 3.0 # 未发现可清除目标 = 仅光学定位3s
@dataclass(frozen=True)
class SessionConfig:
"""会话与网络策略。arena/robot/request 规则为 [题面],其余为 [工程]。"""
arena_id: str = "default" # [题面] 固定值
base_url: str = "http://127.0.0.1:2026" # [题面] 模拟器默认接口地址
robot_id_max_bytes: int = 64 # [题面] UTF-8 长度 1..64
request_id_max_bytes: int = 128 # [题面] UTF-8 长度 1..128
request_body_max_bytes: int = 65_536 # [题面] 超出返回 HTTP 413
connect_timeout_s: float = 5.0 # [工程] 连接超时
read_timeout_s: float = 15.0 # [工程] 读超时;覆盖"服务器已处理但响应丢失"
max_attempts: int = 3 # [工程] 单动作最多尝试次数(含首次)
retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大
reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数
real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用
@dataclass(frozen=True)
class SearchConfig:
"""搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。"""
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000
grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
bearing_rounding_margin_deg: float = 0.0 # 默认不加舍入裕量;敏感性分析时显式设 0.005
@dataclass(frozen=True)
class ProblemConfig:
"""聚合配置。各模块函数的第一个默认参数,便于单测替换。"""
field: FieldConfig = FieldConfig()
cost: CostConfig = CostConfig()
session: SessionConfig = SessionConfig()
search: SearchConfig = SearchConfig()
DEFAULT_CONFIG = ProblemConfig()
+56
View File
@@ -0,0 +1,56 @@
"""P4 全域覆盖:49 点网格与逐频道覆盖记录。
覆盖基线(推导见 docs/B题建模分析与实施计划.md §4):
S = {(700i, 700j) : i, j ∈ [-3,3]},|S| = 49,覆盖 [-2100,2100]² ⊇ Ω,
且允许访问目标圆域外(边界朝外发射的定向源不漏)。
单元内任意点到 4 顶点距离 <= 700·√2 ≈ 989.95 <= 1000 <= 最小接收半径。
验收标准:
- "路过某点"不算"测过某频道":必须按 (频道, 点) 二维记录;
- 已清除/已定位频道可跳过剩余点,但跳过原因必须留痕;
- 自适应跳点(P6)必须保留逐频道覆盖证据,否则不得启用。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..config import ProblemConfig, Vec2
def coverage_points(config: ProblemConfig = ProblemConfig()) -> list[Vec2]:
"""49 点覆盖网格生成(纯几何,可独立测试)。"""
step = config.search.grid_step_m
half = config.search.grid_half_cells
return [(step * i, step * j)
for j in range(half, -half - 1, -1)
for i in range(-half, half + 1)]
@dataclass
class CoverageLedger:
"""逐频道覆盖账本:(频道, 网格点) -> 是否已测。"""
config: ProblemConfig = field(default_factory=ProblemConfig)
points: list[Vec2] = field(default_factory=lambda: coverage_points())
measured: set = field(default_factory=set) # {(channel, point_index)}
skipped: dict = field(default_factory=dict) # channel -> 原因
def mark_measured(self, channel: int, point_index: int) -> None:
self.measured.add((channel, point_index))
def measured_at(self, channel: int, point_index: int) -> bool:
return (channel, point_index) in self.measured
def skip_channel(self, channel: int, reason: str) -> None:
"""记录整频道跳过原因(如 already_cleared / located_pending_clear)。"""
self.skipped[channel] = reason
def pending_points(self, channel: int) -> list[int]:
"""该频道尚未测量的网格点下标。扫描调度策略未实现(P4)。"""
return [index for index in range(len(self.points))
if not self.measured_at(channel, index)]
def coverage_complete(self, channel: int) -> bool:
"""该频道覆盖是否完成(含显式跳过)。"""
return channel in self.skipped or not self.pending_points(channel)
+81
View File
@@ -0,0 +1,81 @@
"""评估与统计:从自身动作日志离线复算虚拟时刻与统计量。
"演练统计"验收要求:总时间与分项耗时可独立复算 [题面/计划 §6]。
recompute_virtual_time 是回放测试与本模块共用的唯一复算实现,
客户端日志格式为 SessionLog 中 accepted 事件的 {path, request, response}。
"""
from __future__ import annotations
from ..config import CostConfig
from ..protocol.rules import CLEAR_SUCCESS
from ..protocol.timing import clear_cost, measure_cost
def recompute_virtual_time(events: list[dict],
cost: CostConfig = CostConfig()) -> dict:
"""从动作日志复算逐步虚拟时刻。
events 每项形如 {path, request, response}(accepted=true)。
返回逐步表与最终时刻;任何一步与服务器响应偏差超过容差记入 mismatches。
"""
position = (0.0, 0.0)
channel = 1
virtual = 0.0
steps, mismatches = [], []
for index, event in enumerate(events):
path, request = event["path"], event["request"]
response = event["response"]
if response.get("accepted") is not True:
mismatches.append({"index": index, "issue": "响应未被接受"})
continue
if path == "/enter":
position, channel, virtual = (0.0, 0.0), 1, response["virtual_time_s"]
elif path == "/measure":
pos = (request["position"]["x"], request["position"]["y"])
predicted = measure_cost(position, pos, channel, request["channel"], cost)
position, channel = pos, request["channel"]
virtual += predicted.total_s
elif path == "/clear":
pos = (request["position"]["x"], request["position"]["y"])
success = response.get("clear_result") == CLEAR_SUCCESS
predicted = clear_cost(position, pos, success, cost)
position = pos
virtual += predicted.total_s
elif path == "/exit":
pass # /exit 不推进虚拟时钟 [题面]
else:
mismatches.append({"index": index, "issue": f"未知路径 {path}"})
continue
delta = response["virtual_time_s"] - virtual
if abs(delta) > 1e-6:
mismatches.append({"index": index, "path": path,
"recomputed_s": virtual,
"recorded_s": response["virtual_time_s"],
"gap_s": delta})
steps.append({"index": index, "path": path,
"predicted_s": round(virtual, 9),
"recorded_s": response["virtual_time_s"]})
return {"steps": steps, "final_virtual_time_s": virtual,
"mismatches": mismatches}
def summarize(events: list[dict]) -> dict:
"""动作计数摘要:检测/清除/移动距离等基础统计。"""
position = (0.0, 0.0)
measures, clears, cleared, distance_m = 0, 0, 0, 0.0
for event in events:
request = event["request"]
if event["path"] in ("/measure", "/clear"):
pos = (request["position"]["x"], request["position"]["y"])
distance_m += ((pos[0] - position[0]) ** 2
+ (pos[1] - position[1]) ** 2) ** 0.5
position = pos
if event["path"] == "/measure":
measures += 1
elif event["path"] == "/clear":
clears += 1
if event["response"].get("clear_result") == CLEAR_SUCCESS:
cleared += 1
return {"measures": measures, "clears": clears, "cleared": cleared,
"distance_m": distance_m}
+9
View File
@@ -0,0 +1,9 @@
"""P2 几何内核:B题问题一有界误差方位交会定位。仅用标准库,已完工。
问题二首次测向安全候选盘证书一并放在本包(planning 模块)。
"""
from .localization import Measurement, solve, smallest_enclosing_circle
from .planning import first_bearing_candidate_disk
__all__ = ["Measurement", "solve", "smallest_enclosing_circle", "first_bearing_candidate_disk"]
+38
View File
@@ -0,0 +1,38 @@
"""python -m mcm26b.geometry observations.json [-o result.json]"""
import argparse
import json
from dataclasses import replace
from math import isfinite
from pathlib import Path
from .localization import Measurement, solve
def main(argv=None):
parser = argparse.ArgumentParser(description="B题问题一:角域交、直径与包围圆")
parser.add_argument("input", type=Path, help="包含measurements数组的JSON文件")
parser.add_argument("-o", "--output", type=Path)
parser.add_argument("--rounding-margin-deg", type=float, default=0,
help="额外误差半角,默认0;可显式设0.005进行舍入敏感性分析")
args = parser.parse_args(argv)
try:
source = json.loads(args.input.read_text(encoding="utf-8-sig"))
measurements = [Measurement(**row) for row in source["measurements"]]
if not isfinite(args.rounding_margin_deg) or args.rounding_margin_deg < 0:
raise ValueError("额外误差半角必须为有限非负数")
measurements = [replace(m, half_angle_deg=m.half_angle_deg+args.rounding_margin_deg)
for m in measurements]
result = solve(measurements)
result["error_policy"] = {"additional_half_angle_deg": args.rounding_margin_deg,
"effective_half_angles_deg": [m.half_angle_deg for m in measurements]}
except (ValueError, TypeError, KeyError, OSError) as exc:
parser.error(str(exc))
text = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()
+269
View File
@@ -0,0 +1,269 @@
"""Intersect forward bearing wedges without an artificial bounding box.
All calculations are floating point with normalized station coordinates.
The reference algorithm deliberately favors auditability over asymptotic speed.
"""
from dataclasses import dataclass
from itertools import combinations
from math import cos, sin, radians, hypot, isfinite
from random import Random
Point = tuple[float, float]
EPS = 1e-9
PARALLEL_EPS = 1e-12
@dataclass(frozen=True)
class Measurement:
x: float
y: float
bearing_deg: float
half_angle_deg: float = 1.0
def __post_init__(self):
values = (self.x, self.y, self.bearing_deg, self.half_angle_deg)
if any(isinstance(v, bool) or not isinstance(v, (int, float))
or not isfinite(v) for v in values):
raise ValueError("坐标、示向度和误差半角必须是有限数值")
if not 0 <= self.half_angle_deg < 90:
raise ValueError("误差半角必须在 [0, 90) 度;题设为1度")
def cross(a: Point, b: Point) -> float:
return a[0] * b[1] - a[1] * b[0]
def distance(a: Point, b: Point) -> float:
return hypot(a[0] - b[0], a[1] - b[1])
def halfplanes(m: Measurement) -> list[tuple[float, float, float]]:
"""Return a*x+b*y<=c with unit normals, including the forward constraint."""
theta = m.bearing_deg % 360
lower, upper = radians(theta - m.half_angle_deg), radians(theta + m.half_angle_deg)
middle = radians(theta)
normals = [(sin(lower), -cos(lower)), (-sin(upper), cos(upper)),
(-cos(middle), -sin(middle))]
return [(a, b, a * m.x + b * m.y) for a, b in normals]
def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]:
"""Monotone chain, CCW, no repeated final vertex; retain segment endpoints."""
ordered = sorted(points)
unique = []
for p in ordered:
if not unique or distance(p, unique[-1]) > eps:
unique.append(p)
if len(unique) <= 2:
return unique
def chain(seq):
out = []
for p in seq:
while len(out) >= 2:
a = (out[-1][0] - out[-2][0], out[-1][1] - out[-2][1])
b = (p[0] - out[-1][0], p[1] - out[-1][1])
# Distance-to-line tolerance, not an unscaled area tolerance.
if cross(a, b) > eps * max(hypot(*a), hypot(*b), eps):
break
out.pop()
out.append(p)
return out
return chain(unique)[:-1] + chain(reversed(unique))[:-1]
def diameter(points: list[Point]):
if not points:
raise ValueError("空点集没有本程序定义下的直径")
a = b = points[0]
value = 0.0
for p, q in combinations(points, 2):
d = distance(p, q)
if d > value:
value, a, b = d, p, q
return value, (a, b)
def _circumcircle(a: Point, b: Point, c: Point):
u = (b[0] - a[0], b[1] - a[1])
v = (c[0] - a[0], c[1] - a[1])
det = 2 * cross(u, v)
if abs(det) <= PARALLEL_EPS * max(hypot(*u) * hypot(*v), 1e-300):
return None
uu, vv = u[0] ** 2 + u[1] ** 2, v[0] ** 2 + v[1] ** 2
center = (a[0] + (uu * v[1] - vv * u[1]) / det,
a[1] + (u[0] * vv - v[0] * uu) / det)
return center, distance(center, a)
def _enclosing_circle_reference(points: list[Point], eps: float = EPS):
"""Enumerate all 1/2/3-point supports; O(m^4), appropriate for small m.
Radius is recomputed against every vertex to preserve numerical containment.
Coordinates should be normalized when there are very large offsets.
"""
if not points:
raise ValueError("空集不计算最小包围圆")
xs, ys = [p[0] for p in points], [p[1] for p in points]
best_c = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
best_r = max(distance(best_c, p) for p in points)
def consider(center, radius):
nonlocal best_c, best_r
if radius > best_r + eps:
return
actual = max(distance(center, p) for p in points)
if actual <= radius + eps and actual < best_r:
best_c, best_r = center, actual
for p in points:
consider(p, 0.0)
for a, b in combinations(points, 2):
consider(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), distance(a, b) / 2)
for a, b, c in combinations(points, 3):
circle = _circumcircle(a, b, c)
if circle is not None:
consider(*circle)
return best_c, best_r
def smallest_enclosing_circle(points: list[Point], eps: float = EPS):
"""Welzl's randomized incremental algorithm, using an explicit call stack.
The fixed shuffle makes results reproducible. Expected linear work refers
to randomized input order, not a worst-case guarantee for this fixed seed.
Normalize internally so this public helper also handles translated inputs.
The exhaustive reference is retained for independent regression comparisons.
"""
if not points:
raise ValueError("空集不计算最小包围圆")
if not isfinite(eps) or eps <= 0:
raise ValueError("eps必须为有限正数")
if any(len(p) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float))
or not isfinite(x) for x in p) for p in points):
raise ValueError("点集必须由有限二维坐标组成")
origin = points[0]
scale = max(distance(origin, p) for p in points)
if scale == 0:
return tuple(origin), 0.0
local = [((p[0]-origin[0])/scale, (p[1]-origin[1])/scale) for p in points]
tolerance = min(eps/scale, EPS)
Random(20260910).shuffle(local)
# Frame = (remaining prefix length, forced boundary, returning from child).
stack = [(len(local), (), False)]
circle = None
while stack:
n, boundary, returning = stack.pop()
if n == 0 or len(boundary) == 3:
# Forced support points must lie ON the boundary. For three points
# use their circumcircle, even when their unconstrained MEC is smaller.
circle = _circumcircle(*boundary) if len(boundary) == 3 else None
if circle is None and boundary:
circle = _enclosing_circle_reference(list(boundary), tolerance)
elif not returning:
stack.append((n, boundary, True))
stack.append((n-1, boundary, False))
elif circle is None or distance(circle[0], local[n-1]) > circle[1] + tolerance:
stack.append((n-1, boundary + (local[n-1],), False))
center = (origin[0]+circle[0][0]*scale, origin[1]+circle[0][1]*scale)
# Recompute after transforming back: returned circle contains all input points
# to distance() precision, including any roundoff from the large translation.
return center, max(distance(center, p) for p in points)
def solve(measurements: list[Measurement], eps: float = EPS) -> dict:
"""Solve Q1's pure angular intersection (no disk/range prior added).
Nonempty intersections of <180-degree forward wedges are pointed polyhedra,
so they possess a vertex, even if unbounded. Feasibility follows from vertex
enumeration; unboundedness follows from a nonzero recession direction.
Empty diameter is null, unbounded diameter is null with diameter_infinite=true.
"""
if not isfinite(eps) or eps <= 0:
raise ValueError("eps必须为有限正数")
if not measurements:
return {"status": "unbounded", "vertices": [], "diameter_m": None,
"diameter_infinite": True, "recession_direction": [1.0, 0.0],
"message": "没有观测,整个平面均可行"}
if len(measurements) > 100:
raise ValueError("参考实现最多接收100个检测点;更大规模应使用半平面交算法")
ox = sum(m.x for m in measurements) / len(measurements)
oy = sum(m.y for m in measurements) / len(measurements)
scale = max(1.0, max(hypot(m.x - ox, m.y - oy) for m in measurements))
local = [Measurement((m.x - ox) / scale, (m.y - oy) / scale,
m.bearing_deg, m.half_angle_deg) for m in measurements]
planes = [h for m in local for h in halfplanes(m)]
def inside(p):
return all(a * p[0] + b * p[1] <= c + eps for a, b, c in planes)
feasible_vertices = []
for (a, b, c), (d, e, f) in combinations(planes, 2):
det = a * e - b * d
if abs(det) <= PARALLEL_EPS:
continue
p = ((c * e - b * f) / det, (a * f - c * d) / det)
if inside(p):
feasible_vertices.append(p)
if not feasible_vertices:
return {"status": "empty", "vertices": [], "diameter_m": None,
"diameter_infinite": False, "tolerance_m": eps * scale,
"message": "测向角域没有公共点;检查频道、数据及误差界"}
# A nontrivial 2D recession cone has a ray tangent to an input halfplane.
# Use a much tighter angular threshold than the positional feasibility epsilon.
ray = None
for a, b, _ in planes:
for direction in ((b, -a), (-b, a)):
if all(d * direction[0] + e * direction[1] <= 2e-14
for d, e, _ in planes):
ray = direction
break
if ray is not None:
break
hull = convex_hull(feasible_vertices, eps)
def world(p):
return [ox + p[0] * scale, oy + p[1] * scale]
result = {"status": "unbounded" if ray else
("point" if len(hull) == 1 else "segment" if len(hull) == 2 else "polygon"),
"vertices": [world(p) for p in hull], "tolerance_m": eps * scale,
"diameter_infinite": ray is not None}
if ray is not None:
result.update(diameter_m=None, recession_direction=list(ray),
message="存在非零衰退方向,区域无界,直径为无穷大")
return result
d, pair = diameter(hull)
midpoint = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2)
distances = [distance(midpoint, p) for p in hull]
witness_index = max(range(len(hull)), key=distances.__getitem__)
max_distance = distances[witness_index]
center, radius = smallest_enclosing_circle(hull, eps)
area = abs(sum(cross(hull[i], hull[(i + 1) % len(hull)])
for i in range(len(hull)))) / 2
result.update(diameter_m=d * scale, diameter_pair=[world(p) for p in pair],
area_m2=area * scale ** 2,
diameter_circle={"center": world(midpoint), "radius_m": d * scale / 2,
"max_vertex_distance_m": max_distance * scale,
"covers": max_distance <= d / 2 + eps * 4,
"coverage_tolerance_m": 4 * eps * scale,
"max_excess_m": (max_distance-d/2) * scale,
"outside_vertex_indices": [i for i, value in enumerate(distances)
if value > d/2 + 4*eps],
"witness_vertex": world(hull[witness_index])
if max_distance > d/2 + 4*eps else None},
minimum_enclosing_circle={"center": world(center), "radius_m": radius * scale},
guaranteed_clearable_at_mec_center=radius * scale <= 20 - eps * scale * 4,
clearance_certificate={"model": "angular_intersection_only",
"clear_radius_m": 20.0,
"numerical_margin_m": 4*eps*scale,
"radius_with_margin_m": (radius+4*eps)*scale,
"remaining_margin_m": 20-(radius+4*eps)*scale,
"center": world(center)})
return result
+32
View File
@@ -0,0 +1,32 @@
"""Distance certificates for Q2; does not change Q1's angular polygon model."""
from math import cos, sin, radians, isfinite
from .localization import Measurement
def first_bearing_candidate_disk(measurement: Measurement,
max_range_m: float = 1500,
guaranteed_range_m: float = 1000) -> dict:
"""Enclose the entire truncated first-bearing sector, then erode its disk.
For half-angle <=45 degrees the sector MEC has center distance and radius
a=R/(2*cos(delta)). Intersecting with Omega only shrinks the source set.
A missing sufficient disk is NOT proof that Q is empty. The certificate
guarantees distance only: directional transmitters may still be invisible.
"""
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v)
or v <= 0 for v in (max_range_m, guaranteed_range_m)):
raise ValueError("接收半径必须为有限正数")
if measurement.half_angle_deg > 45:
raise ValueError("此窄角域证书仅支持半角不超过45度;题设为1度")
delta, theta = radians(measurement.half_angle_deg), radians(measurement.bearing_deg % 360)
a = max_range_m/(2*cos(delta))
center = [measurement.x+a*cos(theta), measurement.y+a*sin(theta)]
available = guaranteed_range_m-a
return {"source_model": "first_bearing_sector_with_range_upper_bound",
"sector_enclosing_circle": {"center": center, "radius_m": a},
"sufficient_candidate_disk": {"center": center, "radius_m": available}
if available >= 0 else None,
"candidate_nonempty_certified": available >= 0,
"distance_only": True,
"max_range_m": max_range_m, "guaranteed_range_m": guaranteed_range_m}
+80
View File
@@ -0,0 +1,80 @@
"""P3/P4/P5 策略层:主动选点、全向闭环与定向扩展。
现状:骨架。各入口先固定签名与验收标准,逐阶段填充实现。
P3 第二检测点(验收:保证收到信号;Q 空时退化;near 分支):
- K1 = Ω ∩ W(s1, θ1±δ) ∩ B(s1, 1500);安全候选盘证书见
geometry.planning.first_bearing_candidate_disk(δ<=1.005° 时非空);
- 在证书圆盘内围绕测向轴两侧采样候选点,以"最坏后验包围圆半径"
为主指标、移动+检测代价为次指标;
- 返回 near 时:候选源在 5m 内,直接对当前频道尝试 /clear。
P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止条件正确):
发现(coverage.CoverageLedger)→ 定位(belief 收敛)→ 清除 → 停止判定
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
发现 10 源、连续无信号、候选采样为空都不是停止条件。
P5 定向扩展(验收:朝外边界源能发现;失去信号能清除;不误删真源):
no_signal 是"位置×朝向"联合约束,基线不据此删空间候选;
失去信号后对有界候选做 <=20m 有限圆盘覆盖逐点 /clear,
并按预算上界(格数×请求耗时)决定是否执行。
"""
from __future__ import annotations
from dataclasses import dataclass
from ..config import ProblemConfig
from ..protocol.client import SimulatorClient
@dataclass
class SecondPointPlan:
"""第二检测点的候选与依据(P3 输出形态预览)。"""
position: tuple[float, float]
worst_posterior_radius_m: float
move_cost_s: float
reason: str
def select_second_point(client: SimulatorClient,
first_position, first_bearing_deg: float,
config: ProblemConfig = ProblemConfig()):
"""在安全候选盘内选择第二检测点。未实现:P3。"""
raise NotImplementedError("P3:先实现 belief 候选集,再实现采样评估")
class OmnidirectionalLoop:
"""问题3全向"发现—定位—清除—停止"闭环。未实现:P4。"""
def __init__(self, client: SimulatorClient,
config: ProblemConfig = ProblemConfig()):
self.client = client
self.config = config
def run(self) -> dict:
raise NotImplementedError("P4:依赖 coverage + belief + planner 就位")
class DirectionalExtension:
"""问题4定向源扩展:朝向不确定下的覆盖与后备清除。未实现:P5。"""
def __init__(self, client: SimulatorClient,
config: ProblemConfig = ProblemConfig()):
self.client = client
self.config = config
def run(self) -> dict:
raise NotImplementedError("P5:依赖 P4 闭环稳定后扩展")
def fallback_clear_cells(center, width_m: float, height_m: float,
config: ProblemConfig = ProblemConfig()):
"""失去信号后的后备清除格心列表(<=20m 圆盘覆盖)。
半开区间统一索引,源在格线上时至少归入一格;相交判定数值
不确定时保留格子(宁可多测)。格数即 /clear 请求预算上界。
未实现:P5(先由演练实测请求预算决定启用规模)。
"""
raise NotImplementedError("P5")
+15
View File
@@ -0,0 +1,15 @@
"""P0/P1 协议层:字段表、规则表、计时模型、状态机与客户端。"""
from .client import (ActionRejected, ClearOutcome, DeadlineExceeded,
ExitInfo, MeasureOutcome, ProtocolError,
ProtocolViolation, SimulatorClient)
from .log import SessionLog
from .state import RobotState, StateError
from .timing import CostBreakdown, clear_cost, measure_cost, move_time
from .transport import RequestsTransport, Transport, TransportError, TransportResponse
__all__ = ["ActionRejected", "ClearOutcome", "CostBreakdown", "DeadlineExceeded",
"ExitInfo", "MeasureOutcome", "ProtocolError", "ProtocolViolation",
"RequestsTransport", "RobotState", "SessionLog", "SimulatorClient",
"StateError", "Transport", "TransportError", "TransportResponse",
"clear_cost", "measure_cost", "move_time"]
+288
View File
@@ -0,0 +1,288 @@
"""P1 协议基线:串行、幂等、带现实预算的模拟器客户端。
协议硬规则 [题面] 与实现对应:
- 每个新动作使用新的 request_id;仅在网络失败后重试"完全相同的动作"时,
复用原 request_id 与原请求体 —— 本类通过一次性序列化出 bytes 并原样重发保证;
- 必须同时检查 HTTP 状态与 accepted;accepted=false 不更新状态,
其 virtual_time_s=0 不当作当前虚拟时刻;
- 逐次等待响应、不得并发;本类完全同步,一个动作未决不发起下一个;
- 现实截止用 /enter 响应的 remaining_real_duration_s 建立本地单调时钟,
预留退出余量;/exit 不做预算拦截。
"""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from time import monotonic, sleep
from ..config import ProblemConfig, Vec2
from .log import SessionLog
from .rules import (CLEAR_RESULTS, DIRECTION, EXIT_USER, MEASURE_RESULTS,
POSITION_FIELDS, STATUS_OK, STATUS_RETRYABLE)
from .state import RobotState
from .timing import CostBreakdown
from .transport import RequestsTransport, Transport, TransportError
class ProtocolError(RuntimeError):
"""协议层失败,含 HTTP 状态与响应体;默认不重试(确定性错误)。"""
def __init__(self, message: str, status: int | None = None, body=None):
super().__init__(message)
self.status = status
self.body = body
class ActionRejected(ProtocolError):
"""HTTP 200 但 accepted=false:动作未生效,状态不得更新。"""
def __init__(self, message: str, body=None):
super().__init__(message, status=STATUS_OK, body=body)
class ProtocolViolation(ProtocolError):
"""响应与协议约定不符(字段缺失、值非法、虚拟时刻回退等)。"""
class DeadlineExceeded(RuntimeError):
"""现实预算不足:预留退出余量后剩余时间不够执行新动作。"""
@dataclass(frozen=True)
class MeasureOutcome:
result: str
svd_deg: float | None
virtual_time_s: float
predicted: CostBreakdown
raw: dict
@dataclass(frozen=True)
class ClearOutcome:
result: str
virtual_time_s: float
predicted: CostBreakdown
raw: dict
@dataclass(frozen=True)
class ExitInfo:
reason: str
virtual_time_s: float
raw: dict
def _validate_identifier(name: str, value: str, max_bytes: int) -> None:
if not isinstance(value, str) or not value:
raise ValueError(f"{name} 必须是非空字符串")
encoded = value.encode("utf-8")
if not 1 <= len(encoded) <= max_bytes:
raise ValueError(f"{name} 的 UTF-8 长度须在 1..{max_bytes} 字节")
if any(code < 0x20 or code == 0x7F for code in encoded):
raise ValueError(f"{name} 不能包含控制字符")
def _validate_position(position: dict, limit: float) -> None:
if set(position) != set(POSITION_FIELDS):
raise ValueError("position 字段必须是且只能是 x、y")
for key in ("x", "y"):
value = position[key]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"position.{key} 必须是数值")
if value != value or value in (float("inf"), float("-inf")):
raise ValueError(f"position.{key} 必须是有限数值")
if abs(value) > limit:
raise ValueError(f"position.{key} 绝对值不得超过 {limit} 米")
def _validate_channel(channel) -> int:
# 写成 JSON number 1.0 且恰为整数也接受 [题面];1.5 拒绝。
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
raise ValueError("channel 必须是数值")
if isinstance(channel, float) and not channel.is_integer():
raise ValueError("channel 必须是整数")
channel = int(channel)
if not 1 <= channel <= 20:
raise ValueError("channel 必须在 1..20 范围内")
return channel
class SimulatorClient:
"""四条指令的同步客户端。状态只在 accepted=true 后推进。"""
def __init__(self, robot_id: str, *,
config: ProblemConfig = ProblemConfig(),
transport: Transport | None = None,
log: SessionLog | None = None,
clock=monotonic):
_validate_identifier("robot_id", robot_id, config.session.robot_id_max_bytes)
self.robot_id = robot_id
self.config = config
self.session = config.session
self.transport = transport if transport is not None else RequestsTransport()
self.log = log if log is not None else SessionLog()
self.clock = clock
self.state = RobotState()
self._prefix = uuid.uuid4().hex[:12]
self._seq = 0
self._deadline: float | None = None # 本地单调时钟截止
self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对
# ------------------------------------------------------------ 公共动作 API
def enter(self) -> dict:
response = self._call("/enter", self._payload("/enter"))
self.state.apply_enter(response)
remaining = response["remaining_real_duration_s"]
if not isinstance(remaining, (int, float)) or remaining <= 0:
raise ProtocolViolation("remaining_real_duration_s 缺失或非法", body=response)
self._deadline = self.clock() + remaining
self._server_deadline_ms = (response["real_timestamp_ms"]
+ remaining * 1000.0)
return response
def measure(self, position: Vec2, channel: int) -> MeasureOutcome:
payload = self._payload("/measure", position=position, channel=channel)
response = self._call("/measure", payload)
predicted = self.state.apply_measure(payload, response,
cost=self.config.cost)
result = response.get("measure_result")
if result not in MEASURE_RESULTS:
raise ProtocolViolation(f"measure_result 非法: {result!r}", body=response)
svd = response.get("svd_deg")
if result == DIRECTION:
if not isinstance(svd, (int, float)) or isinstance(svd, bool):
raise ProtocolViolation("direction 响应缺少数值 svd_deg", body=response)
return MeasureOutcome(result, float(svd) if svd is not None else None,
response["virtual_time_s"], predicted, response)
def clear(self, position: Vec2, channel: int) -> ClearOutcome:
payload = self._payload("/clear", position=position, channel=channel)
response = self._call("/clear", payload)
predicted = self.state.apply_clear(payload, response,
cost=self.config.cost)
result = response.get("clear_result")
if result not in CLEAR_RESULTS:
raise ProtocolViolation(f"clear_result 非法: {result!r}", body=response)
return ClearOutcome(result, response["virtual_time_s"], predicted, response)
def exit(self) -> ExitInfo:
response = self._call("/exit", self._payload("/exit"))
self.state.apply_exit(response)
reason = response.get("exit_reason")
if reason != EXIT_USER:
raise ProtocolViolation(f"exit_reason 非法: {reason!r}", body=response)
return ExitInfo(reason, response["virtual_time_s"], response)
# ---------------------------------------------------------------- 预算控制
@property
def real_remaining_s(self) -> float | None:
"""按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。"""
return None if self._deadline is None else self._deadline - self.clock()
def _check_budget(self, path: str) -> None:
if path == "/exit" or self._deadline is None:
return
margin = self.session.reserve_exit_s
if self.real_remaining_s <= margin:
raise DeadlineExceeded(
f"剩余现实时间 {self.real_remaining_s:.1f}s 不足以预留 {margin:.1f}s 退出余量")
# ---------------------------------------------------------------- 内部机制
def _payload(self, path: str, *, position: Vec2 | None = None,
channel: int | None = None) -> dict:
payload = {"arena_id": self.session.arena_id,
"robot_id": self.robot_id,
"request_id": self._next_request_id()}
if position is not None:
x, y = float(position[0]), float(position[1])
_validate_position({"x": x, "y": y}, self.config.field.coord_abs_limit_m)
payload["position"] = {"x": x, "y": y}
if channel is not None:
payload["channel"] = _validate_channel(channel)
return payload
def _next_request_id(self) -> str:
self._seq += 1
request_id = f"{self._prefix}-{self._seq:04d}"
_validate_identifier("request_id", request_id,
self.session.request_id_max_bytes)
return request_id
def _call(self, path: str, payload: dict) -> dict:
"""串行执行一个动作;网络失败原样重发,HTTP 确定性错误立即抛出。"""
self._check_budget(path)
body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
if len(body) > self.session.request_body_max_bytes:
raise ValueError("请求体超过 65536 字节")
headers = {"Content-Type": "application/json"}
url = self.session.base_url + path
attempts, last_error = 0, None
while attempts < self.session.max_attempts:
attempts += 1
started = self.clock()
self.log.append({"event": "attempt", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "request": payload})
try:
result = self.transport.post(
url, body, headers,
timeout=(self.session.connect_timeout_s,
self.session.read_timeout_s))
except TransportError as exc:
last_error = exc
self.log.append({"event": "transport_error", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "error": str(exc),
"elapsed_s": self.clock() - started})
if attempts < self.session.max_attempts:
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
continue
if result.status in STATUS_RETRYABLE:
self.log.append({"event": "retryable_status", "path": path,
"request_id": payload["request_id"],
"attempt": attempts, "status": result.status})
if attempts < self.session.max_attempts:
sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))
continue
if result.status != STATUS_OK:
raise ProtocolError(
f"{path} 返回 HTTP {result.status}", status=result.status,
body=self._safe_json(result.body))
response = self._safe_json(result.body)
if response is None or response.get("accepted") is not True:
raise ActionRejected(
f"{path} 未被接受: {response}", body=response)
self._verify_clock(response)
self.log.append({"event": "accepted", "path": path,
"request_id": payload["request_id"],
"request": payload, "response": response,
"attempts": attempts,
"elapsed_s": self.clock() - started})
return response
raise TransportError(
f"{path} 重试 {attempts} 次仍失败: {last_error}") from last_error
def _verify_clock(self, response: dict) -> None:
"""accepted=true 的虚拟时刻必须单调不减;现实时间戳用于漂移核对。"""
virtual = response.get("virtual_time_s")
if not isinstance(virtual, (int, float)) or isinstance(virtual, bool):
raise ProtocolViolation("accepted 响应缺少 virtual_time_s", body=response)
if virtual < self.state.virtual_time_s - 1e-9:
raise ProtocolViolation(
f"虚拟时刻回退: {self.state.virtual_time_s} -> {virtual}",
body=response)
if self._server_deadline_ms is not None:
drift = (self._server_deadline_ms - response["real_timestamp_ms"]) / 1000.0
if drift < self.session.reserve_exit_s / 2:
# 服务器视角余量告急(本地时钟漂移或请求排队);仅记录,交由上层决策。
self.log.append({"event": "server_time_warning",
"server_remaining_s": drift})
@staticmethod
def _safe_json(body: bytes):
try:
return json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
+34
View File
@@ -0,0 +1,34 @@
"""会话日志:机器狗程序必须自行记录指令序列与响应 [题面] 附件2 12节。
事件逐条追加写入 JSONL,进程崩溃时已完成的动作不丢;
内存中的 events 同时供回放测试与 evaluation 复算使用。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from time import time
@dataclass
class SessionLog:
path: Path | None = None
events: list = field(default_factory=list)
def __post_init__(self):
if self.path is not None:
self.path.parent.mkdir(parents=True, exist_ok=True)
def append(self, event: dict) -> None:
event = {**event, "logged_at_unix_s": time()}
self.events.append(event)
if self.path is not None:
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False, allow_nan=False) + "\n")
def action_events(self) -> list[dict]:
"""仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。"""
return [event for event in self.events if event.get("response") is not None
and event["response"].get("accepted") is True]
+94
View File
@@ -0,0 +1,94 @@
"""P0 规则固定:四条指令的字段表、结果码与错误语义,全部落成可执行数据。
依据 issue/附件/附件2.docx《模拟器通信接口说明及编程指南》第 5-9 节。
客户端用它构造与预校验请求,自建模拟器用它校验并拒绝未知字段;
表格本身即交付物"字段表/规则表",人类可读版本见 docs/协议规则与状态机.md。
"""
from __future__ import annotations
from dataclasses import dataclass
# ---- 结果码 [题面] ----------------------------------------------------------
NO_SIGNAL = "no_signal" # 收不到信号:无未清除源 / 超接收半径 / 定向覆盖角外
NEAR = "near" # 距离 <=5m 且在覆盖角内;不返回 svd_deg
DIRECTION = "direction" # 有信号;附 svd_deg(含 ±1° 误差,保留两位小数)
MEASURE_RESULTS = (NO_SIGNAL, NEAR, DIRECTION)
CLEAR_SUCCESS = "success" # 20m 内发现并清除;耗时 5s
CLEAR_NO_TARGET = "no_target_in_range" # 未发现(无源/已清除/超20m);耗时 3s
CLEAR_RESULTS = (CLEAR_SUCCESS, CLEAR_NO_TARGET)
EXIT_USER = "user_exit" # /exit 成功响应中的固定退出原因
# accepted=false 的响应只含这 3 个字段;virtual_time_s 恒为 0,不是当前时刻 [题面]
BASE_RESPONSE_FIELDS = ("accepted", "real_timestamp_ms", "virtual_time_s")
# ---- 请求字段表 --------------------------------------------------------------
@dataclass(frozen=True)
class FieldSpec:
"""单个请求字段的约束。[题面] 附件2 5.1 节与各指令小节。"""
kind: type # str / dict;数值字段由 sim 按整型/浮点细则单独校验
required: bool = True
max_bytes: int | None = None # 字符串字段的 UTF-8 长度上限
COMMON_FIELDS: dict[str, FieldSpec] = {
"arena_id": FieldSpec(str), # 必须为 "default"
"robot_id": FieldSpec(str, max_bytes=64), # 逐字节等于登录参赛队号
"request_id": FieldSpec(str, max_bytes=128), # 会话内幂等键,新动作用新 ID
}
POSITION_FIELDS: dict[str, FieldSpec] = {
"x": FieldSpec(float), # 有限 JSON number,|x| <= 2_000_000
"y": FieldSpec(float),
}
# 路径 -> 除公共字段外的动作字段;/enter 与 /exit 无附加字段
ACTION_FIELDS: dict[str, dict[str, FieldSpec]] = {
"/enter": {},
"/measure": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
"/clear": {"position": FieldSpec(dict), "channel": FieldSpec(float)},
"/exit": {},
}
ACTION_PATHS = tuple(ACTION_FIELDS)
# channel 写成 JSON number 1.0 且恰为整数也接受;1.5 返回 HTTP 400 [题面]
# ---- 响应字段表(accepted=true 时的附加字段) --------------------------------
RESPONSE_FIELDS: dict[str, tuple[str, ...]] = {
"/enter": ("max_virtual_duration_s", "max_real_duration_s", "remaining_real_duration_s"),
"/measure": ("measure_result",), # direction 时另有 svd_deg
"/clear": ("clear_result",),
"/exit": ("exit_reason",),
}
# ---- 错误语义 [题面] 附件2 5.3 节 --------------------------------------------
STATUS_OK = 200
STATUS_BAD_REQUEST = 400 # JSON 语法/重复键/缺字段/类型/频道或坐标范围
STATUS_CONFLICT = 409 # 同 request_id 对应不同动作,或并发不同动作
STATUS_RETRYABLE = (429, 500) # 流量保护/幂等记录上限、服务器内部错误:可原样重试
def http_status_meaning(status: int) -> str:
table = {
200: "动作已执行,或 JSON 合法但被业务拒绝(看 accepted)",
400: "请求结构错误;不占用 request_id,修正后可复用该 ID",
404: "路径未知或不精确(不接受尾随斜线与查询参数)",
405: "已知路径使用了非 POST 方法",
409: "request_id 幂等冲突或并发发送不同动作",
413: "请求体超过 65536 字节",
415: "Content-Type 或 Content-Encoding 不受支持",
429: "连接/流量保护触发,或本局幂等记录达到上限",
500: "模拟器内部错误",
}
return table.get(status, "未知状态码")
+97
View File
@@ -0,0 +1,97 @@
"""P0/P1 机器狗本地状态机:位置、测向频道、虚拟时刻与进出状态。
更新规则 [题面]:
- 只有 accepted=true 的响应才允许调用 apply_*;accepted=false 一律不更新,
其 virtual_time_s=0 不是当前虚拟时刻;
- 虚拟时刻以最近一次 accepted=true 的响应为准,本地预测值仅作一致性核对;
- /clear 不改变测向机当前频道,也不产生切换耗时;
- 只有合法 /measure 会把测向机频道更新为本次检测频道。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..config import CostConfig, Vec2
from .rules import CLEAR_SUCCESS
from .timing import CostBreakdown, clear_cost, measure_cost
class StateError(RuntimeError):
"""状态机被以非法顺序驱动(例如未 enter 就 apply_measure)。"""
@dataclass
class RobotState:
position: Vec2 = (0.0, 0.0) # /enter 成功后的初始位置 [题面]
channel: int = 1 # /enter 成功后的初始频道 [题面]
virtual_time_s: float = 0.0
entered: bool = False
exited: bool = False
action_count: int = 0
# 预测与实测的偏差记录;strict 模式下出现偏差会直接抛错。
mismatches: list = field(default_factory=list)
# -------------------------------------------------------------- enter/exit
def apply_enter(self, response: dict, *, strict: bool = False) -> None:
self._require_accepted(response)
if strict and (self.entered or self.exited):
raise StateError("重复 /enter 或已退出后再 enter")
self.entered = True
self.virtual_time_s = response["virtual_time_s"]
def apply_exit(self, response: dict) -> None:
self._require_accepted(response)
self.exited = True
self.virtual_time_s = response["virtual_time_s"]
# ----------------------------------------------------------------- measure
def apply_measure(self, request: dict, response: dict, *,
cost: CostConfig = CostConfig(),
strict: bool = False) -> CostBreakdown:
self._require_accepted(response)
if strict and (not self.entered or self.exited):
raise StateError("/measure 要求已 enter 且未退出")
pos = (request["position"]["x"], request["position"]["y"])
channel = request["channel"]
predicted = measure_cost(self.position, pos, self.channel, channel, cost)
predicted = self._advance(pos, channel, response, predicted, strict)
return predicted
# ------------------------------------------------------------------- clear
def apply_clear(self, request: dict, response: dict, *,
cost: CostConfig = CostConfig(),
strict: bool = False) -> CostBreakdown:
self._require_accepted(response)
if strict and (not self.entered or self.exited):
raise StateError("/clear 要求已 enter 且未退出")
pos = (request["position"]["x"], request["position"]["y"])
success = response["clear_result"] == CLEAR_SUCCESS
predicted = clear_cost(self.position, pos, success, cost)
# 频道保持不变:/clear 不切换测向机频道 [题面]
return self._advance(pos, self.channel, response, predicted, strict)
# ------------------------------------------------------------------ 共用
def _advance(self, pos: Vec2, channel: int, response: dict,
predicted: CostBreakdown, strict: bool) -> CostBreakdown:
# 先完成全部校验再落子:strict 模式下失败时不留半更新状态。
actual_delta = response["virtual_time_s"] - self.virtual_time_s
if abs(actual_delta - predicted.total_s) > 1e-6:
message = (f"耗时预测不一致: 预测 {predicted.total_s:.6f}s, "
f"实测 {actual_delta:.6f}s ({predicted})")
if strict:
raise StateError(message)
# 非严格模式:状态仍以响应为准,偏差记入日志供复盘。
self.mismatches.append(
{"action": self.action_count + 1, "predicted_s": predicted.total_s,
"actual_s": actual_delta, "message": message})
self.position = pos
self.channel = channel
self.virtual_time_s = response["virtual_time_s"]
self.action_count += 1
return predicted
@staticmethod
def _require_accepted(response: dict) -> None:
if response.get("accepted") is not True:
raise StateError("accepted=false 的响应不允许更新状态")
+55
View File
@@ -0,0 +1,55 @@
"""P0 纯计时模型:/measure 与 /clear 的耗时分解,不涉及网络与状态。
附件1表2 与附件2第10节的计时示例是唯一权威锚点:
逐步得到 105、111、194、199 秒(tests/test_protocol_timing.py 固化)。
客户端用本模块预测动作代价,模拟器用同一函数推进虚拟时钟,
保证"预测-执行-复算"三方一致。
"""
from __future__ import annotations
from dataclasses import dataclass
from math import dist
from ..config import CostConfig, Vec2
@dataclass(frozen=True)
class CostBreakdown:
"""一次合法动作的三段耗时 [题面]:移动 + 换频道(仅检测)+ 动作本身。"""
move_s: float
switch_s: float
action_s: float
@property
def total_s(self) -> float:
return self.move_s + self.switch_s + self.action_s
def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,
cost: CostConfig = CostConfig()) -> CostBreakdown:
"""合法 /measure 的耗时。移动按直线距离;换频道只看前后频道是否不同。"""
return CostBreakdown(
move_s=dist(prev_pos, pos) / cost.speed_mps,
switch_s=0.0 if channel == prev_channel else cost.switch_s,
action_s=cost.measure_s,
)
def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool,
cost: CostConfig = CostConfig()) -> CostBreakdown:
"""合法 /clear 的耗时。
/clear 的 channel 是目标干扰源频道,不切换测向机频道,也不产生换频道耗时 [题面]。
"""
return CostBreakdown(
move_s=dist(prev_pos, pos) / cost.speed_mps,
switch_s=0.0,
action_s=cost.clear_success_s if success else cost.clear_fail_s,
)
def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float:
"""任意两点间的移动耗时(规划用)。"""
return dist(a, b) / cost.speed_mps
+44
View File
@@ -0,0 +1,44 @@
"""传输抽象:HTTP 细节与协议客户端解耦,便于故障注入与本地模拟。
真实运行用 :class:`RequestsTransport`;自建模拟器经 :class:`LocalSimTransport`
(sim 模块)挂载;回放用 PlaybackTransport。连接失败与读超时统一抛
:class:`TransportError` —— 此类失败后重试"同一动作"必须复用原 request_id
与原请求体 [题面],由 client 层保证。
"""
from __future__ import annotations
from dataclasses import dataclass
import requests
class TransportError(Exception):
"""网络层失败:连接不上、读写超时。动作可能未送达,也可能结果未知。"""
@dataclass(frozen=True)
class TransportResponse:
status: int
body: bytes
class Transport:
"""最小 POST 接口。同步阻塞,一次一个请求(协议要求串行)。"""
def post(self, url: str, body: bytes, headers: dict[str, str],
timeout: tuple[float, float]) -> TransportResponse:
raise NotImplementedError
class RequestsTransport(Transport):
"""基于 requests 的默认实现。timeout=(连接, 读取)。"""
def post(self, url, body, headers, timeout):
try:
response = requests.post(url, data=body, headers=headers, timeout=timeout)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
raise TransportError(str(exc)) from exc
except requests.exceptions.RequestException as exc: # 其它请求级异常同样按网络失败处理
raise TransportError(str(exc)) from exc
return TransportResponse(response.status_code, response.content)
+8
View File
@@ -0,0 +1,8 @@
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
from .playback import PlaybackTransport
from .simulator import LocalSimulator, Source
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "Source"]
+38
View File
@@ -0,0 +1,38 @@
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
状态必须与当时服务器返回的虚拟时刻逐步吻合。
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportResponse
class PlaybackTransport(Transport):
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
def __init__(self, events: list[dict]):
self.events = list(events)
self.index = 0
self.served: list[dict] = []
def post(self, url, body, headers, timeout):
if self.index >= len(self.events):
raise AssertionError("回放日志已耗尽仍有请求")
event = self.events[self.index]
path = urlsplit(url).path
if path != event["path"]:
raise AssertionError(
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
self.index += 1
self.served.append(event)
return TransportResponse(
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
+231
View File
@@ -0,0 +1,231 @@
"""自建本地模拟器:按附件1/2语义复现官方模拟器的可观测行为。
用途(P1 验收"自建回放测试"):不依赖官方模拟器即可测试客户端、
故障恢复与后续策略。真值 Source 只对测试端开放,策略端只能拿到响应。
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from math import atan2, degrees, hypot, isfinite
from random import Random
from time import time
from ..config import ProblemConfig
from ..protocol.rules import (ACTION_FIELDS, CLEAR_NO_TARGET, CLEAR_RESULTS,
CLEAR_SUCCESS, COMMON_FIELDS, DIRECTION,
EXIT_USER, MEASURE_RESULTS, NEAR, NO_SIGNAL,
POSITION_FIELDS)
from ..protocol.timing import clear_cost, measure_cost
@dataclass(frozen=True)
class Source:
"""一局真值。每个频道最多一个源 [题面];构造时强制检查。"""
channel: int
x: float
y: float
r_eff_m: float # 有效接收半径,题目范围 1000..1500
directional: bool = False
heading_deg: float = 0.0 # 定向源朝向;全向源忽略
class LocalSimulator:
"""handle(path, payload) -> (http_status, body_dict)。"""
def __init__(self, sources: list[Source], *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(),
error_half_deg: float | None = None, error_seed: int = 20260910,
rtt_ms: float = 40.0):
channels = [s.channel for s in sources]
if len(channels) != len(set(channels)):
raise ValueError("每个频道最多对应一个干扰源")
for s in sources:
if not (config.field.channel_min <= s.channel <= config.field.channel_max):
raise ValueError("频道超出 1..20")
if not s.directional and s.heading_deg != 0.0:
raise ValueError("全向源不应有朝向")
self.sources = list(sources)
self.robot_id = robot_id
self.config = config
self.field = config.field
self.cost = config.cost
self.error_half_deg = (self.field.bearing_error_half_deg
if error_half_deg is None else error_half_deg)
self._rng = Random(error_seed)
self._rtt_ms = rtt_ms
self.entered = False
self.exited = False
self.position = (0.0, 0.0)
self.channel = self.field.channel_min
self.virtual_time_s = 0.0
self.cleared: set[int] = set()
self._idempotency: dict[str, tuple[str, int, dict]] = {} # id -> (指纹, 状态码, 响应)
# ------------------------------------------------------------------ 主入口
def handle(self, path: str, payload) -> tuple[int, dict]:
if path not in ACTION_FIELDS:
return 404, self._reject(path)
if not isinstance(payload, dict):
return 400, self._reject(path)
# 1) 公共字段存在性与类型(缺失/类型错 -> 400 [题面])
for name, spec in COMMON_FIELDS.items():
value = payload.get(name)
if not isinstance(value, str) or (spec.max_bytes
and len(value.encode("utf-8")) > spec.max_bytes):
return 400, self._reject(path)
if not value:
return 400, self._reject(path)
# 2) 动作字段存在性 -> 400
spec = ACTION_FIELDS[path]
for name in spec:
if name not in payload:
return 400, self._reject(path)
# 3) 未知字段 -> 200 accepted=false(不占用 request_id [题面])
known = set(COMMON_FIELDS) | set(spec)
if set(payload) != known:
return 200, self._not_accepted()
# 4) arena / robot 不匹配 -> 200 accepted=false(不占用 request_id [题面])
if payload["arena_id"] != "default" or payload["robot_id"] != self.robot_id:
return 200, self._not_accepted()
# 4.5) position 下的未知字段同样 -> 200 accepted=false [题面 7.4]
if path in ("/measure", "/clear"):
position = payload.get("position")
if isinstance(position, dict) and set(position) - set(POSITION_FIELDS):
return 200, self._not_accepted()
# 5) 幂等:同一 ID 同一内容回放首次响应;不同内容 409 [题面]
request_id = payload["request_id"]
fingerprint = self._fingerprint(path, payload)
if request_id in self._idempotency:
seen_fp, seen_status, seen_body = self._idempotency[request_id]
if seen_fp == fingerprint:
return seen_status, dict(seen_body)
return 409, self._reject(path)
# 6) 业务状态机
status, body = self._execute(path, payload)
if status == 200 and body.get("accepted") is True:
self._idempotency[request_id] = (fingerprint, status, dict(body))
return status, body
# ------------------------------------------------------------------ 动作
def _execute(self, path: str, payload: dict) -> tuple[int, dict]:
if path == "/enter":
if self.entered or self.exited:
return 200, self._not_accepted()
self.entered = True
return 200, self._ok({
"max_virtual_duration_s": self.field.max_virtual_duration_s,
"max_real_duration_s": self.field.max_real_duration_s,
"remaining_real_duration_s": self.field.max_real_duration_s,
})
if not self.entered or self.exited:
return 200, self._not_accepted()
if path == "/exit":
self.exited = True
return 200, self._ok({"exit_reason": EXIT_USER})
# /measure 与 /clear 共同的字段校验 -> 400 [题面]
position = payload.get("position")
if not isinstance(position, dict) or not set(POSITION_FIELDS) <= set(position):
return 400, self._reject(path)
for key in ("x", "y"):
value = position[key]
if isinstance(value, bool) or not isinstance(value, (int, float)) \
or not isfinite(value) or abs(value) > self.field.coord_abs_limit_m:
return 400, self._reject(path)
channel = payload.get("channel")
if isinstance(channel, bool) or not isinstance(channel, (int, float)) \
or not float(channel).is_integer() \
or not self.field.channel_min <= channel <= self.field.channel_max:
return 400, self._reject(path)
channel = int(channel)
pos = (float(position["x"]), float(position["y"]))
if path == "/measure":
return self._measure(pos, channel)
return self._clear(pos, channel)
def _measure(self, pos, channel: int) -> tuple[int, dict]:
breakdown = measure_cost(self.position, pos, self.channel, channel, self.cost)
result, svd = self._observe(pos, channel)
self.position = pos
self.channel = channel # 只有合法 /measure 更新频道 [题面]
self._advance(breakdown.total_s)
body = {"measure_result": result}
if result == DIRECTION:
body["svd_deg"] = svd
return 200, self._ok(body)
def _clear(self, pos, channel: int) -> tuple[int, dict]:
source = self._source(channel)
success = (source is not None and source.channel not in self.cleared
and hypot(pos[0] - source.x, pos[1] - source.y)
<= self.field.clear_radius_m)
breakdown = clear_cost(self.position, pos, success, self.cost)
self.position = pos # /clear 不改变频道 [题面]
if success:
self.cleared.add(channel)
self._advance(breakdown.total_s)
result = CLEAR_SUCCESS if success else CLEAR_NO_TARGET
return 200, self._ok({"clear_result": result})
# ------------------------------------------------------------------ 观测
def _observe(self, pos, channel: int):
source = self._source(channel)
if source is None or channel in self.cleared:
return NO_SIGNAL, None
distance = hypot(pos[0] - source.x, pos[1] - source.y)
if distance > source.r_eff_m:
return NO_SIGNAL, None
if source.directional:
bearing_to_point = degrees(atan2(pos[1] - source.y, pos[0] - source.x))
if self._angular_gap(bearing_to_point, source.heading_deg) \
> self.field.directional_half_angle_deg:
return NO_SIGNAL, None
if distance <= self.field.near_radius_m:
return NEAR, None
true_bearing = degrees(atan2(source.y - pos[1], source.x - pos[0]))
error = self._rng.uniform(-self.error_half_deg, self.error_half_deg)
svd = round(true_bearing + error, self.field.bearing_decimals) % 360.0
return DIRECTION, svd
def _source(self, channel: int):
for source in self.sources:
if source.channel == channel:
return source
return None
@staticmethod
def _angular_gap(a: float, b: float) -> float:
return abs((a - b + 180.0) % 360.0 - 180.0)
# ------------------------------------------------------------------ 计时与响应
def _advance(self, seconds: float) -> None:
self.virtual_time_s += seconds
def _ok(self, extra: dict) -> dict:
self._rtt_ms += 40.0
body = {"accepted": True,
"real_timestamp_ms": 1_760_000_000_000.0 + self._rtt_ms,
"virtual_time_s": self.virtual_time_s, **extra}
return body
def _not_accepted(self) -> dict:
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
"virtual_time_s": 0} # accepted=false 时恒为 0 [题面]
def _reject(self, path: str) -> dict:
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
"virtual_time_s": 0, "detail": f"{path} 请求被拒绝"}
@staticmethod
def _fingerprint(path: str, payload) -> str:
canonical = json.dumps({"path": path, "payload": payload},
sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+53
View File
@@ -0,0 +1,53 @@
"""测试侧传输:把 LocalSimulator 挂到 Transport 接口 + 故障注入脚本。
故障脚本用于 P1 验收"失败重试不污染状态":
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportError, TransportResponse
from .simulator import LocalSimulator
class LocalSimTransport(Transport):
"""内存回环:不经过网络栈的本地模拟器传输。"""
def __init__(self, simulator: LocalSimulator):
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
path = urlsplit(url).path
payload = json.loads(body.decode("utf-8"))
self.received.append((path, payload))
status, response = self.simulator.handle(path, payload)
return TransportResponse(status, json.dumps(response, ensure_ascii=False).encode("utf-8"))
class ScriptedFaultTransport(Transport):
"""按脚本注入故障后转发给内层传输。"""
def __init__(self, inner: Transport, script: list):
self.inner = inner
self.script = list(script)
self.sent_requests: list[bytes] = []
def post(self, url, body, headers, timeout):
self.sent_requests.append(body)
fault = self.script.pop(0) if self.script else None
if fault == "drop_before":
raise TransportError("injected: connection refused before send")
if fault == "timeout_after":
self.inner.post(url, body, headers, timeout) # 服务器已受理
raise TransportError("injected: read timeout after server applied")
if isinstance(fault, tuple) and fault and fault[0] == "status":
return TransportResponse(int(fault[1]), b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}')
return self.inner.post(url, body, headers, timeout)
-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);
-131
View File
@@ -1,131 +0,0 @@
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
@@ -1,107 +0,0 @@
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
@@ -1,47 +0,0 @@
"""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()
-185
View File
@@ -1,185 +0,0 @@
<div id="q1-localization">
<style>
#q1-localization .q1-plot { width:100%; display:block; overflow:hidden; }
#q1-localization .q1-legend { display:flex; flex-wrap:wrap; gap:12px; margin-block:12px; }
#q1-localization .q1-legend span { display:inline-flex; align-items:center; gap:6px; }
#q1-localization .q1-swatch { display:inline-block; width:18px; height:3px; }
#q1-localization .q1-stats { display:flex; flex-wrap:wrap; gap:12px 24px; margin-block:12px; }
#q1-localization .q1-status { margin-block:8px; }
#q1-localization .q1-plot text { fill:var(--foreground); font-size:12px; font-weight:400; }
#q1-localization details { margin-block:12px; }
#q1-localization summary { cursor:pointer; }
</style>
<h2>带误差的交会定位</h2>
<div class="viz-controls">
<label class="form-label">观测案例
<select class="form-select" data-q1="case" aria-label="观测案例"></select>
</label>
<label class="form-label">视图
<select class="form-select" data-q1="view" aria-label="视图">
<option value="region">定位区域放大</option>
<option value="global">检测点全景</option>
</select>
</label>
<label class="form-label">已加入测点 <span data-q1="step-label" class="tabular-nums"></span>
<input class="form-range" data-q1="step" type="range" min="1" max="3" value="3" step="1" aria-label="已加入测点数量">
</label>
</div>
<div class="q1-stats tabular-nums" aria-live="polite" data-q1="stats"></div>
<svg class="q1-plot" data-q1="plot" role="img" aria-label="检测角域、定位多边形、直径线段和两个包围圆"></svg>
<div class="q1-legend text-small">
<span><i class="q1-swatch" style="background:var(--viz-series-1)"></i>定位区域</span>
<span><i class="q1-swatch" style="background:var(--viz-series-2)"></i>直径线段及直径圆</span>
<span><i class="q1-swatch" style="background:var(--viz-series-3)"></i>最小包围圆</span>
<span>□ 检测点 + 合成真值 ◎ 覆盖反例顶点</span>
</div>
<div class="q1-status text-small" data-q1="status" aria-live="polite"></div>
<details>
<summary>检测点与示向度</summary>
<div class="table-responsive">
<table class="table table-sm tabular-nums">
<thead><tr><th>测点</th><th class="text-end">x / m</th><th class="text-end">y / m</th><th class="text-end">示向度 / °</th><th class="text-end">误差半角 / °</th></tr></thead>
<tbody data-q1="rows"></tbody>
</table>
</div>
</details>
<script>
(() => {
const root=document.getElementById('q1-localization');
const cases=/*__Q1_DATA__*/;
const get=name=>root.querySelector('[data-q1="'+name+'"]');
const picker=get('case'), step=get('step'), view=get('view'), svg=get('plot');
const NS='http://www.w3.org/2000/svg';
const fmt=x=>Number.isFinite(x)?x.toLocaleString('zh-CN',{maximumFractionDigits:4}):'—';
const names={polygon:'有界多边形',segment:'线段',point:'单点',empty:'空集',unbounded:'无界区域'};
function node(tag,attrs={},parent=svg,text=null) {
const el=document.createElementNS(NS,tag);
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,String(v)));
if(text!==null)el.textContent=text;
parent.appendChild(el);return el;
}
cases.forEach((c,i)=>picker.add(new Option(c.name,i)));
picker.value=String(Math.max(0,cases.findIndex(c=>c.source==='official_practice')));
function load() {
const c=cases[+picker.value];
step.max=String(c.measurements.length);step.value=step.max;draw();
}
function draw() {
const c=cases[+picker.value], n=+step.value, ms=c.measurements.slice(0,n), r=c.frames[n-1];
get('step-label').textContent=n+' / '+c.measurements.length;
get('stats').replaceChildren();
const values=[['区域',names[r.status]],['直径 D',r.diameter_infinite?'∞':fmt(r.diameter_m)+' m'],
['最小包围半径',r.minimum_enclosing_circle?fmt(r.minimum_enclosing_circle.radius_m)+' m':'—']];
values.forEach(([label,value])=>{const span=document.createElement('span');span.textContent=label+':'+value;get('stats').appendChild(span);});
const source=c.source==='official_practice'?'官方演练观测;真实源位置未公开':'本地合成案例';
get('status').textContent=source+' · '+(r.status==='unbounded'?'阴影只显示视窗内部分,真实区域无界':
r.status==='empty'?'这些观测在给定误差界下不相容':
'直径圆'+(r.diameter_circle.covers?'可以覆盖':'不能覆盖,最大超出 '+fmt(r.diameter_circle.max_excess_m)+' m')+
' · 清除半径余量 '+fmt(r.clearance_certificate.remaining_margin_m)+' m');
get('rows').replaceChildren();
ms.forEach((m,i)=>{
const tr=document.createElement('tr');
[String(i+1),fmt(m.x),fmt(m.y),fmt(m.bearing_deg),fmt(m.half_angle_deg)].forEach((v,j)=>{
const td=document.createElement('td');td.textContent=v;if(j)td.className='text-end';tr.appendChild(td);
});get('rows').appendChild(tr);
});
svg.replaceChildren();
const width=Math.max(280,root.getBoundingClientRect().width);
const height=Math.max(310,Math.min(440,width*.65));
svg.setAttribute('viewBox',`0 0 ${width} ${height}`);
svg.style.height=height+'px';
node('title',{},svg,c.name+':'+names[r.status]);
node('desc',{},svg,get('status').textContent);
const l=64,t=20,right=20,bottom=46,w=width-l-right,h=height-t-bottom;
const finite=['polygon','segment','point'].includes(r.status);
let pts=(view.value==='region'&&finite)?r.vertices.map(p=>[...p]):ms.map(m=>[m.x,m.y]);
if(!pts.length)pts=[[0,0]];
if(view.value==='global'&&finite)pts.push(...r.vertices);
if(c.target)pts.push(c.target);
if(finite&&view.value==='region') {
[r.diameter_circle,r.minimum_enclosing_circle].forEach(circle=>{
const [x,y]=circle.center,d=circle.radius_m;
pts.push([x-d,y-d],[x+d,y+d]);
});
}
const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]);
const cx=(Math.min(...xs)+Math.max(...xs))/2, cy=(Math.min(...ys)+Math.max(...ys))/2;
let dx=Math.max(...xs)-Math.min(...xs),dy=Math.max(...ys)-Math.min(...ys);
const minSpan=finite&&view.value==='region'?Math.max(2,(r.diameter_m||0)*.08):200;
const scale=Math.min(w/Math.max(dx*1.3,minSpan),h/Math.max(dy*1.3,minSpan));
const xmin=cx-w/(2*scale),xmax=cx+w/(2*scale),ymin=cy-h/(2*scale),ymax=cy+h/(2*scale);
const X=x=>l+(x-xmin)*scale,Y=y=>t+h-(y-ymin)*scale;
const defs=node('defs');const clip=node('clipPath',{id:'q1-clip'},defs);
node('rect',{x:l,y:t,width:w,height:h},clip);
for(let i=0;i<4;i++) {
const x=xmin+(xmax-xmin)*i/3, y=ymin+(ymax-ymin)*i/3;
node('line',{x1:X(x),x2:X(x),y1:t,y2:t+h,stroke:'var(--border)'});
node('line',{x1:l,x2:l+w,y1:Y(y),y2:Y(y),stroke:'var(--border)'});
const tick=z=>Math.abs(z)>=1e6?z.toExponential(1):Math.abs(xmax-xmin)<20?z.toFixed(1):Math.round(z).toString();
node('text',{x:X(x),y:t+h+19,'text-anchor':i===0?'start':i===3?'end':'middle'},svg,tick(x));
node('text',{x:l-8,y:Y(y)+4,'text-anchor':'end'},svg,tick(y));
}
node('text',{x:l+w/2,y:height-7,'text-anchor':'middle',class:'axis-title','data-axis':'x'},svg,'x / m');
node('text',{x:14,y:t+h/2,'text-anchor':'middle',transform:`rotate(-90 14 ${t+h/2})`,class:'axis-title','data-axis':'y'},svg,'y / m');
const layer=node('g',{'clip-path':'url(#q1-clip)'});
let displayPoly;
if(finite)displayPoly=r.vertices;
else if(r.status==='empty')displayPoly=[];
else {
// Display clipping ONLY: Python's result remains explicitly unbounded.
displayPoly=[[xmin,ymin],[xmax,ymin],[xmax,ymax],[xmin,ymax]];
ms.forEach(m=>{
const theta=m.bearing_deg*Math.PI/180,d=m.half_angle_deg*Math.PI/180;
const normals=[[Math.sin(theta-d),-Math.cos(theta-d)],[-Math.sin(theta+d),Math.cos(theta+d)],[-Math.cos(theta),-Math.sin(theta)]];
normals.forEach(([a,b])=>{
const bound=a*m.x+b*m.y,out=[];
for(let i=0;i<displayPoly.length;i++) {
const p=displayPoly[i],q=displayPoly[(i+1)%displayPoly.length];
const f=a*p[0]+b*p[1]-bound,g=a*q[0]+b*q[1]-bound;
if(f<=1e-8)out.push(p);
if((f<0&&g>0)||(f>0&&g<0))out.push([p[0]+(q[0]-p[0])*f/(f-g),p[1]+(q[1]-p[1])*f/(f-g)]);
}displayPoly=out;
});
});
}
if(displayPoly.length>=3)node('polygon',{points:displayPoly.map(p=>X(p[0])+','+Y(p[1])).join(' '),fill:'var(--viz-series-1)','fill-opacity':.18,stroke:'var(--viz-series-1)','stroke-width':1.5},layer);
const reach=Math.hypot(xmax-xmin,ymax-ymin)+Math.max(...ms.map(m=>Math.hypot(m.x-cx,m.y-cy)))*2;
ms.forEach((m,i)=>{
[-m.half_angle_deg,0,m.half_angle_deg].forEach(offset=>{
const a=(m.bearing_deg+offset)*Math.PI/180;
node('line',{x1:X(m.x),y1:Y(m.y),x2:X(m.x+reach*Math.cos(a)),y2:Y(m.y+reach*Math.sin(a)),
stroke:'var(--foreground)','stroke-opacity':offset===0?.35:.22,'stroke-width':1,'stroke-dasharray':offset===0?'none':'4 4'},layer);
});
if(m.x>=xmin&&m.x<=xmax&&m.y>=ymin&&m.y<=ymax) {
node('rect',{x:X(m.x)-4,y:Y(m.y)-4,width:8,height:8,fill:'var(--foreground)'},layer);
node('text',{x:Math.min(l+w-22,X(m.x)+9),y:Math.max(t+14,Y(m.y)-9)},layer,'S'+(i+1));
}
});
if(finite) {
[r.diameter_circle,r.minimum_enclosing_circle].forEach((circle,i)=>{
node('circle',{cx:X(circle.center[0]),cy:Y(circle.center[1]),r:circle.radius_m*scale,
fill:'none',stroke:`var(--viz-series-${i+2})`,'stroke-width':2,'stroke-dasharray':i===0?'6 4':'none'},layer);
});
const [a,b]=r.diameter_pair;
node('line',{x1:X(a[0]),y1:Y(a[1]),x2:X(b[0]),y2:Y(b[1]),stroke:'var(--viz-series-2)','stroke-width':2.5},layer);
r.vertices.forEach((p,i)=>{
const mark=node('circle',{cx:X(p[0]),cy:Y(p[1]),r:4,fill:'var(--viz-series-1)'},layer);
node('title',{},mark,`V${i+1} (${fmt(p[0])}, ${fmt(p[1])})`);
});
const witness=r.diameter_circle.witness_vertex;
if(witness)node('circle',{cx:X(witness[0]),cy:Y(witness[1]),r:8,
fill:'none',stroke:'var(--viz-series-2)','stroke-width':2},layer);
}
if(c.target&&c.target[0]>=xmin&&c.target[0]<=xmax&&c.target[1]>=ymin&&c.target[1]<=ymax) {
const x=X(c.target[0]),y=Y(c.target[1]);
node('path',{d:`M${x-5},${y}h10 M${x},${y-5}v10`,stroke:'var(--foreground)','stroke-width':2},layer);
}
node('rect',{x:l,y:t,width:w,height:h,fill:'none',stroke:'var(--border)','data-chart-frame':''});
}
picker.addEventListener('change',load);step.addEventListener('input',draw);view.addEventListener('change',draw);
new ResizeObserver(draw).observe(root);
load();
})();
</script>
</div>
File diff suppressed because one or more lines are too long
-25
View File
@@ -1,25 +0,0 @@
:root {
color-scheme:light dark;
--background:light-dark(#ffffff,#191c21);
--foreground:light-dark(#24282e,#eceff3);
--border:light-dark(#d9dfe5,#424a55);
--muted-foreground:light-dark(#56616c,#b4bdc8);
--viz-series-1:light-dark(#146e9a,#6cc8ed);
--viz-series-2:light-dark(#c6503a,#ff947d);
--viz-series-3:light-dark(#198064,#64d6ac);
--font-size-base:14px;
}
body { max-width:1000px; margin:0 auto; padding:24px; background:var(--background);color:var(--foreground);font:var(--font-size-base)/1.6 system-ui,"Microsoft YaHei",sans-serif; }
h2 {font-size:1.4em;font-weight:500;margin-block:0 18px;}
.viz-controls {display:flex;gap:16px;flex-wrap:wrap;align-items:end;}
.form-label {display:flex;flex-direction:column;gap:6px;flex:1 1 180px;min-width:0;}
.form-select {font:inherit;color:var(--foreground);background:var(--background);border:1px solid var(--border);padding:8px;max-width:100%;border-radius:5px;}
.form-range {width:100%;min-height:30px;accent-color:var(--viz-series-1);}
.text-small {font-size:12px;}
.tabular-nums {font-variant-numeric:tabular-nums;}
.table-responsive {overflow-x:auto;}
.table {width:100%;border-collapse:collapse;}
.table th,.table td {padding:6px 8px;border-bottom:1px solid var(--border);}
.table th {font-weight:500;text-align:left;}
.table .text-end {text-align:right;}
@media(max-width:480px) {body{padding:12px;}.form-label{flex-basis:100%;}.form-select{font-size:16px;}}