feat: 3
This commit is contained in:
+32
-4
@@ -46,7 +46,13 @@ def _sim_check(args) -> int:
|
||||
|
||||
|
||||
def _run(args) -> int:
|
||||
"""问题3/4闭环。--self-test 时真值只存在于测试端入口,策略端仅见响应。"""
|
||||
"""问题3/4闭环。--self-test 时真值只存在于测试端入口,策略端仅见响应。
|
||||
|
||||
实机预算:--self-test 时本地模拟器按 --real-budget-s(默认
|
||||
session.self_test_real_budget_s = 1800s,即 30 分钟实机)返回
|
||||
remaining_real_duration_s;正式对接以服务器 /enter 返回为准(题面
|
||||
程序运行上限 20 分钟,不可延长)。
|
||||
"""
|
||||
config = ProblemConfig()
|
||||
session = replace(config.session,
|
||||
base_url=args.base_url or config.session.base_url)
|
||||
@@ -56,23 +62,39 @@ def _run(args) -> int:
|
||||
bearing_rounding_margin_deg=args.bearing_margin_deg))
|
||||
truth = None
|
||||
transport = None
|
||||
real_budget_s = None
|
||||
if args.self_test:
|
||||
real_budget_s = (args.real_budget_s if args.real_budget_s is not None
|
||||
else config.session.self_test_real_budget_s)
|
||||
config = replace(config,
|
||||
field=replace(config.field,
|
||||
max_real_duration_s=real_budget_s))
|
||||
if args.problem == 4:
|
||||
sources = mixed_scene(seed=args.seed, count=args.sources,
|
||||
directional=args.directional)
|
||||
directional=args.directional, config=config)
|
||||
else:
|
||||
sources = random_omni_scene(seed=args.seed, count=args.sources)
|
||||
sources = random_omni_scene(seed=args.seed, count=args.sources,
|
||||
config=config)
|
||||
truth = sorted(source.channel for source in sources)
|
||||
transport = LocalSimTransport(LocalSimulator(sources, robot_id=args.robot_id,
|
||||
config=config))
|
||||
elif args.real_budget_s is not None:
|
||||
print("--real-budget-s 仅在 --self-test 下生效;正式测试以服务器 "
|
||||
"/enter 返回的 remaining_real_duration_s 为准。", file=sys.stderr)
|
||||
log = SessionLog(path=Path(args.log)) if args.log else SessionLog()
|
||||
client = SimulatorClient(args.robot_id, config=config, transport=transport, log=log)
|
||||
loop_cls = DirectionalExtension if args.problem == 4 else OmnidirectionalLoop
|
||||
summary = loop_cls(client, config).run()
|
||||
summary["robot_id"] = args.robot_id
|
||||
if real_budget_s is not None:
|
||||
summary["self_test_real_budget_s"] = real_budget_s
|
||||
if args.summary_steps is not None and len(summary.get("steps", [])) > args.summary_steps:
|
||||
summary["steps"] = summary["steps"][:args.summary_steps]
|
||||
summary["steps_truncated"] = True
|
||||
if truth is not None:
|
||||
# 仅测试端核对:清除集合是否与真值一致。
|
||||
summary["truth"] = {"source_channels": truth,
|
||||
"source_count": len(truth),
|
||||
"all_cleared": sorted(summary["cleared_channels"]) == truth}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0 if summary["completed"] and (truth is None or summary["truth"]["all_cleared"]) else 1
|
||||
@@ -105,10 +127,16 @@ def main(argv=None) -> int:
|
||||
help="问题3=全向源,问题4=混合定向源(默认 3)")
|
||||
run.add_argument("--bearing-margin-deg", type=float, default=0.005,
|
||||
help="示向度舍入裕量(服务器保留两位小数;默认 0.005°)")
|
||||
run.add_argument("--real-budget-s", type=float, default=None,
|
||||
help="自检实机预算秒数(默认 1800=30 分钟;仅 --self-test 生效,"
|
||||
"正式测试以服务器 /enter 返回为准)")
|
||||
run.add_argument("--summary-steps", type=int, default=None,
|
||||
help="summary 中保留的逐步计时条数上限(默认全部;完整明细在日志)")
|
||||
run.add_argument("--self-test", action="store_true",
|
||||
help="走本地模拟器与随机场景(测试端自检,不连官方)")
|
||||
run.add_argument("--seed", type=int, default=20260910, help="自检场景种子")
|
||||
run.add_argument("--sources", type=int, default=10, help="自检场景源数量")
|
||||
run.add_argument("--sources", type=int, default=None,
|
||||
help="自检源数量(默认按题面在 10~16 中随机)")
|
||||
run.add_argument("--directional", type=int, default=3,
|
||||
help="问题4自检场景中定向源数量(--problem 4 生效)")
|
||||
run.set_defaults(func=_run)
|
||||
|
||||
@@ -63,6 +63,10 @@ class SessionConfig:
|
||||
retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大
|
||||
reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数
|
||||
real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用
|
||||
# [工程] 自检/演练实机预算:题面正式测试程序运行上限 1200s 不可延长;
|
||||
# 自检经 --real-budget-s 覆盖 LocalSimulator 返回的 remaining_real_duration_s,
|
||||
# 默认 1800s(30 分钟实机),用于验证"限时内持续运行直到找全"的行为。
|
||||
self_test_real_budget_s: float = 1800.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -87,6 +91,12 @@ class SearchConfig:
|
||||
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
|
||||
# (首次选点+对跖复核+2 次环探;达限转后备清除)
|
||||
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
|
||||
# ---- 持续搜索 [工程,2026-09-11 用户决策] ----
|
||||
# 全部频道"已清除或证明不存在"后不提前退出:只复扫未清除频道,
|
||||
# 直到清除数达 16、实机预算耗尽或虚拟安全余量不足(扫到限时为止)。
|
||||
persistent_search: bool = True
|
||||
virtual_reserve_s: float = 10_000.0 # 虚拟时长安全余量:剩余不足时停止发起动作,
|
||||
# 避免触及 100 小时上限被模拟器强制结束
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+195
-21
@@ -9,11 +9,13 @@ P3 第二检测点(验收:保证收到信号;Q 空时退化;near 分支
|
||||
最优解;选定后按 1/4 间距加密复核并如实上报数值;
|
||||
- 返回 near 时:候选源在 5m 内,直接对当前频道尝试 /clear。
|
||||
|
||||
P4 全向闭环(验收:10/16 源正确退出;边界朝外不漏;停止条件正确):
|
||||
发现(coverage.CoverageLedger)→ 定位(belief 收敛)→ 清除 → 停止判定
|
||||
(清除数达 16,或每频道均有清除成功/不存在证明)→ /exit。
|
||||
发现 10 源、连续无信号、候选采样为空都不是停止条件;
|
||||
现实预算耗尽退出记为未证明完成(completed=false),不与成功混记。
|
||||
P4 全向闭环(验收:10/16/0 源场景、边界源、预算耗尽与故障注入):
|
||||
发现(coverage.CoverageLedger)→ 定位(belief 收敛)→ 清除 → 持续复扫
|
||||
→ /exit。全部频道"已清除或证明不存在"后按 persistent_search(默认开
|
||||
[用户决策 2026-09-11])只复扫未清除频道,扫到限时为止(清除数达 16 /
|
||||
实机预算耗尽 / 虚拟安全余量不足)。completed 表示任务证明完成(结束
|
||||
时每频道均 CLEARED/ABSENT 且无中断);accounted_virtual_time_s 记录
|
||||
首次证明找全的虚拟时刻,与"复扫到限时才停"相互独立。
|
||||
断连/协议错误/守恒性破坏中断时同样尽力 /exit 并产出未完成 summary
|
||||
(stop_reason 记中断类别,附逐频道状态快照),不静默丢失会话。
|
||||
|
||||
@@ -192,12 +194,16 @@ def select_second_point(client: SimulatorClient, first_position: Vec2,
|
||||
|
||||
|
||||
class OmnidirectionalLoop:
|
||||
"""问题3全向"发现—定位—清除—停止"闭环(P4 基线)。
|
||||
"""问题3全向"发现—定位—清除—持续复扫"闭环(P4 基线 + 持续搜索)。
|
||||
|
||||
串行驱动 SimulatorClient:覆盖阶段在 49 点网格上对每个 UNKNOWN 频道
|
||||
检测;发现 direction 即进入该频道的定位子循环(选点→测量→收敛→
|
||||
证书清除),near 直接清除。停止条件只有三种:清除数达 16(数量上界
|
||||
证明)、每频道均 CLEARED/ABSENT、现实预算耗尽(记为未完成)。
|
||||
证书清除),near 直接清除。全部频道"已清除或证明不存在"后按
|
||||
``search.persistent_search``(默认开 [用户决策 2026-09-11])只复扫
|
||||
未清除频道,直到清除数达 16、实机预算耗尽或虚拟安全余量不足。
|
||||
stop_reason 记录循环结束原因;``completed`` 表示任务证明完成
|
||||
(结束时每频道均 CLEARED/ABSENT 且无中断),与"复扫到限时才停"
|
||||
相互独立:accounted_virtual_time_s 记录首次证明找全的虚拟时刻。
|
||||
"""
|
||||
|
||||
def __init__(self, client: SimulatorClient,
|
||||
@@ -209,6 +215,8 @@ class OmnidirectionalLoop:
|
||||
self.cleared: list[int] = []
|
||||
self.anomalies: list[dict] = []
|
||||
self.stop_reason: str | None = None
|
||||
# 首次达成"每频道均已清除或证明不存在"的虚拟时刻(找全证明)。
|
||||
self.accounted_virtual_time_s: float | None = None
|
||||
# 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上
|
||||
# 反复消耗 3 秒/次的失败清除。
|
||||
self._may_clear: set[int] = set()
|
||||
@@ -223,7 +231,17 @@ class OmnidirectionalLoop:
|
||||
self._coverage_phase()
|
||||
if self.stop_reason is None:
|
||||
self._mark_absent_after_coverage()
|
||||
self.stop_reason = self._resolve_detected()
|
||||
self._resolve_detected()
|
||||
self._record_accounted()
|
||||
if self.config.search.persistent_search:
|
||||
self._persistent_phase()
|
||||
if self.stop_reason is None:
|
||||
if len(self.cleared) >= self.config.field.source_count_max:
|
||||
self.stop_reason = "sixteen_cleared"
|
||||
elif self._all_accounted():
|
||||
self.stop_reason = "all_resolved"
|
||||
else:
|
||||
self.stop_reason = "unresolved_channels"
|
||||
except DeadlineExceeded:
|
||||
self.stop_reason = "real_budget_exhausted"
|
||||
except (TransportError, ProtocolError, InconsistentBelief) as exc:
|
||||
@@ -248,6 +266,29 @@ class OmnidirectionalLoop:
|
||||
summary["exit_error"] = _error_brief(exit_error)
|
||||
return summary
|
||||
|
||||
# ------------------------------------------------------------ 预算与证明
|
||||
def _virtual_cap_reached(self) -> bool:
|
||||
"""虚拟安全余量不足:置 stop_reason 并返回 True,调用方立即收尾。
|
||||
|
||||
余量须大于单次动作耗时上界(网格最远移动 ~1200s + 检测 5s),
|
||||
search.virtual_reserve_s=10000s [工程] 提供充分裕度,避免触及
|
||||
附件2 §4.5 的 100 小时上限被模拟器强制结束。
|
||||
"""
|
||||
if (self.client.state.virtual_time_s + self.config.search.virtual_reserve_s
|
||||
< self.config.field.max_virtual_duration_s):
|
||||
return False
|
||||
self.stop_reason = "virtual_time_cap"
|
||||
return True
|
||||
|
||||
def _all_accounted(self) -> bool:
|
||||
return all(belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)
|
||||
for belief in self.belief.channels.values())
|
||||
|
||||
def _record_accounted(self) -> None:
|
||||
"""首次"每频道均已清除或证明不存在"的时刻留档(找全证明)。"""
|
||||
if self.accounted_virtual_time_s is None and self._all_accounted():
|
||||
self.accounted_virtual_time_s = self.client.state.virtual_time_s
|
||||
|
||||
# ------------------------------------------------------------ 覆盖阶段
|
||||
def _pending_channels_at(self, index: int) -> list[int]:
|
||||
return [channel for channel, belief in self.belief.channels.items()
|
||||
@@ -275,6 +316,8 @@ class OmnidirectionalLoop:
|
||||
return
|
||||
point = self.ledger.points[index]
|
||||
for channel in self._pending_channels_at(index):
|
||||
if self._virtual_cap_reached():
|
||||
return
|
||||
outcome = self.client.measure(point, channel)
|
||||
self.ledger.mark_measured(channel, index)
|
||||
self.belief.update_measure(channel, point, outcome.result,
|
||||
@@ -289,9 +332,76 @@ class OmnidirectionalLoop:
|
||||
else: # 理论上不发生(near 候选半径 5m);保守记录
|
||||
self._anomaly("near_without_certificate", channel,
|
||||
position=point)
|
||||
if self._count_limit_reached():
|
||||
if self.stop_reason is not None or self._count_limit_reached():
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------ 持续复扫
|
||||
def _persistent_phase(self) -> None:
|
||||
"""全部频道已清除/证明不存在后的持续复扫 [用户决策 2026-09-11]。
|
||||
|
||||
只扫未清除频道(DETECTED 继续定位,ABSENT 全网格复测,防模拟器
|
||||
或覆盖证明假设偏差),直到清除数达 16 或虚拟安全余量不足;
|
||||
实机预算由客户端 DeadlineExceeded 兜底(扫到限时为止)。
|
||||
"""
|
||||
while self.stop_reason is None:
|
||||
if self._count_limit_reached():
|
||||
return
|
||||
if self._virtual_cap_reached():
|
||||
return
|
||||
self._persistent_round()
|
||||
|
||||
def _persistent_round(self) -> None:
|
||||
"""一轮全网格复扫:网格点最近邻序,逐点测量全部未清除频道。"""
|
||||
measured: set[tuple[int, int]] = set()
|
||||
while self.stop_reason is None:
|
||||
index = self._next_resweep_point(measured)
|
||||
if index is None:
|
||||
return
|
||||
point = self.ledger.points[index]
|
||||
for channel in self._resweep_channels_at(index, measured):
|
||||
if self._virtual_cap_reached():
|
||||
return
|
||||
measured.add((channel, index))
|
||||
was_absent = (self.belief.channels[channel].status
|
||||
is ChannelStatus.ABSENT)
|
||||
outcome = self.client.measure(point, channel)
|
||||
self.belief.update_measure(channel, point, outcome.result,
|
||||
outcome.svd_deg)
|
||||
if was_absent and outcome.result in (DIRECTION, NEAR):
|
||||
# 复测推翻了覆盖缺席证明:守恒性留痕,按新证据定位清除。
|
||||
self._anomaly("absent_detected_on_resweep", channel,
|
||||
position=point, result=outcome.result)
|
||||
if outcome.result == DIRECTION:
|
||||
self._localize_and_clear(channel)
|
||||
elif outcome.result == NEAR:
|
||||
region = self.belief.candidate_set(channel)
|
||||
cert = clear_certificate(region, channel, self.config)
|
||||
if cert.certified:
|
||||
self._attempt_clear(channel, cert.center, cert)
|
||||
else:
|
||||
self._anomaly("near_without_certificate", channel,
|
||||
position=point)
|
||||
if self.stop_reason is not None:
|
||||
return
|
||||
|
||||
def _next_resweep_point(self, measured: set[tuple[int, int]]) -> int | None:
|
||||
"""尚有未清除频道待复测的最近网格点(贪心最近邻 [工程])。"""
|
||||
position = self.client.state.position
|
||||
best_index, best_distance = None, 0.0
|
||||
for index, point in enumerate(self.ledger.points):
|
||||
if not self._resweep_channels_at(index, measured):
|
||||
continue
|
||||
distance = math.dist(position, point)
|
||||
if best_index is None or distance < best_distance:
|
||||
best_index, best_distance = index, distance
|
||||
return best_index
|
||||
|
||||
def _resweep_channels_at(self, index: int,
|
||||
measured: set[tuple[int, int]]) -> list[int]:
|
||||
return [channel for channel, belief in self.belief.channels.items()
|
||||
if belief.status is not ChannelStatus.CLEARED
|
||||
and (channel, index) not in measured]
|
||||
|
||||
def _count_limit_reached(self) -> bool:
|
||||
if len(self.cleared) >= self.config.field.source_count_max:
|
||||
self.stop_reason = "sixteen_cleared"
|
||||
@@ -308,6 +418,8 @@ class OmnidirectionalLoop:
|
||||
search = self.config.search
|
||||
self._may_clear.add(channel)
|
||||
for _ in range(search.max_localize_steps):
|
||||
if self._virtual_cap_reached():
|
||||
return False
|
||||
region = self.belief.candidate_set(channel)
|
||||
if region is None:
|
||||
return False
|
||||
@@ -355,33 +467,51 @@ class OmnidirectionalLoop:
|
||||
and self.ledger.coverage_complete(channel):
|
||||
self.belief.mark_absent(channel)
|
||||
|
||||
def _resolve_detected(self) -> str:
|
||||
"""覆盖完成后,对仍未清除的 DETECTED 频道做第二轮定位。"""
|
||||
def _resolve_detected(self) -> None:
|
||||
"""覆盖完成后,对仍未清除的 DETECTED 频道做第二轮定位。
|
||||
|
||||
不再在此决定停止:持续搜索模式下由 _persistent_phase 接力,
|
||||
非持续模式由 run() 依据 _all_accounted 判定 all_resolved。
|
||||
"""
|
||||
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
|
||||
if self._count_limit_reached() or self._virtual_cap_reached():
|
||||
return
|
||||
self._localize_and_clear(channel)
|
||||
unresolved = [channel for channel, belief in self.belief.channels.items()
|
||||
if belief.status not in (ChannelStatus.CLEARED,
|
||||
ChannelStatus.ABSENT)]
|
||||
return "all_resolved" if not unresolved else "unresolved_channels"
|
||||
if self.stop_reason is not None:
|
||||
return
|
||||
|
||||
def _summary(self, info) -> dict:
|
||||
"""结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。"""
|
||||
"""结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。
|
||||
|
||||
completed = 无中断且结束时每频道均 CLEARED/ABSENT(任务证明完成);
|
||||
stop_reason 另行记录循环结束原因(持续复扫模式下通常是
|
||||
virtual_time_cap / real_budget_exhausted / sixteen_cleared)。
|
||||
"""
|
||||
events = self.client.log.action_events()
|
||||
stats = summarize(events)
|
||||
report = recompute_virtual_time(events)
|
||||
steps = self._step_report()
|
||||
cleared = self.belief.channels_by_status(ChannelStatus.CLEARED)
|
||||
absent = self.belief.channels_by_status(ChannelStatus.ABSENT)
|
||||
resolved = set(cleared) | set(absent)
|
||||
unresolved = [channel for channel in sorted(self.belief.channels)
|
||||
if channel not in resolved]
|
||||
completed = (self.stop_reason == "sixteen_cleared"
|
||||
or (self.stop_reason == "all_resolved" and not unresolved))
|
||||
virtual_time_s = round((info.virtual_time_s if info is not None
|
||||
else self.client.state.virtual_time_s), 3)
|
||||
# 清除数达 16 即数量上界证明(其余频道无论状态如何都不可能有源);
|
||||
# 否则要求每频道均 CLEARED/ABSENT。
|
||||
completed = (self._abort_detail is None
|
||||
and (len(self.cleared) >= self.config.field.source_count_max
|
||||
or not unresolved))
|
||||
summary = {
|
||||
"completed": completed,
|
||||
"stop_reason": self.stop_reason,
|
||||
"exit_reason": "exit_unreachable" if info is None else info.reason,
|
||||
"virtual_time_s": round((info.virtual_time_s if info is not None
|
||||
else self.client.state.virtual_time_s), 3),
|
||||
"virtual_time_s": virtual_time_s,
|
||||
"accounted_virtual_time_s": (round(self.accounted_virtual_time_s, 3)
|
||||
if self.accounted_virtual_time_s is not None
|
||||
else None),
|
||||
"persistent_search": self.config.search.persistent_search,
|
||||
"cleared_channels": cleared,
|
||||
"absent_channels": absent,
|
||||
"unresolved_channels": unresolved,
|
||||
@@ -390,6 +520,11 @@ class OmnidirectionalLoop:
|
||||
"clear_success": stats["cleared"],
|
||||
"total_requests": stats["measures"] + stats["clears"],
|
||||
"move_distance_m": round(stats["distance_m"], 1),
|
||||
# 题面口径:定位清除总时间(全程虚拟时长)/ 被清除干扰源个数。
|
||||
"avg_locate_clear_time_s": (round(virtual_time_s / len(cleared), 3)
|
||||
if cleared else None),
|
||||
"step_totals": steps["totals"],
|
||||
"steps": steps["rows"],
|
||||
"anomalies": self.anomalies,
|
||||
"virtual_recompute_mismatches": len(report["mismatches"]),
|
||||
"channel_status": self._channel_status_snapshot(),
|
||||
@@ -398,6 +533,43 @@ class OmnidirectionalLoop:
|
||||
summary["abort_detail"] = _error_brief(self._abort_detail)
|
||||
return summary
|
||||
|
||||
def _step_report(self) -> dict:
|
||||
"""每步计时汇总 [用户决策 2026-09-11]:实机墙钟 + 比赛虚拟耗时分项。"""
|
||||
totals = {"count": 0, "real_elapsed_total_s": 0.0,
|
||||
"real_since_enter_total_s": 0.0,
|
||||
"virtual_move_s": 0.0, "virtual_switch_s": 0.0,
|
||||
"virtual_measure_action_s": 0.0, "virtual_clear_action_s": 0.0,
|
||||
"virtual_total_s": 0.0}
|
||||
rows = []
|
||||
for index, step in enumerate(self.client.log.step_events()):
|
||||
cost = step["virtual_cost"]
|
||||
totals["count"] += 1
|
||||
totals["real_elapsed_total_s"] += step["real_elapsed_s"]
|
||||
totals["real_since_enter_total_s"] = step.get(
|
||||
"real_since_enter_s", totals["real_since_enter_total_s"])
|
||||
totals["virtual_move_s"] += cost["move_s"]
|
||||
totals["virtual_switch_s"] += cost["switch_s"]
|
||||
if step["path"] == "/measure":
|
||||
totals["virtual_measure_action_s"] += cost["action_s"]
|
||||
elif step["path"] == "/clear":
|
||||
totals["virtual_clear_action_s"] += cost["action_s"]
|
||||
totals["virtual_total_s"] += cost["total_s"]
|
||||
rows.append({"index": index, "action": step["path"].lstrip("/"),
|
||||
"result": step.get("result"),
|
||||
"channel": step.get("channel"),
|
||||
"position": step.get("position"),
|
||||
"real_elapsed_ms": round(step["real_elapsed_s"] * 1000.0, 3),
|
||||
"real_since_enter_s": step.get("real_since_enter_s"),
|
||||
"move_s": round(cost["move_s"], 3),
|
||||
"switch_s": round(cost["switch_s"], 3),
|
||||
"action_s": round(cost["action_s"], 3),
|
||||
"step_virtual_s": round(cost["total_s"], 3),
|
||||
"virtual_time_s": step["virtual_time_s"]})
|
||||
for key, value in totals.items():
|
||||
if isinstance(value, float):
|
||||
totals[key] = round(value, 3)
|
||||
return {"totals": totals, "rows": rows}
|
||||
|
||||
def _channel_status_snapshot(self) -> dict:
|
||||
"""逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。"""
|
||||
snapshot = {}
|
||||
@@ -441,6 +613,8 @@ class DirectionalExtension(OmnidirectionalLoop):
|
||||
self._may_clear.add(channel)
|
||||
steps = 0
|
||||
while steps < search.max_localize_steps:
|
||||
if self._virtual_cap_reached():
|
||||
return False
|
||||
region = self.belief.candidate_set(channel)
|
||||
if region is None:
|
||||
return False
|
||||
|
||||
@@ -129,10 +129,13 @@ class SimulatorClient:
|
||||
self._seq = 0
|
||||
self._deadline: float | None = None # 本地单调时钟截止
|
||||
self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对
|
||||
self._enter_monotonic: float | None = None # 实机计时起点(/enter 成功)
|
||||
|
||||
# ------------------------------------------------------------ 公共动作 API
|
||||
def enter(self) -> dict:
|
||||
response = self._call("/enter", self._payload("/enter"))
|
||||
started = self.clock()
|
||||
payload = self._payload("/enter")
|
||||
response = self._call("/enter", payload)
|
||||
self.state.apply_enter(response)
|
||||
remaining = response["remaining_real_duration_s"]
|
||||
if not isinstance(remaining, (int, float)) or remaining <= 0:
|
||||
@@ -140,9 +143,13 @@ class SimulatorClient:
|
||||
self._deadline = self.clock() + remaining
|
||||
self._server_deadline_ms = (response["real_timestamp_ms"]
|
||||
+ remaining * 1000.0)
|
||||
self._enter_monotonic = self.clock()
|
||||
self._log_step("/enter", payload, response, started,
|
||||
predicted=CostBreakdown(0.0, 0.0, 0.0))
|
||||
return response
|
||||
|
||||
def measure(self, position: Vec2, channel: int) -> MeasureOutcome:
|
||||
started = self.clock()
|
||||
payload = self._payload("/measure", position=position, channel=channel)
|
||||
response = self._call("/measure", payload)
|
||||
predicted = self.state.apply_measure(payload, response,
|
||||
@@ -154,10 +161,13 @@ class SimulatorClient:
|
||||
if result == DIRECTION:
|
||||
if not isinstance(svd, (int, float)) or isinstance(svd, bool):
|
||||
raise ProtocolViolation("direction 响应缺少数值 svd_deg", body=response)
|
||||
self._log_step("/measure", payload, response, started, result=result,
|
||||
predicted=predicted, channel=payload["channel"])
|
||||
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:
|
||||
started = self.clock()
|
||||
payload = self._payload("/clear", position=position, channel=channel)
|
||||
response = self._call("/clear", payload)
|
||||
predicted = self.state.apply_clear(payload, response,
|
||||
@@ -165,14 +175,20 @@ class SimulatorClient:
|
||||
result = response.get("clear_result")
|
||||
if result not in CLEAR_RESULTS:
|
||||
raise ProtocolViolation(f"clear_result 非法: {result!r}", body=response)
|
||||
self._log_step("/clear", payload, response, started, result=result,
|
||||
predicted=predicted, channel=payload["channel"])
|
||||
return ClearOutcome(result, response["virtual_time_s"], predicted, response)
|
||||
|
||||
def exit(self) -> ExitInfo:
|
||||
response = self._call("/exit", self._payload("/exit"))
|
||||
started = self.clock()
|
||||
payload = self._payload("/exit")
|
||||
response = self._call("/exit", payload)
|
||||
self.state.apply_exit(response)
|
||||
reason = response.get("exit_reason")
|
||||
if reason != EXIT_USER:
|
||||
raise ProtocolViolation(f"exit_reason 非法: {reason!r}", body=response)
|
||||
self._log_step("/exit", payload, response, started,
|
||||
predicted=CostBreakdown(0.0, 0.0, 0.0))
|
||||
return ExitInfo(reason, response["virtual_time_s"], response)
|
||||
|
||||
# ---------------------------------------------------------------- 预算控制
|
||||
@@ -181,6 +197,34 @@ class SimulatorClient:
|
||||
"""按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。"""
|
||||
return None if self._deadline is None else self._deadline - self.clock()
|
||||
|
||||
def _log_step(self, path: str, payload: dict, response: dict,
|
||||
started: float, *, result: str | None = None,
|
||||
predicted: CostBreakdown, channel: int | None = None) -> None:
|
||||
"""每步计时留档 [用户决策 2026-09-11]:实机墙钟耗时 + 比赛虚拟耗时分项
|
||||
(移动=直线距离/5、切频道 1s、检测 5s、清除成功 5s/未发现 3s)。
|
||||
|
||||
step 事件不含 response 字段,不会混入 action_events / 虚拟时刻复算。
|
||||
"""
|
||||
now = self.clock()
|
||||
entry: dict = {"event": "step", "path": path,
|
||||
"request_id": payload["request_id"],
|
||||
"real_elapsed_s": round(now - started, 6),
|
||||
"virtual_time_s": response["virtual_time_s"],
|
||||
"virtual_cost": {"move_s": round(predicted.move_s, 6),
|
||||
"switch_s": round(predicted.switch_s, 6),
|
||||
"action_s": round(predicted.action_s, 6),
|
||||
"total_s": round(predicted.total_s, 6)}}
|
||||
if self._enter_monotonic is not None:
|
||||
entry["real_since_enter_s"] = round(now - self._enter_monotonic, 6)
|
||||
if "position" in payload:
|
||||
entry["position"] = {"x": payload["position"]["x"],
|
||||
"y": payload["position"]["y"]}
|
||||
if channel is not None:
|
||||
entry["channel"] = channel
|
||||
if result is not None:
|
||||
entry["result"] = result
|
||||
self.log.append(entry)
|
||||
|
||||
def _check_budget(self, path: str) -> None:
|
||||
if path == "/exit" or self._deadline is None:
|
||||
return
|
||||
|
||||
@@ -32,3 +32,7 @@ class SessionLog:
|
||||
"""仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。"""
|
||||
return [event for event in self.events if event.get("response") is not None
|
||||
and event["response"].get("accepted") is True]
|
||||
|
||||
def step_events(self) -> list[dict]:
|
||||
"""每步计时事件(客户端 _log_step 写入,按发生顺序)。"""
|
||||
return [event for event in self.events if event.get("event") == "step"]
|
||||
|
||||
+26
-21
@@ -1,8 +1,9 @@
|
||||
"""自检场景生成器(测试端专用)。
|
||||
|
||||
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
|
||||
场景覆盖题面强调的边界情形:近圆域边界源、最小/最大接收半径两档,
|
||||
以及问题4的定向源(含朝向背离圆心的朝外边界源)。
|
||||
默认完全按题面生成:源总数 10~16 随机、位置在目标圆域内均匀分布、
|
||||
频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
|
||||
边界情形(近圆域边界源、接收半径两档、定向朝外源)由专门测试显式构造。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,25 +11,29 @@ from __future__ import annotations
|
||||
import math
|
||||
from random import Random
|
||||
|
||||
from ..config import ProblemConfig
|
||||
from .simulator import Source
|
||||
|
||||
|
||||
def random_omni_scene(seed: int = 20260910, count: int = 10) -> list[Source]:
|
||||
"""全向源场景:频道互异,含近圆域边界点,接收半径 1000/1500 交替。"""
|
||||
if not 0 <= count <= 20:
|
||||
raise ValueError("源数量须在 0..20")
|
||||
def random_omni_scene(seed: int = 20260910, count: int | None = None,
|
||||
config: ProblemConfig = ProblemConfig()) -> list[Source]:
|
||||
"""全向源场景 [题面]:count=None 时源总数在 10..16 均匀随机。
|
||||
|
||||
位置在半径 omega_radius_m 圆域内按面积均匀(r = R*sqrt(u)),
|
||||
有效接收半径在 [r_eff_min, r_eff_max] 均匀,频道从 1..20 无重复抽样。
|
||||
"""
|
||||
field = config.field
|
||||
rng = Random(seed)
|
||||
channels = rng.sample(range(1, 21), count)
|
||||
if count is None:
|
||||
count = rng.randint(field.source_count_min, field.source_count_max)
|
||||
if not 0 <= count <= field.channel_max:
|
||||
raise ValueError(f"源数量须在 0..{field.channel_max}")
|
||||
channels = rng.sample(range(field.channel_min, field.channel_max + 1), count)
|
||||
sources = []
|
||||
for index, channel in enumerate(channels):
|
||||
if index == 0:
|
||||
radius = rng.uniform(1780.0, 1799.0) # 近圆域边界
|
||||
elif index == 1 and count > 1:
|
||||
radius = rng.uniform(1780.0, 1799.0)
|
||||
else:
|
||||
radius = rng.uniform(0.0, 1795.0)
|
||||
for channel in channels:
|
||||
radius = field.omega_radius_m * math.sqrt(rng.random())
|
||||
angle = rng.uniform(0.0, 2 * math.pi)
|
||||
r_eff = 1000.0 if index % 2 == 0 else 1500.0
|
||||
r_eff = rng.uniform(field.r_eff_min_m, field.r_eff_max_m)
|
||||
sources.append(Source(channel=channel,
|
||||
x=radius * math.cos(angle),
|
||||
y=radius * math.sin(angle),
|
||||
@@ -36,21 +41,21 @@ def random_omni_scene(seed: int = 20260910, count: int = 10) -> list[Source]:
|
||||
return sources
|
||||
|
||||
|
||||
def mixed_scene(seed: int = 20260910, count: int = 10,
|
||||
directional: int = 3) -> list[Source]:
|
||||
def mixed_scene(seed: int = 20260910, count: int | None = None,
|
||||
directional: int = 3,
|
||||
config: ProblemConfig = ProblemConfig()) -> list[Source]:
|
||||
"""问题4混合场景:count 个源中 directional 个为定向源。
|
||||
|
||||
改造 random_omni_scene 的前 directional 个源:近圆域边界的前两个定向源
|
||||
朝向取"背离圆心"(发射半平面基本朝向域外,覆盖网格仍保证可发现,
|
||||
即"朝外边界源不漏"验收情形),其余定向源朝向随机。
|
||||
"""
|
||||
if not 0 <= directional <= count:
|
||||
raise ValueError("定向源数量须在 0..count")
|
||||
sources = list(random_omni_scene(seed=seed, count=count))
|
||||
sources = list(random_omni_scene(seed=seed, count=count, config=config))
|
||||
directional = min(directional, len(sources))
|
||||
rng = Random(seed + 1)
|
||||
for index in range(directional):
|
||||
source = sources[index]
|
||||
if index < 2 and count > 1 and math.hypot(source.x, source.y) > 1700.0:
|
||||
if index < 2 and len(sources) > 1 and math.hypot(source.x, source.y) > 1700.0:
|
||||
heading = math.degrees(math.atan2(source.y, source.x)) % 360.0
|
||||
else:
|
||||
heading = rng.uniform(0.0, 360.0)
|
||||
|
||||
@@ -127,6 +127,12 @@ class LocalSimulator:
|
||||
})
|
||||
if not self.entered or self.exited:
|
||||
return 200, self._not_accepted()
|
||||
if path in ("/measure", "/clear") \
|
||||
and self.virtual_time_s >= self.field.max_virtual_duration_s:
|
||||
# 虚拟世界限时 360000s 防死循环 [题面/附件2 §4.5:达限结束测试]。
|
||||
# 官方在达限时关闭接口;本地以 accepted=false 模拟,作为计划层
|
||||
# 虚拟安全余量(search.virtual_reserve_s)失效时的兜底。
|
||||
return 200, self._not_accepted()
|
||||
if path == "/exit":
|
||||
self.exited = True
|
||||
return 200, self._ok({"exit_reason": EXIT_USER})
|
||||
|
||||
Reference in New Issue
Block a user