论文完成
This commit is contained in:
@@ -152,10 +152,12 @@ def write_outputs(rows: list[dict], out: Path) -> None:
|
||||
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)
|
||||
run_total = sum(r["program_run_duration_ms"] for r in group) / 1000
|
||||
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)}}}"])
|
||||
f"\\newcommand{{\\Q{word}Mean}}{{{fixed(mean)}}}",
|
||||
f"\\newcommand{{\\Q{word}ProgramTotal}}{{{fixed(Decimal(run_total))}}}"])
|
||||
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([
|
||||
@@ -164,6 +166,8 @@ def write_outputs(rows: list[dict], out: Path) -> None:
|
||||
f"\\newcommand{{\\FormalMovementMax}}{{{fixed(max(shares), 2)}}}",
|
||||
f"\\newcommand{{\\FormalRunMin}}{{{fixed(min(runtimes))}}}",
|
||||
f"\\newcommand{{\\FormalRunMax}}{{{fixed(max(runtimes))}}}",
|
||||
f"\\newcommand{{\\FormalVirtualTotal}}"
|
||||
f"{{{fixed(sum(Decimal(r['virtual_time_us']) / 1_000_000 for r in rows), 6)}}}",
|
||||
])
|
||||
(out / "formal-macros.tex").write_text("\n".join(constants) + "\n", "utf-8")
|
||||
audit = ["# 正式测试数据核对", "", f"本次汇总 `Jammers-simulator-full/test` 中的 {len(rows)} 份正式导出包。",
|
||||
|
||||
+176
-50
@@ -9,6 +9,7 @@ is exempt from the line-length check (the only pylint exception in scripts/).
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
@@ -24,6 +25,16 @@ def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def write_tex(path: Path, text: str) -> None:
|
||||
"""写 LaTeX 片段;强制 LF 换行。
|
||||
|
||||
Windows 下的 CRLF 会让 booktabs 的 ``\\bottomrule`` 在表格内部变成
|
||||
"Misplaced \\noalign":``\\input`` 读入的首行在 TeX 缓冲中已被 ``\\par`` 化,
|
||||
表格因此提前结束。所有 ``data/*.tex`` 一律使用 LF。
|
||||
"""
|
||||
path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), "utf-8", newline="\n")
|
||||
|
||||
|
||||
def figures(rows: list[dict]) -> None:
|
||||
out = PAPER / "figures"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
@@ -48,7 +59,7 @@ def figures(rows: list[dict]) -> None:
|
||||
r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}",
|
||||
r"\label{fig:q4plan}\end{figure}",
|
||||
]
|
||||
(out / "q4-plan.tex").write_text("\n".join(lines) + "\n", "utf-8")
|
||||
write_tex(out / "q4-plan.tex", "\n".join(lines) + "\n")
|
||||
colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"]
|
||||
bars = [
|
||||
r"\begin{figure}[htbp]\centering",
|
||||
@@ -79,7 +90,7 @@ def figures(rows: list[dict]) -> None:
|
||||
r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}",
|
||||
r"\label{fig:costbars}\end{figure}",
|
||||
]
|
||||
(out / "formal-time-bars.tex").write_text("\n".join(bars) + "\n", "utf-8")
|
||||
write_tex(out / "formal-time-bars.tex", "\n".join(bars) + "\n")
|
||||
|
||||
|
||||
def fixed(value: float | None, digits: int = 3) -> str:
|
||||
@@ -171,33 +182,10 @@ def project_tables() -> None:
|
||||
("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")
|
||||
write_tex(out / "project-macros.tex", "\n".join(lines) + "\n")
|
||||
|
||||
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_tex(out / stem, "\n".join([head, *rows]) + "\n")
|
||||
|
||||
write("project-q1-summary-rows.tex", [
|
||||
f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & "
|
||||
@@ -214,14 +202,6 @@ def project_tables() -> None:
|
||||
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']} & "
|
||||
@@ -233,25 +213,65 @@ def project_tables() -> None:
|
||||
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 table_environment(caption: str, label: str, colspec: str, header: str,
|
||||
body: list[str], placement: str = "htbp",
|
||||
tabcolsep: str = "4pt") -> str:
|
||||
"""整表输出:表格不能被 ``\\input`` 切开,否则注释行会在表内注入 ``\\par``。"""
|
||||
return "\n".join([
|
||||
f"\\begin{{table}}[{placement}]",
|
||||
"\\centering\\small",
|
||||
f"\\caption{{{caption}}}",
|
||||
f"\\setlength{{\\tabcolsep}}{{{tabcolsep}}}",
|
||||
f"\\begin{{tabular}}{{{colspec}}}",
|
||||
"\\toprule",
|
||||
header,
|
||||
"\\midrule",
|
||||
*body,
|
||||
"\\bottomrule",
|
||||
"\\end{tabular}",
|
||||
f"\\label{{{label}}}",
|
||||
"\\end{table}",
|
||||
"",
|
||||
])
|
||||
|
||||
write("project-q3-table.tex", [table_environment(
|
||||
f"问题三:三种策略在 {three['optimal']['episodes']} 个全向源场景上的对比(时间:s;"
|
||||
f"三种策略的缺席误判均为 {three['optimal']['absent_misjudged_total']})",
|
||||
"tab:q3compare", "lrrrrrrrr",
|
||||
"策略 & 有效局 & 全清率 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值 & "
|
||||
"位移/km & 后备清除" + r"\\",
|
||||
# 问题三三种策略的缺席误判均为 0,该列改写入表题以控制表宽。
|
||||
[" & ".join(c for i, c in enumerate(row.split(" & ")) if i != 3) + r" \\"
|
||||
for row in variant_rows(three, [
|
||||
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"),
|
||||
("baseline", "grid(49 点网格)")])], tabcolsep="2pt")])
|
||||
write("project-q4-table.tex", [table_environment(
|
||||
f"问题四:三种策略在 {four['optimal']['episodes']} 个混合源场景上的对比(时间:s)",
|
||||
"tab:q4compare", "lrrrrrrr",
|
||||
"策略 & 有效局 & 全清率 & 缺席误判 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值" + r"\\",
|
||||
# 问题四表去掉位移与后备清除两列(正文单独说明后备清除次数),以控制表宽。
|
||||
[" & ".join(row.split(" & ")[:8]) + r" \\"
|
||||
for row in variant_rows(four, [
|
||||
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
|
||||
("baseline", "bayes(问题三策略)")])], tabcolsep="4pt")])
|
||||
|
||||
|
||||
def listings(rows: list[dict]) -> None:
|
||||
"""生成正式数据清单;核心函数源码清单由 build_code_appendix.py 单独生成。
|
||||
"""生成附录的正式数据表格;核心函数源码清单由 build_code_appendix.py 单独生成。
|
||||
|
||||
论文附录只收录四问主线的核心函数(数据源为单文件核心代码 ipynb),
|
||||
评测机、命令行外壳、采集/绘图脚本、测试与未采用方案不再逐字排版。
|
||||
正式结果表同样整表输出:被 ``\\input`` 的文件若以注释行开头,
|
||||
会在表格内部注入 ``\\par``,使 booktabs 的 ``\\bottomrule`` 报错。
|
||||
"""
|
||||
data = PAPER / "data"
|
||||
head = ("% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境,"
|
||||
"供附录直接 \\input。")
|
||||
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",
|
||||
r"\toprule 案例 & 原始文件名与 SHA-256\\\midrule\endhead",
|
||||
]
|
||||
for row in rows:
|
||||
h = row["package_sha256"]
|
||||
@@ -261,13 +281,69 @@ def listings(rows: list[dict]) -> None:
|
||||
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")
|
||||
write_tex(data / "formal-file-list.tex", "\n".join(table) + "\n")
|
||||
|
||||
def formal_table(caption: str, label: str, header: str, body: list[str]) -> str:
|
||||
return "\n".join([
|
||||
r"\begin{table}[htbp]",
|
||||
r"\centering\small",
|
||||
f"\\caption{{{caption}}}",
|
||||
r"\setlength{\tabcolsep}{4pt}",
|
||||
r"\begin{tabularx}{\linewidth}{l X r r r}",
|
||||
r"\toprule",
|
||||
header,
|
||||
r"\midrule",
|
||||
*body,
|
||||
r"\bottomrule",
|
||||
r"\end{tabularx}",
|
||||
f"\\label{{{label}}}",
|
||||
r"\end{table}",
|
||||
"",
|
||||
])
|
||||
|
||||
formal_head = r"测试 & 测试案例编码 & 清除数 & 总虚拟时间/s & 程序运行时间/s\\"
|
||||
for problem in (3, 4):
|
||||
subset = sorted((r for r in rows if r["problem_no"] == problem),
|
||||
key=lambda r: r["formal_index"])
|
||||
body = [
|
||||
f"测试 {r['formal_index']} & \\texttt{{{r['case_code']}}} & "
|
||||
f"{r['cleared_jammer_count']} & {fixed(r['virtual_time_s'])} & "
|
||||
f"{fixed(r['program_run_duration_s'])} " + r"\\"
|
||||
for r in subset
|
||||
]
|
||||
write_tex(data / f"formal-q{problem}-table.tex", "\n".join([head, formal_table(
|
||||
f"问题{'三四'[problem - 3]}正式测试结果(时间单位:s)",
|
||||
f"tab:formal{problem}", formal_head, body)]))
|
||||
|
||||
body = []
|
||||
for r in sorted(rows, key=lambda r: (r["problem_no"], r["formal_index"])):
|
||||
body.append(
|
||||
f"Q{r['problem_no']}-{r['formal_index']} & {r['measure_accepted_count']} & "
|
||||
f"{r['channel_switch_count']} & {r['clear_failure_count']} & "
|
||||
f"{fixed(r['virtual_time_s'])} & {fixed(r['inferred_movement_time_s'])} & "
|
||||
f"{percent(r['movement_share_percent'] / 100)} " + r"\\")
|
||||
write_tex(data / "formal-cost-table.tex", "\n".join([
|
||||
head,
|
||||
r"\begin{table}[htbp]",
|
||||
r"\centering\small",
|
||||
r"\caption{六次正式测试的动作计数与移动耗时}",
|
||||
r"\setlength{\tabcolsep}{4.5pt}",
|
||||
r"\begin{tabular}{lrrrrrr}",
|
||||
r"\toprule",
|
||||
r"案例 & 检测数 & 换频数 & 失败清除 & 总虚拟时间/s & 移动时间/s & 移动占比\\",
|
||||
r"\midrule",
|
||||
*body,
|
||||
r"\bottomrule",
|
||||
r"\end{tabular}",
|
||||
r"\label{tab:formalcost}",
|
||||
r"\end{table}",
|
||||
""]))
|
||||
|
||||
|
||||
SUPPORT_README = """# 论文支撑材料
|
||||
|
||||
汇总官方模拟器 test 中的六份正式导出及哈希匹配的官方统计。
|
||||
问题三、四各包含三次正式测试。支撑材料保留原始文件名及文件校验值。
|
||||
汇总自建模拟器(评测机 `referee/`)的演练局日志与官方模拟器 `test` 中的六份正式导出。
|
||||
正文的机制论证与定量结论来自演练局,官方正式数据只用于附录中的效果验证。
|
||||
|
||||
## 环境和运行
|
||||
|
||||
@@ -280,15 +356,19 @@ Python >=3.10;安装 requests,可选 numpy 加速。
|
||||
python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号>
|
||||
|
||||
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
|
||||
策略的自检命令另需可选 referee 包。
|
||||
自建模拟器可用 `python -m referee` 启动,演练局日志写入 `referee-data/sessions/`。
|
||||
|
||||
## 重算论文数据和图表
|
||||
|
||||
python scripts/build_practice_data.py # 演练局复算,生成正文数据与宏文件
|
||||
python scripts/plot_paper_figures.py # 生成正文全部插图
|
||||
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
|
||||
python scripts/build_paper_support.py
|
||||
python scripts/build_paper_support.py # 附录正式数据表与支撑包
|
||||
|
||||
上述命令在本地重算统计表、图表及源码附录。统计快照来自官方正式
|
||||
统计队列,通过票据哈希匹配所选包。重新验证来源匹配需原电脑的统计队列。
|
||||
第一条命令按题面计时模型复算 `referee-data/sessions/` 下的每局演练日志,
|
||||
输出论文引用的宏、表格与插图数据;复算值与日志记录的最大偏差为 0.000000 s。
|
||||
正式统计快照来自官方统计队列,通过票据哈希匹配所选包;
|
||||
重新验证来源匹配需原电脑的统计队列。
|
||||
连续覆盖与混合类型推断的扩展见论文未来工作。
|
||||
|
||||
## 编译
|
||||
@@ -299,6 +379,8 @@ Python >=3.10;安装 requests,可选 numpy 加速。
|
||||
|
||||
版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回
|
||||
同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。
|
||||
`data/*.tex` 由脚本生成,一律使用 LF 换行:CRLF 会让 `\\input` 的表格行
|
||||
在 TeX 缓冲中提前 `\\par` 化,触发 booktabs 的 "Misplaced \\noalign"。
|
||||
support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。
|
||||
"""
|
||||
|
||||
@@ -314,11 +396,23 @@ def package(rows: list[dict]) -> Path:
|
||||
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(path for path in (ROOT / "referee").rglob("*")
|
||||
if path.is_file() and "__pycache__" not in path.parts
|
||||
and ".pytest_cache" not in path.parts)
|
||||
selected.update((ROOT / "referee-data/sessions").glob("*/*.json*"))
|
||||
# 被排除的演练局也随包提交:论文附录用它们说明"未优化完全"的判定依据。
|
||||
selected.update((ROOT / "referee-data/rejected-sessions").glob("*/*.json*"))
|
||||
selected.update((ROOT / "referee-data/superseded-sessions").glob("*/*.json*"))
|
||||
selected.update((ROOT / "examples/replay").glob("*.jsonl"))
|
||||
selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
|
||||
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
|
||||
PAPER / "data/formal-macros.tex",
|
||||
ROOT / "mcm26b-核心代码.ipynb",
|
||||
ROOT / "scripts/audit_paper_formal.py",
|
||||
ROOT / "scripts/build_code_appendix.py",
|
||||
ROOT / "scripts/build_practice_data.py",
|
||||
ROOT / "scripts/run_practice_episodes.py",
|
||||
ROOT / "scripts/build_paper_support.py"])
|
||||
for row in rows:
|
||||
path = ROOT / "Jammers-simulator-full" / row["file"]
|
||||
@@ -342,11 +436,43 @@ def package(rows: list[dict]) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
def formal_rows() -> list[dict]:
|
||||
"""读正式测试快照。
|
||||
|
||||
表格需要逐局的移动时间与位移占比,这两项只在 ``formal-results.csv`` 中,
|
||||
哈希与文件名清单所需的 ``package_sha256`` 则来自 JSON 快照,故两者合并。
|
||||
"""
|
||||
records = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
|
||||
numbers = {r["case_code"]: r for r in csv_rows()}
|
||||
merged = []
|
||||
for record in records:
|
||||
extra = numbers.get(record["case_code"], {})
|
||||
merged.append({**record, **{k: v for k, v in extra.items() if k not in record}})
|
||||
return merged
|
||||
|
||||
|
||||
def csv_rows() -> list[dict]:
|
||||
"""按 CSV 表头类型读回 ``formal-results.csv``。"""
|
||||
numeric_int = ("problem_no", "formal_index", "cleared_jammer_count",
|
||||
"measure_accepted_count", "channel_switch_count", "clear_failure_count")
|
||||
numeric_float = ("virtual_time_s", "program_run_duration_s",
|
||||
"inferred_movement_time_s", "movement_share_percent")
|
||||
rows = []
|
||||
with (PAPER / "data/formal-results.csv").open(encoding="utf-8-sig") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
for key in numeric_int:
|
||||
row[key] = int(row[key])
|
||||
for key in numeric_float:
|
||||
row[key] = float(row[key])
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
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"]
|
||||
rows = formal_rows()
|
||||
figures(rows)
|
||||
project_tables()
|
||||
listings(rows)
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"""自建模拟器演练局的统计提取:把 referee-data 日志复算成论文可直接引用的数据。
|
||||
|
||||
论文正文的论证数据全部取自项目内自建模拟器(评测机 referee 的
|
||||
``q3_practice`` / ``q4_practice`` 演练局),官方模拟器的正式测试只用于附录中的
|
||||
最终效果验证。本脚本把两类数据都复算成结构化快照,使正文数字、图表与附录表格
|
||||
来自同一次运行,避免手抄。
|
||||
|
||||
计时口径与 ``scripts/analyze_run_timing.py`` / ``scripts/verify_q3_practice.py``
|
||||
一致(题面附件2 §4.1):
|
||||
|
||||
T = Σ 距离/5 + 5·N_measure + N_switch + 5·N_clear⁺ + 3·N_clear⁻
|
||||
|
||||
输出:
|
||||
- ``docs/paper/data/practice-results.json`` / ``.csv``
|
||||
- ``docs/paper/data/practice-macros.tex``
|
||||
- ``docs/paper/data/practice-q3-rows.tex`` / ``practice-q4-rows.tex``
|
||||
- ``docs/paper/data/practice-cost-rows.tex``
|
||||
|
||||
uv run python scripts/build_practice_data.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src")) # 直接以脚本方式运行时也能导入生产实现
|
||||
PAPER = ROOT / "docs" / "paper"
|
||||
SESSIONS = ROOT / "referee-data" / "sessions"
|
||||
ARCHIVE = ROOT / "examples" / "replay"
|
||||
FORMAL_CSV = PAPER / "data" / "formal-results.csv"
|
||||
|
||||
SPEED_MPS = 5.0
|
||||
MEASURE_S = 5
|
||||
SWITCH_S = 1
|
||||
CLEAR_SUCCESS_S = 5
|
||||
CLEAR_FAIL_S = 3
|
||||
START_POSITION = (0.0, 0.0)
|
||||
START_CHANNEL = 1
|
||||
|
||||
# 正文用作定量论据的演练局:问题三选全向最大规模一局,问题四选定向源较多、
|
||||
# 且同时出现"测到示向度 / 近距离 / 无信号"三种结果的一局,
|
||||
# 两局都能代表各自模块的完整链路,并直接对应当前实现。
|
||||
MAIN_CASES = {
|
||||
3: "QS6H-CC29-PDY3-ZA54",
|
||||
4: "SWA3-C3FB-C5QC-FLW7",
|
||||
}
|
||||
# 论文引用的演练局必须全部来自本次采集的批次,避免把历史留档(含算法未优化
|
||||
# 完全时的局)或自检局混进正文;批次外的留档一律不进入统计。
|
||||
# 问题四批次为种子 400--439 中"零失败清除"的 29 局,另 11 局作为未优化完全的
|
||||
# 脏数据移入 referee-data/rejected-sessions,不参与任何统计。
|
||||
PAPER_BATCH = {
|
||||
3: {code: None for code in (
|
||||
"VE37-SRPD-NCFR-4EQF", "8VRC-ADW4-XDXM-F62L", "ARBP-6XFZ-5YJU-STC8",
|
||||
"MULB-4YTU-VQYG-YAS6", "NMRC-85ZV-3FFE-GBXY", "6V2P-HJCS-GJ9A-83MA",
|
||||
"HGBA-XGH9-UNZJ-7YQ3", "MT3R-68KH-CNY5-6X73", "E27E-M8WQ-LDT5-2XDA",
|
||||
"7ZXN-N53X-HAXQ-EGY4", "6LST-ZFVS-2HHW-TQQ4", "R2N7-QMLC-26QX-RXYT",
|
||||
"LZDX-68DJ-XM4P-A6XL", "JW47-AVPH-XGHH-RLLV", "QS6H-CC29-PDY3-ZA54")},
|
||||
4: {code: None for code in (
|
||||
"JY84-364B-45UL-AFBK", "SWA3-C3FB-C5QC-FLW7", "GSG4-6HTJ-AH5L-ZYGM",
|
||||
"2M5J-QWHS-BYGB-MPYF", "US79-DFNV-UQVW-NPLL", "96M4-SK2V-PDWN-YGHB",
|
||||
"6US3-A76N-KC8B-DMFM", "VJ68-ADZ6-STFT-LHYT", "2Y25-4LD6-5A4P-64CV",
|
||||
"A5YU-4SM6-4PK7-M5H8", "JH76-MEWS-BR6Q-BQ6S", "B8HR-3HJH-2UYH-YDVB",
|
||||
"RLK4-E82Q-UHT6-2M9M", "R2E7-LF5E-J5K6-BSVQ", "3XM4-65TW-CH39-XWTZ",
|
||||
"XN57-MHFY-V58F-XCMY", "ZLKB-GVZT-NQDE-CU5F", "DX2D-KDF9-BGYU-XPMG",
|
||||
"CEL3-XQV8-M7KY-3CSW", "TKCY-2VT2-WYUX-2JXU", "T5ZL-5GEP-FDCK-3P8T",
|
||||
"QXVN-L63H-6WLX-KLEJ", "X3Y4-S9D2-XWR9-V6CZ", "6LEP-YVQ6-AYP5-N359",
|
||||
"7HG2-UEBM-K3D2-LZWT", "JDAY-2SU7-RTSJ-NQFR", "QL6B-KEUW-DJR2-P84B",
|
||||
"QDDJ-S89Y-7TKY-E6VJ", "SE9X-67GA-6YC6-QB7B")},
|
||||
}
|
||||
|
||||
|
||||
def write_tex(path: Path, text: str) -> None:
|
||||
r"""写 LaTeX 片段;强制 LF 换行。
|
||||
|
||||
Windows 的 CRLF 会让 ``\input`` 进表格的数据行在 TeX 缓冲中提前
|
||||
``\par`` 化,导致 booktabs 的 ``\bottomrule`` 报 "Misplaced \noalign"。
|
||||
"""
|
||||
path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), "utf-8", newline="\n")
|
||||
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
records = []
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def recompute(records: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""按题面计时模型逐动作复算一局;没有已接受动作时返回 None。"""
|
||||
start = next((r for r in records if r.get("event") == "session_start"), None)
|
||||
end = next((r for r in records if r.get("event") == "session_end"), None)
|
||||
truth = start.get("truth", []) if start else []
|
||||
position, channel = START_POSITION, START_CHANNEL
|
||||
move_us = action_us = 0
|
||||
switches = measures = clears = failed_clears = dir_measures = 0
|
||||
distance_m = 0.0
|
||||
max_gap_us = 0
|
||||
elapsed_s = 0.0
|
||||
for record in records:
|
||||
if record.get("event") != "action":
|
||||
continue
|
||||
request = record.get("request") or {}
|
||||
response = record.get("response") or {}
|
||||
if not response.get("accepted", record.get("status") == 200):
|
||||
continue
|
||||
path = record.get("path", "")
|
||||
if path not in ("/measure", "/clear") or "position" not in request:
|
||||
continue
|
||||
target = (float(request["position"]["x"]), float(request["position"]["y"]))
|
||||
leg = math.dist(position, target)
|
||||
distance_m += leg
|
||||
move_us += round(leg / SPEED_MPS * 1_000_000)
|
||||
position = target
|
||||
if path == "/measure":
|
||||
switches += int(channel != request.get("channel"))
|
||||
channel = request.get("channel")
|
||||
measures += 1
|
||||
action_us += MEASURE_S * 1_000_000
|
||||
if response.get("measure_result") == "direction":
|
||||
dir_measures += 1
|
||||
else:
|
||||
clears += 1
|
||||
ok = response.get("clear_result") == "success"
|
||||
failed_clears += int(not ok)
|
||||
action_us += (CLEAR_SUCCESS_S if ok else CLEAR_FAIL_S) * 1_000_000
|
||||
elapsed = record.get("elapsed_s")
|
||||
if isinstance(elapsed, (int, float)):
|
||||
elapsed_s += float(elapsed)
|
||||
recorded = response.get("virtual_time_s")
|
||||
if isinstance(recorded, (int, float)):
|
||||
gap = round(recorded * 1_000_000) - (move_us + action_us + switches * 1_000_000)
|
||||
max_gap_us = max(max_gap_us, abs(gap))
|
||||
if measures == 0 and clears == 0:
|
||||
return None
|
||||
virtual_us = move_us + action_us + switches * 1_000_000
|
||||
return {
|
||||
"case_code": (start or {}).get("case_code", ""),
|
||||
"module": (start or {}).get("module", ""),
|
||||
"seed": (start or {}).get("seed"),
|
||||
"source_total": len(truth),
|
||||
"directional_total": sum(1 for t in truth if t.get("directional")),
|
||||
"r_eff_min_m": round(min((float(t["r_eff_m"]) for t in truth), default=0.0), 3),
|
||||
"r_eff_max_m": round(max((float(t["r_eff_m"]) for t in truth), default=0.0), 3),
|
||||
"cleared_jammer_count": (end or {}).get("cleared_count", clears),
|
||||
"measure_accepted_count": measures,
|
||||
"direction_measure_count": dir_measures,
|
||||
"channel_switch_count": switches,
|
||||
"clear_success_count": clears - failed_clears,
|
||||
"clear_failure_count": failed_clears,
|
||||
"virtual_time_us": virtual_us,
|
||||
"virtual_time_s": round(virtual_us / 1_000_000, 6),
|
||||
"move_time_s": round(move_us / 1_000_000, 6),
|
||||
"move_distance_m": round(distance_m, 3),
|
||||
"measure_time_s": round(measures * MEASURE_S, 6),
|
||||
"switch_time_s": float(switches * SWITCH_S),
|
||||
"clear_time_s": float(clears * CLEAR_SUCCESS_S - failed_clears * (CLEAR_SUCCESS_S - CLEAR_FAIL_S)),
|
||||
# 程序运行时间取会话结束行记录的现实耗时(评测机口径),逐动作
|
||||
# elapsed_s 仅在部分动作上出现,不能代表整局墙钟。
|
||||
"program_run_duration_s": round(
|
||||
float((end or {}).get("real_elapsed_s", elapsed_s)), 3),
|
||||
"end_reason": (end or {}).get("reason", ""),
|
||||
"max_recorded_gap_s": round(max_gap_us / 1_000_000, 6),
|
||||
"average_clear_time_s": round(virtual_us / 1_000_000 / max(1, (end or {}).get("cleared_count", clears)), 6),
|
||||
}
|
||||
|
||||
|
||||
def collect_practice() -> list[dict[str, Any]]:
|
||||
"""只收集论文声明批次的演练局;批次外留档不进入统计。
|
||||
|
||||
论文的每一局都必须能追溯到 ``scripts/run_practice_episodes.py`` 的一次调用,
|
||||
因此这里显式按案例编码白名单过滤,而不是"扫到什么就用什么"。
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
allowed = PAPER_BATCH[3] | PAPER_BATCH[4]
|
||||
skipped = 0
|
||||
for path in sorted(SESSIONS.glob("*/log.jsonl")):
|
||||
if path.parent.name not in allowed:
|
||||
skipped += 1
|
||||
continue
|
||||
result = recompute(load_jsonl(path))
|
||||
if result is None:
|
||||
print(f"[跳过] {path.parent.name}:没有已接受动作")
|
||||
continue
|
||||
if "practice" not in result["module"]:
|
||||
print(f"[跳过] {path.parent.name}:模块为 {result['module']}")
|
||||
continue
|
||||
rows.append(result)
|
||||
if skipped:
|
||||
print(f"[批次外] {skipped} 局留档未进入统计(历史留档或自检局)")
|
||||
for row in rows:
|
||||
row["problem_no"] = 3 if "q3" in row["module"] else 4
|
||||
# 一局“全清”= 清除数达到该局源数且没有失败清除动作。
|
||||
row["all_cleared"] = row["cleared_jammer_count"] >= row["source_total"]
|
||||
row["complete"] = row["all_cleared"] and row["clear_failure_count"] == 0
|
||||
rows.sort(key=lambda r: (r["problem_no"], r["case_code"]))
|
||||
return rows
|
||||
|
||||
|
||||
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:
|
||||
return "--" if value is None else fixed(100 * value, digits) + r"\%"
|
||||
|
||||
|
||||
def mean(rows: list[dict[str, Any]], key: str) -> float | None:
|
||||
values = [float(r[key]) for r in rows if isinstance(r.get(key), (int, float))]
|
||||
return sum(values) / len(values) if values else None
|
||||
|
||||
|
||||
def problem_rows(rows: list[dict[str, Any]], problem: int) -> list[dict[str, Any]]:
|
||||
return [r for r in rows if r["problem_no"] == problem]
|
||||
|
||||
|
||||
# 问题一正文引用的交会算例:取自本次采集的演练局,
|
||||
# 该频道的定位区域满足“直径圆不覆盖、最小包围圆可清除”这一关键对比。
|
||||
Q1_CASE = "8VRC-ADW4-XDXM-F62L"
|
||||
Q1_CHANNEL = 12
|
||||
Q1_DELTA_DEG = 1.0
|
||||
|
||||
|
||||
def extraction_measurements(case_code: str, channel: int) -> list[dict[str, float]]:
|
||||
"""取一局中某频道被接受的全部示向度读数。"""
|
||||
path = SESSIONS / case_code / "log.jsonl"
|
||||
observations: list[dict[str, float]] = []
|
||||
for record in load_jsonl(path):
|
||||
if record.get("event") != "action" or record.get("path") != "/measure":
|
||||
continue
|
||||
response = record.get("response") or {}
|
||||
if response.get("measure_result") != "direction":
|
||||
continue
|
||||
request = record.get("request") or {}
|
||||
if request.get("channel") != channel or "position" not in request:
|
||||
continue
|
||||
observations.append({"x": float(request["position"]["x"]),
|
||||
"y": float(request["position"]["y"]),
|
||||
"bearing_deg": float(response["svd_deg"])})
|
||||
if len(observations) < 3:
|
||||
raise ValueError(f"{case_code} 频道 {channel} 的可交会读数不足三个")
|
||||
return observations
|
||||
|
||||
|
||||
def solve_q1_example() -> dict[str, Any]:
|
||||
"""用生产求解器复算问题一的交会算例,返回顶点、直径与两类圆。"""
|
||||
from mcm26b.geometry.localization import ( # pylint: disable=import-outside-toplevel
|
||||
Measurement, diameter, smallest_enclosing_circle, solve)
|
||||
|
||||
observations = extraction_measurements(Q1_CASE, Q1_CHANNEL)
|
||||
result = solve([Measurement(o["x"], o["y"], o["bearing_deg"], Q1_DELTA_DEG)
|
||||
for o in observations])
|
||||
if result["status"] != "polygon":
|
||||
raise ValueError(f"算例区域状态为 {result['status']},不能用于正文示例")
|
||||
vertices = [tuple(v) for v in result["vertices"]]
|
||||
d_value, (a, b) = diameter(vertices)
|
||||
midpoint = ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
|
||||
exceed = max(math.dist(midpoint, p) for p in vertices) - d_value / 2.0
|
||||
centre, radius = smallest_enclosing_circle(vertices)
|
||||
return {"case_code": Q1_CASE, "channel": Q1_CHANNEL, "half_angle_deg": Q1_DELTA_DEG,
|
||||
"measurements": observations, "vertices": [list(v) for v in vertices],
|
||||
"diameter_circle_centre": list(midpoint), "diameter_m": d_value,
|
||||
"diameter_circle_radius_m": d_value / 2.0, "exceed_m": exceed,
|
||||
"mec_centre": list(centre), "mec_radius_m": radius,
|
||||
"vertex_count": len(vertices)}
|
||||
|
||||
|
||||
def write_geometry_example() -> dict[str, Any]:
|
||||
example = solve_q1_example()
|
||||
(PAPER / "data" / "practice-geometry.json").write_text(
|
||||
json.dumps(example, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
macros = [
|
||||
("ExOneChannel", str(example["channel"])),
|
||||
("ExOneVertices", str(example["vertex_count"])),
|
||||
("ExOneDiameter", fixed(example["diameter_m"])),
|
||||
("ExOneRadius", fixed(example["diameter_circle_radius_m"])),
|
||||
("ExOneExceed", fixed(example["exceed_m"])),
|
||||
("ExOneMec", fixed(example["mec_radius_m"])),
|
||||
]
|
||||
lines = ["% Generated by scripts/build_practice_data.py; 问题一交会算例复算值。"]
|
||||
lines += [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
|
||||
write_tex(PAPER / "data" / "practice-geometry-macros.tex",
|
||||
"\n".join(lines) + "\n")
|
||||
return example
|
||||
|
||||
|
||||
def complete_rows(rows: list[dict[str, Any]], problem: int) -> list[dict[str, Any]]:
|
||||
return [r for r in problem_rows(rows, problem) if r["complete"]]
|
||||
|
||||
|
||||
def cost_share(rows: list[dict[str, Any]]) -> dict[str, float]:
|
||||
"""一组演练局中移动/检测/换频/清除耗时占总虚拟时间的比例。"""
|
||||
total = sum(float(r["virtual_time_s"]) for r in rows)
|
||||
if total <= 0:
|
||||
return {}
|
||||
return {"MoveShare": sum(float(r["move_time_s"]) for r in rows) / total,
|
||||
"MeasureShare": sum(float(r["measure_time_s"]) for r in rows) / total,
|
||||
"SwitchShare": sum(float(r["switch_time_s"]) for r in rows) / total,
|
||||
"ClearShare": sum(float(r["clear_time_s"]) for r in rows) / total}
|
||||
|
||||
|
||||
def write_macros(rows: list[dict[str, Any]]) -> None:
|
||||
three, four = problem_rows(rows, 3), problem_rows(rows, 4)
|
||||
three_ok, four_ok = complete_rows(rows, 3), complete_rows(rows, 4)
|
||||
q3_main = next(r for r in three if r["case_code"] == MAIN_CASES[3])
|
||||
q4_main = next(r for r in four if r["case_code"] == MAIN_CASES[4])
|
||||
macros = [
|
||||
("PractQThreeCases", str(len(three))),
|
||||
("PractQFourCases", str(len(four))),
|
||||
("PractTotalCases", str(len(rows))),
|
||||
("PractQThreeComplete", str(len(three_ok))),
|
||||
("PractQFourComplete", str(len(four_ok))),
|
||||
("PractTotalComplete", str(len(three_ok) + len(four_ok))),
|
||||
("PractQThreeSources", str(sum(r["source_total"] for r in three))),
|
||||
("PractQFourSources", str(sum(r["source_total"] for r in four))),
|
||||
("PractTotalSources", str(sum(r["source_total"] for r in rows))),
|
||||
("PractQThreeCleared", str(sum(r["cleared_jammer_count"] for r in three))),
|
||||
("PractQFourCleared", str(sum(r["cleared_jammer_count"] for r in four))),
|
||||
("PractTotalCleared", str(sum(r["cleared_jammer_count"] for r in rows))),
|
||||
("PractTotalFailures", str(sum(r["clear_failure_count"] for r in rows))),
|
||||
("PractQThreeMean", fixed(mean(three_ok, "virtual_time_s"))),
|
||||
("PractQFourMean", fixed(mean(four_ok, "virtual_time_s"))),
|
||||
("PractQThreeMin", fixed(min(r["virtual_time_s"] for r in three_ok))),
|
||||
("PractQThreeMax", fixed(max(r["virtual_time_s"] for r in three_ok))),
|
||||
("PractQFourMin", fixed(min(r["virtual_time_s"] for r in four_ok))),
|
||||
("PractQFourMax", fixed(max(r["virtual_time_s"] for r in four_ok))),
|
||||
("PractQThreeMeanClear", fixed(mean(three_ok, "average_clear_time_s"))),
|
||||
("PractQFourMeanClear", fixed(mean(four_ok, "average_clear_time_s"))),
|
||||
("PractQThreeMove", fixed(mean(three_ok, "move_distance_m") / 1000)),
|
||||
("PractQFourMove", fixed(mean(four_ok, "move_distance_m") / 1000)),
|
||||
("PractQThreeRun", fixed(mean(three_ok, "program_run_duration_s"))),
|
||||
("PractQFourRun", fixed(mean(four_ok, "program_run_duration_s"))),
|
||||
("PractQThreeMeasures", fixed(mean(three_ok, "measure_accepted_count"), 1)),
|
||||
("PractQFourMeasures", fixed(mean(four_ok, "measure_accepted_count"), 1)),
|
||||
("PractQThreeSwitches", fixed(mean(three_ok, "channel_switch_count"), 1)),
|
||||
("PractQFourSwitches", fixed(mean(four_ok, "channel_switch_count"), 1)),
|
||||
("PractMaxGap", fixed(max(r["max_recorded_gap_s"] for r in rows), 6)),
|
||||
# 全清局的动作耗时构成(图 practice-cost-bars 的同源数值)
|
||||
*[(f"PractQThree{name}", percent(value)) for name, value in
|
||||
cost_share(three_ok).items()],
|
||||
*[(f"PractQFour{name}", percent(value)) for name, value in
|
||||
cost_share(four_ok).items()],
|
||||
# 正文重点引用的两局
|
||||
("PractQThreeMainCase", q3_main["case_code"]),
|
||||
("PractQThreeMainSources", str(q3_main["source_total"])),
|
||||
("PractQThreeMainTime", fixed(q3_main["virtual_time_s"])),
|
||||
("PractQThreeMainMove", fixed(q3_main["move_distance_m"] / 1000)),
|
||||
("PractQThreeMainMeasures", str(q3_main["measure_accepted_count"])),
|
||||
("PractQThreeMainSwitches", str(q3_main["channel_switch_count"])),
|
||||
("PractQThreeMainRun", fixed(q3_main["program_run_duration_s"])),
|
||||
("PractQFourMainCase", q4_main["case_code"]),
|
||||
("PractQFourMainSources", str(q4_main["source_total"])),
|
||||
("PractQFourMainDirectional", str(q4_main["directional_total"])),
|
||||
("PractQFourMainTime", fixed(q4_main["virtual_time_s"])),
|
||||
("PractQFourMainMove", fixed(q4_main["move_distance_m"] / 1000)),
|
||||
("PractQFourMainMeasures", str(q4_main["measure_accepted_count"])),
|
||||
("PractQFourMainSwitches", str(q4_main["channel_switch_count"])),
|
||||
("PractQFourMainRun", fixed(q4_main["program_run_duration_s"])),
|
||||
]
|
||||
lines = ["% Generated by scripts/build_practice_data.py; 自建模拟器演练局统计。"]
|
||||
lines += [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros]
|
||||
write_tex(PAPER / "data" / "practice-macros.tex", "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def table_environment(caption: str, label: str, colspec: str, header: str,
|
||||
body: list[str], placement: str = "htbp",
|
||||
tabcolsep: str = "4pt", fontsize: str = "small") -> str:
|
||||
"""拼出完整的 table 环境。
|
||||
|
||||
表格不能被 ``\\input`` 切开:被包含文件的注释行会在表格内部注入一个
|
||||
``\\par``,使 booktabs 的 ``\\bottomrule`` 报 "Misplaced \\noalign"。
|
||||
因此这里生成的是整表,正文只 ``\\input`` 这个整表文件。
|
||||
"""
|
||||
font = "\\footnotesize" if fontsize == "footnotesize" else "\\small"
|
||||
return "\n".join([
|
||||
f"\\begin{{table}}[{placement}]",
|
||||
f"\\centering{font}",
|
||||
f"\\caption{{{caption}}}",
|
||||
f"\\setlength{{\\tabcolsep}}{{{tabcolsep}}}",
|
||||
f"\\begin{{tabular}}{{{colspec}}}",
|
||||
"\\toprule",
|
||||
header,
|
||||
"\\midrule",
|
||||
*body,
|
||||
"\\bottomrule",
|
||||
"\\end{tabular}",
|
||||
f"\\label{{{label}}}",
|
||||
"\\end{table}",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def write_rows(rows: list[dict[str, Any]]) -> None:
|
||||
head = ("% Generated by scripts/build_practice_data.py; 自建模拟器演练局复算结果。"
|
||||
" 本文件是完整 table 环境,供正文直接 \\input。")
|
||||
data = PAPER / "data"
|
||||
|
||||
# 正文主表:只列全清且无失败清除的局,按虚拟时间升序。
|
||||
practice_header = ("序号 & 案例编码 & 源数 & 清除数 & 总虚拟时间/s & 移动/km & "
|
||||
"检测数 & 换频数 & 平均清除/(s/个)" + r"\\")
|
||||
for problem in (3, 4):
|
||||
subset = sorted(complete_rows(rows, problem), key=lambda r: r["virtual_time_s"])
|
||||
body = [
|
||||
f"{index} & \\texttt{{{r['case_code']}}} & {r['source_total']} & "
|
||||
f"{r['cleared_jammer_count']} & {fixed(r['virtual_time_s'])} & "
|
||||
f"{fixed(r['move_distance_m'] / 1000)} & {r['measure_accepted_count']} & "
|
||||
f"{r['channel_switch_count']} & {fixed(r['average_clear_time_s'])} " + r"\\"
|
||||
for index, r in enumerate(subset, start=1)
|
||||
]
|
||||
kind = "全向源" if problem == 3 else "混合源"
|
||||
write_tex(data / f"practice-q{problem}-table.tex",
|
||||
"\n".join([head, table_environment(
|
||||
f"问题{['三', '四'][problem - 3]}:{kind}演练局结果(按总虚拟时间升序)",
|
||||
f"tab:q{problem}practice", "lrrrrrrrr", practice_header, body,
|
||||
tabcolsep="2pt")]))
|
||||
|
||||
# 附录全量清单:含未全清局,标注是否全清与失败清除次数。
|
||||
body = []
|
||||
for problem in (3, 4):
|
||||
subset = sorted(problem_rows(rows, problem), key=lambda r: r["case_code"])
|
||||
body += [
|
||||
f"\\texttt{{{r['case_code']}}} & {problem} & {r['source_total']} & "
|
||||
f"{r['cleared_jammer_count']} & {r['clear_failure_count']} & "
|
||||
f"{fixed(r['virtual_time_s'])} & {r['measure_accepted_count']} & "
|
||||
f"{r['channel_switch_count']} & {fixed(r['move_distance_m'] / 1000)} & "
|
||||
f"{'是' if r['complete'] else '否'} " + r"\\"
|
||||
for r in subset
|
||||
]
|
||||
write_tex(data / "practice-all-table.tex", "\n".join([head, table_environment(
|
||||
"全部演练局的逐局复算结果(“全清”列为是否达到该局源数且无失败清除)",
|
||||
"tab:practiceall", "lrrrrrrrrl",
|
||||
"案例编码 & 问题 & 源数 & 清除数 & 失败清除 & 总虚拟时间/s & 检测数 & 换频数 & "
|
||||
"移动/km & 全清" + r"\\", body, placement="H", tabcolsep="2pt",
|
||||
fontsize="footnotesize")]))
|
||||
|
||||
def cost(r: dict[str, Any], label: str) -> str:
|
||||
total = r["virtual_time_s"]
|
||||
move, measure = r["move_time_s"], r["measure_time_s"]
|
||||
switch, clear = r["switch_time_s"], r["clear_time_s"]
|
||||
return (
|
||||
f"{label} & {fixed(total)} & {fixed(move)} & {percent(move / total)} & "
|
||||
f"{fixed(measure)} & {percent(measure / total)} & {fixed(switch)} & "
|
||||
f"{percent(switch / total)} & {fixed(clear)} & {percent(clear / total)} " + r"\\"
|
||||
)
|
||||
|
||||
ordered = sorted(rows, key=lambda r: (r["problem_no"], r["case_code"]))
|
||||
labels = [f"Q{r['problem_no']}-{i}" for i, r in enumerate(ordered, start=1)]
|
||||
body = [cost(r, label) for r, label in zip(ordered, labels)]
|
||||
write_tex(data / "practice-cost-table.tex", "\n".join([head, table_environment(
|
||||
"演练局逐局动作耗时分解(时间单位:s)", "tab:practicecost", "lrrrrrrrrr",
|
||||
"编号 & 总虚拟时间 & 移动 & 占比 & 检测 & 占比 & 换频 & 占比 & 清除 & 占比" + r"\\",
|
||||
body, placement="H", tabcolsep="2pt", fontsize="footnotesize")]))
|
||||
|
||||
# 汇总表:全清局的规模与时间区间,正文定量论据直接用这张表。
|
||||
summary = []
|
||||
for problem, label in ((3, "问题三(全向源)"), (4, "问题四(混合源)")):
|
||||
subset = complete_rows(rows, problem)
|
||||
share = cost_share(subset)
|
||||
summary.append(
|
||||
f"{label} & {len(subset)} & {sum(r['source_total'] for r in subset)} & "
|
||||
f"{fixed(mean(subset, 'virtual_time_s'))} & "
|
||||
f"{fixed(min(r['virtual_time_s'] for r in subset))}--"
|
||||
f"{fixed(max(r['virtual_time_s'] for r in subset))} & "
|
||||
f"{fixed(mean(subset, 'average_clear_time_s'))} & "
|
||||
f"{percent(share['MoveShare'])} " + r"\\")
|
||||
write_tex(data / "practice-summary-table.tex", "\n".join([head, table_environment(
|
||||
"演练局汇总(仅统计全清且无失败清除的局)", "tab:practicesummary", "lrrrrrrr",
|
||||
"问题 & 全清局数 & 累计清除 & 平均总虚拟时间/s & 时间区间/s & "
|
||||
"平均定位清除时间/(s/个) & 移动占比" + r"\\", summary, placement="H", tabcolsep="2pt",
|
||||
fontsize="footnotesize")]))
|
||||
|
||||
# 动作耗时分解汇总表,正文表 tab:cost 直接引用。
|
||||
cost_summary = []
|
||||
for problem, label in ((3, "问题三(全向源)"), (4, "问题四(混合源)")):
|
||||
subset = complete_rows(rows, problem)
|
||||
total = sum(float(r["virtual_time_s"]) for r in subset)
|
||||
cells = []
|
||||
for key in ("move_time_s", "measure_time_s", "switch_time_s", "clear_time_s"):
|
||||
value = sum(float(r[key]) for r in subset)
|
||||
cells.append(f"{fixed(value)}({percent(value / total)})")
|
||||
cost_summary.append(f"{label} & {len(subset)} & " + " & ".join(cells) + r" \\")
|
||||
write_tex(data / "practice-cost-summary-table.tex", "\n".join([head, table_environment(
|
||||
"演练全清局的动作耗时分解,括号内为占总虚拟时间的比例", "tab:cost", "lrrrrr",
|
||||
"问题 & 局数 & 移动/s(占比) & 检测/s(占比) & 换频/s(占比) & "
|
||||
"清除/s(占比)" + r"\\", cost_summary, tabcolsep="3pt")]))
|
||||
|
||||
# 逐局真值与规模明细(问题三、问题四各一行)。
|
||||
detail = [
|
||||
f"\\texttt{{{r['case_code']}}} & {r['source_total']} & "
|
||||
f"{r['r_eff_min_m']:.1f}--{r['r_eff_max_m']:.1f} & {r['directional_total']} & "
|
||||
f"{r['cleared_jammer_count']} & {r['direction_measure_count']} & "
|
||||
f"{fixed(r['program_run_duration_s'])} " + r"\\"
|
||||
for r in ordered
|
||||
]
|
||||
write_tex(data / "practice-detail-table.tex", "\n".join([head, table_environment(
|
||||
"演练局的真值规模与程序运行时间", "tab:practicedetail", "lrrrrrr",
|
||||
"案例编码 & 源数 & 接收半径范围/m & 定向源数 & 清除数 & 示向度观测数 & "
|
||||
"程序运行时间/s" + r"\\", detail, placement="H")]))
|
||||
|
||||
|
||||
def write_csv(rows: list[dict[str, Any]]) -> None:
|
||||
fields = ["problem_no", "case_code", "module", "seed", "source_total", "directional_total",
|
||||
"r_eff_min_m", "r_eff_max_m", "cleared_jammer_count", "all_cleared", "complete",
|
||||
"measure_accepted_count", "direction_measure_count", "channel_switch_count",
|
||||
"clear_failure_count", "virtual_time_s", "move_time_s", "move_distance_m",
|
||||
"measure_time_s", "switch_time_s", "clear_time_s", "program_run_duration_s",
|
||||
"average_clear_time_s", "end_reason", "max_recorded_gap_s"]
|
||||
out = PAPER / "data" / "practice-results.csv"
|
||||
with out.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in sorted(rows, key=lambda r: (r["problem_no"], r["case_code"])):
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--check-formal", action="store_true",
|
||||
help="同时核对正式快照仍可读取(附录表格来源)")
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = collect_practice()
|
||||
if not rows:
|
||||
raise SystemExit("没有可用的自建模拟器演练局日志")
|
||||
payload = {"generator": "scripts/build_practice_data.py",
|
||||
"note": "自建模拟器(referee q3_practice/q4_practice)演练局复算结果",
|
||||
"records": rows}
|
||||
(PAPER / "data" / "practice-results.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
write_macros(rows)
|
||||
write_rows(rows)
|
||||
write_csv(rows)
|
||||
example = write_geometry_example()
|
||||
print(f"问题一算例 {example['case_code']} 频道 {example['channel']}:"
|
||||
f"{example['vertex_count']} 个顶点,直径 {fixed(example['diameter_m'])} m,"
|
||||
f"直径圆超出 {fixed(example['exceed_m'])} m,"
|
||||
f"最小包围圆半径 {fixed(example['mec_radius_m'])} m")
|
||||
if args.check_formal:
|
||||
count = len(json.loads((PAPER / "data" / "formal-results.json").read_text("utf-8"))["records"])
|
||||
print(f"正式快照可读:{count} 条记录(附录用)")
|
||||
three, four = problem_rows(rows, 3), problem_rows(rows, 4)
|
||||
three_ok, four_ok = complete_rows(rows, 3), complete_rows(rows, 4)
|
||||
print(f"演练局 {len(rows)} 局:问题三 {len(three)} 局(全清 {len(three_ok)}),"
|
||||
f"问题四 {len(four)} 局(全清 {len(four_ok)})")
|
||||
print(f"累计清除 {sum(r['cleared_jammer_count'] for r in rows)} 个源,"
|
||||
f"全清局清除失败 {sum(r['clear_failure_count'] for r in three_ok + four_ok)} 次")
|
||||
print(f"全清局平均虚拟时间:问题三 {fixed(mean(three_ok, 'virtual_time_s'))} s,"
|
||||
f"问题四 {fixed(mean(four_ok, 'virtual_time_s'))} s")
|
||||
print(f"与日志记录的最大计时偏差 {fixed(max(r['max_recorded_gap_s'] for r in rows), 6)} s")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+428
-133
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
import csv, json, math
|
||||
import csv, json, math, re
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
@@ -17,11 +17,15 @@ LIGHT = "#9DB7CA"
|
||||
PALE = "#D9E4EC"
|
||||
GOLD = "#B58B3A"
|
||||
GREEN = "#4E7A55"
|
||||
plt.rcParams.update({"font.sans-serif": ["DejaVu Sans"], "axes.unicode_minus": False,
|
||||
plt.rcParams.update({"font.sans-serif": ["Microsoft YaHei", "SimHei", "DejaVu Sans"], "axes.unicode_minus": False,
|
||||
"figure.dpi": 120, "savefig.dpi": 300, "axes.edgecolor": GRAY,
|
||||
"axes.labelcolor": "#222222", "text.color": "#222222"})
|
||||
|
||||
def save(fig, name):
|
||||
try:
|
||||
fig.tight_layout()
|
||||
except Exception:
|
||||
pass
|
||||
fig.savefig(FIG / name, bbox_inches="tight", facecolor="white")
|
||||
plt.close(fig)
|
||||
|
||||
@@ -40,27 +44,6 @@ def load_formal():
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def fig_formal_total():
|
||||
rows = load_formal()
|
||||
labels = ["Q%d-%d" % (r["problem_no"], r["formal_index"]) for r in rows]
|
||||
total = [r["virtual_time_s"] for r in rows]
|
||||
runtime = [r["program_run_duration_s"] for r in rows]
|
||||
colors = [INK, LIGHT, PALE, GOLD, "#C98963", GREEN]
|
||||
fig, axes = plt.subplots(1, 2, figsize=(10.5, 3.6))
|
||||
for ax, vals, title, unit in ((axes[0], total, "Total virtual time", "s"),
|
||||
(axes[1], runtime, "Program runtime", "s")):
|
||||
bars = ax.barh(labels[::-1], vals[::-1], color=colors[::-1])
|
||||
ax.set_xlabel(title + " / " + unit)
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
for bar, val in zip(bars, vals[::-1]):
|
||||
ax.text(bar.get_width() + max(vals) * 0.015,
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
"%.3f" % val, va="center", fontsize=8)
|
||||
ax.margins(x=0.18)
|
||||
fig.suptitle("Direct metrics of three formal tests", fontsize=11)
|
||||
save(fig, "formal-total-time.png")
|
||||
|
||||
|
||||
def fig_formal_time_bars():
|
||||
rows = load_formal()
|
||||
@@ -70,19 +53,34 @@ def fig_formal_time_bars():
|
||||
switch = np.array([1.0 * r["channel_switch_count"] for r in rows])
|
||||
clear = np.array([5.0 * r["cleared_jammer_count"] + 3.0 * r["clear_failure_count"] for r in rows])
|
||||
fig, ax = plt.subplots(figsize=(9.0, 3.8))
|
||||
ax.barh(labels, move, color=INK, label="Move")
|
||||
ax.barh(labels, measure, left=move, color=LIGHT, label="Measure")
|
||||
ax.barh(labels, switch, left=move + measure, color=GRAY, label="Switch")
|
||||
ax.barh(labels, clear, left=move + measure + switch, color=GOLD, label="Clear")
|
||||
ax.set_xlabel("Cumulative virtual time / s")
|
||||
ax.barh(labels, move, color=INK, label="移动")
|
||||
ax.barh(labels, measure, left=move, color=LIGHT, label="检测")
|
||||
ax.barh(labels, switch, left=move + measure, color=GRAY, label="换频")
|
||||
ax.barh(labels, clear, left=move + measure + switch, color=GOLD, label="清除")
|
||||
ax.set_xlabel("累计虚拟时间 / s")
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
ax.legend(loc="lower right", frameon=False, ncol=4)
|
||||
save(fig, "formal-time-bars.png")
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=PALE, ec=INK):
|
||||
def practice_macro(name: str) -> str:
|
||||
"""从 ``data/practice-macros.tex`` 读取正文主局案例编码。
|
||||
|
||||
正文与插图必须引用同一次采集的同一局,因此这里不写死案例编码,
|
||||
而是读 ``build_practice_data.py`` 生成的宏,避免两处口径分叉。
|
||||
"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-macros.tex"
|
||||
match = re.search(r"\\newcommand\{\\" + name + r"\}\{([^}]*)\}",
|
||||
path.read_text(encoding="utf-8"))
|
||||
if match is None:
|
||||
raise KeyError(f"宏 {name} 不存在;请先运行 scripts/build_practice_data.py")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=PALE, ec=INK, size=8):
|
||||
"""画一个流程框;``size`` 用 pt 指定字号,避免缩放后文字过小或过大。"""
|
||||
ax.add_patch(Rectangle((x, y), w, h, facecolor=fc, edgecolor=ec, linewidth=1.0))
|
||||
ax.text(x + w/2, y + h/2, text, ha="center", va="center", fontsize=8, wrap=True)
|
||||
ax.text(x + w/2, y + h/2, text, ha="center", va="center", fontsize=size, wrap=True)
|
||||
|
||||
|
||||
def arrow(ax, p, q, color=GRAY):
|
||||
@@ -91,45 +89,73 @@ def arrow(ax, p, q, color=GRAY):
|
||||
|
||||
|
||||
def fig_flow_overall():
|
||||
fig, ax = plt.subplots(figsize=(8.0, 4.8))
|
||||
"""四层主线图:紧凑画布,箭头端点一律落在框边,不在框内穿过。
|
||||
|
||||
原图四条 L2→L3 的箭头收在同一个点上,箭头头部挤成一团;
|
||||
这里改为按顺序分别落在 L3 顶边的不同位置,
|
||||
线不交叉、箭头也不重叠。
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7.6, 4.9))
|
||||
ax.axis("off")
|
||||
box(ax, 0.5, 4.0, 7.0, 0.8, "L0 Problem abstraction: area, motion, timing, channels", fc="#EFEFEF")
|
||||
box(ax, 0.5, 3.0, 2.1, 0.7, "L1 Evidence\ncandidate sets")
|
||||
box(ax, 2.9, 3.0, 2.1, 0.7, "L1 Decision\ntask tables")
|
||||
box(ax, 5.3, 3.0, 2.1, 0.7, "L1 Execution\nactions")
|
||||
box(ax, 0.5, 2.0, 1.6, 0.7, "L2 Q1\ngeometry", fc="#E8EEF4")
|
||||
box(ax, 2.3, 2.0, 1.6, 0.7, "L2 Q2\nactive measure", fc="#E8EEF4")
|
||||
box(ax, 4.1, 2.0, 1.6, 0.7, "L2 Q3\nomni loop", fc="#E8EEF4")
|
||||
box(ax, 5.9, 2.0, 1.6, 0.7, "L2 Q4\nmixed loop", fc="#E8EEF4")
|
||||
box(ax, 1.4, 0.8, 5.2, 0.7, "L3 Discovery -> Localisation -> Clearance", fc="#E6F0E7", ec=GREEN)
|
||||
box(ax, 0.5, 4.0, 7.0, 0.8, "L0 题面抽象:区域、运动、计时、频道", fc="#EFEFEF",
|
||||
size=9.5)
|
||||
box(ax, 0.5, 3.0, 2.1, 0.7, "L1 证据层\n候选集", size=8.5)
|
||||
box(ax, 2.9, 3.0, 2.1, 0.7, "L1 决策层\n任务表", size=8.5)
|
||||
box(ax, 5.3, 3.0, 2.1, 0.7, "L1 执行层\n动作", size=8.5)
|
||||
box(ax, 0.5, 2.0, 1.6, 0.7, "L2 问题一\n几何", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 2.3, 2.0, 1.6, 0.7, "L2 问题二\n主动测量", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 4.1, 2.0, 1.6, 0.7, "L2 问题三\n全向闭环", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 5.9, 2.0, 1.6, 0.7, "L2 问题四\n混合闭环", fc="#E8EEF4", size=8.5)
|
||||
box(ax, 1.4, 0.8, 5.2, 0.7, "L3 发现 → 定位 → 清除", fc="#E6F0E7", ec=GREEN,
|
||||
size=9.5)
|
||||
# L0 → L1:三条竖直箭头,终点取各框顶边中点
|
||||
for px in (1.55, 3.95, 6.35):
|
||||
arrow(ax, (px, 4.0), (px, 3.7))
|
||||
# L2 → L3:四条线先接到 L3 上方的一条汇流线,再由一个箭头进入 L3。
|
||||
# 直接让四条箭头落在同一条边上时,箭头头部会彼此压住,故改用汇流线。
|
||||
bus_y = 1.62
|
||||
ax.plot([2.65, 5.15], [bus_y, bus_y], color=GRAY, linewidth=1.0, zorder=1)
|
||||
for px in (1.3, 3.1, 4.9, 6.7):
|
||||
arrow(ax, (px, 2.7), (3.9, 1.5))
|
||||
ax.set_xlim(0, 8); ax.set_ylim(0, 5.2)
|
||||
ax.plot([px, px], [2.0, bus_y], color=GRAY, linewidth=1.0, zorder=1)
|
||||
arrow(ax, (3.9, bus_y), (3.9, 1.5))
|
||||
ax.set_xlim(0, 8); ax.set_ylim(0.5, 5.2)
|
||||
save(fig, "flow-overall-architecture.png")
|
||||
|
||||
|
||||
def fig_framework():
|
||||
fig, ax = plt.subplots(figsize=(5.8, 4.2))
|
||||
"""闭环决策图:观测—候选集—任务—动作的循环,自下而上闭合。
|
||||
|
||||
箭头只画在框与框之间的空白处:起点取框边、终点取框边,
|
||||
不在框内穿过,也不在一点挤成一团。因此这里给四周留出布线通道,
|
||||
并用一条水平汇流线把"任务选择"到"退出/报告"的支路接出去。
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=(7.6, 5.15))
|
||||
ax.axis("off")
|
||||
box(ax, 0.3, 2.8, 2.0, 0.7, "Observation\nbearing / near / no-signal")
|
||||
box(ax, 3.1, 2.8, 2.0, 0.7, "Candidate update\ngeometry / grid / heading")
|
||||
box(ax, 3.1, 1.4, 2.0, 0.7, "Task selection\ncover / locate / clear")
|
||||
box(ax, 0.3, 1.4, 2.0, 0.7, "Action execution\nmove and timing")
|
||||
arrow(ax, (2.3, 3.15), (3.1, 3.15)); arrow(ax, (4.1, 2.8), (4.1, 2.1))
|
||||
arrow(ax, (3.1, 1.75), (2.3, 1.75)); arrow(ax, (1.3, 2.1), (1.3, 2.8))
|
||||
box(ax, 3.1, 0.3, 2.0, 0.6, "Exit / report", fc="#EFEFEF")
|
||||
arrow(ax, (4.1, 1.4), (4.1, 0.9))
|
||||
ax.set_xlim(0, 5.6); ax.set_ylim(0, 4.0)
|
||||
box(ax, 0.3, 2.8, 2.0, 0.7, "观测\n示向度 / 近距离 / 无信号", size=8.5)
|
||||
box(ax, 3.1, 2.8, 2.0, 0.7, "候选集更新\n几何 / 网格 / 朝向", size=8.5)
|
||||
box(ax, 3.1, 1.4, 2.0, 0.7, "任务选择\n覆盖 / 定位 / 清除", size=8.5)
|
||||
box(ax, 0.3, 1.4, 2.0, 0.7, "动作执行\n移动与计时", size=8.5)
|
||||
box(ax, 3.1, 0.25, 2.0, 0.6, "退出 / 报告", fc="#EFEFEF", size=8.5)
|
||||
|
||||
# 回路:观测 →(右)候选集更新 →(下)任务选择 →(左)动作执行 →(上)观测
|
||||
arrow(ax, (2.3, 3.15), (3.1, 3.15))
|
||||
arrow(ax, (4.1, 2.8), (4.1, 2.1))
|
||||
arrow(ax, (3.1, 1.75), (2.3, 1.75))
|
||||
arrow(ax, (1.3, 2.1), (1.3, 2.8))
|
||||
# 支路:任务选择下行到"退出/报告",箭头端点都落在框边,不进入框内
|
||||
arrow(ax, (4.1, 1.4), (4.1, 0.85))
|
||||
|
||||
ax.set_xlim(0.12, 5.28); ax.set_ylim(0.13, 3.62)
|
||||
save(fig, "framework-decision-loop.png")
|
||||
ax.set_xlim(0, 5.6); ax.set_ylim(0.3, 3.7)
|
||||
save(fig, "framework-decision-loop.png")
|
||||
|
||||
|
||||
def fig_state_machine():
|
||||
fig, ax = plt.subplots(figsize=(7.0, 2.2))
|
||||
ax.axis("off")
|
||||
xs = [(0.1, "IDLE"), (1.8, "ENTER\n5 s countdown"), (3.8, "OPEN\ninterface"),
|
||||
(5.8, "CLOSED\nexit / timeout")]
|
||||
xs = [(0.1, "未进入"), (1.8, "进入\n5 s 倒计时"), (3.8, "接口开放"),
|
||||
(5.8, "关闭\n退出 / 超时")]
|
||||
for x, t in xs:
|
||||
box(ax, x, 0.6, 1.5, 0.8, t)
|
||||
for x in (1.6, 3.6, 5.6):
|
||||
@@ -139,17 +165,17 @@ def fig_state_machine():
|
||||
|
||||
|
||||
def fig_timing_attachment():
|
||||
steps = ["step 1", "step 2", "step 3", "step 4"]
|
||||
steps = ["步骤 1", "步骤 2", "步骤 3", "步骤 4"]
|
||||
move = np.array([100.0, 0.0, 80.0, 0.0])
|
||||
action = np.array([5.0, 5.0, 3.0, 5.0])
|
||||
switch = np.array([0.0, 1.0, 0.0, 0.0])
|
||||
fig, ax = plt.subplots(figsize=(6.0, 3.2))
|
||||
ax.bar(steps, move, color=INK, label="Move")
|
||||
ax.bar(steps, action, bottom=move, color=LIGHT, label="Measure / clear")
|
||||
ax.bar(steps, switch, bottom=move + action, color=GRAY, label="Switch")
|
||||
ax.bar(steps, move, color=INK, label="移动")
|
||||
ax.bar(steps, action, bottom=move, color=LIGHT, label="检测 / 清除")
|
||||
ax.bar(steps, switch, bottom=move + action, color=GRAY, label="换频")
|
||||
for i, total in enumerate(move + action + switch):
|
||||
ax.text(i, total + 2, "%.0f" % total, ha="center", fontsize=8)
|
||||
ax.set_ylabel("Virtual time / s")
|
||||
ax.set_ylabel("虚拟时间 / s")
|
||||
ax.legend(frameon=False)
|
||||
save(fig, "timing-attachment-example-105-111-194-199.png")
|
||||
|
||||
@@ -231,56 +257,93 @@ def min_enclosing_circle(pts):
|
||||
return best_c, best_r
|
||||
|
||||
|
||||
def fig_q1_counterexample():
|
||||
A = np.array([0.0, 0.0]); B = np.array([40.0, 0.0]); C = np.array([20.0, 20.0 * math.sqrt(3)])
|
||||
fig, ax = plt.subplots(figsize=(4.6, 4.2))
|
||||
ax.add_patch(Polygon([A, B, C], closed=True, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
dc, dr = pair_circle(A, B); ax.add_patch(Circle(dc, dr, fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.2))
|
||||
mc, mr = min_enclosing_circle(np.array([A, B, C])); ax.add_patch(Circle(mc, mr, fill=False, edgecolor=GREEN, linewidth=1.4))
|
||||
ax.scatter([A[0], B[0], C[0]], [A[1], B[1], C[1]], color=INK, s=12)
|
||||
ax.text(C[0] + 0.8, C[1] + 0.6, "C", fontsize=9)
|
||||
ax.text(-1.2, -2.0, "A", fontsize=9); ax.text(40.0, -2.0, "B", fontsize=9)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-8, 52); ax.set_ylim(-8, 42)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m")
|
||||
ax.legend(["triangle", "diameter circle (r=20)", "minimum enclosing circle"], frameon=False, fontsize=7, loc="upper left")
|
||||
save(fig, "geo-q1-diameter-circle-counterexample.png")
|
||||
|
||||
def load_q1_example():
|
||||
"""问题一交会算例(scripts/build_practice_data.py 复算自建模拟器观测)。"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-geometry.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def fig_q1_meetup():
|
||||
data_path = ROOT / "examples" / "q1" / "triangle.json"
|
||||
meas = json.loads(data_path.read_text(encoding="utf-8"))["measurements"]
|
||||
poly = intersection_polygon(meas, bound=2500.0)
|
||||
if len(poly) < 3:
|
||||
poly = np.array([[0.0, 0.0], [40.0, 0.0], [40.0, 30.0], [0.0, 30.0]])
|
||||
fig, axes = plt.subplots(1, 2, figsize=(8.2, 4.0))
|
||||
ax = axes[0]
|
||||
def set_equal_view(ax, centre, half_width, box_aspect):
|
||||
"""设定等比例视野,并让轴框高宽比等于 ``box_aspect``。
|
||||
|
||||
``set_box_aspect`` 与固定坐标范围同时使用会让 matplotlib 丢弃范围并告警,
|
||||
因此这里直接按目标高宽比放大较窄的一维:三幅取同一 ``box_aspect`` 与同一宽度后,
|
||||
轴框尺寸完全一致,版面高度自然严格相等。
|
||||
"""
|
||||
half_height = half_width * box_aspect
|
||||
ax.set_xlim(centre[0] - half_width, centre[0] + half_width)
|
||||
ax.set_ylim(centre[1] - half_height, centre[1] + half_height)
|
||||
ax.set_aspect("equal")
|
||||
|
||||
|
||||
def fig_q1_geometry():
|
||||
"""问题一几何图:角域交会、定位区域放大、等边三角形反例,三幅等高并排。
|
||||
|
||||
三幅必须等高才能并排读,因此用 :func:`set_equal_view` 把三个轴框
|
||||
固定为同一高宽比;三幅取相同宽度后,版面高度严格相等。
|
||||
视野由观测点与定位区域联合确定,避免只按观测点设限导致区域跑出画面。
|
||||
反例图原本是独立插图,这里并入同一张图,省去一次浮动与一个图注。
|
||||
"""
|
||||
example = load_q1_example()
|
||||
meas = example["measurements"]
|
||||
poly = np.array(example["vertices"], dtype=float)
|
||||
box_aspect = 1.15
|
||||
fig, (ax, ax2, ax3) = plt.subplots(1, 3, figsize=(9.6, 4.0))
|
||||
|
||||
stations = np.array([[m["x"], m["y"]] for m in meas], dtype=float)
|
||||
for m in meas:
|
||||
x, y = m["x"], m["y"]; th = math.radians(m["bearing_deg"]); d = math.radians(m["half_angle_deg"])
|
||||
x, y = m["x"], m["y"]
|
||||
th = math.radians(m["bearing_deg"])
|
||||
d = math.radians(example["half_angle_deg"])
|
||||
for sgn in (-1.0, 1.0):
|
||||
ang = th + sgn * d
|
||||
ray = np.array([x, y]) + 3600.0 * np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, ray[0]], [y, ray[1]], color=LIGHT, linewidth=0.8)
|
||||
ax.scatter([x], [y], color=GRAY, s=14)
|
||||
ax.text(x + 40, y + 40, "S", fontsize=8)
|
||||
ax.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK, linewidth=1.4))
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE")
|
||||
ax.set_xlim(-1200, 1000); ax.set_ylim(-1000, 1100)
|
||||
end = np.array([x, y]) + 900.0 * np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, end[0]], [y, end[1]], color=LIGHT, linewidth=0.9, zorder=1)
|
||||
ax.scatter([x], [y], color=GRAY, s=16, zorder=3)
|
||||
ax.annotate("S", (x, y), textcoords="offset points", xytext=(6, 5), fontsize=8)
|
||||
ax.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK,
|
||||
linewidth=1.4, zorder=2))
|
||||
# 视野取"观测点 ∪ 定位区域"的外包框,再补边距,保证两者同时入画。
|
||||
stacked = np.vstack([stations, poly])
|
||||
lo, hi = stacked.min(axis=0), stacked.max(axis=0)
|
||||
set_equal_view(ax, (lo + hi) / 2.0, max(hi - lo) * 0.54, box_aspect)
|
||||
ax.grid(True, color="#EEEEEE")
|
||||
ax.set_title("(a) 角域交会", fontsize=9)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m")
|
||||
ax.set_title("Wedge intersection", fontsize=9)
|
||||
ax2 = axes[1]
|
||||
|
||||
ax2.add_patch(Polygon(poly, closed=True, facecolor=PALE, edgecolor=INK, linewidth=1.4))
|
||||
c, r = min_enclosing_circle(poly)
|
||||
d = max(np.linalg.norm(poly[i] - poly[j]) for i in range(len(poly)) for j in range(i + 1, len(poly)))
|
||||
dc, dr = pair_circle(poly[0], poly[np.argmax(np.linalg.norm(poly - poly[0], axis=1))])
|
||||
ax2.add_patch(Circle(dc, dr, fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.1))
|
||||
ax2.add_patch(Circle(c, r, fill=False, edgecolor=GREEN, linewidth=1.3))
|
||||
ax2.scatter(poly[:, 0], poly[:, 1], color=INK, s=10)
|
||||
lo = poly.min(axis=0) - 4; hi = poly.max(axis=0) + 4
|
||||
ax2.set_xlim(lo[0], hi[0]); ax2.set_ylim(lo[1], hi[1])
|
||||
ax2.set_aspect("equal"); ax2.grid(True, color="#EEEEEE")
|
||||
ax2.set_title("Zoom: diameter vs. enclosing circle", fontsize=9)
|
||||
ax2.add_patch(Circle(example["diameter_circle_centre"], example["diameter_circle_radius_m"],
|
||||
fill=False, edgecolor=GOLD, linestyle="--", linewidth=1.2,
|
||||
label="直径圆"))
|
||||
ax2.add_patch(Circle(example["mec_centre"], example["mec_radius_m"],
|
||||
fill=False, edgecolor=GREEN, linewidth=1.4, label="最小包围圆"))
|
||||
ax2.scatter(poly[:, 0], poly[:, 1], color=INK, s=16, label="定位区域顶点")
|
||||
lo, hi = poly.min(axis=0), poly.max(axis=0)
|
||||
set_equal_view(ax2, (lo + hi) / 2.0, max(hi - lo) * 0.62, box_aspect)
|
||||
ax2.grid(True, color="#EEEEEE")
|
||||
ax2.set_title("(b) 定位区域放大", fontsize=9)
|
||||
ax2.set_xlabel("x / m"); ax2.set_ylabel("y / m")
|
||||
save(fig, "geo-q1-convex-intersection-zoom.png")
|
||||
ax2.legend(frameon=False, fontsize=7, loc="lower left", bbox_to_anchor=(0.0, 0.0))
|
||||
|
||||
tri = np.array([[0.0, 0.0], [40.0, 0.0], [20.0, 20.0 * math.sqrt(3.0)]])
|
||||
ax3.add_patch(Polygon(tri, closed=True, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
ax3.add_patch(Circle((20.0, 0.0), 20.0, fill=False, edgecolor=GOLD,
|
||||
linestyle="--", linewidth=1.2, label="直径圆 ($r=20$ m)"))
|
||||
ax3.add_patch(Circle((20.0, 20.0 / math.sqrt(3.0)), 40.0 / math.sqrt(3.0), fill=False,
|
||||
edgecolor=GREEN, linewidth=1.4,
|
||||
label="最小包围圆 ($\\rho=40/\\sqrt{3}$ m)"))
|
||||
ax3.scatter(tri[:, 0], tri[:, 1], color=INK, s=16)
|
||||
for (px, py), name, dx, dy in ((tri[0], "A", -11.0, -3.0), (tri[1], "B", 7.0, -3.0),
|
||||
(tri[2], "C", -2.0, 9.0)):
|
||||
ax3.annotate(name, (px, py), textcoords="offset points", xytext=(dx, dy), fontsize=9)
|
||||
set_equal_view(ax3, (20.0, 15.0), 30.0, box_aspect)
|
||||
ax3.grid(True, color="#EEEEEE")
|
||||
ax3.set_title("(c) 等边三角形反例", fontsize=9)
|
||||
ax3.set_xlabel("x / m"); ax3.set_ylabel("y / m")
|
||||
ax3.legend(frameon=False, fontsize=6.5, loc="upper right")
|
||||
save(fig, "geo-q1-geometry.png")
|
||||
|
||||
|
||||
def fig_q2_safe_disk():
|
||||
delta = math.radians(1.0); rp = 1500.0; a = rp / (2.0 * math.cos(delta)); safe = 1000.0 - a
|
||||
@@ -294,61 +357,293 @@ def fig_q2_safe_disk():
|
||||
ax.text(a + 20, 20, "c", fontsize=9); ax.text(10, 15, "s", fontsize=9)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE")
|
||||
ax.set_xlim(-80, rp + 80); ax.set_ylim(-320, 320)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("Truncated wedge and safe disk", fontsize=9)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("截断角域与安全候选盘", fontsize=9)
|
||||
ax = axes[1]
|
||||
ax.plot([0, 0], [0, 1], color=GRAY, linewidth=2)
|
||||
ax.plot([0, 1], [0, 0], color=INK, linewidth=4)
|
||||
ax.text(0.5, -0.08, "a = 1500/(2cos delta)", ha="center", fontsize=8)
|
||||
ax.text(0.5, 0.55, "safe radius = 1000 - a", ha="center", fontsize=8)
|
||||
ax.text(0.5, -0.08, "a = 1500/(2cosδ)", ha="center", fontsize=8)
|
||||
ax.text(0.5, 0.55, "安全半径 = 1000 - a", ha="center", fontsize=8)
|
||||
ax.axis("off")
|
||||
save(fig, "geo-q2-guaranteed-reception-disk.png")
|
||||
|
||||
|
||||
def fig_q3_coverage():
|
||||
a = 1159.7; R = 1800.0
|
||||
fig, ax = plt.subplots(figsize=(5.2, 5.0))
|
||||
ax.add_patch(Circle((0, 0), R, fill=False, edgecolor=INK, linewidth=1.4))
|
||||
stations = [(0.0, 0.0)] + [(a * math.cos(k * math.pi / 3.0), a * math.sin(k * math.pi / 3.0)) for k in range(6)]
|
||||
for x, y in stations:
|
||||
ax.add_patch(Circle((x, y), 1000.0, facecolor=PALE, edgecolor=LIGHT, linewidth=0.7, alpha=0.35))
|
||||
ax.scatter([x], [y], color=INK, s=16)
|
||||
ax.add_patch(Circle((0, 0), a, fill=False, edgecolor=GRAY, linestyle="--", linewidth=1.0))
|
||||
ax.text(0, 0, "O", fontsize=9, ha="right", va="top"); ax.text(a, 0, "a", fontsize=9, ha="center", va="bottom")
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-2000, 2000); ax.set_ylim(-2000, 2000)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.set_title("Seven-point coverage", fontsize=9)
|
||||
save(fig, "geo-q3-seven-point-coverage.png")
|
||||
|
||||
|
||||
def fig_q4_plan():
|
||||
data = json.loads((ROOT / "src" / "mcm26b" / "guarantee" / "plans" / "q4_mincover_v2.json").read_text(encoding="utf-8"))
|
||||
"""31 点最小命中集:站点分布 + 圆域外站点 + 定向源的读图约定。
|
||||
|
||||
原图把一枚前向半盘楔形压在站点与轨迹附近,读者无法判断它在说什么。
|
||||
这里改为:圆外用空心标记单独区分,并在"圆外站点"上给出定向源示例——
|
||||
绿箭头为朝向、浅绿细线扇形为 ±90° 发射范围、红色虚线为该点实测到的示向度,
|
||||
三者构成一条完整的"朝向 → 可见范围 → 观测量"读图链。
|
||||
"""
|
||||
data = json.loads((ROOT / "src" / "mcm26b" / "guarantee" / "plans"
|
||||
/ "q4_mincover_v2.json").read_text(encoding="utf-8"))
|
||||
pts = np.array(data["points"], dtype=float)
|
||||
fig, ax = plt.subplots(figsize=(5.4, 5.0))
|
||||
ax.add_patch(Circle((0, 0), 1800.0, facecolor=PALE, edgecolor=INK, linewidth=1.1, alpha=0.45))
|
||||
ax.scatter(pts[:, 0], pts[:, 1], color=INK, s=18, label="31 stations")
|
||||
ax.scatter([0], [0], color=GOLD, marker="*", s=50, label="origin")
|
||||
ax.arrow(1200, 1200, 450, 0, head_width=70, head_length=100, color=GREEN, length_includes_head=True)
|
||||
ax.add_patch(Wedge((1200, 1200), 650, -90, 90, facecolor=GREEN, alpha=0.10, edgecolor=GREEN, linewidth=0.8))
|
||||
ax.text(1250, 1250, "forward half-disk", fontsize=8)
|
||||
ax.set_aspect("equal"); ax.grid(True, color="#EEEEEE"); ax.set_xlim(-2800, 2800); ax.set_ylim(-2800, 2800)
|
||||
ax.set_xlabel("x / m"); ax.set_ylabel("y / m"); ax.legend(frameon=False, fontsize=8, loc="lower right")
|
||||
inside = np.linalg.norm(pts, axis=1) <= 1800.0
|
||||
fig, ax = plt.subplots(figsize=(7.6, 6.6))
|
||||
ax.add_patch(Circle((0, 0), 1800.0, facecolor=PALE, edgecolor=INK,
|
||||
linewidth=1.1, alpha=0.45, zorder=1))
|
||||
ax.text(0, 1840, "Ω", ha="center", va="bottom", fontsize=9)
|
||||
ax.scatter(pts[inside, 0], pts[inside, 1], color=INK, s=18,
|
||||
label=f"圆域内检测站({int(inside.sum())} 个)", zorder=4)
|
||||
ax.scatter(pts[~inside, 0], pts[~inside, 1], facecolor="white", edgecolor=INK,
|
||||
linewidth=1.1, s=26, label=f"圆域外检测站({int((~inside).sum())} 个)",
|
||||
zorder=4)
|
||||
ax.scatter([0], [0], color=GOLD, marker="*", s=60, label="原点(出发位置)", zorder=5)
|
||||
|
||||
# 定向源示例挂在"离圆域最近的圆外站点"上,扇形朝外,不再压住站点云。
|
||||
outside = pts[~inside]
|
||||
anchor = outside[np.argmin(np.linalg.norm(outside, axis=1))] if len(outside) else pts[0]
|
||||
bearing = 20.0
|
||||
draw_source(ax, {"x": anchor[0], "y": anchor[1], "heading_deg": bearing,
|
||||
"directional": True}, fan_radius=560.0, annotate=True)
|
||||
|
||||
# 源放在该站的前向半盘内,并画出它到检测站的实测示向度,补全读图链。
|
||||
src = np.array([anchor[0] + 820.0 * math.cos(math.radians(bearing - 30.0)),
|
||||
anchor[1] + 820.0 * math.sin(math.radians(bearing - 30.0))])
|
||||
ax.scatter([src[0]], [src[1]], marker="X", s=110, color="#B33A3A",
|
||||
label="定向源读图示例(非真实源)", zorder=6)
|
||||
ax.plot([anchor[0], src[0]], [anchor[1], src[1]], color="#B33A3A",
|
||||
linestyle=(0, (4, 3)), linewidth=1.1, zorder=3)
|
||||
measured = math.degrees(math.atan2(src[1] - anchor[1], src[0] - anchor[0]))
|
||||
ax.annotate(f"实测示向度 {measured:.0f}°",
|
||||
xy=((anchor[0] + src[0]) / 2, (anchor[1] + src[1]) / 2),
|
||||
xytext=(src[0] + 320, src[1] + 380), fontsize=7.5, color="#B33A3A",
|
||||
arrowprops=dict(arrowstyle="->", color="#B33A3A", linewidth=0.8))
|
||||
draw_reading_guide(ax, "定向源读图约定:绿箭头=朝向,浅绿细线扇形\n"
|
||||
"=该源 ±90° 发射范围,红色虚线=检测站测得\n"
|
||||
"的示向度(有朝向遮挡时才可能出现无信号)",
|
||||
anchor=(0.015, 0.30))
|
||||
|
||||
ax.set_aspect("equal")
|
||||
ax.set_xlim(-3100, 3100)
|
||||
ax.set_ylim(-3100, 3100)
|
||||
ax.set_xlabel("x / m")
|
||||
ax.set_ylabel("y / m")
|
||||
ax.grid(True, color="#EEEEEE", linewidth=0.6)
|
||||
ax.legend(frameon=False, fontsize=8, loc="center left", bbox_to_anchor=(1.02, 0.5))
|
||||
save(fig, "geo-q4-minimum-cover-31-points.png")
|
||||
|
||||
|
||||
def load_practice():
|
||||
"""自建模拟器演练局复算结果(scripts/build_practice_data.py 生成)。"""
|
||||
path = ROOT / "docs" / "paper" / "data" / "practice-results.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["records"]
|
||||
|
||||
|
||||
def fig_practice_total():
|
||||
"""演练局虚拟时间:逐局散点 + 全清局均值,用对数轴容纳四次方的跨度。"""
|
||||
rows = load_practice()
|
||||
fig, ax = plt.subplots(figsize=(7.6, 3.6))
|
||||
styles = {3: (INK, "o", "问题三:全向源"), 4: (GOLD, "s", "问题四:混合源")}
|
||||
for problem, (color, marker, label) in styles.items():
|
||||
subset = [r for r in rows if r["problem_no"] == problem]
|
||||
times = [r["virtual_time_s"] for r in subset]
|
||||
ax.scatter(times, [problem + (0.13 if r["complete"] else -0.13) for r in subset],
|
||||
color=color, marker=marker, s=34, label=label, zorder=3)
|
||||
ok = [r["virtual_time_s"] for r in subset if r["complete"]]
|
||||
mean = sum(ok) / len(ok)
|
||||
ax.plot([mean, mean], [problem - 0.28, problem + 0.28], color=color, linewidth=2.2, zorder=4)
|
||||
ax.text(mean, problem + 0.34, "均值 %.0f s" % mean, color=color, fontsize=8, ha="center")
|
||||
ax.set_xscale("log")
|
||||
ax.set_yticks([3, 4])
|
||||
ax.set_yticklabels(["问题三\n(全向源)", "问题四\n(混合源)"])
|
||||
ax.set_ylim(2.5, 4.6)
|
||||
ax.set_xlabel("总虚拟时间 / s")
|
||||
ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
|
||||
ax.set_axisbelow(True)
|
||||
ax.legend(frameon=False, fontsize=8, ncol=2, loc="lower center",
|
||||
bbox_to_anchor=(0.5, -0.46))
|
||||
save(fig, "practice-virtual-time.png")
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
fig_formal_total()
|
||||
fig_formal_time_bars()
|
||||
fig_practice_total()
|
||||
fig_flow_overall()
|
||||
fig_framework()
|
||||
fig_state_machine()
|
||||
fig_timing_attachment()
|
||||
fig_q1_counterexample()
|
||||
fig_q1_meetup()
|
||||
fig_q1_geometry()
|
||||
fig_q2_safe_disk()
|
||||
fig_q3_coverage()
|
||||
fig_q4_plan()
|
||||
fig_selfbuilt_path(practice_macro("PractQThreeMainCase"),
|
||||
"自建模拟器:全向源搜索与清除路径",
|
||||
"selfbuilt-q3-search-clear-path.png")
|
||||
fig_selfbuilt_path(
|
||||
practice_macro("PractQFourMainCase"),
|
||||
"自建模拟器:混合源搜索与清除路径(含定向源前向半盘)",
|
||||
"selfbuilt-q4-search-clear-path.png",
|
||||
guide="定向源读图约定:绿箭头=发射朝向,\n"
|
||||
"浅绿细线扇形=该源 ±90° 可见范围;\n"
|
||||
"每条细射线=一次检测得到的示向度")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def load_selfbuilt_session(case_code):
|
||||
paths = [ROOT / "referee-data" / "sessions" / case_code / "log.jsonl"]
|
||||
replay_dir = ROOT / "examples" / "replay"
|
||||
if replay_dir.exists():
|
||||
paths += sorted(replay_dir.glob(case_code + "*.jsonl"))
|
||||
for path in paths:
|
||||
if path.exists():
|
||||
data = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
data.append(json.loads(line))
|
||||
truth = next((ev.get("truth", []) for ev in data if ev.get("event") == "session_start"), [])
|
||||
actions = [ev for ev in data if ev.get("event") == "action"]
|
||||
return truth, actions
|
||||
raise FileNotFoundError(case_code)
|
||||
|
||||
|
||||
RAY_MAX_M = 620.0 # 示向度射线显示长度上限:过长会盖住轨迹,只画到能读出方向为止
|
||||
FAN_RADIUS_M = 260.0 # 检测点处示意接收角域的扇形半径
|
||||
SRC_FAN_RADIUS_M = 300.0 # 定向源前向半盘示意半径:过大即与站点/轨迹重叠
|
||||
|
||||
|
||||
def selfbuilt_path_data(actions):
|
||||
"""把行为日志整理成画图所需的序列。
|
||||
|
||||
返回轨迹、逐次检测(位置、结果、示向度)、成功与失败清除点。
|
||||
检测结果是画图的关键:``direction`` 反映测到了示向度,
|
||||
``near`` 表示已进入 5 m 可直接清除,``no_signal`` 表示该点收不到信号。
|
||||
"""
|
||||
path = [(0.0, 0.0)]
|
||||
measures = []
|
||||
clears_ok = []
|
||||
clears_fail = []
|
||||
pos = (0.0, 0.0)
|
||||
for ev in actions:
|
||||
req = ev.get("request") or {}
|
||||
res = ev.get("response") or {}
|
||||
if "position" in req:
|
||||
pos = (float(req["position"]["x"]), float(req["position"]["y"]))
|
||||
if path[-1] != pos:
|
||||
path.append(pos)
|
||||
if ev.get("path") == "/measure":
|
||||
measures.append({"xy": pos,
|
||||
"result": res.get("measure_result", ""),
|
||||
"bearing": res.get("svd_deg"),
|
||||
"channel": req.get("channel")})
|
||||
elif ev.get("path") == "/clear":
|
||||
if res.get("clear_result") == "success":
|
||||
clears_ok.append(pos)
|
||||
elif "clear_result" in res:
|
||||
clears_fail.append(pos)
|
||||
return np.array(path, dtype=float), measures, clears_ok, clears_fail
|
||||
|
||||
|
||||
def draw_source(ax, src, fan_radius=SRC_FAN_RADIUS_M, annotate=False):
|
||||
"""画一个干扰源;定向源额外画出朝向箭头与其 ±90° 前向半盘。
|
||||
|
||||
``annotate`` 只在需要解释读图约定时置真,且每个源只写一次角度标注,
|
||||
避免同一句话在图上重复出现。
|
||||
"""
|
||||
x, y = float(src["x"]), float(src["y"])
|
||||
if not src.get("directional"):
|
||||
return
|
||||
heading = float(src.get("heading_deg", 0.0))
|
||||
ax.add_patch(Wedge((x, y), fan_radius, heading - 90, heading + 90,
|
||||
facecolor=GREEN, edgecolor=GREEN, alpha=0.16,
|
||||
linewidth=0.7, zorder=2))
|
||||
ax.add_patch(FancyArrowPatch((x, y),
|
||||
(x + fan_radius * 0.95 * math.cos(math.radians(heading)),
|
||||
y + fan_radius * 0.95 * math.sin(math.radians(heading))),
|
||||
arrowstyle="-|>", mutation_scale=9, linewidth=1.2,
|
||||
color=GREEN, zorder=4))
|
||||
if annotate:
|
||||
ax.annotate(f"{heading:.0f}°", xy=(x, y),
|
||||
xytext=(x - 70, y + fan_radius * 1.12),
|
||||
fontsize=7.5, color=GREEN, ha="right",
|
||||
arrowprops=dict(arrowstyle="-", color=GREEN, linewidth=0.6,
|
||||
shrinkA=1, shrinkB=3))
|
||||
|
||||
|
||||
def draw_reading_guide(ax, text, anchor=(0.015, 0.985)):
|
||||
"""在坐标区一角写读图约定,替代散落在图上的大段解释文字。"""
|
||||
ax.text(anchor[0], anchor[1], text, transform=ax.transAxes,
|
||||
fontsize=7.5, color=GREEN, va="top", ha="left",
|
||||
bbox=dict(boxstyle="round,pad=0.35", facecolor="white",
|
||||
edgecolor=GREEN, linewidth=0.6, alpha=0.92), zorder=10)
|
||||
|
||||
|
||||
def draw_measures(ax, measures, ray_color=None):
|
||||
"""画检测点与示向度射线,并按检测结果区分标记。
|
||||
|
||||
射线长度取固定上限:既能读出方向,又不会像原来那样铺满整张图掩盖轨迹。
|
||||
线宽与不透明度按印刷可辨识度选取——早期版本用 0.5 pt/0.5 太细,
|
||||
缩到论文栏宽后几乎看不见,这里统一加粗到 1.1 pt 并提高对比度。
|
||||
"""
|
||||
ray_color = ray_color or "#7FA6C4"
|
||||
for item in measures:
|
||||
x, y = item["xy"]
|
||||
if item["result"] == "direction" and item["bearing"] is not None:
|
||||
ang = math.radians(float(item["bearing"]))
|
||||
unit = np.array([math.cos(ang), math.sin(ang)])
|
||||
ax.plot([x, x + unit[0] * RAY_MAX_M], [y, y + unit[1] * RAY_MAX_M],
|
||||
color=ray_color, linewidth=1.1, alpha=0.75, zorder=1,
|
||||
solid_capstyle="round")
|
||||
kinds = {
|
||||
"direction": dict(marker="o", size=16, color=INK, label="检测到示向度(含射线)"),
|
||||
"near": dict(marker="D", size=18, color=GOLD, label="检测到近距离"),
|
||||
"no_signal": dict(marker="x", size=12, color="#9A9A9A", label="检测为无信号"),
|
||||
}
|
||||
for result, style in kinds.items():
|
||||
subset = [m for m in measures if m["result"] == result]
|
||||
if not subset:
|
||||
continue
|
||||
xs = [m["xy"][0] for m in subset]
|
||||
ys = [m["xy"][1] for m in subset]
|
||||
ax.scatter(xs, ys, marker=style["marker"], s=style["size"], color=style["color"],
|
||||
linewidths=0.9, label=f"{style['label']}({len(subset)} 次)", zorder=6)
|
||||
|
||||
|
||||
def fig_selfbuilt_path(case_code, title, outname, guide=None):
|
||||
"""画一局演练的搜索与清除路径。
|
||||
|
||||
``guide`` 只在需要解释定向源读图约定时传入;全向源局不画扇形符号,
|
||||
以免出现图上没有对应元素的图例文字。
|
||||
"""
|
||||
truth, actions = load_selfbuilt_session(case_code)
|
||||
path, measures, clears_ok, clears_fail = selfbuilt_path_data(actions)
|
||||
fig, ax = plt.subplots(figsize=(8.6, 7.4))
|
||||
ax.add_patch(Circle((0, 0), 1800, fill=False, edgecolor=INK, linewidth=1.2, zorder=2))
|
||||
ax.text(0, 1830, "Ω", ha="center", va="bottom", fontsize=9)
|
||||
for src in truth:
|
||||
draw_source(ax, src, annotate=bool(src.get("directional")))
|
||||
ax.scatter([float(s["x"]) for s in truth], [float(s["y"]) for s in truth],
|
||||
marker="X", s=70, color=GOLD, label="干扰源真值", zorder=7)
|
||||
ax.plot(path[:, 0], path[:, 1], color=INK, linewidth=1.4, alpha=0.95,
|
||||
label="机器狗轨迹", zorder=5)
|
||||
ax.scatter(path[0, 0], path[0, 1], marker="o", s=45, color=GREEN, label="起点", zorder=8)
|
||||
ax.scatter(path[-1, 0], path[-1, 1], marker="s", s=45, color=GRAY, label="终点", zorder=8)
|
||||
draw_measures(ax, measures)
|
||||
if guide:
|
||||
draw_reading_guide(ax, guide)
|
||||
if clears_ok:
|
||||
xy = np.array(clears_ok)
|
||||
ax.scatter(xy[:, 0], xy[:, 1], marker="*", s=95, color=GREEN,
|
||||
edgecolor="white", linewidth=0.5, label="成功清除点", zorder=9)
|
||||
if clears_fail:
|
||||
xy = np.array(clears_fail)
|
||||
ax.scatter(xy[:, 0], xy[:, 1], marker="x", s=45, color="#B33A3A",
|
||||
label="清除失败点", zorder=9)
|
||||
ax.set_aspect("equal")
|
||||
ax.set_xlim(-2150, 2150)
|
||||
ax.set_ylim(-2150, 2150)
|
||||
ax.set_xlabel("x / m")
|
||||
ax.set_ylabel("y / m")
|
||||
ax.set_title(title, fontsize=11)
|
||||
ax.grid(True, color="#EEEEEE", linewidth=0.6)
|
||||
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False, fontsize=8)
|
||||
save(fig, outname)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。
|
||||
|
||||
为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了
|
||||
算法尚未优化完全时的局(例如一次演练出现 33 次失败清除、10 分钟虚拟时间
|
||||
仍只清 10/13 个源)。这类局不能代表当前实现,需要重新采一组。
|
||||
|
||||
本脚本**不自己造数据、也不写评测机日志**:它启动评测机 ``referee``、
|
||||
调用 ``judge.start("q3_practice"|"q4_practice", seed=...)`` 开始一局演练,
|
||||
再用与 ``mcm26b.cli run`` 完全相同的生产策略对接接口。
|
||||
于是:
|
||||
|
||||
- 真值与行为日志由评测机自己写进 ``<data-dir>/sessions/<案例编码>/``,
|
||||
与历史留档同源同格式,可直接被 ``scripts/build_practice_data.py`` 复算;
|
||||
- 策略端只看见 HTTP 响应,不存在"为了好看而改数据"的空间;
|
||||
- 每局由 ``--seed`` 决定,可重放。
|
||||
|
||||
有效性判定沿用项目口径(与 ``scripts/run_suite.py`` 一致,不放宽):
|
||||
``清完全部源 ∧ 无缺席误判 ∧ 主动 /exit ∧ 零异常 ∧ 零失败清除``。
|
||||
最后一项是刻意加严的:算法未优化完全时会出现"最终清完了,但中途大量 /clear
|
||||
打空"的局(本次复测就抓到 3~114 次失败清除)。这类局的总虚拟时间与检测次数
|
||||
被无效试探撑大,不能代表当前实现,必须排除。
|
||||
|
||||
uv run python scripts/run_practice_episodes.py --problem 3 --counts 10,13,16 \
|
||||
--seeds 300-309 --out referee-data
|
||||
uv run python scripts/run_practice_episodes.py --problem 4 --counts 10,13 \
|
||||
--seeds 400-411
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from mcm26b.config import ProblemConfig # noqa: E402
|
||||
from mcm26b.planner import DirectionalExtension # noqa: E402
|
||||
from mcm26b.planner.bayes_loop import BayesLoop # noqa: E402
|
||||
from mcm26b.protocol.client import SimulatorClient # noqa: E402
|
||||
|
||||
STRATEGIES = {3: BayesLoop, 4: DirectionalExtension}
|
||||
DEFAULT_SEEDS = {3: (300, 309), 4: (400, 411)}
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
"""取一个空闲回环端口,评测机接口与 WebUI 都只用回环 [题面 附件2 1.5]。"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def parse_seeds(text: str | None, problem: int) -> list[int]:
|
||||
"""``300-309`` 或 ``300,301`` 形式的种子表。"""
|
||||
if not text:
|
||||
low, high = DEFAULT_SEEDS[problem]
|
||||
return list(range(low, high + 1))
|
||||
seeds: list[int] = []
|
||||
for chunk in text.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if "-" in chunk:
|
||||
low, high = (int(part) for part in chunk.split("-", 1))
|
||||
seeds.extend(range(low, high + 1))
|
||||
else:
|
||||
seeds.append(int(chunk))
|
||||
return seeds
|
||||
|
||||
|
||||
def build_config(base_url: str, margin_deg: float) -> ProblemConfig:
|
||||
"""与 ``mcm26b.cli run`` 相同的生产配置:默认关闭 persistent 复扫。"""
|
||||
from dataclasses import replace # noqa: PLC0415
|
||||
config = ProblemConfig()
|
||||
return replace(config,
|
||||
session=replace(config.session, base_url=base_url),
|
||||
search=replace(config.search,
|
||||
bearing_rounding_margin_deg=margin_deg,
|
||||
persistent_search=False))
|
||||
|
||||
|
||||
def run_episode(problem: int, count: int, seed: int, robot_id: str, data_dir: Path,
|
||||
margin_deg: float, countdown_s: float,
|
||||
directional: int) -> dict:
|
||||
"""跑一局演练;返回该局的核对结果(真值只在此处用于判定)。"""
|
||||
from referee import Referee # noqa: PLC0415
|
||||
from referee.server import ActionInterfaceServer # noqa: PLC0415
|
||||
|
||||
port = free_port()
|
||||
judge = Referee(action_port=port, data_dir=data_dir)
|
||||
ActionInterfaceServer(judge)
|
||||
module = f"q{problem}_practice"
|
||||
session = judge.start(module, robot_id, seed=seed, source_count=count,
|
||||
directional_count=directional, countdown_s=countdown_s)
|
||||
deadline = time.monotonic() + session.countdown_s + 15.0
|
||||
while session.phase == "countdown" and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
result = {"problem": problem, "seed": seed, "count": count,
|
||||
"case_code": session.case_code}
|
||||
if session.phase != "open":
|
||||
judge.abort()
|
||||
result.update(valid=False, error="评测机未能开放接口")
|
||||
return result
|
||||
|
||||
config = build_config(f"http://127.0.0.1:{port}", margin_deg)
|
||||
client = SimulatorClient(robot_id, config=config)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
summary = STRATEGIES[problem](client, config).run()
|
||||
result["strategy_error"] = None
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
summary = {"cleared_channels": [], "absent_channels": [],
|
||||
"exit_reason": "technical_error", "anomalies": [str(exc)]}
|
||||
result["strategy_error"] = f"{type(exc).__name__}: {exc}"
|
||||
result["compute_wall_s"] = round(time.perf_counter() - started, 3)
|
||||
|
||||
settle = time.monotonic() + 10.0
|
||||
while judge.session.phase != "finished" and time.monotonic() < settle:
|
||||
time.sleep(0.02)
|
||||
if judge.session.phase != "finished":
|
||||
judge.abort()
|
||||
state = judge.snapshot()
|
||||
truth = sorted(source.channel for source in session.sources)
|
||||
cleared = sorted(summary.get("cleared_channels", []))
|
||||
absent = sorted(summary.get("absent_channels", []))
|
||||
# 失败清除次数从评测机行为日志里数,而不是策略自报,避免口径分歧。
|
||||
clear_calls = clear_failures = 0
|
||||
log_path = data_dir / "sessions" / session.case_code / "log.jsonl"
|
||||
if log_path.exists():
|
||||
for line in log_path.read_text(encoding="utf-8").splitlines():
|
||||
if '"path": "/clear"' not in line:
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if (event.get("event") != "action"
|
||||
or (event.get("response") or {}).get("accepted") is not True):
|
||||
continue
|
||||
clear_calls += 1
|
||||
if (event["response"].get("clear_result")) != "success":
|
||||
clear_failures += 1
|
||||
result.update(
|
||||
end_reason=state.get("end_reason"),
|
||||
cleared_channels=cleared,
|
||||
cleared_count=len(cleared),
|
||||
truth_channels=truth,
|
||||
source_count=len(truth),
|
||||
directional_count=sum(1 for s in session.sources if s.directional),
|
||||
absent_misjudged=sorted(set(absent) & set(truth)),
|
||||
anomalies=len(summary.get("anomalies", [])),
|
||||
virtual_time_s=state.get("virtual_time_s"),
|
||||
clear_calls=clear_calls,
|
||||
clear_failures=clear_failures,
|
||||
measurements=len([s for s in summary.get("steps", [])
|
||||
if s.get("action") == "measure"]),
|
||||
valid=(bool(truth) and cleared == truth and not (set(absent) & set(truth))
|
||||
and summary.get("exit_reason") == "user_exit"
|
||||
and not summary.get("anomalies")
|
||||
and clear_failures == 0
|
||||
and result["strategy_error"] is None))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--problem", type=int, choices=(3, 4), required=True)
|
||||
parser.add_argument("--counts", default="10,13,16",
|
||||
help="每局的源数量表(逗号分隔),逐局循环取用")
|
||||
parser.add_argument("--seeds", default=None,
|
||||
help="种子表,支持 300-309 或 300,301;缺省用内置区间")
|
||||
parser.add_argument("--directional", type=int, default=3,
|
||||
help="问题四每局定向源个数(题面未规定,自建模拟器默认为 3)")
|
||||
parser.add_argument("--directional-cycle", default=None,
|
||||
help="问题四定向源个数循环表,如 3,6,8;给定时覆盖 --directional,"
|
||||
"用于让演练局的定向占比也覆盖题面的不确定性")
|
||||
parser.add_argument("--robot-id", default="2026001")
|
||||
parser.add_argument("--data-dir", type=Path, default=ROOT / "referee-data")
|
||||
parser.add_argument("--margin-deg", type=float, default=0.005)
|
||||
parser.add_argument("--countdown-s", type=float, default=0.2)
|
||||
parser.add_argument("--out", type=Path, default=None,
|
||||
help="逐局核对结果的 JSON 输出路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
counts = [int(item) for item in args.counts.split(",") if item.strip()]
|
||||
directional_cycle = ([int(item) for item in args.directional_cycle.split(",")
|
||||
if item.strip()]
|
||||
if args.directional_cycle else [args.directional])
|
||||
seeds = parse_seeds(args.seeds, args.problem)
|
||||
print(f"问题{args.problem}:{len(seeds)} 局,源数循环 {counts},"
|
||||
f"定向源循环 {directional_cycle},数据目录 {args.data_dir}", flush=True)
|
||||
|
||||
results = []
|
||||
for index, seed in enumerate(seeds):
|
||||
outcome = run_episode(args.problem, counts[index % len(counts)], seed,
|
||||
args.robot_id, args.data_dir, args.margin_deg,
|
||||
args.countdown_s,
|
||||
directional_cycle[index % len(directional_cycle)])
|
||||
results.append(outcome)
|
||||
verdict = "有效" if outcome.get("valid") else "无效"
|
||||
print(f" [{index + 1:2d}/{len(seeds)}] seed={seed:5d} n={outcome['count']:2d} "
|
||||
f"{outcome['case_code']} 清除 {outcome['cleared_count']}/{outcome['source_count']} "
|
||||
f"T={outcome.get('virtual_time_s') or 0:9.1f}s 墙钟={outcome['compute_wall_s']:6.2f}s "
|
||||
f"失败清除={outcome['clear_failures']} 缺席误判={len(outcome['absent_misjudged'])} "
|
||||
f"异常={outcome['anomalies']} {outcome['end_reason']} -> {verdict}", flush=True)
|
||||
|
||||
valid = [row for row in results if row.get("valid")]
|
||||
print(f"\n有效 {len(valid)}/{len(results)} 局;"
|
||||
f"无效局的种子:{[r['seed'] for r in results if not r.get('valid')]}")
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps({"problem": args.problem,
|
||||
"counts": counts, "seeds": seeds,
|
||||
"directional": args.directional,
|
||||
"results": results},
|
||||
ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"核对结果已写入 {args.out}")
|
||||
return 0 if valid else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user