46
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
## 交付文件
|
||||
|
||||
- main.pdf:论文、正式数据清单与完整源码附录。
|
||||
- main.pdf:论文、正式数据清单与四问核心函数代码附录。
|
||||
- main.tex、sections/:可编辑 LaTeX 源文件。
|
||||
- data/formal-results.json、formal-results.csv、audit.md:六份正式测试数据、复算结果与校验说明。
|
||||
- support-materials.zip:源码、六份正式原始日志、统计快照、论文工程及逐文件哈希清单。
|
||||
@@ -37,12 +37,18 @@
|
||||
输出为 main.pdf。VS Code 默认配方为 latexmk (xelatex),自动完成交叉引用所需的多遍编译。
|
||||
需要 TeX Live 的 XeLaTeX、ctex、TikZ,以及原项目 docs/paper/fonts/ 目录。
|
||||
|
||||
在项目根目录生成支撑包:
|
||||
生成附录核心函数清单(数据源为单文件核心代码 ipynb,需先更新该 ipynb):
|
||||
|
||||
python scripts/build_code_appendix.py
|
||||
|
||||
在项目根目录生成支撑包(需要原电脑的 Jammers-simulator-full 正式包):
|
||||
|
||||
python scripts/build_paper_support.py --package
|
||||
|
||||
## 排版与复算
|
||||
|
||||
正文采用原模板的字体、标题、页边距及页码设置。源码附录通过 data/listing-source/ 的显示副本处理数学符号的 LaTeX 转义,支撑包保留原始可执行源码。
|
||||
正文采用原模板的字体、标题、页边距及页码设置。附录代码直接取自单文件核心代码 ipynb,数学符号由导言区的 lstlisting 样式 corecode 映射,不再维护独立的源码显示副本目录。
|
||||
统计表、汇总量和耗时图由同一快照生成;几何图取正文给定参数。
|
||||
正文使用集合估计、凸外包、清除充分条件、集合覆盖、信息增益和最近邻路径排序等概念描述模型。
|
||||
附录只收录四问主线的核心函数(32 个,约 1300 行),评测机、命令行外壳、采集与绘图脚本、
|
||||
测试以及未被采用的方案不进入论文;完整可执行程序见支撑包的 mcm26b-核心代码.ipynb。
|
||||
|
||||
@@ -33,3 +33,17 @@ mcm26b-paper.cls 保持与原仓库模板逐字节一致,文档沿用原模板
|
||||
正式统计按官方整数微秒和毫秒计算,支撑包提供 SHA-256 清单供复核。
|
||||
|
||||
本轮修订前的 PDF 和支撑包保存在 tmp/paper-rewrite/main-before-six-records.pdf 与 support-before-six-records.zip。
|
||||
|
||||
## 清理论文与源码附录(本轮)
|
||||
|
||||
- 清除 7 个文件中已提交的 git 冲突标记(main.tex、results.tex、q1.tex、q3.tex、
|
||||
q4.tex、appendix.tex、scripts/build_paper_support.py):冲突块统一取 PNG 图版一侧,
|
||||
即图全部使用 docs/figures 下由 build_figure_data.py 与 scripts/matlab 生成的插图,
|
||||
与未冲突的 timing/q2 两节风格一致;随之保留演练回放图与三点交会解析算例。
|
||||
- 顺带修正冲突遗留的事实不一致:正式样本由"四份/两次"改为六份(问题三、四各三次),
|
||||
删除"第三次正式记录未提供"的过时表述,结果一节的重复等效距离段落合并。
|
||||
- 附录不再逐字排版全部源码(原 33 份,含评测机、命令行外壳、采集/基准/绘图脚本与测试)。
|
||||
改为"核心程序代码":只列四问主线核心函数 32 个、约 1300 行,每个附一句中文说明;
|
||||
数据源是 mcm26b-核心代码.ipynb,由 scripts/build_code_appendix.py 生成
|
||||
data/source-listings.tex,数学符号在 main.tex 的 corecode 样式里统一映射。
|
||||
- 复算:latexmk -xelatex 通过,50 页,无未解析引用与未定义文献。
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
+1461
-69
File diff suppressed because it is too large
Load Diff
@@ -46,5 +46,4 @@
|
||||
\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};
|
||||
\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};
|
||||
\end{tikzpicture}
|
||||
\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}
|
||||
\label{fig:q4plan}\end{figure}
|
||||
\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}\end{figure}
|
||||
|
||||
Binary file not shown.
+25
-10
@@ -31,6 +31,24 @@
|
||||
\hrule\vspace{5pt}\textbf{#1}\par\vspace{3pt}\hrule\vspace{5pt}
|
||||
}{\vspace{4pt}\hrule\end{minipage}\par\medskip}
|
||||
\renewcommand{\proofname}{证明}
|
||||
% 附录"核心程序代码"的代码排版样式:等宽小字号、行号、长行折行;
|
||||
% 数学符号与 \ { } 通过 literate 映射,避免逐字符转义代码正文。
|
||||
\lstdefinestyle{corecode}{%
|
||||
basicstyle=\ttfamily\fontsize{7.6}{9}\selectfont,numberstyle=\tiny,
|
||||
numbers=left,numbersep=6pt,aboveskip=6pt,belowskip=8pt,
|
||||
xleftmargin=2em,frame=none,breaklines=true,columns=fullflexible,
|
||||
keepspaces=true,
|
||||
literate={\\}{$\backslash$}{0}%
|
||||
{$\{$}{$\{$}{0}%
|
||||
{$\}$}{$\}$}{0}%
|
||||
{→}{$\to$}{0}{≤}{$\le$}{0}{≥}{$\ge$}{0}{∈}{$\in$}{0}%
|
||||
{∩}{$\cap$}{0}{∪}{$\cup$}{0}{≈}{$\approx$}{0}{∅}{$\varnothing$}{0}%
|
||||
{−}{$-$}{0}{×}{$\times$}{0}{√}{$\surd$}{0}%
|
||||
{α}{$\alpha$}{0}{δ}{$\delta$}{0}{θ}{$\theta$}{0}{λ}{$\lambda$}{0}%
|
||||
{π}{$\pi$}{0}{ρ}{$\rho$}{0}{φ}{$\varphi$}{0}%
|
||||
{°}{$^\circ$}{0}{±}{$\pm$}{0}{²}{$^2$}{0}{Ω}{$\Omega$}{0}%
|
||||
{⟹}{$\Longrightarrow$}{0}{∀}{$\forall$}{0}{—}{---}{0}%
|
||||
{/}{/}{0}{§}{\S}{0}}
|
||||
\hypersetup{pdftitle={基于集合估计与覆盖优化的无线电干扰源自动定位清除},
|
||||
pdfauthor={},pdfsubject={2026年全国大学生数学建模竞赛B题},pdfkeywords={交会定位,最小包围圆,主动测量,覆盖搜索}}
|
||||
\input{data/formal-macros}
|
||||
@@ -73,15 +91,10 @@
|
||||
三次正式测试分别清除 $13$、$10$、$14$ 个源,平均定位清除时间为
|
||||
$720.171$、$927.130$、$614.884$ s/个。
|
||||
|
||||
<<<<<<< HEAD
|
||||
实验数据取自官方模拟器 \texttt{test} 目录的六份正式导出及对应统计,
|
||||
累计清除 $\FormalClearCount$ 个源,清除失败计数为零。
|
||||
两问按清除数量加权的平均耗时分别为 $\QThreeWeighted$ 与 $\QFourWeighted$ s/个,
|
||||
移动时间占各次总耗时的 $\FormalMovementMin\%$ 至 $\FormalMovementMax\%$。
|
||||
=======
|
||||
上述四次测试均主动退出且清除失败计数为零。
|
||||
正式成绩仅取自官方模拟器 \texttt{test} 目录所选正式导出包及其匹配统计;
|
||||
正式数据未公开源总数,故不据此宣称全清率,问题三、四的第三次正式记录均未提供。
|
||||
六次正式测试均主动退出且清除失败计数为零。
|
||||
正式成绩仅取自官方模拟器 \texttt{test} 目录下的六份正式导出包及其匹配统计;
|
||||
正式数据未公开源总数,故本文只用清除数量、平均定位清除时间与程序运行时间评价,
|
||||
不据此宣称全清率。
|
||||
|
||||
\textbf{对比实验}以仓库内的配对基准集复核上述方法:
|
||||
问题一朴素基线在随机算例上直径平均绝对误差 $\ProjQOneRandMaeBase$ m、
|
||||
@@ -92,7 +105,9 @@ $720.171$、$927.130$、$614.884$ s/个。
|
||||
问题四直接搬用问题三策略时出现 $\ProjQFourAbsentBase$ 次缺席误判。
|
||||
该组数据来自自建冻结基准集与演练局留档,只用于机制说明和相对行为比较,
|
||||
与正式成绩分节陈述,不合并统计。
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
四问的求解核心已整理为约 $5000$ 行的单文件程序,并按四问主线在附录中列出核心函数
|
||||
与一句话说明;评测机、命令行外壳、采集与绘图脚本、测试以及未被采用的方案不计入正文。
|
||||
|
||||
\vspace{0.4em}
|
||||
\noindent{\heiti 关键词:}有界误差;交会定位;最小包围圆;主动测量;定向覆盖
|
||||
|
||||
@@ -33,28 +33,17 @@ python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.j
|
||||
\toprule
|
||||
文件或目录 & 用途\\
|
||||
\midrule
|
||||
<<<<<<< HEAD
|
||||
\pathtext{src/mcm26b/} & 几何计算、集合估计、覆盖优化、任务排序与通信实现\\
|
||||
\pathtext{src/mcm26b/guarantee/plans/} & 第四问站点方案与离散验证参数\\
|
||||
\pathtext{scripts/audit_paper_formal.py} & 正式文件核对、统计计算与表格生成\\
|
||||
\pathtext{scripts/build_paper_support.py} & 矢量图、源码附录与支撑包生成\\
|
||||
\pathtext{docs/paper/data/} & 六个正式案例的统计快照、表格及校验值\\
|
||||
\pathtext{docs/paper/sections/} & 分章论文源文件\\
|
||||
\pathtext{docs/paper/figures/} & 几何示意图与正式耗时图的矢量源文件\\
|
||||
\pathtext{Jammers-simulator-full/test/} & 六份原名官方正式日志\\
|
||||
=======
|
||||
\pathtext{src/mcm26b/} & 四问几何、信念、覆盖、调度与串行通信实现\\
|
||||
\pathtext{src/mcm26b/guarantee/plans/} & 第四问已实现的覆盖计划及验证参数\\
|
||||
\pathtext{project/} & 最优与基线配对对比框架及其冻结基准集结果\\
|
||||
\pathtext{scripts/audit_paper_formal.py} & 正式文件对应核对、匿名快照及表格复算\\
|
||||
\pathtext{scripts/build_paper_support.py} & 对比表、矢量图、源码附录与支撑包生成\\
|
||||
\pathtext{scripts/build_figure_data.py}、\pathtext{scripts/matlab/} & 插图数据准备与 MATLAB 出图脚本\\
|
||||
\pathtext{docs/paper/data/} & 仅四个所选正式案例的可读统计、表格及哈希\\
|
||||
\pathtext{docs/paper/data/} & 六个正式案例的可读统计、表格及校验值\\
|
||||
\pathtext{docs/paper/sections/} & 分章论文源文件\\
|
||||
\pathtext{docs/paper/figures/} & 正式累计耗时图的矢量源文件\\
|
||||
\pathtext{docs/figures/} & 正文插图(PNG)及其数据清单与生成命令\\
|
||||
\pathtext{Jammers-simulator-full/test/} & 四份原名官方加密正式日志\\
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
\pathtext{Jammers-simulator-full/test/} & 六份原名官方正式日志\\
|
||||
\pathtext{support-manifest.json} & 支撑包文件清单及逐文件 SHA-256\\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
@@ -66,9 +55,13 @@ Python 运行时需要 \texttt{requests},\texttt{numpy} 提供可选加速。
|
||||
在线测试由模拟器界面启动,接口就绪后执行策略入口。
|
||||
具体运行指令和编译步骤见支撑包的 \texttt{README.md}。
|
||||
|
||||
\section{完整程序源码}
|
||||
以下列出当前策略、配套模块及论文数据处理程序的完整源码。
|
||||
附录通过生成的显示副本将数学符号转换为 LaTeX 命令,
|
||||
支撑包保留对应的可执行 Python 文件。
|
||||
实验结果见正文正式测试表及附录A的数据清单。
|
||||
\section{核心程序代码}
|
||||
四问的求解核心集中在约 $5000$ 行的单文件程序中,按\textbf{公共层、几何与集合估计层、
|
||||
覆盖保证层、策略层}四层组织;评测机、命令行外壳、数据采集与绘图脚本、
|
||||
单元测试以及未被采用的方案不属于求解核心,不在此列出。
|
||||
完整可执行程序为支撑材料中的 \pathtext{mcm26b-核心代码.ipynb},
|
||||
\pathtext{src/mcm26b/} 保留与之逐字对应的模块化版本,两者函数名一一对应。
|
||||
下列代码为无改动的摘录:函数体与注释与程序一致,只按核心程度取舍。
|
||||
未收录的部分包括独立评测机(按附件协议复现模拟器,与求解代码零耦合)、
|
||||
命令行入口、数据采集与论文绘图脚本、单元测试,以及经实测未采用的候选方案。
|
||||
\input{data/source-listings}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
\subsection{对比口径与有效性判定}
|
||||
本节数据来自仓库内的配对对比框架 \pathtext{project/},
|
||||
用于回答上述几何判据与闭环策略相对朴素做法改进在哪里;
|
||||
它与第~\ref{sec:formal} 节的四份官方正式导出完全分开,两类数字不合并、不相加。
|
||||
它与第~\ref{sec:formal} 节的六份官方正式导出完全分开,两类数字不合并、不相加。
|
||||
框架设三种角色:\textbf{最优实现}直接复用 \pathtext{src/mcm26b} 中已由测试固化的生产代码,
|
||||
只增加输入输出适配;\textbf{基线实现}是同题下更朴素但能独立跑通的对照,
|
||||
不共享最优实现的关键步骤;\textbf{独立参考}与分析算法正交,用于判定谁对谁错。
|
||||
@@ -116,7 +116,8 @@
|
||||
\end{tabular*}
|
||||
\label{tab:projq3}
|
||||
\end{table}
|
||||
三种方法在 $\ProjQThreeScenes$ 个冻结场景(源数 $10$、$13$、$16$,种子与源数版本化)
|
||||
表~\ref{tab:projq3} 汇总三种方法。它们在 $\ProjQThreeScenes$ 个冻结场景
|
||||
(源数 $10$、$13$、$16$,种子与源数版本化)
|
||||
上全部完成全清,缺席误判均为零,差别只在时间:
|
||||
默认 \texttt{bayes} 平均 $\ProjQThreeMeanOpt$ s,
|
||||
七点批量 \texttt{seven} $\ProjQThreeMeanSeven$ s,$49$ 点网格 \texttt{grid} $\ProjQThreeMeanBase$ s。
|
||||
@@ -144,7 +145,7 @@
|
||||
\end{tabular*}
|
||||
\label{tab:projq4}
|
||||
\end{table}
|
||||
$\ProjQFourScenes$ 个场景取自 core 与 boundary 两组,
|
||||
表~\ref{tab:projq4} 汇总三种方法。$\ProjQFourScenes$ 个场景取自 core 与 boundary 两组,
|
||||
boundary 全部是背离圆心发射的边界定向源,属最不利工况。
|
||||
\texttt{directional} 在 $\ProjQFourValidOpt$ 局上全部有效,全清率 $\ProjQFourClearOpt$,
|
||||
平均后备清除 $\ProjQFourFallbackOpt$ 次,对应遮挡导致的定位停滞。
|
||||
|
||||
@@ -95,9 +95,6 @@ $D\le\norm{a-z}+\norm{z-b}\le D$。
|
||||
等号迫使 $z=o$。反之,圆盘为凸集,包含全部顶点即包含它们的凸包。
|
||||
\end{proof}
|
||||
若条件不成立,输出圆外顶点及超出量
|
||||
<<<<<<< HEAD
|
||||
$e=\max_v\norm{v-o}-D/2$,用于量化该圆盘的覆盖误差。
|
||||
=======
|
||||
$e=\max_v\norm{v-o}-D/2$,即得到可独立检查的否定证据。
|
||||
不能仅以直径端点在圆周上判断整个区域被覆盖。
|
||||
图~\ref{fig:q1geometry}(a) 给出一组三点交会算例:左图为三条前向角域及其交集,
|
||||
@@ -107,7 +104,6 @@ $e=\max_v\norm{v-o}-D/2$,即得到可独立检查的否定证据。
|
||||
而最远顶点到圆心的距离为 $12.602$ m,故直径圆并不覆盖定位区域;
|
||||
其最小包围圆半径为 $12.069$ m,满足 $20$ m 的清除判据。
|
||||
这说明直径不超过 $40$ m 与能否清除是两个不同的判据,覆盖判定必须逐顶点检查。
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
\subsection{反例与可清除半径}
|
||||
构造等边三角形
|
||||
@@ -122,26 +118,6 @@ $40/\sqrt3\approx23.094$ m,同样大于清除半径。
|
||||
|
||||
\begin{figure}[htbp]
|
||||
\centering
|
||||
<<<<<<< HEAD
|
||||
\begin{tikzpicture}[x=0.095cm,y=0.095cm,font=\small]
|
||||
\fill[inkblue!10] (0,0)--(40,0)--(20,34.641)--cycle;
|
||||
\draw[thick] (0,0)--(40,0)--(20,34.641)--cycle;
|
||||
\draw[warmgray,dashed,thick] (20,0) circle (20);
|
||||
\draw[inkblue,thick] (20,11.547) circle (23.094);
|
||||
\fill (0,0) circle (0.6) node[below left] {$A$};
|
||||
\fill (40,0) circle (0.6) node[below right] {$B$};
|
||||
\fill (20,34.641) circle (0.6) node[above] {$C$};
|
||||
\fill[warmgray] (20,0) circle (0.6) node[below] {$o$};
|
||||
\fill[inkblue] (20,11.547) circle (0.6) node[right] {$m$};
|
||||
\draw[->,warmgray] (49,-14)--(35,-13);
|
||||
\node[anchor=west,warmgray] at (49,-14) {直径圆:$r=20$};
|
||||
\draw[->,inkblue] (49,24)--(41,22);
|
||||
\node[anchor=west,inkblue] at (49,24) {最小包围圆:$\rho=40/\sqrt3$};
|
||||
\node at (20,-26) {$AB=BC=CA=40$ m};
|
||||
\end{tikzpicture}
|
||||
\caption{等边三角形的直径圆与最小包围圆。}
|
||||
\label{fig:triangle}
|
||||
=======
|
||||
\begin{minipage}[t]{0.49\linewidth}\centering
|
||||
\includegraphics[width=\linewidth]{geo-q1-convex-intersection-zoom.png}
|
||||
\end{minipage}\hfill
|
||||
@@ -154,7 +130,6 @@ $40/\sqrt3\approx23.094$ m,同样大于清除半径。
|
||||
(b) 等边三角形解析反例,虚线直径圆 $r=20$ m 不覆盖顶点 $C$,
|
||||
实线最小包围圆 $\rho=40/\sqrt3\approx23.094$ m 覆盖全部顶点,不是模拟器观测。}
|
||||
\label{fig:q1geometry}
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
\end{figure}
|
||||
|
||||
该形状可由题设的 $\pm1^\circ$ 角域构造出来。例如检测点
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
\section{问题三:全向多源的覆盖搜索与滚动调度}
|
||||
\subsection{统一时间目标与任务状态}
|
||||
<<<<<<< HEAD
|
||||
设整个会话移动距离为 $L$,检测、换频、成功和失败清除次数分别为
|
||||
$N_m,N_s,N_c^+,N_c^-$。忽略每段移动时间的微秒量化时,总虚拟耗时为
|
||||
\begin{equation}
|
||||
T=\frac{L}{5}+5N_m+N_s+5N_c^++3N_c^-.
|
||||
\label{eq:time}
|
||||
\end{equation}
|
||||
优化目标是在全部源清除的约束下最小化 $T$。
|
||||
题目统计的平均定位清除时间为 $\bar T=T/N_c^+$,包含搜索、定位与清除的全部耗时。
|
||||
清除请求中的频道指定操作对象,测向机保持原频道;
|
||||
进入与退出的虚拟耗时为零,因此采用终点自由的开放路径模型。
|
||||
=======
|
||||
按式~\eqref{eq:time} 的计时记账,优化目标是在全部源清除的约束下最小化虚拟耗时 $T$。
|
||||
题目统计的平均定位清除时间是 $\bar T=T/N_c^+$;
|
||||
不能用每次清除动作的固定 $5$ s 代替。
|
||||
清除请求中的频道是目标频道,不改变测向机频道;
|
||||
进入与退出均不增加虚拟时间,因此路径终点自由,无需返回原点。
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
各频道的状态按“未知、已发现、可清除、已清除、已证无源”管理。
|
||||
首次获得 $\direction$ 或 $\near$ 即确立该频道存在源。
|
||||
@@ -57,7 +44,6 @@ $r^2+a^2-\sqrt3ar$。
|
||||
|
||||
\begin{figure}[htbp]
|
||||
\centering
|
||||
<<<<<<< HEAD
|
||||
\begin{tikzpicture}[x=0.0019cm,y=0.0019cm,font=\small]
|
||||
\begin{scope}
|
||||
\clip (0,0) circle (1800);
|
||||
@@ -82,14 +68,10 @@ $r^2+a^2-\sqrt3ar$。
|
||||
{$a=1159.7$ m\\$R=1800$ m\\最坏距离 $\le984.539$ m};
|
||||
\end{tikzpicture}
|
||||
\caption{七点全向覆盖的站点构造与接收圆盘。}
|
||||
=======
|
||||
\includegraphics[width=0.42\linewidth]{geo-q3-seven-point-coverage.png}
|
||||
\caption{七点全向覆盖构造。站点取自配置参数 $a=1159.7$ m,
|
||||
圆圈为 $1000$ m 接收盘,最差点位于 $(0,-1825)$ 附近,最坏距离 $\le984.539$ m。}
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
\label{fig:seven}
|
||||
\end{figure}
|
||||
|
||||
图~\ref{fig:seven} 给出站点构造与最小接收圆盘的关系。
|
||||
顺次从原点访问六个环站的开放路径长为 $6a$。
|
||||
任务总路径由覆盖站间移动以及定位、清除所需的局部移动共同构成。
|
||||
|
||||
@@ -209,9 +191,6 @@ C(\pi)=\frac{\norm{p-q_{\pi_1}}}{5}
|
||||
当前每轮追加有效定位测量上限为 $8$;
|
||||
停滞、协议异常或预算耗尽时,记录各频道状态并终止。
|
||||
程序以接口返回的现实预算为准,并预留退出时间,
|
||||
<<<<<<< HEAD
|
||||
虚拟动作耗时以整数微秒累计,程序运行时间由官方统计单独记录。
|
||||
=======
|
||||
实际计时在整数微秒状态上累计,避免用实机运行秒数代替虚拟动作耗时。
|
||||
|
||||
\subsection{运行过程示例}
|
||||
@@ -228,4 +207,3 @@ C(\pi)=\frac{\norm{p-q_{\pi_1}}}{5}
|
||||
该局为演练案例,非正式测试记录。}
|
||||
\label{fig:replay3}
|
||||
\end{figure}
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
@@ -182,11 +182,6 @@ $N_{\mathrm{cell}}\,t_{\mathrm{RTT},95}+t_{\mathrm{reserve}}$。
|
||||
\item 覆盖任务结束后处理未解决频道;主动退出时报告清除数及停止依据。
|
||||
\end{enumerate}
|
||||
\end{algorithmblock}
|
||||
<<<<<<< HEAD
|
||||
问题四采用站内批量检测与最近邻任务排序。
|
||||
算法结合位置集合、朝向约束和剩余预算确定测点及清除顺序,
|
||||
并以各频道的处理状态作为终止时的输出。
|
||||
=======
|
||||
问题四使用“本站批量扫频、最近候选圆心服务”的调度骨架,
|
||||
并未直接复用问题三的统一信息增益任务表。
|
||||
若要求严格的混合源全清证据,除采用连续覆盖计划外,
|
||||
@@ -208,4 +203,3 @@ $N_{\mathrm{cell}}\,t_{\mathrm{RTT},95}+t_{\mathrm{reserve}}$。
|
||||
紫色半盘为定向源的前向可见区域。该局为演练案例,非正式测试记录。}
|
||||
\label{fig:replay4}
|
||||
\end{figure}
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<<<<<<< HEAD
|
||||
\section{官方正式测试结果与时间分解}
|
||||
\subsection{实验数据与评价指标}
|
||||
实验采用官方模拟器 \pathtext{Jammers-simulator-full/test} 目录中的六份正式导出,
|
||||
包括问题三、四各三次测试。各文件经 SHA-256 校验后,与官方原始导出、
|
||||
结果侧文件及正式统计记录建立对应关系\cite{formal}。
|
||||
案例编码与文件校验值列于附录~\ref{app:data}。
|
||||
=======
|
||||
\section{官方正式测试结果与时间分解}\label{sec:formal}
|
||||
\subsection{数据选择及对应关系核对}
|
||||
本节实验样本严格限定为官方模拟器
|
||||
\pathtext{Jammers-simulator-full/test} 下四个正式导出包。
|
||||
问题三、四各有正式序号 $1$、$2$,文件名及案例编码见结果表和附录。
|
||||
\pathtext{Jammers-simulator-full/test} 下的六份正式导出包,问题三、四各有三次。
|
||||
文件名、案例编码与完整包校验值见结果表与附录~\ref{app:data}。
|
||||
本节表格不采用 \texttt{project/results} 中的自建评测数据,
|
||||
也不将历史演练案例当作正式测试补入;
|
||||
自建配对对比与演练局示例分别在第~\ref{sec:comparison} 节和各问的运行过程示例中单独陈述,
|
||||
两类数据不合并统计。
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
也不将历史演练案例当作正式测试补入:自建配对对比见第~\ref{sec:comparison} 节,
|
||||
演练局示例见问题三、四的运行过程示例,三类数据分开陈述,不合并统计。
|
||||
|
||||
评价指标包括清除数量 $N_c^+$、平均定位清除时间 $\bar T$、程序运行时间
|
||||
$T_{\mathrm{real}}$,以及检测、换频和清除的动作次数。
|
||||
@@ -68,6 +58,7 @@ $T_{\mathrm{real}}$ 采用官方记录的毫秒值。
|
||||
\end{tabularx}
|
||||
\label{tab:formal4}
|
||||
\end{table}
|
||||
表~\ref{tab:formal4} 给出问题四的三次正式结果。
|
||||
三次测试分别清除 $13$、$10$、$14$ 个源,累计 $\QFourCount$ 个,
|
||||
平均定位清除时间分别为 $720.171$、$927.130$、$614.884$ s/个。
|
||||
总虚拟时间之和为 $\QFourTotal$ s,相应的加权平均时间为
|
||||
@@ -111,13 +102,6 @@ T_{\mathrm{move}}
|
||||
\end{table}
|
||||
\input{figures/formal-time-bars}
|
||||
|
||||
<<<<<<< HEAD
|
||||
六次测试中,移动时间占总虚拟耗时的 $\FormalMovementMin\%$ 至 $\FormalMovementMax\%$,
|
||||
是主要时间成本。问题三的检测次数为 $105$、$117$、$117$,
|
||||
问题四为 $349$、$361$、$264$;
|
||||
混合源案例中,朝向约束增加了观测与位置选择的复杂度。
|
||||
因此,后续优化可同时考虑减少移动距离与提高单次观测的信息增益。
|
||||
=======
|
||||
图~\ref{fig:timingmeasured} 按同一记账口径给出一局全向演练的累计时间构成:
|
||||
横轴为动作序号,堆叠高度为累计虚拟时间,
|
||||
移动项在整局中占主要部分,清除阶段表现为阶梯状跃升。
|
||||
@@ -132,18 +116,13 @@ T_{\mathrm{move}}
|
||||
\label{fig:timingmeasured}
|
||||
\end{figure}
|
||||
|
||||
四局移动时间占总虚拟时间的 $76.59\%$ 至 $78.02\%$。
|
||||
从这一组正式样本看,减少移动是影响总耗时的主要方向,
|
||||
检测与换频仍应同时计入路径选择。
|
||||
由 $L_{\mathrm{eq}}=5T_{\mathrm{move}}$ 得四局等效距离分别约为
|
||||
$12.045$、$13.627$、$36.386$、$35.507$ km。
|
||||
它们由已量化的移动时间反推,不是独立轨迹测得的长度。
|
||||
>>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231
|
||||
|
||||
由 $L_{\mathrm{eq}}=5T_{\mathrm{move}}$ 得问题三的等效移动距离依次为
|
||||
$12.045$、$13.627$、$10.824$ km,问题四依次为
|
||||
$36.386$、$35.507$、$35.027$ km。
|
||||
该距离由官方时间计数及移动速度计算。
|
||||
六次测试的移动时间占总虚拟耗时的 $\FormalMovementMin\%$ 至 $\FormalMovementMax\%$,
|
||||
是主要的时间成本,减少移动因而也是缩短总耗时的主要方向,
|
||||
但检测与换频仍需一并计入路径选择。
|
||||
由 $L_{\mathrm{eq}}=5T_{\mathrm{move}}$ 反推等效移动距离:
|
||||
问题三依次为 $12.045$、$13.627$、$10.824$ km,
|
||||
问题四依次为 $36.386$、$35.507$、$35.027$ km。
|
||||
该等效距离由官方动作计数与移动速度换算得到,不是独立轨迹测得的长度。
|
||||
所有平均值先以官方整数单位求和、相除,最后保留三位小数。
|
||||
|
||||
\subsection{实验结果分析}
|
||||
|
||||
Reference in New Issue
Block a user