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)
|
||||
Reference in New Issue
Block a user