This commit is contained in:
Horldsence
2026-09-13 11:45:01 +08:00
parent fb0325f67f
commit 257d1a9bd7
54 changed files with 11152 additions and 6772 deletions
@@ -1,206 +0,0 @@
"""Audit only the formal exports selected in the official simulator's test folder.
Match the public activation-ticket digest to the official statistics queue,
and the complete package digest to its result sidecar. Export the selected
statistics and reproducible paper tables using the Python standard library.
"""
from __future__ import annotations
import argparse
import base64
import csv
import hashlib
import json
import sqlite3
import struct
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIELDS = (
"entered", "end_reason", "cleared_jammer_count", "measure_accepted_count",
"virtual_time_us", "program_run_duration_ms", "channel_switch_count",
"clear_failure_count", "state",
)
def sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def public_header(path: Path) -> tuple[dict, str]:
data = path.read_bytes()
if data[:8] != b"JMBFLOG1" or len(data) < 14:
raise ValueError(f"Unsupported formal envelope: {path.name}")
version, size = struct.unpack(">HI", data[8:14])
if version != 1 or not 0 < size <= len(data) - 14:
raise ValueError(f"Invalid envelope header: {path.name}")
header = json.loads(data[14:14 + size])
if header["package_type"] != "formal_behavior_log":
raise ValueError("Practice logs are not admitted")
return header, sha(data)
def fixed(value: Decimal, digits: int = 3) -> str:
return str(value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
def derive(row: dict) -> dict:
count = row["cleared_jammer_count"]
total = Decimal(row["virtual_time_us"]) / 1_000_000
movement = total - (5 * row["measure_accepted_count"]
+ row["channel_switch_count"] + 5 * count
+ 3 * row["clear_failure_count"])
if count <= 0 or movement < 0:
raise ValueError("Invalid counts or negative inferred movement time")
return {
"virtual_time_s": str(total),
"average_clear_time_s": fixed(total / count, 6),
"program_run_duration_s": fixed(Decimal(row["program_run_duration_ms"]) / 1000),
"inferred_movement_time_s": str(movement),
"inferred_distance_m": str(5 * movement),
"movement_share_percent": fixed(100 * movement / total, 6),
"source_total": None,
"clear_fraction": None,
}
def collect(sim: Path) -> list[dict]:
selected = sorted((sim / "test").rglob("*.jlog"))
if not selected:
raise ValueError("No formal exports in test/")
queue = sim / "JammersSimulatorData/formal-statistics-queue.sqlite3"
original = sim / "JammersSimulatorData/behavior-logs"
# Read the live queue including its WAL, without writing to the simulator.
with sqlite3.connect(queue.resolve().as_uri() + "?mode=ro", uri=True) as db:
db.row_factory = sqlite3.Row
query = "SELECT activation_ticket_sha256," + ",".join(FIELDS) + " FROM statistics_tasks"
statistics = {r["activation_ticket_sha256"]: dict(r) for r in db.execute(query)}
records = []
for path in selected:
header, digest = public_header(path)
encoded = header["server_ticket_b64"]
ticket_hash = sha(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
sidecar = json.loads((original / (path.stem + ".result.json")).read_text("utf-8"))
for field in ("problem_no", "formal_index", "case_code"):
if header[field] != sidecar[field]:
raise ValueError(f"Mismatched case: {path.name}")
if digest != sidecar["package_sha256"] or digest != sha((original / path.name).read_bytes()):
raise ValueError(f"Mismatched formal package: {path.name}")
matched = statistics[ticket_hash]
row = {name: header[name] for name in ("problem_no", "formal_index", "case_code", "client_version")}
row.update({name: matched[name] for name in FIELDS})
row.update({
"file": path.relative_to(sim).as_posix(), "size_bytes": path.stat().st_size,
"package_sha256": digest, "activation_ticket_sha256": ticket_hash,
"created_at_utc": header["created_at_utc"],
"ended_at_utc": sidecar["ended_at_utc"],
"content_encryption": header["content_encryption"],
"identity_and_ticket_content_omitted": True,
"same_as_official_original": True,
})
row["derived"] = derive(row)
records.append(row)
keys = [(r["problem_no"], r["formal_index"]) for r in records]
if len(set(keys)) != len(keys):
raise ValueError("Duplicate formal attempt")
return records
def write_outputs(rows: list[dict], out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
snapshot = {
"schema": "paper-formal-audit-v1",
"selection": "Only Jammers-simulator-full/test/**/*.jlog",
"statistics_source": "Official formal-statistics-queue.sqlite3; matched by SHA-256 of the public activation ticket",
"coverage": {str(p): sorted(r["formal_index"] for r in rows if r["problem_no"] == p)
for p in (3, 4)},
"measurement_scope": "Per-case official action counts, virtual duration and program duration",
"limitations": [
"Source totals, source types, solver commit and trajectories require additional evidence.",
"Queue state confirmed records a statistics submission acknowledgement.",
],
"records": rows,
}
(out / "formal-results.json").write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", "utf-8")
columns = ["problem_no", "formal_index", "case_code", *FIELDS, "package_sha256"]
with (out / "formal-results.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns + list(rows[0]["derived"]))
writer.writeheader()
for row in rows:
writer.writerow({**{key: row[key] for key in columns}, **row["derived"]})
for problem in (3, 4):
by_index = {r["formal_index"]: r for r in rows if r["problem_no"] == problem}
lines = ["% Generated by scripts/audit_paper_formal.py; seconds; half-up rounding."]
for index in (1, 2, 3):
row = by_index.get(index)
if row is None:
lines.append(f"测试 {index} & 未提供记录 & -- & -- & -- " + r"\\")
else:
avg = Decimal(row["virtual_time_us"]) / 1_000_000 / row["cleared_jammer_count"]
lines.append(f"测试 {index} & \\texttt{{{row['case_code']}}} & {row['cleared_jammer_count']} & {fixed(avg)} & {row['derived']['program_run_duration_s']} " + r"\\")
(out / f"formal-q{problem}-rows.tex").write_text("\n".join(lines) + "\n", "utf-8")
cost_lines, constants = [], []
for row in rows:
label = f"Q{row['problem_no']}-{row['formal_index']}"
total = Decimal(row["virtual_time_us"]) / 1_000_000
move = Decimal(row["derived"]["inferred_movement_time_s"])
cost_lines.append(f"{label} & {row['measure_accepted_count']} & {row['channel_switch_count']} & {row['clear_failure_count']} & {fixed(total)} & {fixed(move)} & {fixed(100*move/total, 2)}\\% " + r"\\")
(out / "formal-cost-rows.tex").write_text("\n".join(cost_lines) + "\n", "utf-8")
for problem, word in ((3, "Three"), (4, "Four")):
group = [r for r in rows if r["problem_no"] == problem]
count = sum(r["cleared_jammer_count"] for r in group)
total = sum(Decimal(r["virtual_time_us"]) / 1_000_000 for r in group)
mean = sum(Decimal(r["virtual_time_us"]) / 1_000_000 / r["cleared_jammer_count"] for r in group) / len(group)
constants.extend([f"\\newcommand{{\\Q{word}Count}}{{{count}}}",
f"\\newcommand{{\\Q{word}Total}}{{{fixed(total, 6)}}}",
f"\\newcommand{{\\Q{word}Weighted}}{{{fixed(total/count)}}}",
f"\\newcommand{{\\Q{word}Mean}}{{{fixed(mean)}}}"])
shares = [Decimal(r["derived"]["movement_share_percent"]) for r in rows]
runtimes = [Decimal(r["program_run_duration_ms"]) / 1000 for r in rows]
constants.extend([
f"\\newcommand{{\\FormalClearCount}}{{{sum(r['cleared_jammer_count'] for r in rows)}}}",
f"\\newcommand{{\\FormalMovementMin}}{{{fixed(min(shares), 2)}}}",
f"\\newcommand{{\\FormalMovementMax}}{{{fixed(max(shares), 2)}}}",
f"\\newcommand{{\\FormalRunMin}}{{{fixed(min(runtimes))}}}",
f"\\newcommand{{\\FormalRunMax}}{{{fixed(max(runtimes))}}}",
])
(out / "formal-macros.tex").write_text("\n".join(constants) + "\n", "utf-8")
audit = ["# 正式测试数据核对", "", f"本次汇总 `Jammers-simulator-full/test` 中的 {len(rows)} 份正式导出包。",
"利用公开头部的案例信息、包哈希及票据哈希,与原始导出、结果侧文件和官方正式统计数据库建立对应关系。", "",
"| 案例 | 正式序号 | 原件一致 | 包 SHA-256 |", "| --- | --- | --- | --- |"]
for row in rows:
audit.append(f"| {row['case_code']} | Q{row['problem_no']}-{row['formal_index']} | 是 | `{row['package_sha256']}` |")
audit += ["", "虚拟时间保留官方整数微秒,程序运行时间采用官方毫秒。平均定位清除时间 = 总虚拟秒数 / 清除数。",
"移动时间由总时长减去检测、换频、成功及失败清除耗时计算。",
"`confirmed` 表示统计提交确认,`user_exit` 表示主动退出。",
"实验评价采用清除数量、逐案例平均定位清除时间、程序运行时间和动作耗时分解。",
"扩展评价所需的源总数与配对场景设计见论文未来工作。", ""]
(out / "audit.md").write_text("\n".join(audit), "utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sim-root", type=Path, default=ROOT / "Jammers-simulator-full")
parser.add_argument("--out", type=Path, default=ROOT / "docs/paper/data")
parser.add_argument("--snapshot", type=Path, help="Regenerate tables from the audited anonymous snapshot, without the simulator database")
args = parser.parse_args()
if args.snapshot:
rows = json.loads(args.snapshot.read_text("utf-8"))["records"]
for row in rows:
if derive(row) != row["derived"]:
raise ValueError("Snapshot arithmetic changed")
path = args.sim_root / row["file"]
if path.exists() and public_header(path)[1] != row["package_sha256"]:
raise ValueError("Snapshot and formal export disagree")
else:
rows = collect(args.sim_root)
write_outputs(rows, args.out)
for row in rows:
print(f"Q{row['problem_no']}-{row['formal_index']} {row['case_code']}: "
f"cleared={row['cleared_jammer_count']} average={row['derived']['average_clear_time_s']} s "
f"real={row['derived']['program_run_duration_s']} s")
if __name__ == "__main__":
main()
@@ -1,393 +0,0 @@
"""Build vector figure sources, comparison tables, source listings, and a support ZIP.
Formal figures use the audited snapshot only. Geometric figures use declared
algorithm parameters, not fabricated observations or hidden simulator data.
The paired optimal-versus-baseline tables are generated from project/results/*.json
so that no number in the comparison section is hand copied.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import zipfile
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAPER = ROOT / "docs/paper"
PROJECT_RESULTS = ROOT / "project/results"
# Display copies only: executable Python files remain byte-for-byte unchanged.
ESCAPE_OPEN = "(" + "*@"
ESCAPE_CLOSE = "@*" + ")"
LATEX_SYMBOLS = {
"\u03b1": r"\alpha", "\u03b4": r"\delta", "\u03b8": r"\theta",
"\u03bb": r"\lambda", "\u03c0": r"\pi", "\u03c1": r"\rho",
"\u03c6": r"\varphi", "\u2308": r"\lceil", "\u2309": r"\rceil",
"\u2200": r"\forall", "\u2203": r"\exists", "\u2205": r"\varnothing",
"\u2208": r"\in", "\u2227": r"\wedge", "\u2229": r"\cap",
"\u222a": r"\cup", "\u2248": r"\approx", "\u2264": r"\le",
"\u2265": r"\ge", "\u2286": r"\subseteq", "\u2287": r"\supseteq",
"\u27f9": r"\Longrightarrow",
}
def escaped_listing(source: str) -> str:
if ESCAPE_OPEN in source or ESCAPE_CLOSE in source:
raise ValueError("Source collides with the LaTeX escape delimiters")
return "".join(
ESCAPE_OPEN + r"\ensuremath{" + LATEX_SYMBOLS[c] + "}" + ESCAPE_CLOSE
if c in LATEX_SYMBOLS else c for c in source
)
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def figures(rows: list[dict]) -> None:
out = PAPER / "figures"
out.mkdir(parents=True, exist_ok=True)
plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8"))
lines = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]",
r"\fill[inkblue!5] (0,0) circle (1800);",
r"\draw[thick] (0,0) circle (1800);",
r"\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};",
r"\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};",
]
for x in (-2000, -1000, 1000, 2000):
lines += [fr"\draw ({x},-35)--({x},35);",
fr"\node[below,font=\scriptsize] at ({x},-50) {{{x}}};"]
for x, y in plan["points"]:
lines.append(fr"\fill[inkblue] ({x},{y}) circle (33);")
lines += [
r"\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};",
r"\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};",
r"\end{tikzpicture}",
r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}",
r"\label{fig:q4plan}\end{figure}",
]
(out / "q4-plan.tex").write_text("\n".join(lines) + "\n", "utf-8")
colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"]
bars = [
r"\begin{figure}[htbp]\centering",
r"\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]",
]
for j, row in enumerate(rows):
y = len(rows) - 1 - j
components = [Decimal(row["derived"]["inferred_movement_time_s"]),
Decimal(5 * row["measure_accepted_count"]),
Decimal(row["channel_switch_count"]),
Decimal(5 * row["cleared_jammer_count"] + 3 * row["clear_failure_count"])]
label = f"Q{row['problem_no']}-{row['formal_index']}"
bars.append(fr"\node[anchor=east] at (-100,{y + 0.3}) {{{label}}};")
current = Decimal(0)
for amount, color in zip(components, colors):
bars.append(fr"\fill[{color}] ({current},{y}) rectangle ({current + amount},{y + 0.6});")
current += amount
bars.append(fr"\node[anchor=west,font=\scriptsize] at ({current + 80},{y + 0.3}) {{{current:.1f}}};")
bars.append(r"\draw[->] (0,-0.25)--(10500,-0.25);")
for tick in range(0, 10001, 2000):
bars.append(fr"\draw ({tick},-0.25)--({tick},-0.35) node[below,font=\scriptsize] {{{tick}}};")
bars.append(r"\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};")
for x, color, label in zip((0, 2600, 5200, 7600), colors, ("移动", "检测", "换频", "清除")):
bars += [fr"\fill[{color}] ({x},-1.3) rectangle ({x + 350},-1.05);",
fr"\node[anchor=west] at ({x + 450},-1.175) {{{label}}};"]
bars += [
r"\end{tikzpicture}",
r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}",
r"\label{fig:costbars}\end{figure}",
]
(out / "formal-time-bars.tex").write_text("\n".join(bars) + "\n", "utf-8")
def fixed(value: float | None, digits: int = 3) -> str:
if value is None:
return "--"
return str(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))
def percent(value: float | None, digits: int = 1) -> str:
if value is None:
return "--"
return fixed(100 * value, digits) + r"\%"
def flag(value: bool | None) -> str:
return "--" if value is None else ("是" if value else "否")
def project_tables() -> None:
"""Generate the optimal-versus-baseline comparison tables from project/results."""
out = PAPER / "data"
out.mkdir(parents=True, exist_ok=True)
data = {name: json.loads((PROJECT_RESULTS / f"{name}.json").read_text("utf-8"))
for name in ("q1", "q2", "q3", "q4")}
examples, random = data["q1"]["aggregate_examples"], data["q1"]["aggregate_random"]
two = data["q2"]["aggregate"]
three, paired3 = data["q3"]["aggregate"]["variants"], data["q3"]["aggregate"]["paired"]
four, paired4 = data["q4"]["aggregate"]["variants"], data["q4"]["aggregate"]["paired"]
head = "% Generated by scripts/build_paper_support.py from project/results/*.json."
macros = [
("ProjQOneExampleCases", str(examples["cases"])),
("ProjQOneRandomCases", str(random["cases"])),
("ProjQOneFixedStatusBase", percent(examples["status_accuracy_baseline"])),
("ProjQOneFixedMaeBase", fixed(examples["diameter_mae_baseline_m"])),
("ProjQOneFixedMaeMaxBase", fixed(examples["diameter_max_err_baseline_m"])),
("ProjQOneFixedCoverBase", percent(examples["coverage_accuracy_baseline"])),
("ProjQOneFixedMsOpt", fixed(examples["runtime_optimal_mean_ms"])),
("ProjQOneFixedMsBase", fixed(examples["runtime_baseline_mean_ms"])),
("ProjQOneRandStatusBase", percent(random["status_accuracy_baseline"])),
("ProjQOneRandMaeBase", fixed(random["diameter_mae_baseline_m"])),
("ProjQOneRandMaeMaxBase", fixed(random["diameter_max_err_baseline_m"])),
("ProjQOneRandCoverBase", percent(random["coverage_accuracy_baseline"])),
("ProjQOneRandMsOpt", fixed(random["runtime_optimal_mean_ms"])),
("ProjQOneRandMsBase", fixed(random["runtime_baseline_mean_ms"])),
("ProjQTwoCases", str(two["optimal"]["cases"])),
("ProjQTwoInitialRadius", fixed(two["_initial_radius_mean_m"])),
("ProjQTwoGuaranteeOpt", percent(two["optimal"]["guaranteed_rate"])),
("ProjQTwoGuaranteeMec", percent(two["baseline_mec"]["guaranteed_rate"])),
("ProjQTwoGuaranteePerp", percent(two["baseline_perp"]["guaranteed_rate"])),
("ProjQTwoWorstOpt", fixed(two["optimal"]["worst_posterior_mean_m"])),
("ProjQTwoWorstRef", fixed(two["reference"]["worst_posterior_mean_m"])),
("ProjQTwoRatioOpt", fixed(two["optimal"]["final_ratio_mean"])),
("ProjQTwoRatioMec", fixed(two["baseline_mec"]["final_ratio_mean"])),
("ProjQTwoWorstPerp", fixed(two["baseline_perp"]["worst_posterior_mean_m"])),
("ProjQTwoWorstMec", fixed(two["baseline_mec"]["worst_posterior_mean_m"])),
("ProjQThreeScenes", str(three["optimal"]["episodes"])),
("ProjQThreeMeanOpt", fixed(three["optimal"]["virtual_time_mean_s"])),
("ProjQThreeMeanSeven", fixed(three["seven"]["virtual_time_mean_s"])),
("ProjQThreeMeanBase", fixed(three["baseline"]["virtual_time_mean_s"])),
("ProjQThreeDropOpt", percent(-paired3["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQThreeDropMedianOpt",
percent(-paired3["optimal_vs_baseline"]["median_relative_change"])),
("ProjQThreeDropSeven", percent(-paired3["seven_vs_baseline"]["mean_relative_change"])),
("ProjQThreeFasterOpt",
f"{paired3['optimal_vs_baseline']['faster_scenes']}/{paired3['optimal_vs_baseline']['compared']}"),
("ProjQThreeMeasuresOpt", fixed(three["optimal"]["measures_mean"])),
("ProjQThreeMeasuresBase", fixed(three["baseline"]["measures_mean"])),
("ProjQThreeMoveOpt", fixed(three["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQThreeMoveBase", fixed(three["baseline"]["move_distance_mean_m"] / 1000)),
("ProjQThreeWallOpt", fixed(three["optimal"]["compute_wall_mean_s"])),
("ProjQFourScenes", str(four["optimal"]["episodes"])),
("ProjQFourMeanOpt", fixed(four["optimal"]["virtual_time_mean_s"])),
("ProjQFourMeanGrid", fixed(four["baseline_grid"]["virtual_time_mean_s"])),
("ProjQFourMeanBase", fixed(four["baseline"]["virtual_time_mean_s"])),
("ProjQFourClearOpt", percent(four["optimal"]["cleared_ratio_mean"])),
("ProjQFourClearGrid", percent(four["baseline_grid"]["cleared_ratio_mean"])),
("ProjQFourClearBase", percent(four["baseline"]["cleared_ratio_mean"])),
("ProjQFourAbsentBase", str(four["baseline"]["absent_misjudged_total"])),
("ProjQFourMeasuresOpt", fixed(four["optimal"]["measures_mean"])),
("ProjQFourMeasuresBase", fixed(four["baseline"]["measures_mean"])),
("ProjQFourFallbackOpt", fixed(four["optimal"]["fallback_clear_attempts_mean"])),
("ProjQFourValidOpt",
f"{four['optimal']['valid_episodes']}/{four['optimal']['episodes']}"),
("ProjQFourValidGrid",
f"{four['baseline_grid']['valid_episodes']}/{four['baseline_grid']['episodes']}"),
("ProjQFourRiseOpt", percent(paired4["optimal_vs_baseline"]["mean_relative_change"])),
("ProjQFourMoveOpt", fixed(four["optimal"]["move_distance_mean_m"] / 1000)),
("ProjQFourMoveBase", fixed(four["baseline"]["move_distance_mean_m"] / 1000)),
]
lines = [head] + [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
(out / "project-macros.tex").write_text("\n".join(lines) + "\n", "utf-8")
def write(stem: str, rows: list[str]) -> None:
(out / stem).write_text("\n".join([head, *rows]) + "\n", "utf-8")
write("project-overview-rows.tex", [
"问题 & 核心指标 & 最优实现 & 基线实现 " + r"\\",
f"1 & 直径平均绝对误差 / 覆盖判定准确率 & {fixed(random['diameter_mae_optimal_m'])} m / "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['diameter_mae_baseline_m'])} m / "
f"{percent(random['coverage_accuracy_baseline'])} " + r"\\",
f"2 & 保证接收率 / 最坏后验半径均值 & {percent(two['optimal']['guaranteed_rate'])} / "
f"{fixed(two['optimal']['worst_posterior_mean_m'])} m & {percent(two['baseline_mec']['guaranteed_rate'])} / "
f"{fixed(two['baseline_mec']['worst_posterior_mean_m'])} m " + r"\\",
f"3 & 全清率 / 平均虚拟耗时 & {percent(three['optimal']['cleared_ratio_mean'])} / "
f"{fixed(three['optimal']['virtual_time_mean_s'])} s & {percent(three['baseline']['cleared_ratio_mean'])} / "
f"{fixed(three['baseline']['virtual_time_mean_s'])} s " + r"\\",
f"4 & 全清率 / 缺席误判总数 & {percent(four['optimal']['cleared_ratio_mean'])} / "
f"{four['optimal']['absent_misjudged_total']} & {percent(four['baseline']['cleared_ratio_mean'])} / "
f"{four['baseline']['absent_misjudged_total']} " + r"\\",
])
write("project-q1-example-rows.tex", [
f"{row['name']} & {fixed(row['diameter_ref_m'])} & {fixed(row['diameter_opt_m'])} & "
f"{fixed(row['diameter_base_m'])} & {fixed(row['abs_err_base_m'])} & "
f"{flag(row['covers_ref'])} & {flag(row['covers_opt'])} & {flag(row['covers_base'])} " + r"\\"
for row in data["q1"]["examples"]
])
write("project-q1-summary-rows.tex", [
f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & "
f"{fixed(examples['diameter_mae_optimal_m'])} & {fixed(examples['diameter_max_err_optimal_m'])} & "
f"{percent(examples['coverage_accuracy_optimal'])} & {fixed(examples['runtime_optimal_mean_ms'])} " + r"\\",
f"最优(随机 {random['cases']} 例) & {percent(random['status_accuracy_optimal'])} & "
f"{fixed(random['diameter_mae_optimal_m'])} & {fixed(random['diameter_max_err_optimal_m'])} & "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['runtime_optimal_mean_ms'])} " + r"\\",
f"基线(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_baseline'])} & "
f"{fixed(examples['diameter_mae_baseline_m'])} & {fixed(examples['diameter_max_err_baseline_m'])} & "
f"{percent(examples['coverage_accuracy_baseline'])} & {fixed(examples['runtime_baseline_mean_ms'])} " + r"\\",
f"基线(随机 {random['cases']} 例) & {percent(random['status_accuracy_baseline'])} & "
f"{fixed(random['diameter_mae_baseline_m'])} & {fixed(random['diameter_max_err_baseline_m'])} & "
f"{percent(random['coverage_accuracy_baseline'])} & {fixed(random['runtime_baseline_mean_ms'])} " + r"\\",
])
write("project-q2-rows.tex", [
f"{label} & {percent(two[key]['guaranteed_rate'])} & {fixed(two[key]['worst_posterior_mean_m'])} & "
f"{fixed(two[key]['worst_posterior_median_m'])} & {fixed(two[key]['improvement_mean_m'])} & "
f"{fixed(two[key]['final_ratio_mean'])} & {fixed(two[key]['move_cost_mean_s'], 2)} " + r"\\"
for key, label in (("optimal", "算法2(默认选点)"), ("reference", "参考:盘内网格枚举"),
("baseline_perp", "基线:垂直 1000 m"), ("baseline_mec", "基线:候选圆心"))
])
def variant_rows(table: dict, mapping: list[tuple[str, str]]) -> list[str]:
return [
f"{label} & {table[key]['valid_episodes']}/{table[key]['episodes']} & "
f"{percent(table[key]['cleared_ratio_mean'])} & {table[key]['absent_misjudged_total']} & "
f"{fixed(table[key]['virtual_time_mean_s'])} & {fixed(table[key]['virtual_time_median_s'])} & "
f"{fixed(table[key]['virtual_time_max_s'])} & {fixed(table[key]['measures_mean'], 1)} & "
f"{fixed(table[key]['move_distance_mean_m'] / 1000)} & {fixed(table[key]['fallback_clear_attempts_mean'], 1)} "
+ r"\\"
for key, label in mapping
]
write("project-q3-rows.tex", variant_rows(three, [
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"), ("baseline", "grid(49 点网格)")]))
write("project-q4-rows.tex", variant_rows(four, [
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
("baseline", "bayes(问题三策略)")]))
def listings(rows: list[dict]) -> None:
data = PAPER / "data"
files = sorted((ROOT / "src/mcm26b").rglob("*.py"))
files += [ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_paper_support.py"]
text = [
r"\begingroup",
r"\linespread{1.0}\selectfont",
r"\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,"
r"aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none}",
]
for path in files:
rel = path.relative_to(ROOT).as_posix()
display = data / "listing-source" / (rel + ".tex")
display.parent.mkdir(parents=True, exist_ok=True)
display.write_text(escaped_listing(path.read_text("utf-8")), "utf-8")
display_rel = display.relative_to(PAPER).as_posix()
escape_option = "escapeinside={" + ESCAPE_OPEN + "}{" + ESCAPE_CLOSE + "}"
text += [fr"\subsection*{{\pathtext{{{rel}}}}}",
fr"\lstinputlisting[{escape_option}]{{{display_rel}}}"]
text.append(r"\endgroup")
(data / "source-listings.tex").write_text("\n".join(text) + "\n", "utf-8")
table = [
r"\begin{longtable}{p{1.15cm}p{13.3cm}}",
r"\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\",
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead",
r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endhead",
]
for row in rows:
h = row["package_sha256"]
label = f"Q{row['problem_no']}-{row['formal_index']}"
table += [
fr"{label} & \pathtext{{{Path(row['file']).name}}}\\",
fr"& {{\scriptsize\texttt{{{h[:32]}}}\allowbreak\texttt{{{h[32:]}}}}}\\[4pt]",
]
table += [r"\bottomrule\end{longtable}"]
(data / "formal-file-list.tex").write_text("\n".join(table) + "\n", "utf-8")
SUPPORT_README = """# 论文支撑材料
汇总官方模拟器 test 中的六份正式导出及哈希匹配的官方统计。
问题三、四各包含三次正式测试。支撑材料保留原始文件名及文件校验值。
## 环境和运行
Python >=3.10;安装 requests,可选 numpy 加速。
在本目录使用 PowerShell:
$env:PYTHONPATH = (Resolve-Path src).Path
python -m mcm26b.cli --help
python -m mcm26b.cli run --problem 3 --strategy bayes --robot-id <模拟器登录队号>
python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号>
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
策略的自检命令另需可选 referee 包。
## 重算论文数据和图表
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py
上述命令在本地重算统计表、图表及源码附录。统计快照来自官方正式
统计队列,通过票据哈希匹配所选包。重新验证来源匹配需原电脑的统计队列。
连续覆盖与混合类型推断的扩展见论文未来工作。
## 编译
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回
同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。
support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。
"""
def package(rows: list[dict]) -> Path:
selected = set((ROOT / "src/mcm26b").rglob("*.py"))
selected.update((ROOT / "src/mcm26b/guarantee/plans").glob("*.json"))
selected.update((PAPER / "data").rglob("*"))
selected.update((PAPER / "figures").glob("*.tex"))
selected.update((PAPER / "sections").glob("*.tex"))
selected.update((ROOT / "docs/figures").glob("*.png"))
selected.update((ROOT / "docs/figures").glob("*.json"))
selected.add(ROOT / "docs/figures/README.md")
selected.update(path for path in (ROOT / "project").rglob("*")
if path.is_file() and "__pycache__" not in path.parts)
selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_paper_support.py"])
for row in rows:
path = ROOT / "Jammers-simulator-full" / row["file"]
if digest(path.read_bytes()) != row["package_sha256"]:
raise ValueError("Original formal package changed")
selected.add(path)
blobs = {p.relative_to(ROOT).as_posix(): p.read_bytes() for p in sorted(selected) if p.is_file()}
blobs["README.md"] = SUPPORT_README.encode("utf-8")
blobs["requirements.txt"] = b"requests>=2.31\nnumpy>=1.24\n"
manifest = [{"file": name, "bytes": len(value), "sha256": digest(value)} for name, value in sorted(blobs.items())]
blobs["support-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
out = PAPER / "support-materials.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, value in sorted(blobs.items()):
entry = zipfile.ZipInfo(name, (2026, 9, 13, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, value)
if out.stat().st_size > 20_000_000:
raise ValueError("Support archive exceeds 20 MB")
print(f"Support archive: {len(blobs)} files, {out.stat().st_size} bytes")
return out
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", action="store_true")
args = parser.parse_args()
rows = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
figures(rows)
project_tables()
listings(rows)
if args.package:
package(rows)
if __name__ == "__main__":
main()
@@ -1,21 +0,0 @@
"""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"]
@@ -1,284 +0,0 @@
"""P3 逐频道信念状态:候选集合 K_c 维护与频道处理状态。
核心不变式(验收标准):
- 保守性:真源永远属于维护中的候选集合。候选集用"圆盘外切多边形 (*@\ensuremath{\cap}@*)
测向角域半平面"的凸多边形表示——外切多边形严格包含圆盘,半平面裁剪
带正容差,因此每步更新只会放大而不会缩小真实候选集;
- direction 读数:K_c (*@\ensuremath{\subseteq}@*) Ω (*@\ensuremath{\cap}@*) W(s, (*@\ensuremath{\theta}@*)±(*@\ensuremath{\delta}@*)_eff) (*@\ensuremath{\cap}@*) B(s, 1500),后续读数再交
同样的约束;(*@\ensuremath{\delta}@*)_eff = 误差半角 + 示向度舍入裕量(服务器保留两位小数,
舍入本身引入 (*@\ensuremath{\le}@*)0.005° 偏差);
- no_signal 读数(P4,全向源):基线不据此删空间候选——对定向源它是
"位置×朝向"联合约束,单独维护空间候选会误删真源(P5 才启用);
"该频道不存在源"的证明由覆盖账本(coverage.CoverageLedger)给出;
- near 读数:源在 5m 内,候选集收缩为 B(s, 5),可直接产生清除证书;
- /clear 成功:状态转 CLEARED;失败只说明"该频道 20m 内无可清除源",
不得直接删除超出 20m 的候选,必须先补测再重试。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from enum import Enum
from ..config import ProblemConfig, Vec2
from ..geometry.localization import smallest_enclosing_circle
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
# 半平面裁剪的正容差(米)。坐标量级 ~2100,浮点误差远小于它;
# 容差只向"保留更多候选"的方向倾斜,不破坏保守性。
_CLIP_TOL_M = 1e-6
_DEDUPE_TOL_M = 1e-7
class InconsistentBelief(RuntimeError):
"""读数把候选集剪空等违背保守性的情形;须停机核查而非继续跑。"""
class ChannelStatus(Enum):
UNKNOWN = "unknown" # 尚无有效证据
DETECTED = "detected" # 收到过 direction/near,待定位
LOCATED = "located" # 候选集包围圆半径 <= 20,可清除
CLEARED = "cleared" # 清除成功
ABSENT = "absent" # 覆盖证明不存在(P4)
def circumscribed_polygon(center: Vec2, radius_m: float, sides: int) -> list[Vec2]:
"""圆的外切正多边形顶点(外接半径 r/cos((*@\ensuremath{\pi}@*)/n)),严格包含圆盘。
近似误差(外接圆超出圆盘的部分)为 r(1/cos((*@\ensuremath{\pi}@*)/n) - 1) (*@\ensuremath{\le}@*) r(1-cos((*@\ensuremath{\pi}@*)/n))·sec²,
调用方用 (1-cos((*@\ensuremath{\pi}@*)/n)) 口径记账即可(略保守偏大)。
"""
if sides < 8:
raise ValueError("外切多边形边数过少,近似误差不可控")
circumradius = radius_m / math.cos(math.pi / sides)
step = 2 * math.pi / sides
return [(center[0] + circumradius * math.cos(step * i + step / 2),
center[1] + circumradius * math.sin(step * i + step / 2))
for i in range(sides)]
def polygon_halfplanes(polygon: list[Vec2]) -> list[tuple[float, float, float]]:
"""CCW 多边形 -> 外法线半平面列表 (a, b, c),含义 a·x + b·y <= c。"""
planes = []
count = len(polygon)
for index in range(count):
p, q = polygon[index], polygon[(index + 1) % count]
dx, dy = q[0] - p[0], q[1] - p[1]
norm = math.hypot(dx, dy)
if norm <= 1e-12:
continue
a, b = dy / norm, -dx / norm # CCW 的右法线朝外
planes.append((a, b, a * p[0] + b * p[1]))
return planes
def clip_halfplane(polygon: list[Vec2], a: float, b: float, c: float,
tol: float = _CLIP_TOL_M) -> list[Vec2]:
"""Sutherland–Hodgman 凸多边形半平面裁剪,保留 a·x + b·y <= c + tol。
边界交点取在无容差直线上;进入判定带正容差,保证"贴边"的真点不丢。
"""
if not polygon:
return []
output: list[Vec2] = []
count = len(polygon)
for index in range(count):
p, q = polygon[index], polygon[(index + 1) % count]
fp = a * p[0] + b * p[1] - c
fq = a * q[0] + b * q[1] - c
p_in, q_in = fp <= tol, fq <= tol
if p_in:
output.append(p)
if p_in != q_in:
ratio = fp / (fp - fq)
output.append((p[0] + ratio * (q[0] - p[0]),
p[1] + ratio * (q[1] - p[1])))
return _dedupe(output)
def _dedupe(points: list[Vec2], tol: float = _DEDUPE_TOL_M) -> list[Vec2]:
unique: list[Vec2] = []
for point in points:
if not unique or math.dist(point, unique[-1]) > tol:
unique.append(point)
if len(unique) > 1 and math.dist(unique[0], unique[-1]) <= tol:
unique.pop()
return unique
class CandidateRegion:
"""某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。"""
def __init__(self, polygon: list[Vec2], slack_m: float = 0.0):
self.polygon = list(polygon)
self.slack_m = float(slack_m) # 外包近似引入的半径向误差记账
self._mec: tuple[Vec2, float] | None = None
# ------------------------------------------------------------ 构造
@classmethod
def disk(cls, center: Vec2, radius_m: float, sides: int,
slack_m: float | None = None) -> "CandidateRegion":
if slack_m is None:
slack_m = radius_m * (1 - math.cos(math.pi / sides))
return cls(circumscribed_polygon(center, radius_m, sides), slack_m)
@classmethod
def initial(cls, station: Vec2, config: ProblemConfig) -> "CandidateRegion":
"""首个 direction 后的基础集:Ω (*@\ensuremath{\cap}@*) B(station, r_max) 的保守外包。"""
field_cfg, sides = config.field, config.search.polygon_sides
slack = field_cfg.omega_radius_m * (1 - math.cos(math.pi / sides))
region = cls.disk((0.0, 0.0), field_cfg.omega_radius_m, sides, slack)
region.clip_disk(station, field_cfg.r_eff_max_m, sides)
return region
# ------------------------------------------------------------ 收缩
def _clip(self, a: float, b: float, c: float) -> None:
polygon = clip_halfplane(self.polygon, a, b, c)
if not polygon:
raise InconsistentBelief(
f"候选区域被半平面 ({a:.6f},{b:.6f},{c:.6f}) 剪空:读数与保守集矛盾")
self.polygon = polygon
self._mec = None
def clip_disk(self, center: Vec2, radius_m: float, sides: int) -> None:
"""与圆盘 B(center, radius) 的外切多边形求交(近似误差入账)。"""
for a, b, c in polygon_halfplanes(circumscribed_polygon(center, radius_m, sides)):
self._clip(a, b, c)
self.slack_m += radius_m * (1 - math.cos(math.pi / sides))
def clip_wedge(self, station: Vec2, bearing_deg: float,
half_angle_deg: float) -> None:
"""与测向角域 W(station, bearing±half_angle) 求交(半角须 <90°)。
两个边界半平面与 localization.halfplanes 同口径,0°/360° 跨界由
sin/cos 直接处理,无需分支。
"""
if not 0 < half_angle_deg < 90:
raise ValueError("角域半角须在 (0, 90) 度")
lower = math.radians(bearing_deg - half_angle_deg)
upper = math.radians(bearing_deg + half_angle_deg)
sx, sy = float(station[0]), float(station[1])
self._clip(math.sin(lower), -math.cos(lower),
math.sin(lower) * sx - math.cos(lower) * sy)
self._clip(-math.sin(upper), math.cos(upper),
-math.sin(upper) * sx + math.cos(upper) * sy)
# ------------------------------------------------------------ 查询
def mec(self) -> tuple[Vec2, float]:
"""最小包围圆(圆心, 半径)。半径是真实候选集 (*@\ensuremath{\rho}@*) 的上界。"""
if self._mec is None:
if not self.polygon:
raise InconsistentBelief("候选区域为空")
self._mec = smallest_enclosing_circle(self.polygon)
return self._mec
def witnesses(self, spacing_m: float) -> list[Vec2]:
"""顶点 + 边界按间距细分的采样点(最坏后验评估用)。"""
if len(self.polygon) < 3:
return list(self.polygon)
points = list(self.polygon)
count = len(self.polygon)
for index in range(count):
p, q = self.polygon[index], self.polygon[(index + 1) % count]
length = math.dist(p, q)
steps = int(length // spacing_m)
for k in range(1, steps + 1):
ratio = k / (steps + 1)
points.append((p[0] + ratio * (q[0] - p[0]),
p[1] + ratio * (q[1] - p[1])))
return _dedupe(points, tol=1e-9)
def contains(self, point: Vec2, tol_m: float = 0.05) -> bool:
"""点是否在候选集内(带容差;用于真源保留性检验,不做删点依据)。"""
if not self.polygon:
return False
if len(self.polygon) == 1:
return math.dist(point, self.polygon[0]) <= tol_m + self.slack_m
if len(self.polygon) == 2:
p, q = self.polygon
return _point_segment_distance(point, p, q) <= tol_m + self.slack_m
for index, p in enumerate(self.polygon):
q = self.polygon[(index + 1) % len(self.polygon)]
if (q[0] - p[0]) * (point[1] - p[1]) - (q[1] - p[1]) * (point[0] - p[0]) \
< -tol_m - self.slack_m:
return False
return True
def _point_segment_distance(point: Vec2, p: Vec2, q: Vec2) -> float:
vx, vy = q[0] - p[0], q[1] - p[1]
length_sq = vx * vx + vy * vy
if length_sq <= 1e-18:
return math.dist(point, p)
t = max(0.0, min(1.0, ((point[0] - p[0]) * vx + (point[1] - p[1]) * vy) / length_sq))
return math.dist(point, (p[0] + t * vx, p[1] + t * vy))
@dataclass
class ChannelBelief:
channel: int
status: ChannelStatus = ChannelStatus.UNKNOWN
region: CandidateRegion | None = None
measurements: list = field(default_factory=list) # 逐步观测记录(审计用)
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)}
@property
def effective_half_angle_deg(self) -> float:
"""示向度误差半角 + 舍入裕量:服务器保留两位小数引入 (*@\ensuremath{\le}@*)0.005° 偏差。"""
return (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
def update_measure(self, channel: int, position: Vec2, result: str,
svd_deg: float | None) -> None:
"""按读数收敛候选集合;真源保留性由保守表示保证。"""
belief = self.channels[channel]
belief.measurements.append({"position": tuple(position), "result": result,
"svd_deg": svd_deg})
if result == DIRECTION:
if svd_deg is None:
raise ValueError("direction 读数必须携带 svd_deg")
sides = self.config.search.polygon_sides
if belief.region is None:
belief.region = CandidateRegion.initial(position, self.config)
else:
# 后续 direction 同样隐含距离 <= r_max [题面]
belief.region.clip_disk(position, self.config.field.r_eff_max_m, sides)
belief.region.clip_wedge(position, svd_deg, self.effective_half_angle_deg)
belief.status = ChannelStatus.DETECTED
elif result == NEAR:
sides = self.config.search.polygon_sides
if belief.region is None:
belief.region = CandidateRegion.disk(
tuple(position), self.config.field.near_radius_m, sides)
else:
belief.region.clip_disk(position, self.config.field.near_radius_m, sides)
belief.status = ChannelStatus.DETECTED
elif result == NO_SIGNAL:
# P3/P4 基线:不据此删空间候选(缺席证明由覆盖账本给出)。
pass
else:
raise ValueError(f"未知测向结果: {result!r}")
def candidate_set(self, channel: int) -> CandidateRegion | None:
"""该频道当前候选集合(保守外包形式);尚无 direction/near 时为 None。"""
return self.channels[channel].region
def mark_cleared(self, channel: int) -> None:
self.channels[channel].status = ChannelStatus.CLEARED
def mark_absent(self, channel: int) -> None:
self.channels[channel].status = ChannelStatus.ABSENT
def channels_by_status(self, *statuses: ChannelStatus) -> list[int]:
return sorted(channel for channel, belief in self.channels.items()
if belief.status in statuses)
@@ -1,388 +0,0 @@
"""位置×朝向联合可行集(第四问定向源)。
现状问题(docs/第三四问架构优化与运行提速方案.md §1.3 F3):第四问的失败**全部**
来自定向源(评测机两局:全向 17/17 清除,定向仅 2/6)。原因不是覆盖不足,而是
策略只知道"源可能在某个空间区域",不知道"它朝着哪边"——于是选点只能靠固定
45° 阶梯试探,一旦试探失败就退化到 (*@\ensuremath{\le}@*)400 格 20 m 盲清。
本模块把"看不看得见"变成可推理的量:为已在 Ω 上维护的网格幸存集
(:class:`~mcm26b.particles.ChannelParticles`)附加每格 24 档朝向可行性掩码,
得到 (位置 × 朝向) 联合可行集。观测语义严格对齐评测机
``referee.simulator._observe``:
- 距离 > r_eff → no_signal;定向且源→读点方向与朝向夹角 > 90° → no_signal;
- 距离 (*@\ensuremath{\le}@*) 5 m → near(**仍需可见**:3 m 处背向的定向源同样返回 no_signal);
- 其余 → direction(读数 = 源→读点方位 + 有界误差)。
更新规则(全部保守,只删"必然不可能"的格/档):
=============== ============================================ ==================
观测 位置 朝向
=============== ============================================ ==================
``direction``@s 距离 (*@\ensuremath{\le}@*) 1500、角向 |gap| (*@\ensuremath{\le}@*) (*@\ensuremath{\delta}@*)_eff + 格裕量 必须**可能**覆盖 s
``near``@s 距离 (*@\ensuremath{\le}@*) 5 必须**可能**覆盖 s
``no_signal``@s **不删**(可能是朝向遮挡或距离不足) 必须**确定不**覆盖 s
(仅当整格必在 r_eff_min 内才约束朝向)
=============== ============================================ ==================
阈值全部取保守侧,并计入**格内角裕量**
``asin(margin / max(dist − margin, 1))``(格内任一点相对格心方位偏差上界):
- "必须可能覆盖":``gap (*@\ensuremath{\le}@*) 90° + 半档 + 查表舍入 + 格内角裕量``(宁可保留);
- "必须确定不覆盖":先删去 ``gap (*@\ensuremath{\le}@*) 90° − 半档 − 查表舍入 − 格内角裕量`` 的档,
只保留确定看不见 s 的档(同样宁可保留)。
因此真源 (位置, 朝向) 永不被删——这是网格幸存集真源保留定理的二维推广。
(2026-09-12 修正:no_signal 分支原先误用"确定覆盖"掩码做交集,等价于把
规则反向;keep=True 分支也漏计了格内角裕量。两处都会删掉真源朝向档,
现由 ``tests/test_joint_belief.py`` 的随机观测序列保留性测试固化。)
用途:
1. ``prob_detect``:该点能看见该频道任一可行 (位置, 朝向) 的概率质量,
用于**可见性选点**(取代固定阶梯)——只影响选点顺序,不承担安全责任;
2. ``expected_posterior_radius``:一次测量后的期望后验 MEC 半径(D1 值函数);
3. ``is_absent``:联合集空 (*@\ensuremath{\Longrightarrow}@*) 该频道不存在源(严格,与覆盖证明同一条件);
4. 位置边际仍由凸证书负责 20 m 清除判定,本模块不改变任何证书口径。
"""
from __future__ import annotations
import math
from ..config import ProblemConfig, Vec2
from ..particles import ChannelParticles, _branch_radius_array
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
HEADING_BINS = 24 # 15° 一档
_BIN_HALF_DEG = 180.0 / HEADING_BINS # 7.5°
_FULL_MASK = (1 << HEADING_BINS) - 1
_GRID_DEG = 3.0 # 角度查表分辨率
_GRID_N = int(round(360.0 / _GRID_DEG))
try: # numpy 仅作可选加速后端(与 particles 一致)
import numpy as _np
from ..particles import _np as _particles_np
if _particles_np is None: # MCM26B_NO_NUMPY 或缺失:强制纯 Python
_np = None
except Exception: # pragma: no cover - 环境无 numpy
_np = None
def heading_gap(a_deg: float, b_deg: float) -> float:
"""两个朝向角的最小夹角(度)。"""
return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)
def _cover_mask(bearing_deg: float, half_deg: float) -> int:
"""与该方位夹角不超过 ``half_deg`` 的朝向档掩码。"""
mask = 0
for k in range(HEADING_BINS):
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) <= half_deg:
mask |= 1 << k
return mask
# 查表:以 bearing 为轴、半角 90° 的可见半平面。
# - COVER:**可能**覆盖该方位(含半档与查表舍入的保守侧);
# - SILENT:**确定**覆盖该方位(内缩半档与舍入)——离散对照表,供审计与
# 文档比对;实际裁剪走 _cover_mask / _far_mask,因为还必须逐格计入
# "格内角裕量"(格心到格内任一点的方位偏差上界),无法预先制表。
_COVER_TABLE = tuple(
_cover_mask(_GRID_DEG * i, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2)
for i in range(_GRID_N))
_SILENT_TABLE = tuple(
_cover_mask(_GRID_DEG * i, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2)
for i in range(_GRID_N))
def _far_mask(bearing_deg: float, exclude_half_deg: float) -> int:
"""与方位夹角**大于** ``exclude_half_deg`` 的朝向档掩码(no_signal 用)。
与 :func:`_cover_mask` 互补:no_signal 只允许保留"确定看不见"的朝向档。
"""
mask = 0
for k in range(HEADING_BINS):
if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) > exclude_half_deg:
mask |= 1 << k
return mask
if _np is not None: # 12 位半字 popcount 查表(朝向掩码 24 位)与半平面查表数组
_POP16 = _np.array([bin(value).count("1") for value in range(1 << 12)],
dtype=_np.int64)
_COVER_ARR = _np.array(_COVER_TABLE, dtype=_np.int64)
else: # pragma: no cover - 无 numpy 分支
_POP16 = _COVER_ARR = None
def _popcount(mask: int) -> int:
return bin(mask).count("1")
def _table_index(bearing_deg: float) -> int:
return int(round((bearing_deg % 360.0) / _GRID_DEG)) % _GRID_N
class JointBelief:
"""单频道 (位置 × 朝向) 联合可行集。"""
def __init__(self, config: ProblemConfig = ProblemConfig()):
self.config = config
self.field = config.field
self.position = ChannelParticles(config)
self.masks: dict[int, int] = {} # 格索引 -> 朝向掩码(缺省为全档)
self._sample_cache: tuple[int, list[int]] | None = None
# ------------------------------------------------------------ 查询
def __len__(self) -> int:
return len(self.position)
def is_absent(self) -> bool:
"""联合集空 (*@\ensuremath{\Longrightarrow}@*) 该频道不存在源(真源保留定理的二维推广)。"""
return not self.position.alive
def _mask(self, index: int) -> int:
return self.masks.get(index, _FULL_MASK)
def sample(self, size: int = 256) -> list[int]:
"""均匀抽样幸存格(评分用;证书始终用完整集合)。"""
cached = self._sample_cache
if cached is not None and cached[0] == self.position.version:
return cached[1]
alive = sorted(self.position.alive)
if len(alive) > size:
step = len(alive) / size
alive = [alive[int(k * step) % len(alive)] for k in range(size)]
self._sample_cache = (self.position.version, alive)
return alive
def _mask_popcount(self, index: int) -> int:
return _popcount(self._mask(index))
def expected_posterior_radius(self, reader: Vec2, size: int | None = None) -> float:
"""一次测量后的期望后验 MEC 半径(米);D1 值函数。
与 :meth:`ChannelParticles.expected_posterior_radius` 同族,差别只在
no_signal 分支概率:定向源不仅会因距离 > r_eff 静默,还会因朝向遮挡
静默,因此
p_vis(p) = P(r_eff (*@\ensuremath{\ge}@*) |p−q|) × cover_ratio(p, q)
其中 ``cover_ratio`` 是该格朝向掩码中"可能覆盖 q 方位"的档位占比。
分支半径语义:
- direction(可见,非 near):读数为 q 处的方位 ±(*@\ensuremath{\delta}@*),后验收缩到该方位
楔形与当前可行集之交,半径取幸存子采样的质心最大距离;
- no_signal:定向源的静默**不删空间候选**,位置后验半径保持当前值;
- near:后验为 5m 盘。
返回值只用于**选点排序**,不参与任何安全判定;证书仍由凸/网格口径给出。
"""
pf = self.position
if not pf.alive:
return 0.0
if size is None:
size = self.config.bayes.score_subsample
indices = self.sample(max(2, size))
if not indices:
return 0.0
stride = max(len(indices) // max(self.config.bayes.mc_representatives, 1), 1)
representatives = indices[::stride]
if _np is not None:
return self._epr_vector(reader, indices, representatives)
return self._epr_python(reader, indices, representatives) # pragma: no cover
def _epr_vector(self, reader: Vec2, indices: list[int],
representatives: list[int]) -> float:
pf = self.position
points = _np.array([pf.cell_center(index) for index in indices], dtype=float)
masks = _np.array([self._mask(index) for index in indices], dtype=_np.int64)
reps = _np.array([pf.cell_center(index) for index in representatives],
dtype=float)
rep_masks = _np.array([self._mask(index) for index in representatives],
dtype=_np.int64)
dx, dy = points[:, 0] - reader[0], points[:, 1] - reader[1]
dist = _np.hypot(dx, dy)
bearing = _np.degrees(_np.arctan2(dy, dx))
rdx, rdy = reps[:, 0] - reader[0], reps[:, 1] - reader[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
cover = _COVER_ARR[_np.round(_np.mod(rbearing, 360.0) / _GRID_DEG)
.astype(_np.int64) % _GRID_N]
visible_mask = rep_masks & cover
bits = (_POP16[visible_mask & 0xFFF]
+ _POP16[(visible_mask >> 12) & 0xFFF])
cover_ratio = bits / float(HEADING_BINS)
p_silent_range = _np.clip((rdist - self.field.r_eff_min_m)
/ (self.field.r_eff_max_m
- self.field.r_eff_min_m), 0.0, 1.0)
p_vis = (1.0 - p_silent_range) * cover_ratio
p_ns = 1.0 - p_vis
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
in_wedge = ((gaps <= self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg + 1e-9)
& (dist[None, :] <= self.field.r_eff_max_m + pf.margin_m))
eps_radius = pf.spacing_m / 2.0
radii = []
for row in in_wedge:
member = points[row]
radii.append(_branch_radius_array(member)
if len(member) else eps_radius)
radii = _np.array(radii, dtype=float)
current = pf.mec()[1]
near = rdist <= self.field.near_radius_m + pf.margin_m
values = _np.where(near, self.field.near_radius_m + pf.margin_m,
p_ns * current + p_vis * radii)
return float(values.mean())
def _epr_python(self, reader: Vec2, indices: list[int],
representatives: list[int]) -> float: # pragma: no cover
"""纯 Python 回退(与向量版逐分支同语义,供无 numpy 环境与对拍)。"""
pf = self.position
points = [pf.cell_center(index) for index in indices]
masks = [self._mask(index) for index in indices]
eps_radius = pf.spacing_m / 2.0
current = pf.mec()[1]
half_angle = (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
total = 0.0
for rep_index in representatives:
rx, ry = pf.cell_center(rep_index)
distance = math.hypot(rx - reader[0], ry - reader[1])
if distance <= self.field.near_radius_m + pf.margin_m:
total += self.field.near_radius_m + pf.margin_m
continue
reading = math.degrees(math.atan2(ry - reader[1], rx - reader[0]))
cover = _COVER_TABLE[_table_index(reading)]
bits = _popcount(self._mask(rep_index) & cover)
ratio = bits / float(HEADING_BINS)
if distance <= self.field.r_eff_min_m:
p_range = 1.0
elif distance >= self.field.r_eff_max_m:
p_range = 0.0
else:
p_range = ((self.field.r_eff_max_m - distance)
/ (self.field.r_eff_max_m - self.field.r_eff_min_m))
p_vis = p_range * ratio
members = []
for point, mask in zip(points, masks):
if math.dist(point, reader) > self.field.r_eff_max_m + pf.margin_m:
continue
bearing = math.degrees(math.atan2(point[1] - reader[1],
point[0] - reader[0]))
if heading_gap(bearing, reading) <= half_angle + 1e-9:
members.append(point)
if members:
mx = sum(p[0] for p in members) / len(members)
my = sum(p[1] for p in members) / len(members)
radius = max(math.hypot(p[0] - mx, p[1] - my) for p in members)
else:
radius = eps_radius
total += (1.0 - p_vis) * current + p_vis * radius
return total / max(len(representatives), 1)
def prob_detect(self, reader: Vec2, size: int = 256) -> float:
"""在 ``reader`` 处能看见任一可行 (位置, 朝向) 的概率质量(启发式)。
r_eff ~ U[1000, 1500] 解析积分;朝向按可行档位占比计权。
只用于选点排序,不参与任何安全判定。
"""
cells = self.sample(size)
if not cells:
return 0.0
total = 0.0
span = self.field.r_eff_max_m - self.field.r_eff_min_m
for index in cells:
gx, gy = self.position.cell_center(index)
distance = math.hypot(reader[0] - gx, reader[1] - gy)
if distance > self.field.r_eff_max_m:
continue
if distance <= self.field.r_eff_min_m:
p_range = 1.0
else:
p_range = (self.field.r_eff_max_m - distance) / span
if p_range <= 0.0:
continue
bearing = math.degrees(math.atan2(reader[1] - gy, reader[0] - gx))
cover = _COVER_TABLE[_table_index(bearing)]
bits = bin(self._mask(index) & cover).count("1")
if bits:
total += p_range * bits / HEADING_BINS
return total / len(cells)
# ------------------------------------------------------------ 观测更新
def _apply_heading(self, station: Vec2, keep: bool,
within_m: float | None = None) -> None:
"""按可见性约束裁剪朝向档(保守:只删"必然不可能"的档)。
``keep=True``(direction / near)(*@\ensuremath{\Longrightarrow}@*) 朝向必须**可能**覆盖 station:
保留与方位夹角 (*@\ensuremath{\le}@*) 90° + 半档 + 查表舍入 + 格内角裕量的档。
``keep=False``(no_signal 且整格必在 r_eff_min 内)(*@\ensuremath{\Longrightarrow}@*) 朝向必须
**确定不覆盖** station:只保留与方位夹角 > 90° − 半档 − 查表舍入
− 格内角裕量的档。
``格内角裕量 = asin(margin / max(dist − margin, 1))``:格内任一点
相对格心的方位偏差上界。两个方向都必须计入它,否则会误删真源的
朝向档(keep=True 漏计)或保留必然可见的档(keep=False 漏计)。
"""
margin_m = self.position.margin_m
doomed: list[int] = []
for index in self.position.alive:
gx, gy = self.position.cell_center(index)
dx, dy = station[0] - gx, station[1] - gy
distance = math.hypot(dx, dy)
if within_m is not None and distance > within_m:
continue
bearing = math.degrees(math.atan2(dy, dx))
if distance <= margin_m + 1.0:
# 格贴测点:格内方位可指向任意方向,任何一侧都不可据此裁剪
continue
angular_margin = math.degrees(
math.asin(min(0.9999, margin_m / (distance - margin_m))))
if keep:
updated = self._mask(index) & _cover_mask(
bearing, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2 + angular_margin)
else:
updated = self._mask(index) & _far_mask(
bearing, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2 - angular_margin)
if updated == 0:
doomed.append(index)
self.masks.pop(index, None)
elif updated != _FULL_MASK:
self.masks[index] = updated
if doomed:
self.position.discard(doomed)
def apply(self, station: Vec2, result: str,
svd_deg: float | None = None) -> None:
"""按读数更新联合可行集(保守,真源 (位置, 朝向) 永不被删)。"""
if result == DIRECTION:
self.position.apply_measure(station, DIRECTION, svd_deg,
directional=True)
self._apply_heading(station, keep=True)
elif result == NEAR:
# near 同样要求可见(评测机先判朝向再判距离)
self.position.apply_measure(station, NEAR, directional=True)
self._apply_heading(station, keep=True)
elif result == NO_SIGNAL:
# 距离阈值必须取**保守侧**:只有"整格都必然落在 r_eff_min 圆盘内"
# 时,no_signal 才一定由朝向遮挡解释。格心到格内任一点 (*@\ensuremath{\le}@*) margin,
# 故阈值取 r_eff_min − margin;取 + margin 会把 1000~1010m 的模糊带
# 也当成"必须不覆盖",从而可能删掉真源的朝向档(破坏真源保留性)。
self._apply_heading(station, keep=False,
within_m=max(
0.0, self.field.r_eff_min_m
- self.position.margin_m))
else:
raise ValueError(f"未知测向结果: {result!r}")
# ------------------------------------------------------------ 诊断
def summary(self) -> dict:
"""联合集规模快照(写入策略 summary,供审计与论文复算)。"""
cells = self.sample(256)
bins = [bin(self._mask(index)).count("1") for index in cells]
return {"cells": len(self.position),
"sampled_mean_heading_bins": (round(sum(bins) / len(bins), 2)
if bins else 0.0),
"heading_bins_total": HEADING_BINS}
@@ -1,374 +0,0 @@
"""统一命令行入口:mcm26b <子命令>。
- q1 问题一角域交会求解(P2 已完工)
- sim-check 用评测机复现附件计时示例(P0 自检)
- run 闭环:发现—定位—清除—退出(--problem 3 全向 / 4 混合源;
--self-test 走独立评测机 referee)
解耦约定:运行时代码零导入评测机;referee/harness 仅在 --self-test 与
sim-check 分支内懒加载(测试侧 dev 依赖)。
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import threading
import time
from dataclasses import replace
from pathlib import Path
from .config import ProblemConfig
from .geometry.__main__ import main as q1_main
from .planner import DirectionalExtension, OmnidirectionalLoop
from .planner.bayes_loop import BayesLoop
from .planner.seven import SevenBatch
from .protocol.client import SimulatorClient
from .protocol.log import SessionLog
from .protocol.rules import EXIT_USER
Q3_STRATEGIES = {"grid": OmnidirectionalLoop, "seven": SevenBatch, "bayes": BayesLoop}
def _load_referee():
"""懒加载独立评测机与测试侧适配层(运行时/正式对接不触发)。"""
from .harness import (LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
from referee import LocalSimulator
return (LocalSimulator, LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
def _referee_missing():
print("未安装独立评测机包 referee(mcm26b-referee)。请先 `uv sync` 安装"
" dev 依赖,或 pip install mcm26b-referee;正式对接官方模拟器无需本包。",
file=sys.stderr)
return 2
# ---------------------------------------------------------------- 一键本地评测
def _port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _any_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _acquire_port(name: str, requested: int) -> int:
"""端口被占时积极询问:交互终端逐次询问(回车=自动改空闲端口),
非交互环境(脚本/CI)直接改用空闲端口并告知,绝不静默失败。"""
if _port_free(requested):
return requested
if sys.stdin.isatty():
while True:
auto = _any_free_port()
try:
answer = input(f"[一键评测] {name}端口 {requested} 已被占用:"
f"回车改用空闲端口 {auto},或输入其他端口:").strip()
except (EOFError, KeyboardInterrupt):
return auto
if not answer:
return auto
if answer.isdigit() and 1024 <= int(answer) <= 65535:
if _port_free(int(answer)):
return int(answer)
print(f"端口 {answer} 同样被占用,请再试。")
continue
print("请输入 1024..65535 的端口号,或直接回车让评测机自选。")
auto = _any_free_port()
print(f"[一键评测] {name}端口 {requested} 已被占用(非交互环境),"
f"自动改用空闲端口 {auto}。", file=sys.stderr)
return auto
def _start_local_judge(args):
"""启动独立评测机(机器狗接口 + WebUI 回放)并确认开始一局演练。
返回 (judge, session, action_port, ui_port);失败返回 None(已打印原因)。
"""
try:
from referee import Referee
from referee.server import ActionInterfaceServer
from referee.webui import WebUIServer
except ImportError:
_referee_missing()
return None
action_port = _acquire_port("机器狗接口", args.judge_port)
ui_port = None
judge = Referee(action_port=action_port, data_dir=Path(args.judge_data_dir))
ActionInterfaceServer(judge)
if not args.judge_no_ui:
ui_port = _acquire_port("WebUI", args.judge_ui_port)
ui = WebUIServer(judge, ui_port)
threading.Thread(target=ui.serve_forever, daemon=True,
name="mcm26b-local-judge-ui").start()
module = f"q{args.problem}_practice"
session = judge.start(module, args.robot_id, seed=args.seed,
countdown_s=args.judge_countdown_s)
deadline = time.monotonic() + session.countdown_s + 10
while session.phase == "countdown" and time.monotonic() < deadline:
time.sleep(0.05)
if session.phase != "open":
print("[一键评测] 评测机未能开放接口,已中止。", file=sys.stderr)
judge.abort()
return None
print(f"[一键评测] 案例编码 {session.case_code} · {session.module.title}"
f" · 接口 http://127.0.0.1:{action_port}")
if ui_port is not None:
print(f"[一键评测] WebUI http://127.0.0.1:{ui_port}"
f"(实时界面与\"回放\"Tab 逐步回看,数据目录 {args.judge_data_dir})")
return judge, session, action_port, ui_port
def _settle_local_judge(judge, timeout_s: float = 3.0) -> None:
"""等看门狗确认本局结束(/exit 后毫秒级);仅超时才兜底手工中止。"""
deadline = time.monotonic() + timeout_s
while judge.session.phase != "finished" and time.monotonic() < deadline:
time.sleep(0.05)
if judge.session.phase != "finished":
judge.abort()
def _finish_local_judge(judge, session, summary: dict) -> None:
"""等待评测机确认结束,把结束原因、真值统计与回放入口并入 summary。"""
_settle_local_judge(judge, timeout_s=10.0)
state = judge.snapshot()
truth_channels = sorted(s.channel for s in session.sources)
cleared = sorted(summary.get("cleared_channels", []))
summary["local_judge"] = {
"case_code": session.case_code,
"module": session.module.title,
"end_reason": state.get("end_reason"),
"end_reason_text": state.get("end_reason_text"),
"virtual_time_s": state.get("virtual_time_s"),
"request_count": state.get("request_count"),
"truth": {"source_channels": truth_channels,
"source_count": len(truth_channels),
"omni_count": sum(1 for s in session.sources if not s.directional),
"directional_count": sum(1 for s in session.sources if s.directional)},
"all_cleared": cleared == truth_channels,
"log": state.get("log"),
}
def _sim_check(args) -> int:
"""附件1表2/附件2第10节计时示例:期望 105/111/194/199 秒。"""
try:
local_simulator, local_sim_transport, *_ = _load_referee()
except ImportError:
return _referee_missing()
simulator = local_simulator([], robot_id=args.robot_id)
client = SimulatorClient(args.robot_id, transport=local_sim_transport(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:
"""问题3/4闭环。
三种对接方式:--local-judge 一键本地评测(自动起 referee 并对接);
--self-test 内存回环自检(不占端口);默认对接 --base-url 的模拟器
(官方或本地 referee)。真值只存在于测试端入口,策略端仅见响应。
实机预算:--self-test 时评测机按 --real-budget-s(默认 1800s)返回
remaining_real_duration_s;其余方式以服务器 /enter 返回为准(题面
程序运行上限 20 分钟,不可延长)。
"""
config = ProblemConfig()
session = replace(config.session,
base_url=args.base_url or config.session.base_url)
config = replace(config,
session=session,
search=replace(config.search,
bearing_rounding_margin_deg=args.bearing_margin_deg,
persistent_search=args.persistent))
if args.persistent:
# 历史教训:一次官方演练在 persistent 复扫下把 98.84% 的虚拟时间
# (346170 s)花在空复扫上。这里给出单轮上界,明确这是"扫到限时"模式。
stations = 49 if args.problem == 4 else 7
per_round = config.field.channel_max * stations * (
config.cost.measure_s + config.cost.switch_s)
print(f"[警告] --persistent:找全后仍复扫未清除频道直到限时。"
f"单轮上界约 {per_round:.0f}s 虚拟时间"
f"({config.field.channel_max} 频道 × {stations} 站点 × "
f"{config.cost.measure_s + config.cost.switch_s:.0f}s),"
f"且会持续多轮;成绩口径请保持默认(关闭)。", file=sys.stderr)
truth = None
transport = None
real_budget_s = None
judge = judge_session = None
if args.local_judge:
if args.self_test:
print("--local-judge 与 --self-test 不能同时使用。", file=sys.stderr)
return 2
if args.base_url:
print("--local-judge 忽略 --base-url(接口地址由评测机自动分配)。",
file=sys.stderr)
started = _start_local_judge(args)
if started is None:
return 2
judge, judge_session, action_port, _ui_port = started
config = replace(config, session=replace(
config.session, base_url=f"http://127.0.0.1:{action_port}"))
elif args.self_test:
try:
(local_simulator, local_sim_transport,
mixed_scene, random_omni_scene, referee_spec) = _load_referee()
except ImportError:
return _referee_missing()
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, config=config)
else:
sources = random_omni_scene(seed=args.seed, count=args.sources,
config=config)
truth = sorted(source.channel for source in sources)
simulator = local_simulator(sources, robot_id=args.robot_id,
spec=referee_spec(config, max_real_duration_s=real_budget_s),
stress_mode=True)
transport = local_sim_transport(simulator)
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)
if args.problem == 4:
strategy = "directional"
loop_cls = DirectionalExtension
else:
strategy = args.strategy
loop_cls = Q3_STRATEGIES[strategy]
try:
summary = loop_cls(client, config).run()
finally:
if judge is not None:
_settle_local_judge(judge)
summary["robot_id"] = args.robot_id
return _finalize_run(args, summary, strategy, truth=truth, judge=judge,
judge_session=judge_session, real_budget_s=real_budget_s)
def _finalize_run(args, summary, strategy, *, truth, judge, judge_session,
real_budget_s) -> int:
"""补齐 summary 元信息、核对真值并输出;返回进程退出码。"""
summary["strategy"] = strategy
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
all_cleared = None
if truth is not None:
# 仅测试端核对:清除集合是否与真值一致。
all_cleared = sorted(summary["cleared_channels"]) == truth
summary["truth"] = {"source_channels": truth,
"source_count": len(truth),
"all_cleared": all_cleared}
if judge is not None:
_finish_local_judge(judge, judge_session, summary)
all_cleared = summary["local_judge"]["all_cleared"]
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if summary["completed"] and (all_cleared is not False) else 1
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="闭环:发现—定位—清除—退出(问题3/4)")
run.add_argument("--robot-id", required=True, help="登录参赛队号,逐字节一致")
run.add_argument("--base-url", default=None,
help="模拟器接口地址(默认取配置 http://127.0.0.1:2026)")
run.add_argument("--log", default=None, help="会话日志 JSONL 输出路径")
run.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="问题3=全向源,问题4=混合定向源(默认 3)")
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES), default="bayes",
help="问题3策略:bayes=粒子滤波+信息增益+滚动调度(默认,"
"自建消融相对 seven 再降 19.5%%),seven=七点批量,"
"grid=49点网格基线;仅 --problem 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("--persistent", action="store_true",
help="复扫保险模式:找全证明后继续复扫未清除频道直到限时"
"(默认关闭:虚拟时间即成绩口径,找全即 /exit)")
run.add_argument("--local-judge", action="store_true",
help="一键本地评测:自动启动独立评测机 referee(机器狗接口+"
"WebUI 实时/回放),确认开始一局演练并对接跑完整闭环;"
"端口被占时主动询问换端口(非交互环境自动改用空闲端口)")
run.add_argument("--judge-port", type=int, default=2026,
help="一键评测的机器狗接口端口(默认 2026;被占时询问/自选)")
run.add_argument("--judge-ui-port", type=int, default=8026,
help="一键评测的 WebUI 端口(默认 8026;被占时询问/自选)")
run.add_argument("--judge-no-ui", action="store_true",
help="一键评测不启动 WebUI(只保留机器狗接口)")
run.add_argument("--judge-countdown-s", type=float, default=5.0,
help="一键评测的确认开始倒计时秒数(题面默认 5 秒,可缩短加速)")
run.add_argument("--judge-data-dir", default="output/referee",
help="一键评测的会话日志目录(默认 output/referee,供 WebUI 回放)")
run.add_argument("--self-test", action="store_true",
help="走独立评测机 referee 与随机场景(测试端自检,不连官方)")
run.add_argument("--seed", type=int, default=20260910, 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)
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())
@@ -1,213 +0,0 @@
"""全项目参数中心:题面常量、协议限制、会话策略与搜索规划参数。
任何魔法数字只允许在这里出现一次。各模块通过 :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 # [工程] 现实单请求高分位估计,预算核算用
# [工程] 自检/演练实机预算:题面正式测试程序运行上限 1200s 不可延长;
# 自检经 --real-budget-s 覆盖 LocalSimulator 返回的 remaining_real_duration_s,
# 默认 1800s(30 分钟实机),用于验证"限时内持续运行直到找全"的行为。
self_test_real_budget_s: float = 1800.0
@dataclass(frozen=True)
class SearchConfig:
"""搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。"""
grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)(*@\ensuremath{\approx}@*)989.95<=1000
grid_half_cells: int = 3 # i,j (*@\ensuremath{\in}@*) [-3,3],49 点覆盖 [-2100,2100]^2 (*@\ensuremath{\supseteq}@*) Ω
fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20
# 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];
# 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。
bearing_rounding_margin_deg: float = 0.005
# ---- P3/P4 在线定位与闭环参数 [工程] ----
polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos((*@\ensuremath{\pi}@*)/192))(*@\ensuremath{\approx}@*)0.24m
witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核
candidate_rings: tuple = (0.0, 0.4, 0.75, 1.0) # 安全接收盘内候选点的半径比例
candidate_angles: int = 12 # 每环候选方向数
clear_margin_m: float = 0.5 # 清除证书的额外工程余量(多边形近似误差之外)
max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限
max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏
# ---- P5 定向扩展参数 [工程] ----
max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算
# (首次选点+对跖复核+2 次环探;达限转后备清除)
fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)
# ---- 覆盖计划(保证层)[工程,2026-09-12 架构优化 P0-2 / S2 硬化] ----
# 第四问发现点集:
# square49 旧的充分条件表(700m 方格,巡回 36.4km);
# q4_mincover_v1 75m/24 档贪心最小集(31 点,巡回 22.9km)——细网格复检
# 在 25m/48 档有 125 个 (p,h) 缺口,是采样过拟合解;
# q4_mincover_v2 **设计裕量**最小集(31 点,r_design=965m、前向角裕量 5°,
# 搜索分辨率 50m/48 档 + 证书分辨率精细剪枝),
# 15m/72 档(325.6 万 (p,h))缺口 0,最坏命中 990m,巡回 23.9km。
# 运行期按同一采样语义复核,未通过即拒绝启动。
q4_coverage_plan: str = "q4_mincover_v2"
verify_coverage_plan: bool = True # 运行期抽样校验(进程内缓存一次)
# ---- 第四问定向源联合信念 [工程,2026-09-12 架构优化 P0-3] ----
# 为已发现的定向频道维护 (位置 × 朝向) 联合可行集:用可见性概率选点,
# 取代固定 45° 阶梯探点;联合集空即给出缺席证明。安全性仍由凸/网格
# 清除证书与覆盖证明负责,本开关只影响选点顺序。
directional_joint_belief: bool = True
# ---- 第四问定向源探点值函数 [工程,2026-09-12 图论与 DP 方案 S3/D1] ----
# 旧评分 ``prob_detect/(移动+6)`` 只问"这点看不看得见源",不问"测完剩多少
# 不确定度",会反复选中同一个近处被遮挡点。D1 口径:先按可见概率过滤
# (只保留 prob_detect (*@\ensuremath{\ge}@*) ratio×最大值的候选),再最小化
# E[后验 MEC 半径](q) + (*@\ensuremath{\lambda}@*)·(当前位置→q→MEC 圆心 的移动秒数 + 检测)
# 9 场景 core 配对(修正联合信念朝向规则之后):
# (*@\ensuremath{\lambda}@*)=0.05:后备 6.8 次 / T 9740 s(值函数主导,为信息多绕路)
# (*@\ensuremath{\lambda}@*)=5 :后备 4.3 次 / T 8604 s(移动项主导,可见性过滤 + 就近抵近)
# 仅移动项(nearest_visible):后备 4.3 次 / T 8602 s,墙钟最低
# 相对旧 visibility 口径(后备 125.6 次、T 9225 s)三者都更好,且 (*@\ensuremath{\lambda}@*) 越大
# 越优说明本题"看得见 + 少走路"比"信息量最大"更重要,故默认取极限口径。
probe_score_mode: str = "nearest_visible" # visibility | expected_radius | nearest_visible
probe_lambda_move_s: float = 5.0 # expected_radius 口径下的移动折算
# 可见概率过滤:只保留 prob_detect (*@\ensuremath{\ge}@*) ratio×最大值的候选再比期望半径,
# 避免"期望上有点信息、实际必被朝向遮挡"的探点。
probe_visibility_ratio: float = 0.7
probe_exclude_blocked: bool = True # 已测过且被遮挡的探点不再重复选取
approach_fractions: tuple = () # D1 抵近点插值比例(0=圆心);默认关闭
# ---- 后备清除路径优化 [工程,2026-09-12 S4/D3] ----
# 实测反例:后备清除在命中真源时立即结束,"整条走格路径最短"并不是正确
# 目标——Or-opt 会把真源推到序列更后面。9 场景配对:最近邻 125.7 次失败
# vs Or-opt 161.4 次(T 8676 → 8940 s)。默认关闭,保留代码与开关。
fallback_optimize_tour: bool = False
# ---- 持续搜索 [工程,2026-09-11 用户决策修订] ----
# 虚拟时间即评测成绩口径(表1:平均定位清除时间):找全证明达成后
# 立即 /exit,不复扫。复扫保险模式(防缺席证明假设偏差)保留,经
# `mcm26b run --persistent` 显式开启:只复扫未清除频道,直到清除数
# 达 16、实机预算耗尽或虚拟安全余量不足。
persistent_search: bool = False
virtual_reserve_s: float = 10_000.0 # 虚拟时长安全余量:剩余不足时停止发起动作,
# 避免触及 100 小时上限被模拟器强制结束
@dataclass(frozen=True)
class BayesConfig:
"""P4b 贝叶斯网格滤波策略参数(问题3新策略 bayes)。[工程]
网格粒子滤波的严格性依赖 Voronoi 裕量:真实源所在格心与源的距离
<= sqrt(2)/2 * particle_spacing_m,所有 kill 判定都内缩该裕量,
因此幸存集永远包含真源(定理见 docs/algo-3-bayes.md §2)。
"""
particle_spacing_m: float = 14.65 # 网格间距;Voronoi 裕量 (*@\ensuremath{\approx}@*) 10.35m;
# 全域约 4.7 万格/频道,查询走粗格索引
# ---- 信息增益评分 [工程,Optuna TPE 调参回填 2026-09-11] ----
# 原调参记录见 output/q3/bayes-tuning/;当前代码的配对验收记录见
# output/q3/optimization-20260911/,随机分布与信息评分仅为工程假设。
score_mode: str = "entropy" # entropy=期望 log 幸存占比(信息增益)|
# radius=期望后验半径(直指证书)
score_subsample: int = 800 # 期望幸存熵估计的粒子子采样规模
mc_representatives: int = 7 # 假设真值代表点数(均匀抽取幸存格)
lambda_move_s: float = 0.05 # 选点评分中移动时间的折算系数(分值/秒);
# 2026-09-12 配对复跑:0.05 优于 0.0105(20 场均值 −2.8%),
# entropy 口径下信息项为 O(1) 对数值,(*@\ensuremath{\lambda}@*)须小型但不可过小
# ---- 路线感知探点 [工程,2026-09-12 P2] ----
# 旧评分只看 current->q + q->MEC,容易选到“信息增益略高但要多绕几百米”
# 的探点。开启后,在候选评分中加入 q 替换当前位置后对当前任务路线代价
# 的增量;A/B 以冻结基准集配对验收。
# 2026-09-12 冻结基准集复跑(18 场景):关闭 3193.1 s vs 开启 3210.3 s,
# 移动 12339.8 m vs 12446.8 m —— 权重 1e-3 的路线项未带来净收益,默认关闭。
route_aware_probe: bool = False
route_aware_insert_weight: float = 0.001 # 路线代价增量的折算系数(小权重,仅微调)
route_aware_clear_weight: float = 0.0 # 额外 q->MEC 清除接近项(基础移动项已含)
wide_rings: tuple = (1.0, 1.5, 2.0) # 无保证接收盘时的探索环(× PF MEC 半径)
# ---- 站点与覆盖 [工程] ----
station_ring_radius_m: float = 1159.7 # 七点环半径;构造时检验全向覆盖解析上界
# ---- 发现-定位融合巡回 [工程,2026-09-12 P0-1,实测无效,默认关闭] ----
# 设想:站点扫频顺带取第二/第三条方位线,取代专用探点。
# 实测(output/suite/q3-fuse-ab*,20 场景配对,2026-09-12):
# 仅追加站点方位线:3454.6s vs 3426.9s(+0.8%,检测 +10.8 次,位移仅 −188m);
# 追加 + 抑制专用探点:4989.0s vs 3426.9s(+45.6%,20/20 场景更慢)。
# 原因:七站环上只有 2–3 个站能对同一源取得有效方位线,且站间交会角
# 不足以保证 20m 证书,专用信息增益探点仍然必要;抑制探点只会白等巡回。
# 代码保留以便复现与后续研究,成绩口径必须保持 False。
fuse_localize_into_tour: bool = False
# ---- 任务排序 [工程] ----
two_opt_enabled: bool = True # 开放路径 2-opt 改进(最近邻打底)
two_opt_passes: int = 4 # 2-opt 最大改进轮数
# ---- 精确任务序(Held-Karp,含换频状态)[工程,2026-09-12 S5/T1] ----
# 任务表典型 6–12 项:|T| (*@\ensuremath{\le}@*) exact_order_limit 时用精确 DP(状态=已访问集合
# × 结束时测向频道),换频代价精确计入;否则回退 NN + 2-opt。0 表示禁用。
# 实测(18 场景):精确 DP 3203.2 s vs 2-opt 3193.1 s —— 逐位最优的"当前
# 任务表"在滚动时域下反而略差(贪心排序的次优性起到探索作用),默认 0;
# 代码与对拍测试保留,可随时开启复核。
exact_order_limit: int = 0
# ---- PF 清除证书 [工程] ----
grid_cert_enabled: bool = True # 允许用幸存集 MEC 颁发清除证书(凸证书之外)
opportunistic_measure: bool = True # 已停靠位置顺路交会;仍逐频道计费
# 预测后验半径至少减少 (1−ratio) 才补测;0.5 优于 0.65(18 场景 3205.4 vs 3210.3 s)
opportunistic_radius_ratio: float = 0.5
@dataclass(frozen=True)
class ProblemConfig:
"""聚合配置。各模块函数的第一个默认参数,便于单测替换。"""
field: FieldConfig = FieldConfig()
cost: CostConfig = CostConfig()
session: SessionConfig = SessionConfig()
search: SearchConfig = SearchConfig()
bayes: BayesConfig = BayesConfig()
DEFAULT_CONFIG = ProblemConfig()
@@ -1,56 +0,0 @@
"""P4 全域覆盖:49 点网格与逐频道覆盖记录。
覆盖基线(推导见 docs/B题建模分析与实施计划.md §4):
S = {(700i, 700j) : i, j (*@\ensuremath{\in}@*) [-3,3]},|S| = 49,覆盖 [-2100,2100]² (*@\ensuremath{\supseteq}@*) Ω,
且允许访问目标圆域外(边界朝外发射的定向源不漏)。
单元内任意点到 4 顶点距离 <= 700·√2 (*@\ensuremath{\approx}@*) 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=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)
@@ -1,85 +0,0 @@
"""评估与统计:从自身动作日志离线复算虚拟时刻与统计量。
"演练统计"验收要求:总时间与分项耗时可独立复算 [题面/计划 §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
virtual_us = 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"]
virtual_us = round(virtual * 1_000_000)
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_us += predicted.total_us
virtual = virtual_us / 1_000_000
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_us += predicted.total_us
virtual = virtual_us / 1_000_000
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}
@@ -1,9 +0,0 @@
"""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"]
@@ -1,40 +0,0 @@
"""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()
@@ -1,289 +0,0 @@
"""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.
Near-parallel plane pairs are intersected instead of skipped (only exactly
parallel pairs are discarded), and the feasibility tolerance scales with the
candidate vertex magnitude, so legitimate far-away vertices of long thin
regions are never silently dropped.
"""
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 _circle_covers(circle, point: Point, tolerance: float) -> bool:
"""Whether the circle already covers point; None means no circle yet."""
if circle is None:
return False
return distance(circle[0], point) <= circle[1] + tolerance
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 not _circle_covers(circle, local[n-1], tolerance):
stack.append((n-1, boundary + (local[n-1],), False))
assert circle is not None
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.
Near-parallel plane pairs are intersected rather than skipped: their
intersection can be a legitimate vertex of a very long thin region
(e.g. stations 1cm apart with bearings 1 and 358.99999999999 deg give a
vertex ~5.7e10 m away). Only exactly-parallel pairs (det == 0) have no
intersection. The feasibility tolerance scales with the vertex magnitude,
since absolute residuals grow with coordinates.
"""
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):
tol = eps * max(1.0, abs(p[0]), abs(p[1]))
return all(a * p[0] + b * p[1] <= c + tol 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 det == 0.0:
continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)
p = ((c * e - b * f) / det, (a * f - c * d) / det)
if isfinite(p[0]) and isfinite(p[1]) and 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
@@ -1,32 +0,0 @@
"""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}
@@ -1,23 +0,0 @@
"""保证层:覆盖计划(CoveragePlan)、证书与运行期校验。
对外只暴露覆盖计划与校验入口;`coverage` 包仍负责逐频道覆盖账本。
"""
from .cover import (CERTIFY_HEADINGS, CERTIFY_STEP_M, OMEGA_RADIUS_M,
R_EFF_MIN_M, CoverageCertificate,
CoveragePlan, PLAN_DIR, VERIFY_HEADINGS, VERIFY_STEP_M,
candidate_lattice, coverage_masks, forward_targets,
greedy_cover, load_plan, nn_tour_length, omega_positions,
plan_instance, plan_names, seven_plan, seven_points,
square_49_plan, verify_full_disk, verify_forward_half_disk,
verify_plan, write_plan)
__all__ = [
"CERTIFY_HEADINGS", "CERTIFY_STEP_M", "OMEGA_RADIUS_M", "R_EFF_MIN_M",
"CoverageCertificate", "CoveragePlan",
"PLAN_DIR", "VERIFY_HEADINGS", "VERIFY_STEP_M", "candidate_lattice",
"coverage_masks", "forward_targets", "greedy_cover", "load_plan",
"nn_tour_length", "omega_positions", "plan_instance", "plan_names",
"seven_plan", "seven_points", "square_49_plan", "verify_full_disk",
"verify_forward_half_disk", "verify_plan", "write_plan",
]
@@ -1,428 +0,0 @@
"""保证层:可离线搜索、可在运行期校验的覆盖计划(CoveragePlan)。
两类覆盖语义(题面约束不同,绝不可混用):
- ``full_disk``(第三问,全向源):在 s 处 no_signal 排除 B(s, r_eff_min),
因此"全点皆 no_signal (*@\ensuremath{\Longrightarrow}@*) 不存在源"要求
``(*@\ensuremath{\cup}@*) B(c, 1000) (*@\ensuremath{\supseteq}@*) Ω``。解析最优解是"原点 + 半径 r 的 6 等分环",
``r`` 的可行区间由 ``validate_seven_coverage`` 给出(本模块提供数值复核)。
- ``forward_half_disk``(第四问,定向源):源可能背向读点,no_signal 不再
排除整个圆盘,缺席证明要求
``(*@\ensuremath{\forall}@*)(p,h) (*@\ensuremath{\in}@*) Ω×S¹, (*@\ensuremath{\exists}@*)c (*@\ensuremath{\in}@*) S: |c−p| (*@\ensuremath{\le}@*) 1000 (*@\ensuremath{\wedge}@*) (c−p)·u(h) (*@\ensuremath{\ge}@*) 0``。
现役 700 m 方格 49 点是充分条件(单元对角 (*@\ensuremath{\le}@*) 1000),但远非必要;
本模块用贪心击中集把点数压到 31,并在同一采样空间上校核。
设计原则(docs/第三四问架构优化与运行提速方案.md §2.3):
**保证 = 离线搜索 + 运行期校验**。点表以 JSON 落盘并携带证书(采样分辨率、
缺口数、最坏命中距离、生成参数哈希),运行期用同一套采样语义重新校核,
未通过即拒绝启动。证书只能用于选点优化,不改变任何安全性证明。
"""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass, field, fields
from functools import lru_cache
from pathlib import Path
from ..config import Vec2
PLAN_DIR = Path(__file__).resolve().parent / "plans"
R_EFF_MIN_M = 1000.0 # [题面] 最小有效接收半径
OMEGA_RADIUS_M = 1800.0 # [题面] 目标圆域半径
SPEED_MPS = 5.0 # [题面] 用于把巡回长度折算成虚拟移动秒数
# 采样分辨率。**校核必须与证书同分辨率**:更粗的采样会放过"删掉一个关键点
# 仍然看似覆盖"的点集,使校核退化为装饰(实测 100 m/12 档即漏判)。
# 75 m / 24 档下一次全域校核约 1 s,且按计划名缓存,每进程只付一次。
CERTIFY_STEP_M = 75.0
CERTIFY_HEADINGS = 24
VERIFY_STEP_M = CERTIFY_STEP_M
VERIFY_HEADINGS = CERTIFY_HEADINGS
@dataclass(frozen=True)
class CoverageCertificate:
"""覆盖证书:采样语义 + 缺口 + 最坏命中距离。"""
kind: str # "full_disk" | "forward_half_disk"
target_step_m: float
headings: int
r_eff_min_m: float
targets: int
uncovered: int
worst_hit_distance_m: float
nn_tour_m: float
generator_sha256: str = ""
# 证书硬化:校核时使用的前向角裕量(度)。与 r_eff_min_m 的设计裕量
# 一起把"采样格覆盖"提升为"连续空间覆盖",见 verify_forward_half_disk。
verify_heading_margin_deg: float = 0.0
@property
def valid(self) -> bool:
return self.uncovered == 0 and self.targets > 0
def as_dict(self) -> dict:
return {"kind": self.kind, "target_step_m": self.target_step_m,
"headings": self.headings, "r_eff_min_m": self.r_eff_min_m,
"targets": self.targets, "uncovered": self.uncovered,
"worst_hit_distance_m": round(self.worst_hit_distance_m, 3),
"nn_tour_m": round(self.nn_tour_m, 1),
"nn_tour_virtual_s": round(self.nn_tour_m / SPEED_MPS, 1),
"generator_sha256": self.generator_sha256,
"verify_heading_margin_deg": self.verify_heading_margin_deg}
@dataclass(frozen=True)
class CoveragePlan:
"""一份覆盖计划:点集 + 语义 + 证书(可为解析证明)。"""
name: str
problem: int
points: tuple[Vec2, ...]
kind: str # "analytic" | "certified"
coverage: str # "full_disk" | "forward_half_disk"
note: str = ""
certificate: CoverageCertificate | None = None
generator: dict = field(default_factory=dict)
def __len__(self) -> int:
return len(self.points)
def as_dict(self) -> dict:
return {"name": self.name, "problem": self.problem, "kind": self.kind,
"coverage": self.coverage, "note": self.note,
"generator": self.generator,
"certificate": (self.certificate.as_dict()
if self.certificate is not None else None),
"points": [[p[0], p[1]] for p in self.points]}
# --------------------------------------------------------------------- 采样
def omega_positions(step_m: float) -> list[Vec2]:
"""Ω 内均匀方格位置采样。"""
span = int(OMEGA_RADIUS_M / step_m) + 1
return [(step_m * i, step_m * j)
for i in range(-span, span + 1) for j in range(-span, span + 1)
if (step_m * i) ** 2 + (step_m * j) ** 2 <= OMEGA_RADIUS_M ** 2]
def forward_targets(step_m: float, headings: int) -> list[tuple[float, float, float]]:
"""(p, h) 目标采样:位置在 Ω 内,朝向均分整圆。"""
return [(x, y, 2.0 * math.pi * k / headings)
for (x, y) in omega_positions(step_m) for k in range(headings)]
def candidate_lattice(spacing_m: float, radius_m: float) -> list[Vec2]:
"""半径 radius_m 圆盘内的方格候选点(离线搜索用)。"""
span = int(radius_m / spacing_m) + 1
return [(spacing_m * i, spacing_m * j)
for i in range(-span, span + 1) for j in range(-span, span + 1)
if (spacing_m * i) ** 2 + (spacing_m * j) ** 2 <= radius_m ** 2]
def _bucket(points: list[Vec2], cell: float) -> dict[tuple[int, int], list[int]]:
table: dict[tuple[int, int], list[int]] = {}
for index, (x, y) in enumerate(points):
table.setdefault((int(x // cell), int(y // cell)), []).append(index)
return table
def coverage_masks(targets, candidates: list[Vec2],
r_eff: float = R_EFF_MIN_M,
heading_margin_deg: float = 0.0) -> list[int]:
"""每个候选点覆盖的目标位掩码(Python 大整数,按位)。
只在与候选点空间桶相邻的格内做距离/前向判定,避免 |T|·|C| 笛卡尔积。
``r_eff`` / ``heading_margin_deg`` 是**设计裕量**:离线搜索时把"命中"
定义得比物理要求更严(距离 (*@\ensuremath{\le}@*) r_eff、前向夹角 (*@\ensuremath{\le}@*) 90°−margin),使采样格
上的覆盖自动蕴含相邻未采样 (p,h) 也被覆盖(S2 证书硬化)。
"""
table = _bucket(candidates, r_eff)
masks = [0] * len(candidates)
sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))
for t_index, (px, py, heading) in enumerate(targets):
ux, uy = math.cos(heading), math.sin(heading)
ci, cj = int(px // r_eff), int(py // r_eff)
bit = 1 << t_index
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = candidates[c_index]
dx, dy = cx - px, cy - py
distance_sq = dx * dx + dy * dy
if distance_sq > r_eff * r_eff:
continue
if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:
continue
masks[c_index] |= bit
return masks
def greedy_cover(masks: list[int], n_targets: int) -> list[int]:
"""贪心集合覆盖:每次取"新增覆盖目标数"最大的候选,返回索引表。"""
full = (1 << n_targets) - 1
covered = 0
chosen: list[int] = []
remaining = list(range(len(masks)))
while covered != full and remaining:
best_index, best_gain = -1, 0
for c_index in remaining:
gain = (masks[c_index] & ~covered).bit_count()
if gain > best_gain:
best_index, best_gain = c_index, gain
if best_index < 0:
break
chosen.append(best_index)
covered |= masks[best_index]
remaining.remove(best_index)
return chosen
def nn_tour_length(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:
"""最近邻开放路径长度(巡回长度上界)。"""
remaining = list(points)
position = start
total = 0.0
while remaining:
nxt = min(remaining, key=lambda q: (q[0] - position[0]) ** 2
+ (q[1] - position[1]) ** 2)
remaining.remove(nxt)
total += math.dist(position, nxt)
position = nxt
return total
# --------------------------------------------------------------------- 校验
def _hit_distance(points: list[Vec2], position: Vec2, heading: float,
r_eff: float) -> float | None:
"""最近的前向命中距离;没有任何前向命中点则返回 None。"""
ux, uy = math.cos(heading), math.sin(heading)
best = None
for (cx, cy) in points:
dx, dy = cx - position[0], cy - position[1]
if dx * dx + dy * dy > r_eff * r_eff:
continue
if dx * ux + dy * uy < -1e-9:
continue
distance = math.hypot(dx, dy)
if best is None or distance < best:
best = distance
return best
def _nearest_distance(points: list[Vec2], position: Vec2,
r_eff: float) -> float | None:
"""最近距离(不要求前向);超过 r_eff 视作未命中。"""
best = None
for (cx, cy) in points:
distance = math.hypot(cx - position[0], cy - position[1])
if distance <= r_eff and (best is None or distance < best):
best = distance
return best
def verify_forward_half_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,
headings: int = VERIFY_HEADINGS,
r_eff: float = R_EFF_MIN_M,
heading_margin_deg: float = 0.0) -> CoverageCertificate:
"""第四问语义:(*@\ensuremath{\forall}@*)(p,h) 存在前向半盘内的被访点。
``heading_margin_deg`` 是**证书硬化**用的前向角裕量:把"命中"收紧为
"前向夹角 (*@\ensuremath{\le}@*) 90° − margin"。与 ``r_eff`` 的设计裕量合用时,采样格上的
覆盖即可推出**连续空间**覆盖:真实 (p',h') 距采样格 (*@\ensuremath{\le}@*) step·√2/2、
方位差 (*@\ensuremath{\le}@*) 180/headings 度,只要
r_eff + step·√2/2 (*@\ensuremath{\le}@*) 1000 且 margin (*@\ensuremath{\ge}@*) 180/headings/2
就能保证 |c−p'| (*@\ensuremath{\le}@*) 1000 且 (c−p')·u(h') (*@\ensuremath{\ge}@*) 0。这正是 S2 取代"粗采样
假通过"的方式。默认 0 表示按物理条件校核。
"""
points = list(points)
targets = forward_targets(step_m, headings)
table = _bucket(points, r_eff)
missing, worst = 0, 0.0
sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))
for (px, py, heading) in targets:
ux, uy = math.cos(heading), math.sin(heading)
ci, cj = int(px // r_eff), int(py // r_eff)
best = None
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = points[c_index]
dx, dy = cx - px, cy - py
distance_sq = dx * dx + dy * dy
if distance_sq > r_eff * r_eff:
continue
if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:
continue
distance = math.sqrt(distance_sq)
if best is None or distance < best:
best = distance
if best is None:
missing += 1
else:
worst = max(worst, best)
return CoverageCertificate("forward_half_disk", step_m, headings, r_eff,
len(targets), missing, worst,
nn_tour_length(points))
def verify_full_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,
r_eff: float = R_EFF_MIN_M) -> CoverageCertificate:
"""第三问语义:Ω 内每点都在某个被访点的 r_eff 圆盘内。"""
points = list(points)
positions = omega_positions(step_m)
table = _bucket(points, r_eff)
missing, worst = 0, 0.0
for (px, py) in positions:
ci, cj = int(px // r_eff), int(py // r_eff)
best = None
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
for c_index in table.get((ci + di, cj + dj), ()):
cx, cy = points[c_index]
distance = math.hypot(cx - px, cy - py)
if distance <= r_eff and (best is None or distance < best):
best = distance
if best is None:
missing += 1
else:
worst = max(worst, best)
return CoverageCertificate("full_disk", step_m, 1, r_eff, len(positions),
missing, worst, nn_tour_length(points))
def verify_plan(plan: CoveragePlan, step_m: float | None = None,
headings: int | None = None) -> CoverageCertificate:
"""按计划声明的覆盖语义校核。
默认使用**证书自带的采样分辨率**(离线 75 m / 24 档):更粗的运行期采样
会放过"删掉一个关键点仍看似覆盖"的点集,使校核失去意义。校核按计划名
缓存(``load_plan`` 的 lru_cache),每进程只付一次成本。
"""
certificate = plan.certificate
if step_m is None:
step_m = certificate.target_step_m if certificate else VERIFY_STEP_M
if headings is None:
headings = (certificate.headings if certificate
else VERIFY_HEADINGS)
if plan.coverage == "forward_half_disk":
# 硬化证书:按证书自带的距离/角度设计裕量重新校核(旧证书默认
# r_eff=1000、margin=0,与历史口径逐位一致)。
return verify_forward_half_disk(
plan.points, step_m, headings,
r_eff=(certificate.r_eff_min_m if certificate else R_EFF_MIN_M),
heading_margin_deg=(certificate.verify_heading_margin_deg
if certificate else 0.0))
return verify_full_disk(plan.points, step_m, certificate.r_eff_min_m
if certificate else R_EFF_MIN_M)
# --------------------------------------------------------------------- 计划
def seven_points(ring_radius_m: float) -> list[Vec2]:
"""解析七站:原点 + 环上 6 等分点。"""
return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),
ring_radius_m * math.sin(k * math.pi / 3))
for k in range(6)]
def seven_plan(ring_radius_m: float) -> CoveragePlan:
"""第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。"""
return CoveragePlan(
name=f"seven-r{ring_radius_m:.1f}", problem=3, kind="analytic",
coverage="full_disk",
note=("解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;"
"r(*@\ensuremath{\in}@*)[1122.9, 1732] 时成立,巡检半径越小巡回越短"),
points=tuple(seven_points(ring_radius_m)))
def square_49_plan() -> CoveragePlan:
"""第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。"""
return CoveragePlan(
name="square49", problem=4, kind="certified",
coverage="forward_half_disk",
note="单元对角 700√2(*@\ensuremath{\approx}@*)989.95(*@\ensuremath{\le}@*)1000 的充分条件;点数为最小命中集的 1.6 倍",
# 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史
# 基线 grid49_immediate 的逐位可复现。
points=tuple((700.0 * i, 700.0 * j)
for j in range(3, -4, -1) for i in range(-3, 4)))
def _load_plan_file(path: Path) -> CoveragePlan:
payload = json.loads(path.read_text(encoding="utf-8"))
raw_cert = payload.get("certificate") or {}
if raw_cert:
# as_dict() 含 nn_tour_virtual_s 等派生列:只取数据类实际字段。
known = {field.name for field in fields(CoverageCertificate)}
certificate = CoverageCertificate(
**{key: value for key, value in raw_cert.items() if key in known})
else:
certificate = None
return CoveragePlan(
name=payload["name"], problem=int(payload["problem"]),
kind=payload.get("kind", "certified"),
coverage=payload.get("coverage", "forward_half_disk"),
note=payload.get("note", ""),
certificate=certificate,
generator=payload.get("generator", {}),
points=tuple((float(x), float(y)) for x, y in payload["points"]))
@lru_cache(maxsize=None)
def load_plan(name: str, verify: bool = True) -> CoveragePlan:
"""按名称加载覆盖计划;``verify`` 为真时做运行期抽样校验(进程内缓存)。"""
path = PLAN_DIR / f"{name}.json"
if not path.exists():
raise FileNotFoundError(f"未找到覆盖计划 {name!r}({path})")
plan = _load_plan_file(path)
if verify and plan.kind == "certified":
certificate = verify_plan(plan)
if not certificate.valid:
raise ValueError(
f"覆盖计划 {name!r} 运行期校验失败:"
f"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖")
plan = CoveragePlan(
name=plan.name, problem=plan.problem, kind=plan.kind,
coverage=plan.coverage, note=plan.note,
generator=plan.generator,
certificate=CoverageCertificate(
kind=certificate.kind, target_step_m=certificate.target_step_m,
headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,
targets=certificate.targets, uncovered=certificate.uncovered,
worst_hit_distance_m=certificate.worst_hit_distance_m,
nn_tour_m=certificate.nn_tour_m,
generator_sha256=(plan.certificate.generator_sha256
if plan.certificate else "")),
points=plan.points)
return plan
def plan_instance(name: str, verify: bool = True) -> CoveragePlan:
"""统一入口:支持内置计划名与磁盘上的证书计划。"""
if name == "square49":
return square_49_plan()
if name == "seven":
return seven_plan(1200.0)
return load_plan(name, verify)
def plan_names() -> list[str]:
"""全部可加载的计划名(含内置)。"""
builtin = ["square49", "seven"]
return builtin + sorted(p.stem for p in PLAN_DIR.glob("*.json"))
def write_plan(plan: CoveragePlan, path: Path) -> None:
"""落盘计划 + 证书(生成器用)。"""
path.parent.mkdir(parents=True, exist_ok=True)
payload = plan.as_dict()
payload["generator"] = dict(payload.get("generator") or {})
payload["sha256"] = hashlib.sha256(
json.dumps(payload["points"]).encode("utf-8")).hexdigest()
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
@@ -1,19 +0,0 @@
"""测试侧脚手架:把独立评测机(referee 包)接入本队传输与测试。
分层边界(与官方模拟器同构):
- 评测机本体在独立包 ``referee``(零 mcm26b 依赖),运行时代码绝不导入它;
- 本包只在测试/自检入口把 referee 的接口边界适配成 :class:`Transport`,
并提供故障注入与官方演练日志回放。
策略端只见 HTTP 响应;真值 Source 只存在于 referee 与测试端。
"""
from .playback import PlaybackTransport
from .scenes import mixed_scene, random_omni_scene, referee_spec
from .simulator import LocalSimulator
from .suites import SceneSpec, suite, suite_manifest
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "SceneSpec", "mixed_scene",
"random_omni_scene", "referee_spec", "suite", "suite_manifest"]
@@ -1,38 +0,0 @@
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
用于 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"))
@@ -1,55 +0,0 @@
"""自检场景生成(测试端专用):委托 referee.scenes,映射本队配置。
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内
均匀分布、频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
均匀分布是测试假设,题面并未规定官方场景的概率分布。
"""
from __future__ import annotations
from referee import scenes as _referee_scenes
from ..config import ProblemConfig
def referee_spec(config: ProblemConfig = ProblemConfig(), *,
max_real_duration_s: float | None = None):
"""把本队 ProblemConfig 的题面常量映射为 referee 的 Spec。
max_real_duration_s 仅用于 --self-test 的实机预算覆盖(默认 1800s);
正式对接以服务器 /enter 返回为准,评测机默认保持题面 1200s。
"""
from referee import CostSpec, FieldSpec, Spec
field, cost = config.field, config.cost
return Spec(
field=FieldSpec(
omega_radius_m=field.omega_radius_m,
channel_min=field.channel_min, channel_max=field.channel_max,
source_count_min=field.source_count_min,
source_count_max=field.source_count_max,
r_eff_min_m=field.r_eff_min_m, r_eff_max_m=field.r_eff_max_m,
near_radius_m=field.near_radius_m, clear_radius_m=field.clear_radius_m,
bearing_error_half_deg=field.bearing_error_half_deg,
bearing_decimals=field.bearing_decimals,
coord_abs_limit_m=field.coord_abs_limit_m,
directional_half_angle_deg=field.directional_half_angle_deg,
max_virtual_duration_s=field.max_virtual_duration_s,
max_real_duration_s=(field.max_real_duration_s if max_real_duration_s is None
else max_real_duration_s),
test_window_s=field.test_window_s),
cost=CostSpec(speed_mps=cost.speed_mps, measure_s=cost.measure_s,
switch_s=cost.switch_s, clear_success_s=cost.clear_success_s,
clear_fail_s=cost.clear_fail_s))
def random_omni_scene(seed: int = 20260910, count: int | None = None,
config: ProblemConfig = ProblemConfig()):
"""全向源场景 [题面 问题3];签名沿用重构前 mcm26b.sim.scenes。"""
return _referee_scenes.random_omni_scene(seed, count, referee_spec(config))
def mixed_scene(seed: int = 20260910, count: int | None = None,
directional: int = 3, config: ProblemConfig = ProblemConfig()):
"""问题4混合场景:count 个源中 directional 个为定向源。"""
return _referee_scenes.mixed_scene(seed, count, directional, referee_spec(config))
@@ -1,21 +0,0 @@
"""兼容构造入口:本队 ProblemConfig -> referee.LocalSimulator。
评测机实现在独立包 referee(config= 换成了 spec=);本子类只做配置映射,
让既有测试与脚本继续以 ``config=ProblemConfig`` 构造,映射规则见
:func:`harness.scenes.referee_spec`。真值类型 Source 亦来自 referee。
"""
from __future__ import annotations
from referee import LocalSimulator as _RefereeLocalSimulator
from ..config import ProblemConfig
from .scenes import referee_spec
class LocalSimulator(_RefereeLocalSimulator):
def __init__(self, sources, *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(), **kwargs):
super().__init__(sources, robot_id=robot_id,
spec=referee_spec(config), **kwargs)
@@ -1,132 +0,0 @@
"""冻结基准集:第三/四问的固定场景清单与真值构造(测试端专用)。
存在意义(docs/第三四问架构优化与运行提速方案.md §1.4 B6 / §P1-3):
历史评估集是 20 个**单源全向**场景,使 `avg_locate_clear_time_s` 恒等于
`virtual_time_s`,也无法支撑任何第四问结论。基准集必须覆盖题面声明的
源数区间与定向源存在性,并且**版本化、可复现、不可随意增删**——
任何"更快"的声明都要在同一份清单上做配对比较。
真值只在本模块与评测机之间流动;策略端只通过 Transport 看到响应。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from referee import Source
from ..config import ProblemConfig
from .scenes import mixed_scene, random_omni_scene
SUITE_VERSION = 1
# 第三问:沿用历史验证集的种子与源数(seeds 100–105 × 10/13/16)
Q3_SEEDS = (100, 101, 102, 103, 104, 105)
Q3_COUNTS = (10, 13, 16)
# 第四问:三种"定向占比"组成(少量 / 约半数 / 多数)
Q4_SEEDS = (200, 201, 202)
Q4_CONFIGS = ((10, 3), (13, 6), (16, 8))
# 误差场种子偏移:与 benchmark_q3_bayes.py 同口径,保证逐点固定误差可复现
ERROR_SEED_BASE = 20260910
@dataclass(frozen=True)
class SceneSpec:
"""一个冻结场景:标识、题号、源数、定向数与种子。"""
scene_id: str
problem: int
kind: str # "random" | "boundary"
seed: int
error_seed: int
count: int
directional: int = 0
def as_dict(self) -> dict:
return {"scene_id": self.scene_id, "problem": self.problem,
"kind": self.kind, "seed": self.seed,
"error_seed": self.error_seed, "count": self.count,
"directional": self.directional}
def sources(self, config: ProblemConfig | None = None) -> list[Source]:
"""构造真值(仅测试端调用)。"""
config = config or ProblemConfig()
if self.kind == "random" and self.problem == 3:
return random_omni_scene(seed=self.seed, count=self.count,
config=config)
if self.kind == "random":
return mixed_scene(seed=self.seed, count=self.count,
directional=self.directional, config=config)
return _boundary_sources(self, config)
def _boundary_sources(spec: SceneSpec, config: ProblemConfig) -> list[Source]:
"""圆周边界压力场景:源全部位于 Ω 边界、r_eff 取下界。
第三问:全向,扇区接缝角度,检验七站覆盖的边界情形;
第四问:全部定向且朝向**背离圆心**(最不利于发现),检验
"朝外发射的边界源不漏"。
"""
field = config.field
radius = field.omega_radius_m
sources: list[Source] = []
for index in range(spec.count):
angle = math.pi / 6 + 2 * math.pi * index / spec.count
x, y = radius * math.cos(angle), radius * math.sin(angle)
directional = index < spec.directional
sources.append(Source(
channel=index + 1, x=x, y=y, r_eff_m=field.r_eff_min_m,
directional=directional,
heading_deg=(math.degrees(angle) % 360.0) if directional else 0.0))
return sources
def core_scenes(problem: int) -> list[SceneSpec]:
"""主体场景:覆盖题面源数区间与定向占比组成。"""
scenes: list[SceneSpec] = []
if problem == 3:
for seed in Q3_SEEDS:
for count in Q3_COUNTS:
scenes.append(SceneSpec(
scene_id=f"q3-core-s{seed}-n{count}", problem=3,
kind="random", seed=seed, error_seed=ERROR_SEED_BASE + seed,
count=count))
else:
for seed in Q4_SEEDS:
for count, directional in Q4_CONFIGS:
scenes.append(SceneSpec(
scene_id=f"q4-core-s{seed}-n{count}-d{directional}",
problem=4, kind="random", seed=seed,
error_seed=ERROR_SEED_BASE + seed, count=count,
directional=directional))
return scenes
def boundary_scenes(problem: int) -> list[SceneSpec]:
"""圆周边界压力场景(每问 2 场:10 源与 16 源)。"""
if problem == 3:
return [SceneSpec(f"q3-boundary-n{count}", 3, "boundary", 0,
ERROR_SEED_BASE, count)
for count in (10, 16)]
return [SceneSpec(f"q4-boundary-n{count}-d{directional}", 4, "boundary", 0,
ERROR_SEED_BASE, count, directional)
for count, directional in ((10, 5), (16, 8))]
def suite(name: str, problem: int) -> list[SceneSpec]:
"""按名称取场景清单:core / boundary / all。"""
if name == "core":
return core_scenes(problem)
if name == "boundary":
return boundary_scenes(problem)
if name == "all":
return core_scenes(problem) + boundary_scenes(problem)
raise ValueError(f"未知基准集 {name!r}(可选 core/boundary/all)")
def suite_manifest(name: str, problem: int) -> dict:
"""清单头:版本、问题、场景表(写入 manifest.json)。"""
scenes = suite(name, problem)
return {"suite_version": SUITE_VERSION, "suite": name, "problem": problem,
"scenes": [scene.as_dict() for scene in scenes]}
@@ -1,62 +0,0 @@
"""传输适配:评测机接口边界 <-> 本队 Transport 接口 + 故障注入脚本。
LocalSimTransport 把 referee 包的 handle_raw(与 HTTP 服务器共用的同一
边界实现)包成内存回环传输,不经过网络栈;故障注入语义与既有测试一致:
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
from urllib.parse import urlsplit
from referee.interface import handle_raw
from ..protocol.transport import Transport, TransportError, TransportResponse
class LocalSimTransport(Transport):
"""内存回环:把独立评测机接到 Transport 接口(与真实 HTTP 同一边界)。"""
def __init__(self, simulator):
# simulator: referee.LocalSimulator(类型注解走字符串,避免导入开销)
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
return self.request("POST", url, body, headers, timeout)
def request(self, method, url, body, headers, _timeout=None):
"""验证原始请求边界;内存回环不模拟 TCP、UI 或连接限流。"""
target = urlsplit(url)
raw_target = target.path + (f"?{target.query}" if target.query else "")
result = handle_raw(self.simulator, method, raw_target,
list(headers.items()), bytes(body),
on_request=lambda path, payload, _res:
self.received.append((path, payload)))
return TransportResponse(result.status, result.body)
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":
body = b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}'
return TransportResponse(int(fault[1]), body)
return self.inner.post(url, body, headers, timeout)
@@ -1,458 +0,0 @@
"""P4b 贝叶斯网格粒子滤波:逐频道幸存集维护与负观测更新。
与 belief.CandidateRegion(保守凸外包,无法表达非凸"挖洞")互补:
网格幸存集精确表达"角域 (*@\ensuremath{\cap}@*) 距离盘 (*@\ensuremath{\cap}@*) 全部 no_signal 排除盘"的非凸可行集,
从而支持三件凸表示做不到的事——
1. 缺席证明:幸存集为空 (*@\ensuremath{\Longrightarrow}@*) 可行集为空 (*@\ensuremath{\Longrightarrow}@*) 该频道无源(严格,无需覆盖账本);
2. 跳测规则:幸存格到测点的最小距离 > r_max + 裕量 (*@\ensuremath{\Longrightarrow}@*) 该次测量必 no_signal,
且不产生任何新排除,可直接跳过(省 5s 检测 + 1s 换频);
3. 负观测更新:no_signal 排除 B(s, 1000),凸表示不删点,网格直接删格。
严格性(真源保留定理,docs/algo-3-bayes.md §2):格距 h 的 Voronoi 裕量
m = sqrt(2)/2·h 是格心到格内任一点的最大距离。所有 kill 判定均内缩 m,
即"仅当整格与可行集不相交时才删格"。真源所在格与可行集之交含真源本身,
故该格永不被删——任意观测序列后幸存集恒包含真源,且:
- 清除证书:MEC(幸存).R + m <= 20 − 工程余量 (*@\ensuremath{\Longrightarrow}@*) 真源距清除点 <= 20;
- 缺席证明:幸存集为空 (*@\ensuremath{\Longrightarrow}@*) 可行集为空。
观测语义与独立评测机 referee.LocalSimulator 一致 [题面]:
- no_signal:距离 > r_eff >= 1000 (*@\ensuremath{\Longrightarrow}@*) 可行集 (*@\ensuremath{\cap}@*) B(s,1000) = (*@\ensuremath{\varnothing}@*);
- direction:距离 <= 1500,示向度误差 |读数−真值| <= (*@\ensuremath{\delta}@*)_eff(含舍入裕量);
- near:距离 <= 5。
性能设计:kill 与查询全部走 O(1) 集合运算;最近距离查询用粗粒度占用
索引(每格聚合 CF² 个细格)螺旋扫描 + 早退,服务跳测规则的高频调用。
"""
from __future__ import annotations
import math
import os
from .config import ProblemConfig, Vec2
from .geometry.localization import smallest_enclosing_circle
from .protocol.rules import DIRECTION, NEAR, NO_SIGNAL
_ANG_EPS_DEG = 1e-9 # 角向 kill 比较的浮点余量
_DIST_EPS_M = 1e-9
_COARSE_FACTOR = 4 # 粗格每边聚合的细格数
try: # numpy 仅作**可选**加速后端;缺失时全部回退纯 Python
import numpy as _np
except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支
_np = None
if os.environ.get("MCM26B_NO_NUMPY"): # 强制纯 Python:对拍与回退验证用
_np = None
def angular_gap(a_deg: float, b_deg: float) -> float:
"""两个方位角(度)的最小夹角。"""
return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)
def as_sample_array(samples):
"""把 [(x,y), ...] 采样转成 (n,2) float64 数组;numpy 缺失时原样返回。
评分为 O(代表点 × 子采样) 的向量运算,是单局 CPU 的主要开销
(实测占 72%)。调用方在每次选点前转换一次,评分函数按类型分派,
两条实现(numpy / 纯 Python)语义逐位对齐,见 tests/test_particles.py。
"""
if _np is None or isinstance(samples, _np.ndarray) or len(samples) == 0:
return samples
return _np.asarray(samples, dtype=float)
def _branch_radius_array(members) -> float:
"""分支后验半径的数组版:到质心的最大距离。"""
centroid = members.mean(axis=0)
return float(_np.max(_np.hypot(members[:, 0] - centroid[0],
members[:, 1] - centroid[1])))
class ChannelParticles:
"""单个频道的网格幸存集。
网格为覆盖 Ω 外扩一格的轴对齐方格(边长 h),索引 ``i*W + j``;
``alive`` 是幸存格索引集合。初始集 = 格心距原点 <= Ω 半径 + 裕量的
全部格子,因此真源(|p| <= 1800 [题面])所在格必在初始集内。
``version`` 随每次删除自增,调用方可据此缓存派生量(MEC、跳测判定)。
"""
def __init__(self, config: ProblemConfig = ProblemConfig(),
spacing_m: float | None = None):
field = config.field
self.config = config
self.spacing_m = float(spacing_m if spacing_m is not None
else config.bayes.particle_spacing_m)
if not 1.0 <= self.spacing_m <= 200.0:
raise ValueError("网格间距须在 [1, 200] 米")
self.margin_m = self.spacing_m * math.sqrt(2) / 2 # Voronoi 裕量
self.r_eff_min_m = field.r_eff_min_m
self.r_eff_max_m = field.r_eff_max_m
self.near_radius_m = field.near_radius_m
self.half_angle_deg = (field.bearing_error_half_deg
+ config.search.bearing_rounding_margin_deg)
# 覆盖 [-R-m, R+m]² 的格点阵列;格 (i,j) 的中心在
# (x0 + (i+0.5)h, x0 + (j+0.5)h),中心到格内任一点 <= h·√2/2 = 裕量
span = field.omega_radius_m + self.margin_m
self.x0 = -span
self.width = int(math.ceil(2 * span / self.spacing_m)) + 1
limit_sq = (span + 1e-9) ** 2
self.alive: set[int] = set()
self._coarse: dict[int, int] = {} # 粗格索引 -> 幸存细格数
coarse_w = self._coarse_width()
for i in range(self.width):
x = self.x0 + (i + 0.5) * self.spacing_m
for j in range(self.width):
if x * x + (self.x0 + (j + 0.5) * self.spacing_m) ** 2 <= limit_sq:
index = i * self.width + j
self.alive.add(index)
key = (i // _COARSE_FACTOR) * coarse_w + j // _COARSE_FACTOR
self._coarse[key] = self._coarse.get(key, 0) + 1
self.version = 0
self._mec: tuple[Vec2, float] | None = None
# ------------------------------------------------------------ 索引与坐标
def _coarse_width(self) -> int:
return (self.width + _COARSE_FACTOR - 1) // _COARSE_FACTOR
def cell_center(self, index: int) -> Vec2:
i, j = divmod(index, self.width)
return (self.x0 + (i + 0.5) * self.spacing_m,
self.x0 + (j + 0.5) * self.spacing_m)
def discard(self, indices: list[int]) -> None:
"""公开删除入口:联合信念把"朝向不可能"的格一并删除时使用。"""
self._discard(indices)
def _discard(self, indices: list[int]) -> None:
if not indices:
return
coarse_w = self._coarse_width()
for index in indices:
if index not in self.alive:
continue
self.alive.discard(index)
key = (index // self.width // _COARSE_FACTOR) * coarse_w \
+ (index % self.width) // _COARSE_FACTOR
count = self._coarse.get(key, 0) - 1
if count <= 0:
self._coarse.pop(key, None)
else:
self._coarse[key] = count
self.version += 1
self._mec = None
# ------------------------------------------------------------ 观测更新
def _kill_disk(self, center: Vec2, radius_m: float) -> None:
"""删除格心距 center <= radius_m 的全部幸存格(整格判据见模块注释)。"""
if not self.alive:
return
reach = radius_m + _DIST_EPS_M
ilo = max(0, int(math.floor((center[0] - reach - self.x0) / self.spacing_m)))
ihi = min(self.width - 1,
int(math.ceil((center[0] + reach - self.x0) / self.spacing_m)))
jlo = max(0, int(math.floor((center[1] - reach - self.x0) / self.spacing_m)))
jhi = min(self.width - 1,
int(math.ceil((center[1] + reach - self.x0) / self.spacing_m)))
limit_sq = reach * reach
doomed = []
for i in range(ilo, ihi + 1):
dx = self.x0 + (i + 0.5) * self.spacing_m - center[0]
base = i * self.width
for j in range(jlo, jhi + 1):
index = base + j
if index not in self.alive:
continue
dy = self.x0 + (j + 0.5) * self.spacing_m - center[1]
if dx * dx + dy * dy <= limit_sq:
doomed.append(index)
self._discard(doomed)
def _kill_outside_disk(self, center: Vec2, radius_m: float) -> None:
"""删除格心距 center > radius_m 的全部幸存格(保留盘内)。"""
if not self.alive:
return
reach = radius_m + _DIST_EPS_M
doomed = [index for index in self.alive
if math.dist(self.cell_center(index), center) > reach]
self._discard(doomed)
def _kill_outside_wedge(self, station: Vec2, reading_deg: float) -> None:
"""删除角向偏离读数超出 (*@\ensuremath{\delta}@*)_eff + 角向格裕量的幸存格。
角向格裕量 = asin(m / max(dist−m, 1)):格内任一点相对格心的方位
偏差不超过它(点距测点还可再近 m,故分母再内缩 m)。
"""
if not self.alive:
return
sx, sy = float(station[0]), float(station[1])
doomed = []
for index in self.alive:
gx, gy = self.cell_center(index)
dist = math.hypot(gx - sx, gy - sy)
if dist <= self.margin_m + 1.0:
continue # 格贴测点:格内方位可指向任意方向,不可删
angular_margin = math.degrees(
math.asin(min(0.9999, self.margin_m / (dist - self.margin_m))))
gap = angular_gap(math.degrees(math.atan2(gy - sy, gx - sx)), reading_deg)
if gap > self.half_angle_deg + angular_margin + _ANG_EPS_DEG:
doomed.append(index)
self._discard(doomed)
def apply_measure(self, station: Vec2, result: str,
svd_deg: float | None = None, *,
directional: bool = False) -> None:
"""按读数更新幸存集。真源保留性由 Voronoi 裕量保证(模块注释)。
``directional=True``(第四问):no_signal **不删空间候选**——定向源
的静默还可能来自朝向遮挡,单独删空间候选会误删真源;朝向维度由
:class:`~mcm26b.belief.joint.JointBelief` 维护。
"""
if result == NO_SIGNAL:
if directional:
return
# no_signal (*@\ensuremath{\Longrightarrow}@*) dist > r_eff >= 1000:整格落入 B(s, 1000−m) 的删。
self._kill_disk(station, self.r_eff_min_m - self.margin_m)
elif result == NEAR:
self._kill_outside_disk(station, self.near_radius_m + self.margin_m)
elif result == DIRECTION:
if svd_deg is None:
raise ValueError("direction 读数必须携带 svd_deg")
# direction 隐含 dist <= r_max [题面]
self._kill_outside_disk(station, self.r_eff_max_m + self.margin_m)
self._kill_outside_wedge(station, svd_deg)
else:
raise ValueError(f"未知测向结果: {result!r}")
# ------------------------------------------------------------ 查询
def __len__(self) -> int:
return len(self.alive)
def alive_points(self, limit: int | None = None) -> list[Vec2]:
sorted_alive = sorted(self.alive)
if limit is not None and len(sorted_alive) > limit:
step = len(sorted_alive) / limit
indices = {sorted_alive[int(k * step) % len(sorted_alive)]
for k in range(limit)}
indices = sorted(indices)
else:
indices = sorted_alive
return [self.cell_center(index) for index in indices]
def nearest_alive_distance(self, point: Vec2, limit_m: float) -> float | None:
"""最近幸存格心距离;> limit_m 时返回 None(粗格环扫 + 下界剪枝)。
返回值语义:恰为最近幸存格心距离(若 <= limit_m);None 表示所有
幸存格心都超出 limit_m。粗格聚合半径已计入扫描范围,不漏判。
"""
if not self.alive:
return None
coarse_cell_m = self.spacing_m * _COARSE_FACTOR
coarse_reach = coarse_cell_m * math.sqrt(2) / 2
cw = self._coarse_width()
ci_base = int(math.floor((point[0] - self.x0) / coarse_cell_m))
cj_base = int(math.floor((point[1] - self.x0) / coarse_cell_m))
ring_max = int(math.ceil((limit_m + coarse_reach) / coarse_cell_m)) + 1
best: float | None = None
for ring in range(ring_max + 1):
lower_bound = max(0.0, ring - 1) * coarse_cell_m - coarse_reach
if best is not None and lower_bound >= best:
break # 更外环的所有格心都 >= 下界 >= 当前最优
for ci in range(max(0, ci_base - ring), min(cw - 1, ci_base + ring) + 1):
for cj in range(max(0, cj_base - ring), min(cw - 1, cj_base + ring) + 1):
if max(abs(ci - ci_base), abs(cj - cj_base)) != ring:
continue # 只扫当前环(Chebyshev 环)
if (ci * cw + cj) not in self._coarse:
continue
ccx = self.x0 + (ci + 0.5) * coarse_cell_m
ccy = self.x0 + (cj + 0.5) * coarse_cell_m
if math.hypot(ccx - point[0], ccy - point[1]) \
> limit_m + coarse_reach:
continue
i0, j0 = ci * _COARSE_FACTOR, cj * _COARSE_FACTOR
for i in range(i0, min(i0 + _COARSE_FACTOR, self.width)):
base = i * self.width
for j in range(j0, min(j0 + _COARSE_FACTOR, self.width)):
index = base + j
if index not in self.alive:
continue
dist = math.dist(self.cell_center(index), point)
if dist <= limit_m and (best is None or dist < best):
best = dist
return best
def guaranteed_no_signal(self, station: Vec2) -> bool:
"""真源与该测点距离必 > r_max(>= r_eff)(*@\ensuremath{\Longrightarrow}@*) 必 no_signal,测了也无信息。"""
return self.nearest_alive_distance(
station, self.r_eff_max_m + self.margin_m) is None
def mec(self) -> tuple[Vec2, float]:
"""幸存集最小包围圆(圆心, 半径);按 version 缓存。"""
if self._mec is None:
if not self.alive:
raise ValueError("幸存集为空(该频道已可证明缺席)")
points = [self.cell_center(index) for index in self.alive]
self._mec = smallest_enclosing_circle(points)
return self._mec
# ------------------------------------------------------------ 信息增益
def subsample(self, size: int) -> list[Vec2]:
"""按索引序均匀抽取的子采样(索引序(*@\ensuremath{\approx}@*)空间序,代表点空间铺开)。"""
return self.alive_points(limit=size)
# ---------------------------------------------------- 向量化评分(可选)
def _expected_survival_vector(self, point: Vec2, sub, representatives) -> float:
"""``expected_survival`` 的向量化实现(与纯 Python 版逐分支同语义)。"""
count = len(sub)
eps = 1.0 / count
dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]
dist = _np.hypot(dx, dy)
within = int(_np.count_nonzero(
dist <= self.r_eff_min_m - self.margin_m + _DIST_EPS_M))
log_keep_ns = math.log(max(1.0 - within / count, eps))
bearing = _np.degrees(_np.arctan2(dy, dx))
in_range = dist <= self.r_eff_max_m + self.margin_m
rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
survivors = _np.count_nonzero(
(gaps <= self.half_angle_deg + _ANG_EPS_DEG) & in_range[None, :], axis=1)
p_ns = _np.clip((rdist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)
keep_dir = _np.maximum(survivors / count, eps)
values = p_ns * log_keep_ns + (1.0 - p_ns) * _np.log(keep_dir)
near = rdist <= self.near_radius_m + self.margin_m
values = _np.where(near, math.log(eps), values)
return float(values.mean())
def _expected_posterior_radius_vector(self, point: Vec2, sub,
representatives) -> float:
"""``expected_posterior_radius`` 的向量化实现。"""
eps_radius = self.spacing_m / 2.0
dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]
dist = _np.hypot(dx, dy)
bearing = _np.degrees(_np.arctan2(dy, dx))
keep_ns = dist > self.r_eff_min_m - self.margin_m
r_ns = (_branch_radius_array(sub[keep_ns]) if keep_ns.any()
else eps_radius)
rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]
rdist = _np.hypot(rdx, rdy)
rbearing = _np.degrees(_np.arctan2(rdy, rdx))
gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)
dir_mask = ((gaps <= self.half_angle_deg + _ANG_EPS_DEG)
& (dist[None, :] <= self.r_eff_max_m + self.margin_m))
r_dir = _np.array([_branch_radius_array(sub[row]) if row.any()
else eps_radius for row in dir_mask])
p_ns = _np.clip((rdist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)
near = rdist <= self.near_radius_m + self.margin_m
values = _np.where(near, self.near_radius_m + self.margin_m,
p_ns * r_ns + (1.0 - p_ns) * r_dir)
return float(values.mean())
def expected_survival(self, point: Vec2, subsample,
representatives) -> float:
"""E[log 幸存占比]:越小该测点的期望信息增益越大。
r_eff 不确定性解析积分:dist(*@\ensuremath{\in}@*)(1000,1500] 时按 r_eff~U(1000,1500)
取 p_ns=(dist−1000)/500 为 no_signal 分支概率。
- no_signal 分支:删 B(point, 1000−m),幸存 = 子采样中盘外比例;
- direction 分支:以代表点方位为期望读数,角域 (*@\ensuremath{\cap}@*) B(point,1500+m) 内幸存;
- near 分支(dist <= 5+m):可行集收缩到 5m 盘,幸存占比趋近 0。
"""
if not self.alive or len(subsample) == 0 or len(representatives) == 0:
return 0.0
if _np is not None and isinstance(subsample, _np.ndarray) \
and isinstance(representatives, _np.ndarray):
return self._expected_survival_vector(point, subsample, representatives)
eps = 1.0 / len(subsample)
keep_ns = max(1.0 - self.fraction_within(subsample, point,
self.r_eff_min_m - self.margin_m),
eps)
log_keep_ns = math.log(keep_ns)
total = 0.0
for rep in representatives:
dist = math.hypot(rep[0] - point[0], rep[1] - point[1])
if dist <= self.near_radius_m + self.margin_m:
total += math.log(eps)
continue
p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m)))
reading = math.atan2(rep[1] - point[1], rep[0] - point[0])
survivors = 0
for p in subsample:
if math.dist(p, point) > self.r_eff_max_m + self.margin_m:
continue
gap = angular_gap(math.degrees(
math.atan2(p[1] - point[1], p[0] - point[0])),
math.degrees(reading))
if gap <= self.half_angle_deg + _ANG_EPS_DEG:
survivors += 1
keep_dir = max(survivors / len(subsample), eps)
total += p_ns * log_keep_ns + (1.0 - p_ns) * math.log(keep_dir)
return total / max(len(representatives), 1)
def fraction_within(self, samples, point: Vec2, radius_m: float) -> float:
if len(samples) == 0:
return 0.0
reach = radius_m + _DIST_EPS_M
if _np is not None and isinstance(samples, _np.ndarray):
distance = _np.hypot(samples[:, 0] - point[0], samples[:, 1] - point[1])
return float(_np.count_nonzero(distance <= reach)) / len(samples)
count = sum(1 for p in samples if math.dist(p, point) <= reach)
return count / len(samples)
def _branch_radius(self, members) -> float:
"""分支后验的半径代理:幸存子采样到其质心的最大距离(O(n),免 Welzl)。"""
if len(members) == 0:
return 0.0
if _np is not None and isinstance(members, _np.ndarray):
return _branch_radius_array(members)
mx = sum(p[0] for p in members) / len(members)
my = sum(p[1] for p in members) / len(members)
return max(math.hypot(p[0] - mx, p[1] - my) for p in members)
def expected_posterior_radius(self, point: Vec2, subsample,
representatives) -> float:
"""一次测量后期望后验半径(米):选点主指标,直指清除证书阈值。
对每个代表假设(真值候选)按 r_eff~U(1000,1500) 解析积分
no_signal/direction 分支,near 分支(dist <= 5+m)后验为 5m 盘;
各分支半径用幸存子采样的质心最大距离近似(偏差 << 格距,排序用)。
与几何版 worst-posterior 同族,但作用于含负观测的非凸幸存集,
且以期望代替最坏(代表点覆盖幸存集主轴)。
"""
if not self.alive or len(subsample) == 0 or len(representatives) == 0:
return 0.0
if _np is not None and isinstance(subsample, _np.ndarray) \
and isinstance(representatives, _np.ndarray):
return self._expected_posterior_radius_vector(
point, subsample, representatives)
eps_radius = self.spacing_m / 2.0
total = 0.0
for rep in representatives:
dist = math.hypot(rep[0] - point[0], rep[1] - point[1])
if dist <= self.near_radius_m + self.margin_m:
total += self.near_radius_m + self.margin_m
continue
p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)
/ (self.r_eff_max_m - self.r_eff_min_m)))
members_ns = [p for p in subsample
if math.dist(p, point) > self.r_eff_min_m - self.margin_m]
reading = math.atan2(rep[1] - point[1], rep[0] - point[0])
members_dir = []
for p in subsample:
if math.dist(p, point) > self.r_eff_max_m + self.margin_m:
continue
gap = angular_gap(math.degrees(
math.atan2(p[1] - point[1], p[0] - point[0])),
math.degrees(reading))
if gap <= self.half_angle_deg + _ANG_EPS_DEG:
members_dir.append(p)
r_ns = self._branch_radius(members_ns) if members_ns else eps_radius
r_dir = self._branch_radius(members_dir) if members_dir else eps_radius
total += p_ns * r_ns + (1.0 - p_ns) * r_dir
return total / max(len(representatives), 1)
File diff suppressed because it is too large Load Diff
@@ -1,816 +0,0 @@
"""P4b 贝叶斯网格滤波闭环(问题3新策略 ``bayes``)。
在 :class:`OmnidirectionalLoop` 骨架(run/异常收尾/summary/预算/异常留痕)
上的三层重构,对应 docs/algo-3-bayes.md 的架构:
1. 感知层:每频道一个网格幸存集(particles.ChannelParticles),全部读数
(含 no_signal 负观测)即时更新;幸存集为空即缺席证明,无需等覆盖账本;
2. 决策层:统一任务循环——站点扫频、定位测量、清除三类任务合成任务表,
开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP);
3. 选点层:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)
内按"期望 log 幸存占比 + (*@\ensuremath{\lambda}@*)·移动时间"评分(信息增益贪心)。
与基线 SevenBatch 的差异:负观测跳站/跳测(必 no_signal 的 (站,频道) 对
不消耗检测与换频)、PF 缺席证明提前证毕、全局任务排序替代"站内最近邻"。
清除证书仍以凸证书为主(PF 网格证书仅在负观测挖洞使幸存集更紧时提前
触发),两者均满足 Voronoi/保守裕量,不牺牲全清率。
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from ..belief import CandidateRegion, ChannelStatus
from ..config import ProblemConfig, Vec2
from ..coverage import CoverageLedger
from ..particles import ChannelParticles, as_sample_array
from ..protocol.client import SimulatorClient
from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL
from ..protocol.timing import move_time
from . import (ClearCertificate, OmnidirectionalLoop, clear_certificate,
candidate_points, safe_disk_radius, select_measurement_point)
from .seven import validate_seven_coverage
from ..guarantee import seven_plan
@dataclass(frozen=True)
class Task:
"""统一任务表的一项。service_s 为不含移动的固有耗时(检测+换频/清除)。"""
kind: str # "station" | "locate" | "clear"
position: Vec2
service_s: float
channel: int | None = None
station_index: int | None = None
station_channels: tuple[int, ...] = ()
def _point_key(point: Vec2) -> tuple[int, int]:
"""候选点去重键:0.5m 网格取整(同一物理点不重复测量)。"""
return (int(round(point[0] * 2)), int(round(point[1] * 2)))
class BayesLoop(OmnidirectionalLoop):
"""问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。"""
def __init__(self, client: SimulatorClient,
config: ProblemConfig = ProblemConfig()):
super().__init__(client, config)
validate_seven_coverage(config.bayes.station_ring_radius_m, config)
self.coverage_plan = seven_plan(config.bayes.station_ring_radius_m)
self.ledger = CoverageLedger(
config, points=list(self.coverage_plan.points))
self.particles = {channel: ChannelParticles(config)
for channel in self.belief.channels}
self._measured_points: dict[int, set[tuple[int, int]]] = {}
self._clear_failed: set[int] = set() # 证书清除失败后须先补测再重试
self._stall: dict[int, int] = {} # 连续无收缩计数(异常留痕用)
self._skip_cache: dict[tuple[int, int], tuple[int, bool]] = {}
self.skipped_measures = 0 # 跳测规则省下的检测次数
self.no_shrink_measures = 0 # 未收缩幸存集的定位检测(诊断用)
self.pf_absent_channels: list[int] = [] # 由幸存集为空证明缺席的频道
self.opportunistic_measures = 0
# ------------------------------------------------------------ 主循环
def _coverage_phase(self) -> None:
"""统一任务循环:发现 + 定位 + 清除滚动调度,直到任务表为空。"""
while self.stop_reason is None:
if self._count_limit_reached() or self._virtual_cap_reached():
return
if not self._step_once():
return
def _step_once(self) -> bool:
"""构建任务表、排序并执行首任务;无可执行任务返回 False。"""
if self.config.bayes.opportunistic_measure:
self._measure_at_stop()
tasks = self._build_tasks()
if not tasks:
return False
tasks = self._refine_locate_tasks(tasks)
order = self._order_tasks(tasks)
self._execute_task(order[0])
return True
def _measure_at_stop(self) -> None:
"""利用已停靠位置补交会方位;评分只作选测,证书仍用完整角域。
只测尚未在此处测过、有望显著收缩且保证距离可接收的已发现频道。
逐频道调用measure,换频与5秒检测照常计费;不在移动中检测。
此距离保证仅适用于第三问,不能用于未知定向源。
"""
point = self.client.state.position
channels = self.belief.channels_by_status(ChannelStatus.DETECTED)
channels.sort(key=lambda c: (c != self.client.state.channel, c))
for channel in channels:
if self._virtual_cap_reached():
return
if _point_key(point) in self._measured_points.get(channel, set()):
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
continue
region = self.belief.candidate_set(channel)
farthest = max(math.dist(point, v) for v in region.polygon)
if farthest > self.config.field.r_eff_min_m - 1e-3:
continue
radius = region.mec()[1]
vertices = region.polygon
stride = max(1, len(vertices) // self.config.bayes.mc_representatives)
radii = []
delta = (self.config.field.bearing_error_half_deg
+ self.config.search.bearing_rounding_margin_deg)
for target in vertices[::stride]:
theta = math.degrees(math.atan2(target[1]-point[1], target[0]-point[0]))
probe = CandidateRegion(list(vertices), region.slack_m)
probe.clip_wedge(point, theta, delta)
radii.append(probe.mec()[1] if probe.polygon else radius)
if sum(radii) / len(radii) > radius * self.config.bayes.opportunistic_radius_ratio:
continue
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
self.opportunistic_measures += 1
# ------------------------------------------------------------ 任务构建
def _station_pending(self, index: int) -> list[int]:
"""站点待测频道:UNKNOWN、未测过、非"必 no_signal"且未证缺席。"""
point = self.ledger.points[index]
pending = []
for channel, belief in self.belief.channels.items():
if belief.status is not ChannelStatus.UNKNOWN:
continue
if self.ledger.measured_at(channel, index):
continue
if self._skip_station_channel(channel, index, point):
continue
pending.append(channel)
return pending
def _fused_channels_at(self, index: int) -> list[int]:
"""本站要测的频道 = 未发现(UNKNOWN) + 已发现但尚未定证(DETECTED)。
P0-1:把定位并入覆盖巡回。站点三角化可行性(方案 §1.3 F11):
99.28% 的源位置有 (*@\ensuremath{\ge}@*)2 个站在 r_eff_max=1500m 内,最佳交会角中位
122.9°、5 分位 58.5°,因此"顺路取第二条方位线"对绝大多数源成立。
已有证书、已在同一 0.5m 测点测过、或该点必 no_signal 的频道跳过。
实测结论(2026-09-12,`output/suite/q3-fuse-ab*`):本机制**不划算**。
七站环上通常只有 2–3 个站能对该源取得有效方位线,交会角也不足以
直接换到 20m 证书,专用信息增益探点仍然必要;仅在站点顺带测量等于
平白多花约 11 次检测(+0.8%),若同时抑制专用探点则 +45.6%(20/20
场景更慢)。默认关闭,保留代码以便复现与后续研究。
"""
point = self.ledger.points[index]
channels = self._station_pending(index)
selected = set(channels)
if not self.config.bayes.fuse_localize_into_tour:
return channels
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
if channel in selected or self.ledger.measured_at(channel, index):
continue
if _point_key(point) in self._measured_points.get(channel, set()):
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
continue # 已有证书:交给清除任务,不再多测
if self._skip_station_channel(channel, index, point):
continue # 幸存集都在 1500m 外:该点必 no_signal
channels.append(channel)
selected.add(channel)
return channels
def _skip_station_channel(self, channel: int, index: int, point: Vec2) -> bool:
"""跳测规则(按 pf.version 缓存;幸存集单调收缩,判定单调)。"""
pf = self.particles[channel]
if not pf:
return True
cached = self._skip_cache.get((channel, index))
if cached is not None and cached[0] == pf.version:
return cached[1]
value = pf.guaranteed_no_signal(point)
self._skip_cache[(channel, index)] = (pf.version, value)
return value
def _build_tasks(self) -> list[Task]:
tasks: list[Task] = []
for index, point in enumerate(self.ledger.points):
pending = self._fused_channels_at(index)
if not pending:
continue
switches = len(pending) - (1 if self.client.state.channel in pending else 0)
tasks.append(Task("station", tuple(point),
self.config.cost.measure_s * len(pending)
+ self.config.cost.switch_s * max(switches, 0),
station_index=index, station_channels=tuple(pending)))
for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):
cert = self._best_certificate(channel)
if cert is not None and cert.certified \
and channel not in self._clear_failed:
tasks.append(Task("clear", cert.center,
self.config.cost.clear_success_s,
channel=channel))
continue
if self._defer_to_station(channel):
continue # 下一条方位线可由"本就要去的站点"顺路提供
point = self._select_point(channel)
if point is None:
continue
tasks.append(Task("locate", point,
self.config.cost.measure_s + self.config.cost.switch_s,
channel=channel))
return tasks
def _defer_to_station(self, channel: int) -> bool:
"""该频道能否由"本就要去的站点"提供下一条方位线。
P0-1 的关键:仅仅在站点顺带测量是不够的(那只是多做一次检测),
必须同时**抑制专用探点任务**,否则滚动排序仍会优先飞向更近的探点。
判据要求该站本来就有 UNKNOWN 频道待测(覆盖巡回必须去),因此不会
为了等一条方位线而制造新的绕行,也不会出现"无任务可做"的死锁:
站点扫完后 ``_station_pending`` 为空,探点任务自然恢复。
"""
if not self.config.bayes.fuse_localize_into_tour:
return False
for index, point in enumerate(self.ledger.points):
if not self._station_pending(index):
continue
if self.ledger.measured_at(channel, index):
continue
if _point_key(point) in self._measured_points.get(channel, set()):
continue
if self._skip_station_channel(channel, index, point):
continue
return True
return False
# ------------------------------------------------------------ 任务排序
def _order_tasks(self, tasks: list[Task]) -> list[Task]:
"""开放路径排序:小任务表用精确 DP,否则最近邻 + 2-opt。
Held-Karp(S5/T1)把"已访问集合 × 结束时测向频道"并入状态,因此换频
代价是精确的,而不是 :meth:`_path_cost` 里"按频道排序"的贪心近似。
"""
if len(tasks) <= 2:
ordered = sorted(tasks, key=lambda t: (
move_time(self.client.state.position, t.position, self.config.cost),
t.kind, t.channel or 0))
return min((ordered, ordered[::-1]), key=self._path_cost)
limit = self.config.bayes.exact_order_limit
if 0 < limit and len(tasks) <= limit:
exact = self._exact_order(tasks)
if exact is not None:
return exact
order = self._nearest_order(tasks)
if not self.config.bayes.two_opt_enabled:
return order
return self._two_opt(order)
def _service_and_exit(self, task: Task,
channel: int) -> tuple[float, int]:
"""任务在进入频道 ``channel`` 时的固有耗时与离开后的测向频道。"""
cost = self.config.cost
if task.kind == "locate":
return (cost.measure_s + (cost.switch_s if channel != task.channel else 0.0),
task.channel)
if task.kind == "station" and task.station_channels:
pending = sorted(task.station_channels,
key=lambda c: (c != channel, c))
switches = len(pending) - (1 if channel in pending else 0)
return (cost.measure_s * len(pending) + cost.switch_s * switches,
pending[-1])
return task.service_s, channel
def _exact_order(self, tasks: list[Task]) -> list[Task] | None:
"""Held-Karp 精确开放路径(起点固定、终点自由,含换频状态)。
状态 ``(mask, c)`` = 已访问任务集 mask、离开最后一个任务后测向频道为 c;
频道取值域只需 {当前频道} (*@\ensuremath{\cup}@*) {locate 频道} (*@\ensuremath{\cup}@*) {station 离开频道}。
规模 ``2^n·n·|C|``;n 超过 :attr:`BayesConfig.exact_order_limit` 时由
调用方回退启发式。返回 ``None`` 表示状态过大(防御性)。
"""
n = len(tasks)
if n <= 1:
return list(tasks)
start = self.client.state.position
init = self.client.state.channel
channels = {init}
for task in tasks:
if task.kind == "locate":
channels.add(task.channel)
elif task.kind == "station" and task.station_channels:
# 离开频道 = 按"当前频道优先"排序后的最后一个:通常是最大值;
# 进入频道恰为最大值时退化为次大值,故两者都要进状态空间。
ordered = sorted(task.station_channels)
channels.add(ordered[-1])
if len(ordered) > 1:
channels.add(ordered[-2])
channels = sorted(channels)
width = len(channels)
if width * (1 << n) * n > 4_000_000:
return None
index_of = {channel: i for i, channel in enumerate(channels)}
c_init = index_of[init]
service: list[list[float]] = []
exits: list[list[int]] = []
for task in tasks:
row_service, row_exit = [], []
for channel in channels:
value, exit_channel = self._service_and_exit(task, channel)
row_service.append(value)
row_exit.append(index_of[exit_channel])
service.append(row_service)
exits.append(row_exit)
move = [[move_time(tasks[i].position, tasks[j].position, self.config.cost)
for j in range(n)] for i in range(n)]
from_start = [move_time(start, task.position, self.config.cost)
for task in tasks]
inf = float("inf")
# dp[(mask, ci)] = 最小代价;parent[(mask, ci)] = (上一 mask, 上一 ci)
dp: dict[tuple[int, int], float] = {}
last_task: dict[tuple[int, int], int] = {}
parent: dict[tuple[int, int], tuple[int, int] | None] = {}
for k in range(n):
key = (1 << k, exits[k][c_init])
value = from_start[k] + service[k][c_init]
if value < dp.get(key, inf):
dp[key] = value
last_task[key] = k
parent[key] = None
for mask in range(1, 1 << n):
for ci in range(width):
state = (mask, ci)
cost = dp.get(state)
if cost is None:
continue
for k in range(n):
if (mask >> k) & 1:
continue
nxt = (mask | (1 << k), exits[k][ci])
value = cost + move[last_task[state]][k] + service[k][ci]
if value < dp.get(nxt, inf) - 1e-12:
dp[nxt] = value
last_task[nxt] = k
parent[nxt] = state
full = (1 << n) - 1
best_key, best_cost = None, inf
for ci in range(width):
state = (full, ci)
if state in dp and dp[state] < best_cost:
best_key, best_cost = state, dp[state]
if best_key is None:
return None
order: list[Task] = []
key: tuple[int, int] | None = best_key
while key is not None:
order.append(tasks[last_task[key]])
key = parent[key]
order.reverse()
return order
def _nearest_order(self, tasks: list[Task]) -> list[Task]:
remaining = list(tasks)
order = []
position = self.client.state.position
while remaining:
best = min(remaining, key=lambda t: (
move_time(position, t.position, self.config.cost),
t.kind, t.channel or 0))
remaining.remove(best)
order.append(best)
position = best.position
return order
def _path_cost(self, order: list[Task]) -> float:
total = 0.0
position = self.client.state.position
channel = self.client.state.channel
for task in order:
total += move_time(position, task.position, self.config.cost)
if task.kind == "locate":
total += self.config.cost.measure_s
total += self.config.cost.switch_s * (channel != task.channel)
channel = task.channel
elif task.kind == "station" and task.station_channels:
pending = sorted(
task.station_channels,
key=lambda c, channel=channel: (c != channel, c))
total += self.config.cost.measure_s * len(pending)
total += self.config.cost.switch_s * (len(pending) - (channel in pending))
channel = pending[-1]
else:
total += task.service_s # 清除不改变测向频道
position = task.position
return total
def _two_opt(self, order: list[Task]) -> list[Task]:
best = list(order)
best_cost = self._path_cost(best)
for _ in range(self.config.bayes.two_opt_passes):
improved = False
for i in range(len(best) - 1):
for j in range(i + 1, len(best)):
candidate = best[:i] + best[i:j + 1][::-1] + best[j + 1:]
cost = self._path_cost(candidate)
if cost < best_cost - 1e-6:
best, best_cost, improved = candidate, cost, True
if not improved:
break
return best
# ------------------------------------------------------------ 任务执行
def _execute_task(self, task: Task) -> None:
if task.kind == "station":
self._execute_station(task.station_index)
elif task.kind == "clear":
cert = self._best_certificate(task.channel)
if cert is not None and cert.certified:
if not self._attempt_clear(task.channel, cert.center, cert):
self._clear_failed.add(task.channel)
self._may_clear.discard(task.channel)
else:
self._clear_failed.discard(task.channel)
elif task.kind == "locate":
self._execute_locate(task.channel, task.position)
def _execute_station(self, index: int) -> None:
"""单站批量扫频:只测待测频道(跳测规则已过滤),扫完即返回重排。
P0-1:本站同时测量"已发现未定证"频道,使第二/第三条方位线在巡回
途中顺路取得;证书一旦成立即就地清除,不额外绕行。
"""
point = self.ledger.points[index]
pending = self._fused_channels_at(index)
unknown = [c for c, b in self.belief.channels.items()
if b.status is ChannelStatus.UNKNOWN]
self.skipped_measures += len(unknown) - len(
[c for c in pending if self.belief.channels[c].status
is ChannelStatus.UNKNOWN])
# 当前频道优先,减少换频;同站 near 直接清除不打断扫频
pending.sort(key=lambda c: (c != self.client.state.channel, c))
for channel in pending:
if self.stop_reason is not None or self._count_limit_reached() \
or self._virtual_cap_reached():
return
detected_before = (self.belief.channels[channel].status
is ChannelStatus.DETECTED)
outcome = self.client.measure(point, channel)
self.ledger.mark_measured(channel, index)
self._record_measure(channel, point, outcome)
if outcome.result in (DIRECTION, NEAR):
self._may_clear.add(channel)
if outcome.result == NEAR:
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config)
if cert.certified:
if self._attempt_clear(channel, point, cert):
self._clear_failed.discard(channel)
else:
self._clear_failed.add(channel)
self._may_clear.discard(channel)
else:
self._anomaly("near_without_certificate", channel, position=point)
continue
if not detected_before:
continue
if outcome.result == NO_SIGNAL:
# 该频道已被发现:保证接收盘内不可能 no_signal,属守恒性异常
region = self.belief.candidate_set(channel)
safe = (safe_disk_radius(region, self.config)
if region is not None else -1.0)
if safe >= 0 and math.dist(point, region.mec()[0]) <= safe:
self._anomaly("no_signal_in_safe_disk", channel,
position=point)
continue
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
else:
self._clear_failed.add(channel)
self._may_clear.discard(channel)
def _execute_locate(self, channel: int, point: Vec2) -> None:
"""单步定位测量:执行后立即重排(滚动时域),收敛则顺路清除。"""
pf = self.particles[channel]
before = len(pf)
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
after = len(pf)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
self._stall.pop(channel, None)
return
self._clear_failed.add(channel)
self._may_clear.discard(channel)
if after == before:
self._stall[channel] = self._stall.get(channel, 0) + 1
self.no_shrink_measures += 1 # 只是浪费不破坏守恒性,不进异常表
else:
self._stall[channel] = 0
# ------------------------------------------------------------ 感知与证书
def _record_measure(self, channel: int, position: Vec2, outcome) -> None:
"""belief(凸证书)与 PF(幸存集)同步更新;PF 空即证缺席。"""
self.belief.update_measure(channel, position, outcome.result,
outcome.svd_deg)
# 同一 0.5m 测点 × 同一频道的重复检测:历史上一次官方演练有 98.84%
# 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。
key = _point_key(position)
seen = self._measured_points.setdefault(channel, set())
if key in seen:
self.redundant_rescan_measures += 1
seen.add(key)
self._clear_failed.discard(channel) # 新证据落地:允许再次尝试清除
belief = self.belief.channels[channel]
if belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):
return # 已证毕:清除/缺席后的 no_signal 与距离无关,不进 PF
pf = self.particles[channel]
pf.apply_measure(position, outcome.result, outcome.svd_deg)
if not pf:
if belief.status is ChannelStatus.UNKNOWN:
self.belief.mark_absent(channel)
self.absence_evidence[channel] = "pf_empty"
if channel not in self.pf_absent_channels:
self.pf_absent_channels.append(channel)
else:
self._anomaly("pf_empty_while_detected", channel, position=position)
self._apply_count_bound()
def _apply_count_bound(self) -> None:
"""存在证据达到题面源数上界时,其余 UNKNOWN 可由计数直接判缺席。
依据 [题面]:每频道至多一个源、全局限定 10..16 个源。一旦
DETECTED/CLEARED 的不同频道数达到 16,任何尚未出现有效信号的
频道都不可能是源,无须等 7 站覆盖账本跑完。该判定与位置无关,
是严格的计数证明;evidence 与 coverage/pf_empty 分开留痕。
"""
present = [channel for channel, belief in self.belief.channels.items()
if belief.status in (ChannelStatus.DETECTED,
ChannelStatus.CLEARED)]
if len(present) < self.config.field.source_count_max:
return
for channel, belief in self.belief.channels.items():
if belief.status is ChannelStatus.UNKNOWN:
belief.status = ChannelStatus.ABSENT
self.absence_evidence[channel] = "count_bound"
def _best_certificate(self, channel: int) -> ClearCertificate | None:
"""凸证书优先;负观测挖洞使 PF 幸存集更紧时用网格证书补位。"""
region = self.belief.candidate_set(channel)
cert = clear_certificate(region, channel, self.config) \
if region is not None else None
if cert is not None and cert.certified:
return self._near_clear_target(cert)
if self.config.bayes.grid_cert_enabled:
pf = self.particles[channel]
if len(pf):
center, radius = pf.mec()
limit = (self.config.field.clear_radius_m - pf.margin_m
- self.config.search.clear_margin_m)
if radius <= limit:
return self._near_clear_target(
ClearCertificate(channel, center, radius, limit, True))
return cert
def _near_clear_target(self, cert: ClearCertificate) -> ClearCertificate:
"""沿当前来向利用20m清除容差;三角不等式保留整集覆盖证明。"""
position = self.client.state.position
distance = math.dist(position, cert.center)
shift = min(distance, max(0.0, cert.limit_m - cert.radius_m - 1e-6))
if distance <= 1e-9 or shift <= 0:
return cert
target = tuple(c + (p-c)*shift/distance for p, c in zip(position, cert.center))
return ClearCertificate(cert.channel, target, cert.radius_m+shift, cert.limit_m, True)
# ------------------------------------------------------------ 信息增益选点
def _probe_context(self, channel: int):
"""构造某频道下一次定位测量的候选集与信息增益评分函数。
返回 ``(candidates, info_score, mec_center, current_position)``;
候选为空返回 ``None``。``_select_point`` 与路线感知精修共用此逻辑,
保证两者只差“移动代价如何折算”。
"""
bayes, search = self.config.bayes, self.config.search
pf = self.particles[channel]
if not pf:
return None
region = self.belief.candidate_set(channel)
center, radius = pf.mec()
measured = self._measured_points.get(channel, set())
position = self.client.state.position
candidates: list[Vec2] = []
safe = safe_disk_radius(region, self.config) if region is not None else -1.0
if region is not None and safe >= 0:
# 保证接收盘(凸证书口径):任意候选必产生 direction/near
candidates = candidate_points(tuple(region.mec()[0]), safe,
search.candidate_rings,
search.candidate_angles)
# 以实际来向生成对称交会候选,避免固定全局角度网格的旋转偏差。
geometric_center = region.mec()[0]
base_angle = math.atan2(position[1] - geometric_center[1],
position[0] - geometric_center[0])
for fraction in search.candidate_rings:
if fraction <= 0:
continue
for offset in (-math.pi/3, -math.pi/6, 0, math.pi/6, math.pi/3):
angle = base_angle + offset
candidates.append((geometric_center[0] + safe * fraction * math.cos(angle),
geometric_center[1] + safe * fraction * math.sin(angle)))
# 已停靠位置若本身在安全接收盘内,是零移动的合法测点;显式加入
# 可避免为了固定角度环而白白横移。
if math.dist(position, geometric_center) <= safe + 1e-9:
candidates.append(tuple(position))
else:
# 宽候选集:PF MEC 探索环(负观测挖洞后幸存集的包围圆)
for fraction in bayes.wide_rings:
ring = max(radius * fraction, 2.0 * pf.margin_m)
candidates.extend(candidate_points(center, ring, (1.0,),
search.candidate_angles))
candidates.append(tuple(center))
candidates.append(tuple(position))
candidates = [tuple(c) for c in candidates
if _point_key(c) not in measured]
if not candidates:
return None
# 采样一次转成数组(numpy 可用时),使候选评分走向量路径;
# numpy 缺失时 as_sample_array 原样返回列表,语义不变。
subsample = as_sample_array(pf.subsample(bayes.score_subsample))
stride = max(len(subsample) // bayes.mc_representatives, 1)
representatives = subsample[::stride]
def info_score(q: Vec2) -> float:
if bayes.score_mode == "entropy":
return pf.expected_survival(q, subsample, representatives)
return pf.expected_posterior_radius(q, subsample, representatives)
return candidates, info_score, center, position
def _select_point(self, channel: int) -> Vec2 | None:
"""定位测量点:期望 log 幸存占比 + (*@\ensuremath{\lambda}@*)·移动时间 最小的候选。"""
context = self._probe_context(channel)
if context is None:
return None
candidates, info_score, center, position = context
bayes = self.config.bayes
def score(q: Vec2) -> float:
# 一次定位后仍需接近源才能清除;仅计到检测点的移动会偏好绕行。
move = bayes.lambda_move_s * (move_time(position, q, self.config.cost)
+ move_time(q, center, self.config.cost))
return info_score(q) + move
return min(candidates, key=lambda q: (score(q), _point_key(q)))
def _refine_locate_tasks(self, tasks: list[Task]) -> list[Task]:
"""路线感知精修定位任务点(P2,2026-09-12)。
旧评分只比较 ``current->q + q->MEC``,是局部代理;滚动 2-opt 的真实
代价还包含 ``q->next_task``。这里对每个 locate 任务在候选点上计算
“替换该任务后整条当前路线代价的增量”,再与信息增益、MEC 接近项合并
选点。只改变选点启发式,不改变覆盖/清除证明。
"""
bayes = self.config.bayes
if not bayes.route_aware_probe:
return tasks
locate_indices = [index for index, task in enumerate(tasks)
if task.kind == "locate"]
if not locate_indices:
return tasks
base_order = self._order_tasks(tasks)
base_cost = self._path_cost(base_order)
refined = list(tasks)
for task_index in locate_indices:
task = tasks[task_index]
context = self._probe_context(task.channel)
if context is None:
continue
candidates, info_score, center, position = context
try:
order_index = next(index for index, item in enumerate(base_order)
if item is task)
except StopIteration: # pragma: no cover - 同对象应始终存在
order_index = next(index for index, item in enumerate(base_order)
if item.kind == "locate"
and item.channel == task.channel)
best_score: float | None = None
best_point: Vec2 | None = None
for point in candidates:
trial_order = list(base_order)
trial_order[order_index] = Task(
"locate", tuple(point), task.service_s, channel=task.channel)
route_delta = self._path_cost(trial_order) - base_cost
move_proxy = bayes.lambda_move_s * (
move_time(position, point, self.config.cost)
+ move_time(point, center, self.config.cost))
clear_near = move_time(point, center, self.config.cost)
value = (info_score(point)
+ move_proxy
+ bayes.route_aware_insert_weight * route_delta
+ bayes.route_aware_clear_weight * clear_near)
if best_score is None or value < best_score:
best_score, best_point = value, tuple(point)
if best_point is not None:
refined[task_index] = Task(
"locate", best_point, task.service_s, channel=task.channel)
return refined
# ------------------------------------------------------------ 安全网与复扫
def _localize_and_clear(self, channel: int) -> bool:
"""阻塞式定位子循环(resolve 阶段安全网):PF 选点,失败退凸选点。"""
search = self.config.search
self._may_clear.add(channel)
for _ in range(search.max_localize_steps):
if self._virtual_cap_reached():
return False
cert = self._best_certificate(channel)
if cert is not None and cert.certified and channel in self._may_clear:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
continue
point = self._select_point(channel)
if point is None:
region = self.belief.candidate_set(channel)
if region is None:
return False
point = select_measurement_point(
region, self.client.state.position, self.config).position
outcome = self.client.measure(point, channel)
self._record_measure(channel, point, outcome)
self._may_clear.add(channel)
if outcome.result == NO_SIGNAL:
region = self.belief.candidate_set(channel)
if region is not None and safe_disk_radius(region, self.config) >= 0 \
and math.dist(point, region.mec()[0]) \
<= safe_disk_radius(region, self.config):
self._anomaly("no_signal_in_safe_disk", channel, position=point)
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if self._attempt_clear(channel, cert.center, cert):
self._clear_failed.discard(channel)
return True
self._clear_failed.add(channel)
self._may_clear.discard(channel)
return self.belief.channels[channel].status is ChannelStatus.CLEARED
def _persistent_round(self) -> None:
"""一轮复扫:与基线一致的全站复测,但读数同步进 PF(防假设偏差)。"""
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._record_measure(channel, point, outcome)
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:
cert = self._best_certificate(channel)
if cert is not None and cert.certified:
if not self._attempt_clear(channel, cert.center, cert):
self._clear_failed.add(channel)
else:
self._anomaly("near_without_certificate", channel,
position=point)
if self.stop_reason is not None:
return
# ------------------------------------------------------------ 收尾
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "bayes"
report["opportunistic_measures"] = self.opportunistic_measures
report["skipped_measures"] = self.skipped_measures
report["no_shrink_measures"] = self.no_shrink_measures
report["pf_absent_channels"] = sorted(self.pf_absent_channels)
report["pf_alive_final"] = {
str(channel): len(self.particles[channel])
for channel in sorted(self.particles)
if self.belief.channels[channel].status is ChannelStatus.DETECTED}
return report
@@ -1,113 +0,0 @@
"""P4 七点覆盖策略族:解析证明的六边形发现站 + 同站批量扫频。
从 scripts/benchmark_q3_search.py 提升为库代码(A2 基线,官方演练
12/12 全清、虚拟 5232s)。覆盖几何:原点 + 半径 RING_RADIUS_M 环上
6 等分点;域半径R、环半径r时,最近站距离的解析
上界 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000m [题面
r_eff 下界],故任意合法源必被至少一站检出——缺席证明只需该频道
7 站全 no_signal(CoverageLedger.coverage_complete)。
调度变体:
- SevenImmediate:站内即时进入定位子循环(A1);
- SevenBatch:站内只扫频,扫完按"距候选集 MEC 圆心最近"服务队列(A2);
- SevenSweep:全域扫完再统一服务(A3)。
"""
from __future__ import annotations
import math
from ..belief import ChannelStatus
from ..config import ProblemConfig
from ..coverage import CoverageLedger
from ..guarantee import seven_plan
from ..protocol.rules import NEAR
from . import OmnidirectionalLoop, clear_certificate
RING_RADIUS_M = 1200.0 # [工程] 解析证明见 docs/algo-3.md §5 与 geometry_check
def seven_points(ring_radius_m: float = RING_RADIUS_M) -> list[tuple[float, float]]:
"""原点 + 环上 6 等分点的七站覆盖集。"""
return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),
ring_radius_m * math.sin(k * math.pi / 3)) for k in range(6)]
def validate_seven_coverage(ring_radius_m: float, config: ProblemConfig) -> float:
"""六个60°扇区内的最近站最远距离上界;仅证明全向源覆盖。
原点/环站Voronoi交点给出r/√3,目标圆边界的扇区接缝给出另一项。
环半径满足0<r<=√3R时这两项覆盖全部极值。
"""
radius = config.field.omega_radius_m
if not 0 < ring_radius_m <= math.sqrt(3)*radius:
raise ValueError("七站环半径不满足全域覆盖证明的前提")
bound = max(ring_radius_m / math.sqrt(3), math.sqrt(
radius**2 + ring_radius_m**2 - math.sqrt(3)*radius*ring_radius_m))
if bound > config.field.r_eff_min_m - 1e-6:
raise ValueError("七站未覆盖整个目标域,不能据此证明频道缺席")
return bound
class SevenImmediate(OmnidirectionalLoop):
"""七点覆盖 + 站内即时定位(基类覆盖阶段逻辑不变,仅换站集)。"""
def __init__(self, client, config: ProblemConfig = ProblemConfig(),
ring_radius_m: float = RING_RADIUS_M):
super().__init__(client, config)
validate_seven_coverage(ring_radius_m, config)
self.coverage_plan = seven_plan(ring_radius_m)
self.ledger = CoverageLedger(config=config,
points=list(self.coverage_plan.points))
class SevenBatch(SevenImmediate):
"""站内只扫频,扫完按最近候选集圆心顺序服务检测队列(A2 主策略)。"""
defer_all = False
def _serve_queue(self):
# Each detected channel gets at most one localization call in this pass.
queue = self.belief.channels_by_status(ChannelStatus.DETECTED)
while queue:
c = min(queue, key=lambda c: (math.dist(self.client.state.position,
self.belief.candidate_set(c).mec()[0]), c))
queue.remove(c)
self._localize_and_clear(c)
if self._count_limit_reached():
break
def _coverage_phase(self):
while not self._count_limit_reached():
index = self._next_point_index()
if index is None:
return
point = self.ledger.points[index]
pending = self._pending_channels_at(index)
# Keeping the current measuring channel first saves a switch when possible.
pending.sort(key=lambda c: (c != self.client.state.channel, c))
for c in pending:
outcome = self.client.measure(point, c)
self.ledger.mark_measured(c, index)
self.belief.update_measure(c, point, outcome.result, outcome.svd_deg)
if outcome.result == NEAR:
cert = clear_certificate(self.belief.candidate_set(c), c, self.config)
# Same-station near clear; do not displace the station scan.
if cert.certified:
self._attempt_clear(c, point, cert)
if self._count_limit_reached():
return
if not self.defer_all:
self._serve_queue()
def _resolve_detected(self):
self._serve_queue()
unresolved = [c for c, b in self.belief.channels.items()
if b.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)]
return "all_resolved" if not unresolved else "unresolved_channels"
class SevenSweep(SevenBatch):
"""Finish discovery at all necessary stations before servicing the queue."""
defer_all = True
@@ -1,15 +0,0 @@
"""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"]
@@ -1,333 +0,0 @@
"""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 math
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 not math.isfinite(value):
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 # 服务器时间戳交叉核对
self._enter_monotonic: float | None = None # 实机计时起点(/enter 成功)
# ------------------------------------------------------------ 公共动作 API
def enter(self) -> dict:
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:
raise ProtocolViolation("remaining_real_duration_s 缺失或非法", body=response)
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,
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)
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,
cost=self.config.cost)
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:
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)
# ---------------------------------------------------------------- 预算控制
@property
def real_remaining_s(self) -> float | None:
"""按本地单调时钟估计的剩余现实秒数(未 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
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
@@ -1,38 +0,0 @@
"""会话日志:机器狗程序必须自行记录指令序列与响应 [题面] 附件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]
def step_events(self) -> list[dict]:
"""每步计时事件(客户端 _log_step 写入,按发生顺序)。"""
return [event for event in self.events if event.get("event") == "step"]
@@ -1,94 +0,0 @@
"""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, "未知状态码")
@@ -1,97 +0,0 @@
"""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 的响应不允许更新状态")
@@ -1,61 +0,0 @@
"""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.total_us / 1_000_000
@property
def total_us(self) -> int:
"""逐段量化后按整数微秒累计,与官方虚拟时钟一致。"""
return sum(round(value * 1_000_000)
for value in (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
@@ -1,44 +0,0 @@
"""传输抽象: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)
@@ -1,254 +0,0 @@
"""图论/动态规划工具箱:开放路径排序、下界与路由度量(S1/S5)。
设计原则(docs/第三四问图论与动态规划优化方案.md §1、§3.2、§3.4):
- **开放路径**(起点固定为原点、终点自由)是本题的真实图问题:一次作业回合
= 依次停靠若干作业点,代价 = Σ 移动时间 + Σ 动作耗时;
- 小规模(n (*@\ensuremath{\le}@*) ``EXACT_LIMIT``)直接用 Held-Karp 精确 DP,规模更大时用
最近邻打底 + 2-opt / Or-opt 改进,并始终给出 **MST 下界**用于在线后悔值审计;
- 所有函数只做"排序与度量",不承担任何安全责任,也不改变覆盖/清除语义。
约定:边权为**欧氏距离**(米);时间折算由调用方按 ``speed_mps`` 处理。
2-opt / Or-opt 用 O(1) 增量代价评估,使 n (*@\ensuremath{\approx}@*) 10² 量级仍可在毫秒内完成。
"""
from __future__ import annotations
import math
from .config import Vec2
EXACT_LIMIT = 13 # Held-Karp 状态数 2^n·n:n=13 → 106 496,纯 Python 可接受
OR_OPT_LIMIT = 60 # 作业点数超过此值时跳过高阶 Or-opt(见 improve_open_path)
def path_length(order: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:
"""开放路径长度(起点 start,终点自由)。"""
total = 0.0
position = start
for point in order:
total += math.dist(position, point)
position = point
return total
def nearest_neighbor_order(points: list[Vec2],
start: Vec2 = (0.0, 0.0)) -> list[Vec2]:
"""最近邻开放路径(O(n²),启发式上界)。"""
remaining = list(points)
order: list[Vec2] = []
position = start
while remaining:
nxt = min(remaining, key=lambda q: math.dist(position, q))
remaining.remove(nxt)
order.append(nxt)
position = nxt
return order
def two_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 8) -> list[Vec2]:
"""开放路径 2-opt(终点自由:只反转内部片段,不做首尾相接)。
增量代价 O(1):反转 [i..j] 只影响 (prev, i) 与 (j, next) 两条边。
"""
best = list(order)
n = len(best)
if n < 3:
return best
for _ in range(max_passes):
improved = False
for i in range(n - 1):
prev = best[i - 1] if i > 0 else start
pi = best[i]
d_prev_i = math.dist(prev, pi)
for j in range(i + 1, n):
pj = best[j]
nxt = best[j + 1] if j + 1 < n else None
d_prev_j = math.dist(prev, pj)
removed = d_prev_i
added = d_prev_j
if nxt is not None:
removed += math.dist(pj, nxt)
added += math.dist(pi, nxt)
if added < removed - 1e-9:
best[i:j + 1] = best[i:j + 1][::-1]
improved = True
pi = best[i]
d_prev_i = math.dist(prev, pi)
if not improved:
break
return best
def or_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 4, max_segment: int = 3) -> list[Vec2]:
"""Or-opt:把长度 (*@\ensuremath{\le}@*) max_segment 的片段搬家(开放路径,增量代价 O(1))。"""
best = list(order)
n = len(best)
if n < 4:
return best
for _ in range(max_passes):
improved = False
for length in range(1, min(max_segment, n - 1) + 1):
for i in range(0, n - length + 1):
segment = best[i:i + length]
rest = best[:i] + best[i + length:]
for j in range(len(rest) + 1):
if j == i:
continue
candidate = rest[:j] + segment + rest[j:]
if _segment_move_gain(best, i, length, j, start) > 1e-9:
best = candidate
improved = True
break
if improved:
break
if improved:
break
if not improved:
break
return best
def _segment_move_gain(order: list[Vec2], i: int, length: int, j: int,
start: Vec2) -> float:
"""把 order[i:i+length] 插到去掉该段后的第 j 位所省下的长度(正=更省)。"""
rest = order[:i] + order[i + length:]
segment = order[i:i + length]
def length_of(seq: list[Vec2]) -> float:
return path_length(seq, start)
return length_of(order) - length_of(rest[:j] + segment + rest[j:])
def improve_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),
max_passes: int = 8) -> list[Vec2]:
"""最近邻 + 2-opt + Or-opt 的开放路径改进(大规模兜底)。
Or-opt 的搬移评估是 O(n³),作业点数超过 :data:`OR_OPT_LIMIT` 时只做
O(n²) 的 2-opt(度量口径下 Or-opt 的相对收益远小于其墙钟代价)。
"""
order = nearest_neighbor_order(points, start)
order = two_opt_open(order, start, max_passes)
if len(order) > OR_OPT_LIMIT:
return order
return or_opt_open(order, start, max_passes)
def held_karp_open(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> list[Vec2]:
"""Held-Karp 精确开放路径(起点固定、终点自由)。
状态 ``dp[mask][j]`` = 访问集合 mask、当前停在 j 的最小长度;
规模 2^n·n,仅用于 n (*@\ensuremath{\le}@*) :data:`EXACT_LIMIT`。返回最优访问顺序。
"""
n = len(points)
if n == 0:
return []
if n == 1:
return list(points)
dist = [[math.dist(points[i], points[j]) for j in range(n)] for i in range(n)]
from_start = [math.dist(start, points[j]) for j in range(n)]
full = (1 << n) - 1
inf = float("inf")
dp: list[list[float]] = [[inf] * n for _ in range(1 << n)]
parent: list[list[int]] = [[-1] * n for _ in range(1 << n)]
for j in range(n):
dp[1 << j][j] = from_start[j]
for mask in range(1 << n):
row = dp[mask]
for j in range(n):
current = row[j]
if current == inf or not (mask >> j) & 1:
continue
rest = full ^ mask
while rest:
bit = rest & -rest
k = bit.bit_length() - 1
rest ^= bit
candidate = current + dist[j][k]
nxt = mask | bit
if candidate < dp[nxt][k]:
dp[nxt][k] = candidate
parent[nxt][k] = j
best_end = min(range(n), key=lambda j: dp[full][j])
order: list[Vec2] = []
mask, j = full, best_end
while j != -1:
order.append(points[j])
previous = parent[mask][j]
mask ^= 1 << j
j = previous
order.reverse()
return order
def optimal_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),
exact_limit: int = EXACT_LIMIT) -> list[Vec2]:
"""离线最优(或近优)开放路径:小规模精确,大规模 2-opt + Or-opt。"""
unique = _dedupe(points)
if len(unique) <= exact_limit:
return held_karp_open(unique, start)
return improve_open_path(unique, start)
def mst_weight(points: list[Vec2], start: Vec2 | None = (0.0, 0.0)) -> float:
"""最小生成树权重(Prim,O(n²));含 start 时给出开放路径最优值的下界。
开放路径删除其最后一条边即得一条生成树,故 MST 权重 (*@\ensuremath{\le}@*) 最优路径长度。
"""
nodes = ([start] if start is not None else []) + list(points)
n = len(nodes)
if n <= 1:
return 0.0
in_tree = [False] * n
best = [float("inf")] * n
best[0] = 0.0
total = 0.0
for _ in range(n):
u, u_cost = -1, float("inf")
for i in range(n):
if not in_tree[i] and best[i] < u_cost:
u, u_cost = i, best[i]
if u < 0:
break
in_tree[u] = True
total += u_cost
ux, uy = nodes[u]
for v in range(n):
if in_tree[v]:
continue
distance = math.hypot(nodes[v][0] - ux, nodes[v][1] - uy)
if distance < best[v]:
best[v] = distance
return total
def _dedupe(points, cell_m: float = 0.5) -> list[Vec2]:
"""按 ``cell_m`` 网格去重(同一物理点只算一次作业)。"""
seen: set[tuple[int, int]] = set()
unique: list[Vec2] = []
for point in points:
key = (int(round(point[0] / cell_m)), int(round(point[1] / cell_m)))
if key in seen:
continue
seen.add(key)
unique.append((float(point[0]), float(point[1])))
return unique
def route_metrics(service_points, actual_move_m: float,
speed_mps: float = 5.0) -> dict:
"""路由度量:离线最优开放回路、MST 下界与相对 gap(S1 必报列)。"""
unique = _dedupe(service_points)
offline = path_length(optimal_open_path(unique)) if unique else 0.0
lower = mst_weight(unique)
gap = ((actual_move_m - offline) / actual_move_m) if actual_move_m > 0 else 0.0
return {
"distinct_service_points": len(unique),
"offline_opt_open_path_m": round(offline, 1),
"offline_opt_open_path_s": round(offline / speed_mps, 1),
"route_lower_bound_s": round(lower / speed_mps, 1),
"route_gap_vs_offline": round(gap, 4),
}
File diff suppressed because it is too large Load Diff