论文完成
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。
|
||||
|
||||
为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了
|
||||
算法尚未优化完全时的局(例如一次演练出现 33 次失败清除、10 分钟虚拟时间
|
||||
仍只清 10/13 个源)。这类局不能代表当前实现,需要重新采一组。
|
||||
|
||||
本脚本**不自己造数据、也不写评测机日志**:它启动评测机 ``referee``、
|
||||
调用 ``judge.start("q3_practice"|"q4_practice", seed=...)`` 开始一局演练,
|
||||
再用与 ``mcm26b.cli run`` 完全相同的生产策略对接接口。
|
||||
于是:
|
||||
|
||||
- 真值与行为日志由评测机自己写进 ``<data-dir>/sessions/<案例编码>/``,
|
||||
与历史留档同源同格式,可直接被 ``scripts/build_practice_data.py`` 复算;
|
||||
- 策略端只看见 HTTP 响应,不存在"为了好看而改数据"的空间;
|
||||
- 每局由 ``--seed`` 决定,可重放。
|
||||
|
||||
有效性判定沿用项目口径(与 ``scripts/run_suite.py`` 一致,不放宽):
|
||||
``清完全部源 ∧ 无缺席误判 ∧ 主动 /exit ∧ 零异常 ∧ 零失败清除``。
|
||||
最后一项是刻意加严的:算法未优化完全时会出现"最终清完了,但中途大量 /clear
|
||||
打空"的局(本次复测就抓到 3~114 次失败清除)。这类局的总虚拟时间与检测次数
|
||||
被无效试探撑大,不能代表当前实现,必须排除。
|
||||
|
||||
uv run python scripts/run_practice_episodes.py --problem 3 --counts 10,13,16 \
|
||||
--seeds 300-309 --out referee-data
|
||||
uv run python scripts/run_practice_episodes.py --problem 4 --counts 10,13 \
|
||||
--seeds 400-411
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from mcm26b.config import ProblemConfig # noqa: E402
|
||||
from mcm26b.planner import DirectionalExtension # noqa: E402
|
||||
from mcm26b.planner.bayes_loop import BayesLoop # noqa: E402
|
||||
from mcm26b.protocol.client import SimulatorClient # noqa: E402
|
||||
|
||||
STRATEGIES = {3: BayesLoop, 4: DirectionalExtension}
|
||||
DEFAULT_SEEDS = {3: (300, 309), 4: (400, 411)}
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
"""取一个空闲回环端口,评测机接口与 WebUI 都只用回环 [题面 附件2 1.5]。"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def parse_seeds(text: str | None, problem: int) -> list[int]:
|
||||
"""``300-309`` 或 ``300,301`` 形式的种子表。"""
|
||||
if not text:
|
||||
low, high = DEFAULT_SEEDS[problem]
|
||||
return list(range(low, high + 1))
|
||||
seeds: list[int] = []
|
||||
for chunk in text.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if "-" in chunk:
|
||||
low, high = (int(part) for part in chunk.split("-", 1))
|
||||
seeds.extend(range(low, high + 1))
|
||||
else:
|
||||
seeds.append(int(chunk))
|
||||
return seeds
|
||||
|
||||
|
||||
def build_config(base_url: str, margin_deg: float) -> ProblemConfig:
|
||||
"""与 ``mcm26b.cli run`` 相同的生产配置:默认关闭 persistent 复扫。"""
|
||||
from dataclasses import replace # noqa: PLC0415
|
||||
config = ProblemConfig()
|
||||
return replace(config,
|
||||
session=replace(config.session, base_url=base_url),
|
||||
search=replace(config.search,
|
||||
bearing_rounding_margin_deg=margin_deg,
|
||||
persistent_search=False))
|
||||
|
||||
|
||||
def run_episode(problem: int, count: int, seed: int, robot_id: str, data_dir: Path,
|
||||
margin_deg: float, countdown_s: float,
|
||||
directional: int) -> dict:
|
||||
"""跑一局演练;返回该局的核对结果(真值只在此处用于判定)。"""
|
||||
from referee import Referee # noqa: PLC0415
|
||||
from referee.server import ActionInterfaceServer # noqa: PLC0415
|
||||
|
||||
port = free_port()
|
||||
judge = Referee(action_port=port, data_dir=data_dir)
|
||||
ActionInterfaceServer(judge)
|
||||
module = f"q{problem}_practice"
|
||||
session = judge.start(module, robot_id, seed=seed, source_count=count,
|
||||
directional_count=directional, countdown_s=countdown_s)
|
||||
deadline = time.monotonic() + session.countdown_s + 15.0
|
||||
while session.phase == "countdown" and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
result = {"problem": problem, "seed": seed, "count": count,
|
||||
"case_code": session.case_code}
|
||||
if session.phase != "open":
|
||||
judge.abort()
|
||||
result.update(valid=False, error="评测机未能开放接口")
|
||||
return result
|
||||
|
||||
config = build_config(f"http://127.0.0.1:{port}", margin_deg)
|
||||
client = SimulatorClient(robot_id, config=config)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
summary = STRATEGIES[problem](client, config).run()
|
||||
result["strategy_error"] = None
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
summary = {"cleared_channels": [], "absent_channels": [],
|
||||
"exit_reason": "technical_error", "anomalies": [str(exc)]}
|
||||
result["strategy_error"] = f"{type(exc).__name__}: {exc}"
|
||||
result["compute_wall_s"] = round(time.perf_counter() - started, 3)
|
||||
|
||||
settle = time.monotonic() + 10.0
|
||||
while judge.session.phase != "finished" and time.monotonic() < settle:
|
||||
time.sleep(0.02)
|
||||
if judge.session.phase != "finished":
|
||||
judge.abort()
|
||||
state = judge.snapshot()
|
||||
truth = sorted(source.channel for source in session.sources)
|
||||
cleared = sorted(summary.get("cleared_channels", []))
|
||||
absent = sorted(summary.get("absent_channels", []))
|
||||
# 失败清除次数从评测机行为日志里数,而不是策略自报,避免口径分歧。
|
||||
clear_calls = clear_failures = 0
|
||||
log_path = data_dir / "sessions" / session.case_code / "log.jsonl"
|
||||
if log_path.exists():
|
||||
for line in log_path.read_text(encoding="utf-8").splitlines():
|
||||
if '"path": "/clear"' not in line:
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if (event.get("event") != "action"
|
||||
or (event.get("response") or {}).get("accepted") is not True):
|
||||
continue
|
||||
clear_calls += 1
|
||||
if (event["response"].get("clear_result")) != "success":
|
||||
clear_failures += 1
|
||||
result.update(
|
||||
end_reason=state.get("end_reason"),
|
||||
cleared_channels=cleared,
|
||||
cleared_count=len(cleared),
|
||||
truth_channels=truth,
|
||||
source_count=len(truth),
|
||||
directional_count=sum(1 for s in session.sources if s.directional),
|
||||
absent_misjudged=sorted(set(absent) & set(truth)),
|
||||
anomalies=len(summary.get("anomalies", [])),
|
||||
virtual_time_s=state.get("virtual_time_s"),
|
||||
clear_calls=clear_calls,
|
||||
clear_failures=clear_failures,
|
||||
measurements=len([s for s in summary.get("steps", [])
|
||||
if s.get("action") == "measure"]),
|
||||
valid=(bool(truth) and cleared == truth and not (set(absent) & set(truth))
|
||||
and summary.get("exit_reason") == "user_exit"
|
||||
and not summary.get("anomalies")
|
||||
and clear_failures == 0
|
||||
and result["strategy_error"] is None))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--problem", type=int, choices=(3, 4), required=True)
|
||||
parser.add_argument("--counts", default="10,13,16",
|
||||
help="每局的源数量表(逗号分隔),逐局循环取用")
|
||||
parser.add_argument("--seeds", default=None,
|
||||
help="种子表,支持 300-309 或 300,301;缺省用内置区间")
|
||||
parser.add_argument("--directional", type=int, default=3,
|
||||
help="问题四每局定向源个数(题面未规定,自建模拟器默认为 3)")
|
||||
parser.add_argument("--directional-cycle", default=None,
|
||||
help="问题四定向源个数循环表,如 3,6,8;给定时覆盖 --directional,"
|
||||
"用于让演练局的定向占比也覆盖题面的不确定性")
|
||||
parser.add_argument("--robot-id", default="2026001")
|
||||
parser.add_argument("--data-dir", type=Path, default=ROOT / "referee-data")
|
||||
parser.add_argument("--margin-deg", type=float, default=0.005)
|
||||
parser.add_argument("--countdown-s", type=float, default=0.2)
|
||||
parser.add_argument("--out", type=Path, default=None,
|
||||
help="逐局核对结果的 JSON 输出路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
counts = [int(item) for item in args.counts.split(",") if item.strip()]
|
||||
directional_cycle = ([int(item) for item in args.directional_cycle.split(",")
|
||||
if item.strip()]
|
||||
if args.directional_cycle else [args.directional])
|
||||
seeds = parse_seeds(args.seeds, args.problem)
|
||||
print(f"问题{args.problem}:{len(seeds)} 局,源数循环 {counts},"
|
||||
f"定向源循环 {directional_cycle},数据目录 {args.data_dir}", flush=True)
|
||||
|
||||
results = []
|
||||
for index, seed in enumerate(seeds):
|
||||
outcome = run_episode(args.problem, counts[index % len(counts)], seed,
|
||||
args.robot_id, args.data_dir, args.margin_deg,
|
||||
args.countdown_s,
|
||||
directional_cycle[index % len(directional_cycle)])
|
||||
results.append(outcome)
|
||||
verdict = "有效" if outcome.get("valid") else "无效"
|
||||
print(f" [{index + 1:2d}/{len(seeds)}] seed={seed:5d} n={outcome['count']:2d} "
|
||||
f"{outcome['case_code']} 清除 {outcome['cleared_count']}/{outcome['source_count']} "
|
||||
f"T={outcome.get('virtual_time_s') or 0:9.1f}s 墙钟={outcome['compute_wall_s']:6.2f}s "
|
||||
f"失败清除={outcome['clear_failures']} 缺席误判={len(outcome['absent_misjudged'])} "
|
||||
f"异常={outcome['anomalies']} {outcome['end_reason']} -> {verdict}", flush=True)
|
||||
|
||||
valid = [row for row in results if row.get("valid")]
|
||||
print(f"\n有效 {len(valid)}/{len(results)} 局;"
|
||||
f"无效局的种子:{[r['seed'] for r in results if not r.get('valid')]}")
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps({"problem": args.problem,
|
||||
"counts": counts, "seeds": seeds,
|
||||
"directional": args.directional,
|
||||
"results": results},
|
||||
ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"核对结果已写入 {args.out}")
|
||||
return 0 if valid else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user