This commit is contained in:
Horldsence
2026-09-11 03:32:05 +08:00
parent 846b45e420
commit fc70d6edfd
14 changed files with 46032 additions and 65 deletions
+32 -4
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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
+46 -2
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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})