refactor(arch): redesign the code structure
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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, "未知状态码")
|
||||
@@ -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 的响应不允许更新状态")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
|
||||
|
||||
from .playback import PlaybackTransport
|
||||
from .simulator import LocalSimulator, Source
|
||||
from .transport import LocalSimTransport, ScriptedFaultTransport
|
||||
|
||||
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
|
||||
"ScriptedFaultTransport", "Source"]
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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,'&').replace(/</g,'<').replace(/"/g,'"');
|
||||
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);
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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;}}
|
||||
Reference in New Issue
Block a user