diff --git a/.gitignore b/.gitignore index b001efe..927bebb 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ docs/paper/*.synctex.gz # pytest .pytest_cache/ +# Jupyter +.ipynb_checkpoints/ + # 1 output/ \ No newline at end of file diff --git a/README.md b/README.md index 337a733..7167ec5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ - [1-4 问最优与基线实现对比框架](project/README.md)(`uv run python -m project.run`,结果在 `project/results/`) - [最优与基线实现对比(论文口径说明)](docs/最优与基线实现对比.md) - [论文](docs/paper/main.tex) +- **单文件核心代码(问题一~四)**:[`mcm26b-核心代码.ipynb`](mcm26b-核心代码.ipynb)—— + 四问生产实现按"公共层→几何→信念/覆盖→调度→策略→逐问算例"整理成一个可独立运行的 + notebook,不含评测机、命令行外壳、采集/基准/绘图脚本、测试与未采用方案; + 重新生成用 `python scripts/build_core_notebook.py`,零依赖自检用 + `python scripts/run_notebook_cells.py`(按顺序执行全部代码单元)。 ## 项目结构 @@ -42,7 +47,8 @@ project/ # 1-4 问"最优实现 / 基线实现"对比框架:逐问 o # +独立参考,冻结基准集配对出数;见 project/README.md scripts/ # 采集、基准、可视化与校核脚本(含问题四批量仿真 benchmark_q4.py、 # 运行历史计时分解 analyze_run_timing.py、第四问覆盖证书 verify_q4_cover.py、 - # 论文插图 build_figure_data.py + scripts/matlab、日志归档 archive_replay_logs.py) + # 论文插图 build_figure_data.py + scripts/matlab、日志归档 archive_replay_logs.py、 + # 单文件核心代码生成/自检 build_core_notebook.py + run_notebook_cells.py) docs/figures/ # 论文插图(PNG)+ manifest.json(数据来源与 sha256)+ 图清单 output/ examples/ # 演练证据、算例与生成产物 ``` diff --git a/docs/paper/README.md b/docs/paper/README.md index b702b25..d6ab522 100644 --- a/docs/paper/README.md +++ b/docs/paper/README.md @@ -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。 diff --git a/docs/paper/REWRITE_NOTES.md b/docs/paper/REWRITE_NOTES.md index 6ccc21b..f516e5a 100644 --- a/docs/paper/REWRITE_NOTES.md +++ b/docs/paper/REWRITE_NOTES.md @@ -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 页,无未解析引用与未定义文献。 diff --git a/docs/paper/data/listing-source/scripts/audit_paper_formal.py.tex b/docs/paper/data/listing-source/scripts/audit_paper_formal.py.tex deleted file mode 100644 index ede89bf..0000000 --- a/docs/paper/data/listing-source/scripts/audit_paper_formal.py.tex +++ /dev/null @@ -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() diff --git a/docs/paper/data/listing-source/scripts/build_paper_support.py.tex b/docs/paper/data/listing-source/scripts/build_paper_support.py.tex deleted file mode 100644 index a5ee614..0000000 --- a/docs/paper/data/listing-source/scripts/build_paper_support.py.tex +++ /dev/null @@ -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() diff --git a/docs/paper/data/listing-source/src/mcm26b/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/__init__.py.tex deleted file mode 100644 index 29b08f6..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/__init__.py.tex +++ /dev/null @@ -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"] diff --git a/docs/paper/data/listing-source/src/mcm26b/belief/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/belief/__init__.py.tex deleted file mode 100644 index 6318154..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/belief/__init__.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/belief/joint.py.tex b/docs/paper/data/listing-source/src/mcm26b/belief/joint.py.tex deleted file mode 100644 index 320acb5..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/belief/joint.py.tex +++ /dev/null @@ -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} diff --git a/docs/paper/data/listing-source/src/mcm26b/cli.py.tex b/docs/paper/data/listing-source/src/mcm26b/cli.py.tex deleted file mode 100644 index fb71207..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/cli.py.tex +++ /dev/null @@ -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()) diff --git a/docs/paper/data/listing-source/src/mcm26b/config.py.tex b/docs/paper/data/listing-source/src/mcm26b/config.py.tex deleted file mode 100644 index d430286..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/config.py.tex +++ /dev/null @@ -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() diff --git a/docs/paper/data/listing-source/src/mcm26b/coverage/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/coverage/__init__.py.tex deleted file mode 100644 index f4921cf..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/coverage/__init__.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/evaluation/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/evaluation/__init__.py.tex deleted file mode 100644 index 2fb2ec8..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/evaluation/__init__.py.tex +++ /dev/null @@ -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} diff --git a/docs/paper/data/listing-source/src/mcm26b/geometry/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/geometry/__init__.py.tex deleted file mode 100644 index 091e7e4..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/geometry/__init__.py.tex +++ /dev/null @@ -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"] diff --git a/docs/paper/data/listing-source/src/mcm26b/geometry/__main__.py.tex b/docs/paper/data/listing-source/src/mcm26b/geometry/__main__.py.tex deleted file mode 100644 index e43a5a2..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/geometry/__main__.py.tex +++ /dev/null @@ -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() diff --git a/docs/paper/data/listing-source/src/mcm26b/geometry/localization.py.tex b/docs/paper/data/listing-source/src/mcm26b/geometry/localization.py.tex deleted file mode 100644 index c7bfde8..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/geometry/localization.py.tex +++ /dev/null @@ -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 diff --git a/docs/paper/data/listing-source/src/mcm26b/geometry/planning.py.tex b/docs/paper/data/listing-source/src/mcm26b/geometry/planning.py.tex deleted file mode 100644 index 119894e..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/geometry/planning.py.tex +++ /dev/null @@ -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} diff --git a/docs/paper/data/listing-source/src/mcm26b/guarantee/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/guarantee/__init__.py.tex deleted file mode 100644 index a81a843..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/guarantee/__init__.py.tex +++ /dev/null @@ -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", -] diff --git a/docs/paper/data/listing-source/src/mcm26b/guarantee/cover.py.tex b/docs/paper/data/listing-source/src/mcm26b/guarantee/cover.py.tex deleted file mode 100644 index 736ead8..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/guarantee/cover.py.tex +++ /dev/null @@ -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") diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/__init__.py.tex deleted file mode 100644 index 2e972b9..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/__init__.py.tex +++ /dev/null @@ -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"] diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/playback.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/playback.py.tex deleted file mode 100644 index c5a2469..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/playback.py.tex +++ /dev/null @@ -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")) diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/scenes.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/scenes.py.tex deleted file mode 100644 index acf89c2..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/scenes.py.tex +++ /dev/null @@ -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)) diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/simulator.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/simulator.py.tex deleted file mode 100644 index 0c900ab..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/simulator.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/suites.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/suites.py.tex deleted file mode 100644 index 0e2ba61..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/suites.py.tex +++ /dev/null @@ -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]} diff --git a/docs/paper/data/listing-source/src/mcm26b/harness/transport.py.tex b/docs/paper/data/listing-source/src/mcm26b/harness/transport.py.tex deleted file mode 100644 index 08e6075..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/harness/transport.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/particles.py.tex b/docs/paper/data/listing-source/src/mcm26b/particles.py.tex deleted file mode 100644 index 69d44dd..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/particles.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/planner/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/planner/__init__.py.tex deleted file mode 100644 index 93dfe92..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/planner/__init__.py.tex +++ /dev/null @@ -1,1032 +0,0 @@ -"""P3/P4/P5 策略层:主动选点、全向闭环与定向扩展。 - -P3 第二检测点(验收:保证收到信号;Q 空时退化;near 分支): -- K1 = Ω (*@\ensuremath{\cap}@*) W(s1, (*@\ensuremath{\theta}@*)1±(*@\ensuremath{\delta}@*)) (*@\ensuremath{\cap}@*) B(s1, 1500);安全候选盘证书见 - geometry.planning.first_bearing_candidate_disk((*@\ensuremath{\delta}@*)<=1.005° 时非空); -- 在保证接收盘 B(mec_center, 1000-(*@\ensuremath{\rho}@*)) 内环形采样候选点,以 - "最坏后验包围圆半径"为主指标、移动代价为次指标; -- 采样是启发式(目标边界点 × 读数区间端点),不是严格最坏情形 - 最优解;选定后按 1/4 间距加密复核并如实上报数值; -- 返回 near 时:候选源在 5m 内,直接对当前频道尝试 /clear。 - -P4 全向闭环(验收:10/16/0 源场景、边界源、预算耗尽与故障注入): -发现(coverage.CoverageLedger)→ 定位(belief 收敛)→ 清除 → 持续复扫 -→ /exit。全部频道"已清除或证明不存在"后默认立即退出;显式开启 -persistent_search时只复扫未清除频道,扫到限时为止(清除数达 16 / -实机预算耗尽 / 虚拟安全余量不足)。completed 表示任务证明完成(结束 -时每频道均 CLEARED/ABSENT 且无中断);accounted_virtual_time_s 记录 -首次证明找全的虚拟时刻,与"复扫到限时才停"相互独立。 -断连/协议错误/守恒性破坏中断时同样尽力 /exit 并产出未完成 summary -(stop_reason 记中断类别,附逐频道状态快照),不静默丢失会话。 - -P5 定向扩展(问题4混合源闭环): -- 49 点覆盖的发现保证对定向源同样成立(源在单元凸包内,任意发射半平面 - 含至少一个顶点),覆盖阶段不区分源类型; -- 保证接收盘内的 no_signal 只能由朝向遮挡解释 [观测推理]:频道记为 - "观测到定向",对跖探点复核一次;联合约束基线不删空间候选(真源不误删); -- 定位停滞时对有界候选集构造 <=20m 有限圆盘覆盖(fallback_clear_cells), - 预算核对后逐格 /clear(清除与朝向无关);格数超硬顶或预算不足即跳过, - 绝不盲扫。 -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass - -from ..belief import (BeliefState, CandidateRegion, ChannelStatus, - InconsistentBelief, clip_halfplane) -from ..belief.joint import JointBelief -from ..config import ProblemConfig, Vec2 -from ..coverage import CoverageLedger -from ..evaluation import recompute_virtual_time, summarize -from ..guarantee import plan_instance, seven_plan, square_49_plan -from ..protocol.client import (DeadlineExceeded, ProtocolError, - SimulatorClient) -from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL -from ..protocol.timing import move_time -from ..protocol.transport import TransportError -from ..routing import improve_open_path - - -def bearing_deg(a: Vec2, b: Vec2) -> float: - """从 a 指向 b 的方位角(0-360,0 为 x 正方向,逆时针)。""" - return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0 - - -def _error_brief(exc: BaseException) -> dict: - """异常摘要(summary 留档用):类型名 + 截断消息。""" - return {"type": type(exc).__name__, "message": str(exc)[:500]} - - -@dataclass(frozen=True) -class SecondPointPlan: - """下一检测点的候选与依据(P3 输出)。""" - - position: Vec2 - worst_posterior_radius_m: float - move_cost_s: float - reason: str - - -@dataclass(frozen=True) -class ClearCertificate: - """清除证书:候选集外包半径(含近似误差)不超过 20m 阈值。""" - - channel: int - center: Vec2 - radius_m: float - limit_m: float - certified: bool - - -def clear_certificate(region: CandidateRegion, channel: int, - config: ProblemConfig = ProblemConfig()) -> ClearCertificate: - """(*@\ensuremath{\rho}@*)_poly <= 20 - 外包近似误差 - 工程余量 才允许发起清除。""" - center, radius = region.mec() - limit = (config.field.clear_radius_m - region.slack_m - - config.search.clear_margin_m) - return ClearCertificate(channel, center, radius, limit, radius <= limit) - - -def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) -> list[Vec2]: - """保证接收盘内的候选点:盘心 + 各比例半径 × 均匀方向。""" - points = [tuple(center)] - for fraction in rings: - radius = fraction * safe_radius_m - if radius <= 1e-9: - continue - step = 2 * math.pi / angles - for k in range(angles): - angle = step * k + step / 2 - points.append((center[0] + radius * math.cos(angle), - center[1] + radius * math.sin(angle))) - return points - - -def _worst_posterior_radius(region: CandidateRegion, q: Vec2, - witnesses: list[Vec2], half_angle_deg: float, - near_radius_m: float, disk_sides: int) -> float: - """对候选点 q,遍历"目标边界点 × 读数分支"的最坏后验包围半径。 - - 读数分支:direction 的区间端点 (*@\ensuremath{\varphi}@*)(g)±(*@\ensuremath{\delta}@*)(端点是每个 g 的最不利读数), - 以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)(*@\ensuremath{\cap}@*)候选集。 - 保证接收盘内 no_signal 不可能发生,故不枚举该分支。 - 这是启发式近似,不构成最优性证明 [工程]。 - """ - posterior_radii = [] - for g in witnesses: - phi = bearing_deg(q, g) - for reading in (phi - half_angle_deg, phi + half_angle_deg): - probe = CandidateRegion(list(region.polygon), region.slack_m) - probe.clip_wedge(q, reading, half_angle_deg) - if probe.polygon: - posterior_radii.append(probe.mec()[1]) - near_probe = CandidateRegion(list(region.polygon), region.slack_m) - try: - near_probe.clip_disk(q, near_radius_m, disk_sides) - except InconsistentBelief: - pass # B(q, near) 与候选集不相交:near 分支不可能发生,跳过 - else: - if near_probe.polygon: - posterior_radii.append(near_probe.mec()[1]) - if not posterior_radii: - return region.mec()[1] # 无有效采样点时退回当前半径 - return max(posterior_radii) - - -def safe_disk_radius(region: CandidateRegion, - config: ProblemConfig = ProblemConfig()) -> float: - """保证接收盘 B(mec, safe) 的半径;<0 表示无保证((*@\ensuremath{\rho}@*) 超过最小接收半径)。 - - 盘内任意点到候选集每点距离 <= safe + (*@\ensuremath{\rho}@*) + 1e-3 < 1000 <= 最小接收半径, - 故必然收到 direction/near。1mm 数值余量避免浮点贴边被 r_eff=1000 的源 - 判为 no_signal。 - """ - return config.field.r_eff_min_m - region.mec()[1] - 1e-3 - - -def select_measurement_point(region: CandidateRegion, from_position: Vec2, - config: ProblemConfig = ProblemConfig()) -> SecondPointPlan: - """在保证接收盘内选择下一检测点(P3 核心;对任意候选集通用)。 - - 保证接收盘 B(mec_center, 1000-(*@\ensuremath{\rho}@*)):盘内任意点到候选集每点距离 - <= (1000-(*@\ensuremath{\rho}@*)) + (*@\ensuremath{\rho}@*) = 1000 <= 最小接收半径,故必然收到 direction/near, - 不会浪费一次 no_signal。(*@\ensuremath{\rho}@*)>1000 时退化为探索 MEC 圆心(可能 no_signal)。 - """ - field, search = config.field, config.search - center, radius = region.mec() - half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg - - def move_cost(point: Vec2) -> float: - return move_time(from_position, point, config.cost) - - # 1mm 数值余量在 safe_disk_radius 内记账。 - safe_radius = safe_disk_radius(region, config) - if safe_radius < 0: - return SecondPointPlan(tuple(center), radius, move_cost(center), - "无保证接收盘((*@\ensuremath{\rho}@*)>1000):探索 MEC 圆心,可能 no_signal") - coarse = region.witnesses(search.witness_spacing_m) - samples = candidate_points(center, safe_radius, - search.candidate_rings, search.candidate_angles) - near_radius, sides = field.near_radius_m, search.polygon_sides - best_key, best_point, best_worst = None, None, None - for point in samples: - worst = _worst_posterior_radius(region, point, coarse, half_angle, - near_radius, sides) - key = (round(worst, 6), round(move_cost(point), 6)) - if best_key is None or key < best_key: - best_key, best_point, best_worst = key, point, worst - dense = _worst_posterior_radius(region, best_point, - region.witnesses(search.witness_spacing_m / 4), - half_angle, near_radius, sides) - reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;" - f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m") - return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason) - - -def select_second_point(client: SimulatorClient, first_position: Vec2, - first_bearing_deg: float, - config: ProblemConfig = ProblemConfig()) -> SecondPointPlan: - """P3 入口:由首次 direction 构造 K1,返回保证接收的第二检测点方案。""" - region = CandidateRegion.initial(first_position, config) - half_angle = config.field.bearing_error_half_deg \ - + config.search.bearing_rounding_margin_deg - region.clip_wedge(first_position, first_bearing_deg, half_angle) - return select_measurement_point(region, client.state.position, config) - - -class OmnidirectionalLoop: - """问题3全向"发现—定位—清除—持续复扫"闭环(P4 基线 + 持续搜索)。 - - 串行驱动 SimulatorClient:覆盖阶段在 49 点网格上对每个 UNKNOWN 频道 - 检测;发现 direction 即进入该频道的定位子循环(选点→测量→收敛→ - 证书清除),near 直接清除。全部频道"已清除或证明不存在"后按 - ``search.persistent_search``(默认关闭)可选择只复扫 - 未清除频道,直到清除数达 16、实机预算耗尽或虚拟安全余量不足。 - stop_reason 记录循环结束原因;``completed`` 表示任务证明完成 - (结束时每频道均 CLEARED/ABSENT 且无中断),与"复扫到限时才停" - 相互独立:accounted_virtual_time_s 记录首次证明找全的虚拟时刻。 - """ - - def __init__(self, client: SimulatorClient, - config: ProblemConfig = ProblemConfig()): - self.client = client - self.config = config - self.belief = BeliefState(config) - # 覆盖计划是"可离线搜索、可在运行期校验"的一等对象;基类默认保持 - # 历史 49 点表(grid49_immediate 基线口径不变),各问题策略自行覆盖。 - self.coverage_plan = square_49_plan() - self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points)) - self.cleared: list[int] = [] - self.anomalies: list[dict] = [] - self.stop_reason: str | None = None - # 逐频道缺席证据来源(pf_empty / coverage),供审计与论文逐条追溯。 - self.absence_evidence: dict[int, str] = {} - # "同一 0.5m 测点 × 同一频道"的重复检测计数:历史上一次官方演练 - # 有 98.84% 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。 - self.redundant_rescan_measures = 0 - # 首次达成"每频道均已清除或证明不存在"的虚拟时刻(找全证明)。 - self.accounted_virtual_time_s: float | None = None - # 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上 - # 反复消耗 3 秒/次的失败清除。 - self._may_clear: set[int] = set() - # 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。 - self._abort_detail: BaseException | None = None - - # ------------------------------------------------------------ 主流程 - def run(self) -> dict: - """主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。""" - try: - self.client.enter() - self._coverage_phase() - if self.stop_reason is None: - self._mark_absent_after_coverage() - self._resolve_detected() - self._record_accounted() - if self.config.search.persistent_search: - self._persistent_phase() - if self.stop_reason is None: - if len(self.cleared) >= self.config.field.source_count_max: - self.stop_reason = "sixteen_cleared" - elif self._all_accounted(): - self.stop_reason = "all_resolved" - else: - self.stop_reason = "unresolved_channels" - except DeadlineExceeded: - self.stop_reason = "real_budget_exhausted" - except (TransportError, ProtocolError, InconsistentBelief) as exc: - self._abort_detail = exc - if isinstance(exc, TransportError): - self.stop_reason = "transport_failure" - elif isinstance(exc, InconsistentBelief): - self.stop_reason = "inconsistent_belief" - else: - self.stop_reason = "protocol_error" - except Exception as exc: # 意外异常同样收尾,不阻断 /exit 与结果汇总 - self._abort_detail = exc - self.stop_reason = "unexpected_error" - info = None - exit_error = None - try: - info = self.client.exit() - except Exception as exc: # 接口不可用:记录后仍产出未完成状态 - exit_error = exc - summary = self._summary(info) - if exit_error is not None: - summary["exit_error"] = _error_brief(exit_error) - return summary - - # ------------------------------------------------------------ 预算与证明 - def _min_virtual_reserve(self) -> float: - """单动作耗时上界的保守估计,用于虚拟安全余量。 - - 上界 = 2 × 最远站点距离 / 速度 + 检测 + 换频(机器人不会跑到覆盖 - 计划包围区之外执行任务)。配置值通常更宽松;本函数保证即使配置被 - 调小,余量也足以完成最后一个动作而不触及 100 小时上限。 - """ - reach = max((math.dist((0.0, 0.0), point) for point in self.ledger.points), - default=0.0) - action_bound = (2.0 * reach / self.config.cost.speed_mps - + self.config.cost.measure_s + self.config.cost.switch_s) - return max(self.config.search.virtual_reserve_s, action_bound) - - def _virtual_cap_reached(self) -> bool: - """虚拟安全余量不足:置 stop_reason 并返回 True,调用方立即收尾。 - - 余量须大于单次动作耗时上界(见 :meth:`_min_virtual_reserve`), - `search.virtual_reserve_s=10000s` [工程] 提供充分裕度,避免触及 - 附件2 §4.5 的 100 小时上限被模拟器强制结束。 - """ - if (self.client.state.virtual_time_s + self._min_virtual_reserve() - < self.config.field.max_virtual_duration_s): - return False - self.stop_reason = "virtual_time_cap" - return True - - def _all_accounted(self) -> bool: - return all(belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT) - for belief in self.belief.channels.values()) - - def _record_accounted(self) -> None: - """首次"每频道均已清除或证明不存在"的时刻留档(找全证明)。""" - if self.accounted_virtual_time_s is None and self._all_accounted(): - self.accounted_virtual_time_s = self.client.state.virtual_time_s - - # ------------------------------------------------------------ 覆盖阶段 - def _pending_channels_at(self, index: int) -> list[int]: - return [channel for channel, belief in self.belief.channels.items() - if belief.status is ChannelStatus.UNKNOWN - and not self.ledger.measured_at(channel, index)] - - def _next_point_index(self) -> int | None: - """尚有 UNKNOWN 频道待测的最近网格点(贪心最近邻 [工程])。""" - position = self.client.state.position - best_index, best_distance = None, 0.0 - for index, point in enumerate(self.ledger.points): - if not self._pending_channels_at(index): - continue - distance = math.dist(position, point) - if best_index is None or distance < best_distance: - best_index, best_distance = index, distance - return best_index - - def _coverage_phase(self) -> None: - while True: - if self._count_limit_reached(): - return - index = self._next_point_index() - if index is None: - return - point = self.ledger.points[index] - for channel in self._pending_channels_at(index): - if self._virtual_cap_reached(): - return - outcome = self.client.measure(point, channel) - self.ledger.mark_measured(channel, index) - self.belief.update_measure(channel, point, outcome.result, - outcome.svd_deg) - if outcome.result == DIRECTION: - self._localize_and_clear(channel) - elif outcome.result == NEAR: - region = self.belief.candidate_set(channel) - cert = clear_certificate(region, channel, self.config) - if cert.certified: - self._attempt_clear(channel, cert.center, cert) - else: # 理论上不发生(near 候选半径 5m);保守记录 - self._anomaly("near_without_certificate", channel, - position=point) - if self.stop_reason is not None or self._count_limit_reached(): - return - - # ------------------------------------------------------------ 持续复扫 - def _persistent_phase(self) -> None: - """全部频道已清除/证明不存在后的持续复扫 [用户决策 2026-09-11]。 - - 只扫未清除频道(DETECTED 继续定位,ABSENT 全网格复测,防模拟器 - 或覆盖证明假设偏差),直到清除数达 16 或虚拟安全余量不足; - 实机预算由客户端 DeadlineExceeded 兜底(扫到限时为止)。 - """ - while self.stop_reason is None: - if self._count_limit_reached(): - return - if self._virtual_cap_reached(): - return - self._persistent_round() - - def _persistent_round(self) -> None: - """一轮全网格复扫:网格点最近邻序,逐点测量全部未清除频道。""" - measured: set[tuple[int, int]] = set() - while self.stop_reason is None: - index = self._next_resweep_point(measured) - if index is None: - return - point = self.ledger.points[index] - for channel in self._resweep_channels_at(index, measured): - if self._virtual_cap_reached(): - return - measured.add((channel, index)) - was_absent = (self.belief.channels[channel].status - is ChannelStatus.ABSENT) - outcome = self.client.measure(point, channel) - self.belief.update_measure(channel, point, outcome.result, - outcome.svd_deg) - if was_absent and outcome.result in (DIRECTION, NEAR): - # 复测推翻了覆盖缺席证明:守恒性留痕,按新证据定位清除。 - self._anomaly("absent_detected_on_resweep", channel, - position=point, result=outcome.result) - if outcome.result == DIRECTION: - self._localize_and_clear(channel) - elif outcome.result == NEAR: - region = self.belief.candidate_set(channel) - cert = clear_certificate(region, channel, self.config) - if cert.certified: - self._attempt_clear(channel, cert.center, cert) - else: - self._anomaly("near_without_certificate", channel, - position=point) - if self.stop_reason is not None: - return - - def _next_resweep_point(self, measured: set[tuple[int, int]]) -> int | None: - """尚有未清除频道待复测的最近网格点(贪心最近邻 [工程])。""" - position = self.client.state.position - best_index, best_distance = None, 0.0 - for index, point in enumerate(self.ledger.points): - if not self._resweep_channels_at(index, measured): - continue - distance = math.dist(position, point) - if best_index is None or distance < best_distance: - best_index, best_distance = index, distance - return best_index - - def _resweep_channels_at(self, index: int, - measured: set[tuple[int, int]]) -> list[int]: - return [channel for channel, belief in self.belief.channels.items() - if belief.status is not ChannelStatus.CLEARED - and (channel, index) not in measured] - - def _count_limit_reached(self) -> bool: - if len(self.cleared) >= self.config.field.source_count_max: - self.stop_reason = "sixteen_cleared" - return True - return False - - # ------------------------------------------------------------ 定位与清除 - def _localize_and_clear(self, channel: int) -> bool: - """定位子循环:选点测量收敛候选集,拿到清除证书后清除。 - - 返回是否已清除;步数耗尽仍未收敛则保持 DETECTED 留待二轮。 - 进入本函数前该频道刚有一次 direction 读数,允许立即清除。 - """ - search = self.config.search - self._may_clear.add(channel) - for _ in range(search.max_localize_steps): - if self._virtual_cap_reached(): - return False - region = self.belief.candidate_set(channel) - if region is None: - return False - cert = clear_certificate(region, channel, self.config) - if cert.certified and channel in self._may_clear: - if self._attempt_clear(channel, cert.center, cert): - return True - self._may_clear.discard(channel) - continue - plan = select_measurement_point(region, self.client.state.position, - self.config) - outcome = self.client.measure(plan.position, channel) - self.belief.update_measure(channel, plan.position, outcome.result, - outcome.svd_deg) - self._may_clear.add(channel) - if outcome.result == NO_SIGNAL: - self._anomaly("no_signal_in_safe_disk", channel, - position=plan.position, detail=plan.reason) - return self.belief.channels[channel].status is ChannelStatus.CLEARED - - def _attempt_clear(self, channel: int, target: Vec2, - cert: ClearCertificate) -> bool: - outcome = self.client.clear(target, channel) - if outcome.result == CLEAR_SUCCESS: - self.belief.mark_cleared(channel) - self.cleared.append(channel) - return True - if cert.certified: - self._anomaly("clear_failed_with_certificate", channel, position=target, - radius_m=round(cert.radius_m, 3), limit_m=cert.limit_m) - return False - - def _anomaly(self, kind: str, channel: int, **detail) -> None: - """异常只在守恒性被打破或证书失效时出现;每频道限量记录防刷屏。""" - recorded = sum(1 for item in self.anomalies if item["channel"] == channel) - if recorded >= self.config.search.max_anomalies_per_channel: - return - self.anomalies.append({"kind": kind, "channel": channel, **detail}) - - # ------------------------------------------------------------ 收尾 - def _mark_absent_after_coverage(self) -> None: - """全向源缺席证明:全网格无非 no_signal 读数的频道不存在源 [题面]。""" - for channel, belief in self.belief.channels.items(): - if belief.status is ChannelStatus.UNKNOWN \ - and self.ledger.coverage_complete(channel): - self.belief.mark_absent(channel) - self.absence_evidence[channel] = "coverage" - - def _resolve_detected(self) -> None: - """覆盖完成后,对仍未清除的 DETECTED 频道做第二轮定位。 - - 不再在此决定停止:持续搜索模式下由 _persistent_phase 接力, - 非持续模式由 run() 依据 _all_accounted 判定 all_resolved。 - """ - for channel in self.belief.channels_by_status(ChannelStatus.DETECTED): - if self._count_limit_reached() or self._virtual_cap_reached(): - return - self._localize_and_clear(channel) - if self.stop_reason is not None: - return - - def _summary(self, info) -> dict: - """结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。 - - completed = 无中断且结束时每频道均 CLEARED/ABSENT(任务证明完成); - stop_reason 另行记录循环结束原因(持续复扫模式下通常是 - virtual_time_cap / real_budget_exhausted / sixteen_cleared)。 - """ - events = self.client.log.action_events() - stats = summarize(events) - report = recompute_virtual_time(events) - steps = self._step_report() - cleared = self.belief.channels_by_status(ChannelStatus.CLEARED) - absent = self.belief.channels_by_status(ChannelStatus.ABSENT) - resolved = set(cleared) | set(absent) - unresolved = [channel for channel in sorted(self.belief.channels) - if channel not in resolved] - virtual_time_s = round((info.virtual_time_s if info is not None - else self.client.state.virtual_time_s), 3) - # 清除数达 16 即数量上界证明(其余频道无论状态如何都不可能有源); - # 否则要求每频道均 CLEARED/ABSENT。 - completed = (self._abort_detail is None - and (len(self.cleared) >= self.config.field.source_count_max - or not unresolved)) - # 完成依据必须显式区分"数量上界"与"逐频道缺席/清除证明", - # 否则论文与审计会把 16 源截断误读为逐频道证明。 - if self._abort_detail is not None: - completion_basis = "incomplete" - elif len(self.cleared) >= self.config.field.source_count_max: - completion_basis = "count_cap" - elif not unresolved: - completion_basis = "per_channel_proof" - else: - completion_basis = "incomplete" - summary = { - "completed": completed, - "completion_basis": completion_basis, - "stop_reason": self.stop_reason, - "exit_reason": "exit_unreachable" if info is None else info.reason, - "virtual_time_s": virtual_time_s, - "accounted_virtual_time_s": (round(self.accounted_virtual_time_s, 3) - if self.accounted_virtual_time_s is not None - else None), - "persistent_search": self.config.search.persistent_search, - "virtual_reserve_s": round(self._min_virtual_reserve(), 3), - "coverage_plan": self.coverage_plan.name, - "coverage_points": len(self.coverage_plan.points), - "coverage_plan_points": [[p[0], p[1]] for p in self.coverage_plan.points], - "coverage_plan_certificate": ( - self.coverage_plan.certificate.as_dict() - if self.coverage_plan.certificate is not None else None), - "cleared_channels": cleared, - "absent_channels": absent, - "unresolved_channels": unresolved, - "absence_evidence": {str(channel): self.absence_evidence[channel] - for channel in sorted(absent)}, - "redundant_rescan_measures": self.redundant_rescan_measures, - "measures": stats["measures"], - "clears": stats["clears"], - "clear_success": stats["cleared"], - "total_requests": stats["measures"] + stats["clears"], - "move_distance_m": round(stats["distance_m"], 1), - # 题面口径:定位清除总时间(全程虚拟时长)/ 被清除干扰源个数。 - "avg_locate_clear_time_s": (round(virtual_time_s / len(cleared), 3) - if cleared else None), - "step_totals": steps["totals"], - "steps": steps["rows"], - "anomalies": self.anomalies, - "virtual_recompute_mismatches": len(report["mismatches"]), - "channel_status": self._channel_status_snapshot(), - } - if self._abort_detail is not None: - summary["abort_detail"] = _error_brief(self._abort_detail) - return summary - - def _step_report(self) -> dict: - """每步计时汇总 [用户决策 2026-09-11]:实机墙钟 + 比赛虚拟耗时分项。""" - totals = {"count": 0, "real_elapsed_total_s": 0.0, - "real_since_enter_total_s": 0.0, - "virtual_move_s": 0.0, "virtual_switch_s": 0.0, - "virtual_measure_action_s": 0.0, "virtual_clear_action_s": 0.0, - "virtual_total_s": 0.0} - rows = [] - for index, step in enumerate(self.client.log.step_events()): - cost = step["virtual_cost"] - totals["count"] += 1 - totals["real_elapsed_total_s"] += step["real_elapsed_s"] - totals["real_since_enter_total_s"] = step.get( - "real_since_enter_s", totals["real_since_enter_total_s"]) - totals["virtual_move_s"] += cost["move_s"] - totals["virtual_switch_s"] += cost["switch_s"] - if step["path"] == "/measure": - totals["virtual_measure_action_s"] += cost["action_s"] - elif step["path"] == "/clear": - totals["virtual_clear_action_s"] += cost["action_s"] - totals["virtual_total_s"] += cost["total_s"] - rows.append({"index": index, "action": step["path"].lstrip("/"), - "result": step.get("result"), - "channel": step.get("channel"), - "position": step.get("position"), - "real_elapsed_ms": round(step["real_elapsed_s"] * 1000.0, 3), - "real_since_enter_s": step.get("real_since_enter_s"), - "move_s": round(cost["move_s"], 3), - "switch_s": round(cost["switch_s"], 3), - "action_s": round(cost["action_s"], 3), - "step_virtual_s": round(cost["total_s"], 3), - "virtual_time_s": step["virtual_time_s"]}) - for key, value in totals.items(): - if isinstance(value, float): - totals[key] = round(value, 3) - return {"totals": totals, "rows": rows} - - def _channel_status_snapshot(self) -> dict: - """逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。""" - snapshot = {} - for channel, belief in sorted(self.belief.channels.items()): - entry = {"status": belief.status.value} - if belief.region is not None: - entry["posterior_radius_m"] = round(belief.region.mec()[1], 3) - snapshot[str(channel)] = entry - return snapshot - - -class DirectionalExtension(OmnidirectionalLoop): - """问题4混合源闭环(P5):全向基线 + 定向观测处理 + 后备清除。 - - 与 P4 的三点差异(协议衔接见 docs/协议规则表与状态机.md §7): - - 49 点覆盖的发现保证对定向源同样成立,覆盖阶段不区分源类型; - - 保证接收盘内的 no_signal 只能由朝向遮挡解释 [观测推理]:该频道 - 记为"观测到定向",随后对 MEC 圆心取对跖探点复核;联合约束基线 - 不据此删除空间候选(真源不误删); - - 定位停滞(候选集有界但未达清除证书)时构造 <=20m 有限圆盘覆盖, - 预算核对后逐格 /clear:清除与朝向无关 [题面],是定向源不依赖 - 可见性的完备后备。格数超硬顶或现实预算不足则跳过并留痕,绝不盲扫。 - """ - - def __init__(self, client: SimulatorClient, - config: ProblemConfig = ProblemConfig()): - super().__init__(client, config) - # P0-2:第四问切换到同一保证下的最小命中集(默认 q4_mincover_v1)。 - self.coverage_plan = plan_instance(config.search.q4_coverage_plan, - verify=config.search.verify_coverage_plan) - self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points)) - self.directional_observed: list[int] = [] # 观测到定向性的频道 - self.blocked_probes: dict[int, list[Vec2]] = {} - self.fallback_log: list[dict] = [] # 后备清除逐次留痕 - # P0-3:(位置 × 朝向) 联合可行集,仅在频道首次被看见时惰性建立, - # 使其初始化与更新成本只落在"确实存在定向源"的频道上。 - self.joint: dict[int, JointBelief] = {} - self.joint_absent_channels: list[int] = [] - self.visibility_probes = 0 # 由联合信念选出的探点数 - - def _coverage_phase(self) -> None: - """49站的定向覆盖证明不变;每站先串行扫频,再就近服务已发现源。 - - 避免检测一个源后远行清除,再返回同站检测下一个频道。 - no_signal不用于删空间候选,也不使用第三问的七站缺席证明。 - """ - while not self._count_limit_reached() and self.stop_reason is None: - index = self._next_point_index() - if index is None: - return - point = self.ledger.points[index] - pending = self._pending_channels_at(index) - pending.sort(key=lambda c: (c != self.client.state.channel, c)) - for channel in pending: - if self._virtual_cap_reached(): - return - outcome = self.client.measure(point, channel) - self.ledger.mark_measured(channel, index) - self._observe(channel, point, outcome.result, outcome.svd_deg) - if outcome.result == NEAR: - cert = clear_certificate( - self.belief.candidate_set(channel), channel, self.config) - if cert.certified: - self._attempt_clear(channel, point, cert) - if self._count_limit_reached(): - return - queue = self.belief.channels_by_status(ChannelStatus.DETECTED) - while queue and self.stop_reason is None: - channel = min(queue, key=lambda c: ( - math.dist(self.client.state.position, - self.belief.candidate_set(c).mec()[0]), c)) - queue.remove(channel) - self._localize_and_clear(channel) - if self._count_limit_reached(): - return - - # ------------------------------------------------------------ 定位与清除 - def _localize_and_clear(self, channel: int) -> bool: - """定位子循环:选点测量收敛候选集,拿到清除证书后清除。 - - 与 P4 的差异:保证接收盘内的 no_signal 说明探点被朝向遮挡 - [观测推理],走"对跖复核 -> 环探"升级梯子(不计入定位步数, - 由 max_blocked_probes 限流),之后仍未收敛再转后备清除。 - """ - search = self.config.search - self._may_clear.add(channel) - steps = 0 - while steps < search.max_localize_steps: - if self._virtual_cap_reached(): - return False - region = self.belief.candidate_set(channel) - if region is None: - return False - cert = clear_certificate(region, channel, self.config) - if cert.certified and channel in self._may_clear: - if self._attempt_clear(channel, cert.center, cert): - return True - self._may_clear.discard(channel) # 证书失败:补测后才可重试 - continue - if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes: - break # 梯子用尽仍被遮挡:定位停滞,转后备清除 - point = self._next_probe(region, channel) - within_range = (math.dist(point, region.mec()[0]) - <= safe_disk_radius(region, self.config)) - outcome = self.client.measure(point, channel) - self._observe(channel, point, outcome.result, outcome.svd_deg) - self._may_clear.add(channel) - if outcome.result == NO_SIGNAL: - if within_range and channel not in self.directional_observed: - self.directional_observed.append(channel) - self.blocked_probes.setdefault(channel, []).append(point) - continue # 盘外无信号也可能是距离不足,不据此断言定向 - self.blocked_probes.pop(channel, None) - steps += 1 - if self.belief.channels[channel].status is ChannelStatus.CLEARED: - return True - region = self.belief.candidate_set(channel) - if region is None: - return False - cert = clear_certificate(region, channel, self.config) - if cert.certified and channel in self._may_clear: - if self._attempt_clear(channel, cert.center, cert): - return True - self._may_clear.discard(channel) - return self._fallback_clear(channel, region) - - def _observe(self, channel: int, point: Vec2, result: str, - svd_deg: float | None) -> None: - """同步更新凸候选集与 (位置 × 朝向) 联合可行集。 - - 联合集只在频道首次被看见(direction/near)时建立:从未被看见的 - 频道由覆盖证明负责缺席,不需要朝向维度。联合集一旦为空,直接给出 - 该频道的缺席证明(严格,与覆盖证明同一条件),可提前停止对其检测。 - """ - self.belief.update_measure(channel, point, result, svd_deg) - if not self.config.search.directional_joint_belief: - return - joint = self.joint.get(channel) - if joint is None: - if result not in (DIRECTION, NEAR): - return - joint = JointBelief(self.config) - self.joint[channel] = joint - joint.apply(point, result, svd_deg) - if joint.is_absent() and self.belief.channels[channel].status \ - is not ChannelStatus.CLEARED: - self.belief.mark_absent(channel) - self.absence_evidence[channel] = "joint_empty" - if channel not in self.joint_absent_channels: - self.joint_absent_channels.append(channel) - - def _probe_candidates(self, region: CandidateRegion, channel: int) -> list[Vec2]: - """可见性选点的候选点族:保证接收点 + 对跖点 + 环探 + **抵近点**。 - - D1 关键结构性改动:旧点族全部围绕 MEC 圆心固定环采样,远距离不收敛时 - 没有"主动靠近"这一动作,只能退化到 20m 网格盲清。现在补上 - ``center + (*@\ensuremath{\alpha}@*)·(当前位置 − center)``((*@\ensuremath{\alpha}@*)(*@\ensuremath{\in}@*)approach_fractions,(*@\ensuremath{\alpha}@*)=0 即圆心) - 的抵近序列,使策略可以在保持可见的前提下逐次逼近真源,最终以正常 - 角域证书或 near 收尾。 - """ - center, radius = region.mec() - position = self.client.state.position - candidates = [select_measurement_point( - region, position, self.config).position] - safe = safe_disk_radius(region, self.config) - blocked = self.blocked_probes.get(channel, []) - if blocked and safe >= 0: - dx, dy = center[0] - blocked[0][0], center[1] - blocked[0][1] - norm = math.hypot(dx, dy) - if norm > 1e-9: - scale = min(1.0, safe / norm) - candidates.append((center[0] + dx * scale, center[1] + dy * scale)) - # 抵近点:从 MEC 圆心到当前点的插值序列(含圆心本身)。 - for fraction in self.config.search.approach_fractions: - candidates.append((center[0] + (position[0] - center[0]) * fraction, - center[1] + (position[1] - center[1]) * fraction)) - for factor in (0.5, 1.0, 1.5): - ring = max(radius * factor, 2.0 * self.config.field.near_radius_m) - for step in range(8): - angle = math.pi * step / 4.0 - candidates.append((center[0] + ring * math.cos(angle), - center[1] + ring * math.sin(angle))) - unique: list[Vec2] = [] - seen: set[tuple[int, int]] = set() - blocked_keys = {(int(round(point[0] * 2)), int(round(point[1] * 2))) - for point in blocked} \ - if self.config.search.probe_exclude_blocked else set() - for point in candidates: - key = (int(round(point[0] * 2)), int(round(point[1] * 2))) - if key in seen or key in blocked_keys: - continue # 已测过且被遮挡的点:再测不会有任何新信息 - seen.add(key) - unique.append(point) - return unique - - def _visibility_probe(self, region: CandidateRegion, channel: int) -> Vec2 | None: - """按联合信念选探点;联合信念不可用时返回 None(退回梯子)。 - - 三种口径(只影响选点顺序,不承担任何安全责任): - - - ``nearest_visible``(默认):先按可见概率过滤(只保留 - ``prob_detect (*@\ensuremath{\ge}@*) ratio × max`` 的候选,避免选中"实际必然被朝向遮挡"的 - 点),再取 ``当前位置→q→MEC 圆心`` 移动最省者。9 场景 core 实测: - 后备盲清 125.7 → 4.3 次/局、T 9225 → 8602 s、墙钟最低。 - - ``expected_radius``:在同样过滤后最小化 - ``E[后验 MEC 半径](q) + (*@\ensuremath{\lambda}@*)·移动``。(*@\ensuremath{\lambda}@*)=5 时与上面几乎同分(8604 s), - (*@\ensuremath{\lambda}@*) 很小时(0.05)值函数主导,为信息多绕路(9740 s)——本题 - "看得见且少走路"比"单步信息量最大"更重要。 - - ``visibility``(旧口径):最大化 ``prob_detect/(移动+6)``,会反复选中 - 同一个近处被遮挡点(实测 9225 s、后备 125.6 次)。 - """ - joint = self.joint.get(channel) - if joint is None or joint.is_absent(): - return None - position = self.client.state.position - service_s = self.config.cost.measure_s + self.config.cost.switch_s - candidates = self._probe_candidates(region, channel) - if not candidates: - return None - mode = self.config.search.probe_score_mode - if mode == "visibility": - best_point, best_score = None, None - for point in candidates: - probability = joint.prob_detect(point) - if probability <= 0.0: - continue - score = -(probability / (move_time(position, point, self.config.cost) - + service_s)) - if best_score is None or score < best_score: - best_point, best_score = point, score - if best_point is not None: - self.visibility_probes += 1 - return best_point - scored = [(joint.prob_detect(point), point) for point in candidates] - peak = max(probability for probability, _ in scored) - ratio = self.config.search.probe_visibility_ratio - keep = [point for probability, point in scored - if probability >= ratio * peak] or [point for _, point in scored] - center = region.mec()[0] - weight = self.config.search.probe_lambda_move_s - - def approach_cost(point: Vec2) -> float: - return (move_time(position, point, self.config.cost) - + move_time(point, center, self.config.cost) + service_s) - - if mode == "nearest_visible": - # (*@\ensuremath{\lambda}@*) 很大时值函数项对 argmin 已无影响:直接取"可见候选中移动最省", - # 省掉每个候选一次期望后验半径计算(单局墙钟 −3 s)。 - best = min(keep, key=lambda point: (approach_cost(point), point)) - self.visibility_probes += 1 - return best - best = min(keep, key=lambda point: ( - joint.expected_posterior_radius(point) + weight * approach_cost(point), - point)) - self.visibility_probes += 1 - return best - - - - def _next_probe(self, region: CandidateRegion, channel: int) -> Vec2: - """定向遮挡下的探点升级梯子 [工程]。 - - - 未被遮挡过:worst-posterior 选点器(保证接收盘内); - - 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘; - - 之后:绕 MEC 圆心、半径 = (*@\ensuremath{\rho}@*) 的环探,方位自首个被遮挡点起 - 每次 +45°。几何边界必须如实陈述:环上可见弧没有下界—— - 源可位于环上(|g-c| = (*@\ensuremath{\rho}@*))且朝向沿外切线,可见集退化为单个 - 切点,固定步进环探可以全部漏过。梯子只提高提前命中概率, - 不构成接收或命中保证;完备性完全由后备清除兜底。 - """ - visibility = self._visibility_probe(region, channel) - if visibility is not None: - return visibility - blocked = self.blocked_probes.get(channel, []) - center, radius = region.mec() - if not blocked: - return select_measurement_point(region, self.client.state.position, - self.config).position - safe = safe_disk_radius(region, self.config) - first = blocked[0] - if len(blocked) == 1 and safe >= 0: - dx, dy = center[0] - first[0], center[1] - first[1] - norm = math.hypot(dx, dy) - if norm <= 1e-9: - # 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。 - return (center[0] + safe, center[1]) - scale = min(1.0, safe / norm) - # 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。 - return (center[0] + dx * scale, center[1] + dy * scale) - base = math.atan2(first[1] - center[1], first[0] - center[0]) - azimuth = base + math.radians(45.0 * (len(blocked) - 1)) - return (center[0] + radius * math.cos(azimuth), - center[1] + radius * math.sin(azimuth)) - - def _fallback_clear(self, channel: int, region: CandidateRegion) -> bool: - """定位停滞的后备:候选集有限圆盘覆盖逐格清除(格数即请求上界)。""" - search, session = self.config.search, self.config.session - width = max(p[0] for p in region.polygon) - min(p[0] for p in region.polygon) - height = max(p[1] for p in region.polygon) - min(p[1] for p in region.polygon) - cells = fallback_clear_cells(region.polygon, self.config) - entry = {"channel": channel, "cells": len(cells), - "bound": fallback_cell_bound(width, height, self.config), - "posterior_radius_m": round(region.mec()[1], 3)} - self.fallback_log.append(entry) - if len(cells) > search.fallback_max_cells: - # 宽候选集盲扫违背预算纪律:留待定位收敛,不得硬扫 [计划§4]。 - entry["outcome"] = "skipped_cell_cap" - self._anomaly("fallback_cell_cap_exceeded", channel, - cells=len(cells), cap=search.fallback_max_cells) - return False - # 现实预算核对:N 次请求 × 单请求高分位估计 + 退出余量,不足不启动。 - remaining = self.client.real_remaining_s - if remaining is None or len(cells) * session.real_rtt_p95_s \ - + session.reserve_exit_s > remaining: - entry["outcome"] = "skipped_budget" - return False - attempts = 0 - order = self._fallback_order(cells) - for cell in order: - if self._virtual_cap_reached(): - entry["outcome"] = "skipped_virtual_budget" - entry["attempts"] = attempts - return False - attempts += 1 - outcome = self.client.clear(cell, channel) - if outcome.result == CLEAR_SUCCESS: - self.belief.mark_cleared(channel) - self.cleared.append(channel) - entry["outcome"] = "cleared" - entry["attempts"] = attempts - return True - # 候选集保守包含真源,格心距格内任一点 <= 10*sqrt(2) < 20m:全部 - # 失败说明守恒性被打破,按异常留痕,不得据此删除候选。 - entry["outcome"] = "exhausted" - entry["attempts"] = attempts - self._anomaly("fallback_exhausted_without_success", channel, - cells=len(cells)) - return False - - def _fallback_order(self, cells: list[Vec2]) -> list[Vec2]: - """后备清除的走格顺序:最近邻打底 + 2-opt(可选 Or-opt)改进。 - - 旧实现每步重新 ``min(remaining, key=dist)``,等价于最近邻开放路径 - (典型为最优的 1.2–1.3 倍)且每次重扫 O(k²)。这里一次性求解改进序 - (D3),把格间移动压到接近最优;格数不变,故完备性不变。 - """ - if not cells: - return [] - if not self.config.search.fallback_optimize_tour or len(cells) < 4: - position = self.client.state.position - remaining = list(cells) - order: list[Vec2] = [] - while remaining: - nxt = min(remaining, key=lambda c: (math.dist(position, c), c)) - remaining.remove(nxt) - order.append(nxt) - position = nxt - return order - return improve_open_path([tuple(c) for c in cells], - self.client.state.position) - - # ------------------------------------------------------------ 收尾 - def _summary(self, info) -> dict: - report = super()._summary(info) - report["directional_observed_channels"] = list(self.directional_observed) - report["fallback_clears"] = self.fallback_log - # 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。 - report["fallback_clear_attempts"] = sum( - entry.get("attempts", 0) for entry in self.fallback_log) - report["visibility_probes"] = self.visibility_probes - report["joint_absent_channels"] = list(self.joint_absent_channels) - report["joint_final"] = {str(channel): belief.summary() - for channel, belief in sorted(self.joint.items())} - return report - - -def fallback_cell_bound(width_m: float, height_m: float, - config: ProblemConfig = ProblemConfig()) -> int: - """外包框枚举的格数上界 ((*@\ensuremath{\lceil}@*)w/cell(*@\ensuremath{\rceil}@*)+2)((*@\ensuremath{\lceil}@*)h/cell(*@\ensuremath{\rceil}@*)+2)(计划§4 规模检查)。""" - cell = config.search.fallback_clear_grid_m - return (math.ceil(width_m / cell) + 2) * (math.ceil(height_m / cell) + 2) - - -def fallback_clear_cells(polygon: list[Vec2], - config: ProblemConfig = ProblemConfig()) -> list[Vec2]: - """候选外包多边形的 <=20m 有限圆盘覆盖格心列表(P5 后备清除)。 - - 全局半开 20m 网格索引;多边形与格子的相交判定带正容差(宁可多测), - 多边形边界恰在格线上时两侧格子都保留,因此格内任意点距某格心 - <= 10*sqrt(2) < 20m,逐格 /clear 至少一次命中。列表长度即 /clear - 请求预算上界;先核对 fallback_cell_bound,超预算不得逐格盲扫。 - """ - if not polygon: - return [] - cell = config.search.fallback_clear_grid_m - xs = [p[0] for p in polygon] - ys = [p[1] for p in polygon] - i0, i1 = math.floor(min(xs) / cell), math.floor(max(xs) / cell) - j0, j1 = math.floor(min(ys) / cell), math.floor(max(ys) / cell) - eps = 1e-9 - centers: list[Vec2] = [] - for i in range(i0, i1 + 1): - for j in range(j0, j1 + 1): - clipped = list(polygon) - for a, b, c in ((1.0, 0.0, (i + 1) * cell + eps), - (-1.0, 0.0, -(i * cell - eps)), - (0.0, 1.0, (j + 1) * cell + eps), - (0.0, -1.0, -(j * cell - eps))): - clipped = clip_halfplane(clipped, a, b, c, tol=eps) - if not clipped: - break - if clipped: - centers.append(((i + 0.5) * cell, (j + 0.5) * cell)) - return centers diff --git a/docs/paper/data/listing-source/src/mcm26b/planner/bayes_loop.py.tex b/docs/paper/data/listing-source/src/mcm26b/planner/bayes_loop.py.tex deleted file mode 100644 index 49b1bc5..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/planner/bayes_loop.py.tex +++ /dev/null @@ -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 diff --git a/docs/paper/data/listing-source/src/mcm26b/planner/seven.py.tex b/docs/paper/data/listing-source/src/mcm26b/planner/seven.py.tex deleted file mode 100644 index 2bd2803..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/planner/seven.py.tex +++ /dev/null @@ -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 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 diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/__init__.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/__init__.py.tex deleted file mode 100644 index 85bfdf2..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/__init__.py.tex +++ /dev/null @@ -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"] diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/client.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/client.py.tex deleted file mode 100644 index 373f87a..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/client.py.tex +++ /dev/null @@ -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 diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/log.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/log.py.tex deleted file mode 100644 index a35109b..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/log.py.tex +++ /dev/null @@ -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"] diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/rules.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/rules.py.tex deleted file mode 100644 index 8e89aea..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/rules.py.tex +++ /dev/null @@ -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, "未知状态码") diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/state.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/state.py.tex deleted file mode 100644 index 7788266..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/state.py.tex +++ /dev/null @@ -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 的响应不允许更新状态") diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/timing.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/timing.py.tex deleted file mode 100644 index baf1be8..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/timing.py.tex +++ /dev/null @@ -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 diff --git a/docs/paper/data/listing-source/src/mcm26b/protocol/transport.py.tex b/docs/paper/data/listing-source/src/mcm26b/protocol/transport.py.tex deleted file mode 100644 index 0f0f4b5..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/protocol/transport.py.tex +++ /dev/null @@ -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) diff --git a/docs/paper/data/listing-source/src/mcm26b/routing.py.tex b/docs/paper/data/listing-source/src/mcm26b/routing.py.tex deleted file mode 100644 index f8c330e..0000000 --- a/docs/paper/data/listing-source/src/mcm26b/routing.py.tex +++ /dev/null @@ -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), - } diff --git a/docs/paper/data/source-listings.tex b/docs/paper/data/source-listings.tex index 4210530..c77e58b 100644 --- a/docs/paper/data/source-listings.tex +++ b/docs/paper/data/source-listings.tex @@ -1,70 +1,1462 @@ -\begingroup -\linespread{1.0}\selectfont -\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none} -\subsection*{\pathtext{src/mcm26b/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/belief/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/belief/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/belief/joint.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/belief/joint.py.tex} -\subsection*{\pathtext{src/mcm26b/cli.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/cli.py.tex} -\subsection*{\pathtext{src/mcm26b/config.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/config.py.tex} -\subsection*{\pathtext{src/mcm26b/coverage/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/coverage/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/evaluation/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/evaluation/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/geometry/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/geometry/__main__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/__main__.py.tex} -\subsection*{\pathtext{src/mcm26b/geometry/localization.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/localization.py.tex} -\subsection*{\pathtext{src/mcm26b/geometry/planning.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/geometry/planning.py.tex} -\subsection*{\pathtext{src/mcm26b/guarantee/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/guarantee/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/guarantee/cover.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/guarantee/cover.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/playback.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/playback.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/scenes.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/scenes.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/simulator.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/simulator.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/suites.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/suites.py.tex} -\subsection*{\pathtext{src/mcm26b/harness/transport.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/harness/transport.py.tex} -\subsection*{\pathtext{src/mcm26b/particles.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/particles.py.tex} -\subsection*{\pathtext{src/mcm26b/planner/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/planner/bayes_loop.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/bayes_loop.py.tex} -\subsection*{\pathtext{src/mcm26b/planner/seven.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/planner/seven.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/__init__.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/__init__.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/client.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/client.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/log.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/log.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/rules.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/rules.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/state.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/state.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/timing.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/timing.py.tex} -\subsection*{\pathtext{src/mcm26b/protocol/transport.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/protocol/transport.py.tex} -\subsection*{\pathtext{src/mcm26b/routing.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/src/mcm26b/routing.py.tex} -\subsection*{\pathtext{scripts/audit_paper_formal.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/scripts/audit_paper_formal.py.tex} -\subsection*{\pathtext{scripts/build_paper_support.py}} -\lstinputlisting[escapeinside={(*@}{@*)}]{data/listing-source/scripts/build_paper_support.py.tex} +% Generated by scripts/build_code_appendix.py from mcm26b-核心代码.ipynb. +% Do not edit by hand: rerun the generator after the notebook changes. +\begingroup\linespread{1.0}\selectfont +\subsection*{动作计时与开放路径排序} +三段耗时按整数微秒累计;后备清除的走格顺序用最近邻打底并做 2-opt 改进。 + +\noindent\small \texttt{CostBreakdown}:一次合法动作的三段耗时:移动 + 换频道(仅检测)+ 动作本身。 +\begin{lstlisting}[style=corecode] +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)) +\end{lstlisting} + +\noindent\small \texttt{measure\_cost}:合法 /measure 的耗时;移动按直线距离,换频道只看前后频道是否不同。 +\begin{lstlisting}[style=corecode] +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, + ) +\end{lstlisting} + +\noindent\small \texttt{clear\_cost}:合法 /clear 的耗时;清除不切换测向机频道,故不含换频项。 +\begin{lstlisting}[style=corecode] +def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool, + cost: CostConfig = CostConfig()) -> CostBreakdown: + """合法 /clear 的耗时。""" + 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, + ) +\end{lstlisting} + +\noindent\small \texttt{move\_time}:任意两点间的移动耗时,供规划使用。 +\begin{lstlisting}[style=corecode] +def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float: + """任意两点间的移动耗时(规划用)。""" + return dist(a, b) / cost.speed_mps +\end{lstlisting} + +\noindent\small \texttt{nearest\_neighbor\_order}:最近邻开放路径,作为路径排序的启发式上界。 +\begin{lstlisting}[style=corecode] +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 +\end{lstlisting} + +\noindent\small \texttt{two\_opt\_open}:开放路径 2-opt 改进;终点自由,只反转内部片段。 +\begin{lstlisting}[style=corecode] +def two_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0), + max_passes: int = 8) -> list[Vec2]: + """开放路径 2-opt(终点自由:只反转内部片段,不做首尾相接)。""" + 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 +\end{lstlisting} + +\subsection*{问题一:角域交会、直径与最小包围圆} +半平面交求定位区域,顶点对枚举直径,Welzl 算法求最小包围圆,并输出直径圆覆盖判定与清除半径判据。 + +\noindent\small \texttt{Measurement}:一次示向度观测:测点坐标、示向度与误差半角(题设为 $1^\circ$)。 +\begin{lstlisting}[style=corecode] +class Measurement: + + x: float + y: float + bearing_deg: float + half_angle_deg: float = 1.0 +\end{lstlisting} + +\noindent\small \texttt{halfplanes}:一次观测的三个半平面约束:角域两侧边界与前向约束,法向量已单位化。 +\begin{lstlisting}[style=corecode] +def halfplanes(m: Measurement) -> list[tuple[float, float, float]]: + """Return a*x+b*y<=c with unit normals, including the forward…""" + 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] +\end{lstlisting} + +\noindent\small \texttt{convex\_hull}:单调链凸包(逆时针、无重复末点),输入坐标先平移缩放到同一量级。 +\begin{lstlisting}[style=corecode] +def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]: + """Monotone chain, CCW, no repeated final vertex; retain segm…""" + 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] +\end{lstlisting} + +\noindent\small \texttt{diameter}:顶点对枚举区域直径;有界凸集的最远点必为顶点。 +\begin{lstlisting}[style=corecode] +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) +\end{lstlisting} + +\noindent\small \texttt{smallest\_enclosing\_circle}:Welzl 随机增量最小包围圆;固定随机种子保证结果可复现。 +\begin{lstlisting}[style=corecode] +def smallest_enclosing_circle(points: list[Point], eps: float = EPS): + """Welzl's randomized incremental algorithm, using an explici…""" + 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) +\end{lstlisting} + +\noindent\small \texttt{solve}:问题一主入口:半平面交求定位区域,区分空集、无界与退化情形,再给出直径、直径圆覆盖判定与最小包围圆。 +\begin{lstlisting}[style=corecode] +def solve(measurements: list[Measurement], eps: float = EPS) -> dict: + """Solve Q1's pure angular intersection (no disk/range prior…""" + 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 +\end{lstlisting} + +\subsection*{问题二:候选集与保证接收的第二检测点} +候选集用圆盘外切多边形与角域半平面裁剪表示;安全盘半径取$1000-\rho$,盘内候选按最坏后验包围半径、同分按移动代价取优。 + +\noindent\small \texttt{CandidateRegion}:某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。 +\begin{lstlisting}[style=corecode] +class CandidateRegion: + """某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。""" + + @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 后的基础集:Ω ∩ 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_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]: + """最小包围圆(圆心, 半径)。半径是真实候选集 ρ 的上界。""" + 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 +\end{lstlisting} + +\noindent\small \texttt{ClearCertificate}:清除充分条件:候选集外包半径与近似误差之和不超过清除半径。 +\begin{lstlisting}[style=corecode] +class ClearCertificate: + """清除证书:候选集外包半径(含近似误差)不超过 20m 阈值。""" + + channel: int + center: Vec2 + radius_m: float + limit_m: float + certified: bool +\end{lstlisting} + +\noindent\small \texttt{clear\_certificate}:由候选集最小包围圆判定是否可以就地清除。 +\begin{lstlisting}[style=corecode] +def clear_certificate(region: CandidateRegion, channel: int, + config: ProblemConfig = ProblemConfig()) -> ClearCertificate: + """ρ_poly <= 20 - 外包近似误差 - 工程余量 才允许发起清除。""" + center, radius = region.mec() + limit = (config.field.clear_radius_m - region.slack_m + - config.search.clear_margin_m) + return ClearCertificate(channel, center, radius, limit, radius <= limit) +\end{lstlisting} + +\noindent\small \texttt{candidate\_points}:保证接收盘内的候选点:盘心加各比例半径上的均匀方向点。 +\begin{lstlisting}[style=corecode] +def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) -> list[Vec2]: + """保证接收盘内的候选点:盘心 + 各比例半径 × 均匀方向。""" + points = [tuple(center)] + for fraction in rings: + radius = fraction * safe_radius_m + if radius <= 1e-9: + continue + step = 2 * math.pi / angles + for k in range(angles): + angle = step * k + step / 2 + points.append((center[0] + radius * math.cos(angle), + center[1] + radius * math.sin(angle))) + return points +\end{lstlisting} + +\noindent\small \texttt{safe\_disk\_radius}:保证接收盘半径 $1000-\rho$;小于零表示没有保证接收的区域。 +\begin{lstlisting}[style=corecode] +def safe_disk_radius(region: CandidateRegion, + config: ProblemConfig = ProblemConfig()) -> float: + """保证接收盘 B(mec, safe) 的半径;<0 表示无保证(ρ 超过最小接收半径)。""" + return config.field.r_eff_min_m - region.mec()[1] - 1e-3 +\end{lstlisting} + +\noindent\small \texttt{select\_measurement\_point}:在保证接收盘内选下一检测点:主指标为最坏后验包围半径,同分取移动代价较小者。 +\begin{lstlisting}[style=corecode] +def select_measurement_point(region: CandidateRegion, from_position: Vec2, + config: ProblemConfig = ProblemConfig()) -> SecondPointPlan: + """在保证接收盘内选择下一检测点(P3 核心;对任意候选集通用)。""" + field, search = config.field, config.search + center, radius = region.mec() + half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg + + def move_cost(point: Vec2) -> float: + return move_time(from_position, point, config.cost) + + # 1mm 数值余量在 safe_disk_radius 内记账。 + safe_radius = safe_disk_radius(region, config) + if safe_radius < 0: + return SecondPointPlan(tuple(center), radius, move_cost(center), + "无保证接收盘(ρ>1000):探索 MEC 圆心,可能 no_signal") + coarse = region.witnesses(search.witness_spacing_m) + samples = candidate_points(center, safe_radius, + search.candidate_rings, search.candidate_angles) + near_radius, sides = field.near_radius_m, search.polygon_sides + best_key, best_point, best_worst = None, None, None + for point in samples: + worst = _worst_posterior_radius(region, point, coarse, half_angle, + near_radius, sides) + key = (round(worst, 6), round(move_cost(point), 6)) + if best_key is None or key < best_key: + best_key, best_point, best_worst = key, point, worst + dense = _worst_posterior_radius(region, best_point, + region.witnesses(search.witness_spacing_m / 4), + half_angle, near_radius, sides) + reason = (f"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;" + f"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m") + return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason) +\end{lstlisting} + +\noindent\small \texttt{select\_second\_point}:问题二入口:由首次示向度构造候选集并给出第二检测点。 +\begin{lstlisting}[style=corecode] +def select_second_point(client: SimulatorClient, first_position: Vec2, + first_bearing_deg: float, + config: ProblemConfig = ProblemConfig()) -> SecondPointPlan: + """P3 入口:由首次 direction 构造 K1,返回保证接收的第二检测点方案。""" + region = CandidateRegion.initial(first_position, config) + half_angle = config.field.bearing_error_half_deg \ + + config.search.bearing_rounding_margin_deg + region.clip_wedge(first_position, first_bearing_deg, half_angle) + return select_measurement_point(region, client.state.position, config) +\end{lstlisting} + +\subsection*{问题三:覆盖计划、检测记录与清除证书} +覆盖计划与运行期抽样校验、逐频道检测记录,以及七点解析覆盖与全向/定向两种覆盖语义的证书判据。 + +\noindent\small \texttt{CoverageCertificate}:覆盖证书:采样分辨率、位置与朝向目标数、缺口数与最坏命中距离。 +\begin{lstlisting}[style=corecode] +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 = "" + 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} +\end{lstlisting} + +\noindent\small \texttt{CoveragePlan}:覆盖计划:点集、覆盖语义与证书。 +\begin{lstlisting}[style=corecode] +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 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]} +\end{lstlisting} + +\noindent\small \texttt{coverage\_points}:49 点覆盖网格,单元对角 $700\sqrt2\approx989.95\le1000$ m。 +\begin{lstlisting}[style=corecode] +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)] +\end{lstlisting} + +\noindent\small \texttt{CoverageLedger}:逐频道检测记录,按(频道, 网格点)二维记账。 +\begin{lstlisting}[style=corecode] +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) +\end{lstlisting} + +\noindent\small \texttt{seven\_points}:原点与环上六个等分点的七站覆盖集。 +\begin{lstlisting}[style=corecode] +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)] +\end{lstlisting} + +\noindent\small \texttt{seven\_plan}:第三问解析覆盖计划;环半径 $1159.7$ m 时最坏覆盖距离不超过 $984.539$ m。 +\begin{lstlisting}[style=corecode] +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∈[1122.9, 1732] 时成立,巡检半径越小巡回越短"), + points=tuple(seven_points(ring_radius_m))) +\end{lstlisting} + +\noindent\small \texttt{verify\_full\_disk}:第三问覆盖语义:圆域内每点都落在某个被访点的最小接收圆盘内。 +\begin{lstlisting}[style=corecode] +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)) +\end{lstlisting} + +\noindent\small \texttt{verify\_forward\_half\_disk}:第四问覆盖语义:任意位置与朝向都存在前向半盘内的被访点。 +\begin{lstlisting}[style=corecode] +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: + """第四问语义:∀(p,h) 存在前向半盘内的被访点。""" + 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)) +\end{lstlisting} + +\noindent\small \texttt{plan\_instance}:按名称取覆盖计划;证书计划在运行期按同一分辨率复核。 +\begin{lstlisting}[style=corecode] +def plan_instance(name: str, verify: bool = True) -> CoveragePlan: + """统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。""" + if name == "square49": + return square_49_plan() + if name == "seven": + return seven_plan(1200.0) + if name not in EMBEDDED_PLANS: + raise FileNotFoundError(f"未找到覆盖计划 {name!r};现有:{plan_names()}") + plan = embedded_plan(name) + 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 +\end{lstlisting} + +\subsection*{问题三:网格候选集} +逐频道的网格幸存集,按格心裕量 $\ell/\sqrt2$ 内缩后再删格,据此支持负观测更新、跳测规则与缺席证明。 + +\noindent\small \texttt{ChannelParticles}:单个频道的网格候选集:格心裕量 $\ell/\sqrt2$,所有删除判定内缩该裕量,故真源所在格永不被删。 +\begin{lstlisting}[style=corecode] +class ChannelParticles: + """单个频道的网格幸存集。""" + + 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: + """删除角向偏离读数超出 δ_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 ⟹ 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 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)⟹ 必 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]: + """按索引序均匀抽取的子采样(索引序≈空间序,代表点空间铺开)。""" + return self.alive_points(limit=size) + + def expected_survival(self, point: Vec2, subsample, + representatives) -> float: + """E[log 幸存占比]:越小该测点的期望信息增益越大。 + + r_eff 不确定性解析积分:dist∈(1000,1500] 时按 r_eff~U(1000,1500) + 取 p_ns=(dist−1000)/500 为 no_signal 分支概率。 + - no_signal 分支:删 B(point, 1000−m),幸存 = 子采样中盘外比例; + - direction 分支:以代表点方位为期望读数,角域 ∩ 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 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) +\end{lstlisting} + +\subsection*{问题三:信息增益选点与滚动任务排序} +统一任务表由站点扫频、定位测量与清除三类任务组成,每执行一项即重排;定位点按期望信息增益加移动代价评分。 + +\noindent\small \texttt{BayesLoop}:问题三闭环:网格候选集的正负观测更新、信息增益选点与滚动任务排序。 +\begin{lstlisting}[style=corecode] +class BayesLoop(OmnidirectionalLoop): + """问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。""" + + 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 _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 _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 _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 _select_point(self, channel: int) -> Vec2 | None: + """定位测量点:期望 log 幸存占比 + λ·移动时间 最小的候选。""" + 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 _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 +\end{lstlisting} + +\subsection*{问题四:朝向候选、可见性选点与网格遍历清除} +每个幸存格附朝向档掩码构成联合候选集,可见性权重用于探点排序;定位停滞时以 $20$ m 方格遍历清除兜底。 + +\noindent\small \texttt{JointBelief}:单频道的位置与朝向联合候选集;朝向分 $24$ 档,负观测只在整格必落在最小接收半径内时才约束朝向。 +\begin{lstlisting}[style=corecode] +class JointBelief: + """单频道 (位置 × 朝向) 联合可行集。""" + + def is_absent(self) -> bool: + """联合集空 ⟹ 该频道不存在源(真源保留定理的二维推广)。""" + return not self.position.alive + + 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 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 ≥ |p−q|) × cover_ratio(p, q) + + 其中 ``cover_ratio`` 是该格朝向掩码中"可能覆盖 q 方位"的档位占比。 + 分支半径语义: + + - direction(可见,非 near):读数为 q 处的方位 ±δ,后验收缩到该方位 + 楔形与当前可行集之交,半径取幸存子采样的质心最大距离; + - 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 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(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 才一定由朝向遮挡解释。格心到格内任一点 ≤ 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} +\end{lstlisting} + +\noindent\small \texttt{DirectionalExtension}:问题四闭环:定向覆盖计划、遮挡解释与网格遍历清除兜底。 +\begin{lstlisting}[style=corecode] +class DirectionalExtension(OmnidirectionalLoop): + """问题4混合源闭环(P5):全向基线 + 定向观测处理 + 后备清除。""" + + def _localize_and_clear(self, channel: int) -> bool: + """定位子循环:选点测量收敛候选集,拿到清除证书后清除。 + + 与 P4 的差异:保证接收盘内的 no_signal 说明探点被朝向遮挡 + [观测推理],走"对跖复核 -> 环探"升级梯子(不计入定位步数, + 由 max_blocked_probes 限流),之后仍未收敛再转后备清除。 + """ + search = self.config.search + self._may_clear.add(channel) + steps = 0 + while steps < search.max_localize_steps: + if self._virtual_cap_reached(): + return False + region = self.belief.candidate_set(channel) + if region is None: + return False + cert = clear_certificate(region, channel, self.config) + if cert.certified and channel in self._may_clear: + if self._attempt_clear(channel, cert.center, cert): + return True + self._may_clear.discard(channel) # 证书失败:补测后才可重试 + continue + if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes: + break # 梯子用尽仍被遮挡:定位停滞,转后备清除 + point = self._next_probe(region, channel) + within_range = (math.dist(point, region.mec()[0]) + <= safe_disk_radius(region, self.config)) + outcome = self.client.measure(point, channel) + self._observe(channel, point, outcome.result, outcome.svd_deg) + self._may_clear.add(channel) + if outcome.result == NO_SIGNAL: + if within_range and channel not in self.directional_observed: + self.directional_observed.append(channel) + self.blocked_probes.setdefault(channel, []).append(point) + continue # 盘外无信号也可能是距离不足,不据此断言定向 + self.blocked_probes.pop(channel, None) + steps += 1 + if self.belief.channels[channel].status is ChannelStatus.CLEARED: + return True + region = self.belief.candidate_set(channel) + if region is None: + return False + cert = clear_certificate(region, channel, self.config) + if cert.certified and channel in self._may_clear: + if self._attempt_clear(channel, cert.center, cert): + return True + self._may_clear.discard(channel) + return self._fallback_clear(channel, region) + + def _observe(self, channel: int, point: Vec2, result: str, + svd_deg: float | None) -> None: + """同步更新凸候选集与 (位置 × 朝向) 联合可行集。 + + 联合集只在频道首次被看见(direction/near)时建立:从未被看见的 + 频道由覆盖证明负责缺席,不需要朝向维度。联合集一旦为空,直接给出 + 该频道的缺席证明(严格,与覆盖证明同一条件),可提前停止对其检测。 + """ + self.belief.update_measure(channel, point, result, svd_deg) + if not self.config.search.directional_joint_belief: + return + joint = self.joint.get(channel) + if joint is None: + if result not in (DIRECTION, NEAR): + return + joint = JointBelief(self.config) + self.joint[channel] = joint + joint.apply(point, result, svd_deg) + if joint.is_absent() and self.belief.channels[channel].status \ + is not ChannelStatus.CLEARED: + self.belief.mark_absent(channel) + self.absence_evidence[channel] = "joint_empty" + if channel not in self.joint_absent_channels: + self.joint_absent_channels.append(channel) + + def _next_probe(self, region: CandidateRegion, channel: int) -> Vec2: + """定向遮挡下的探点升级梯子 [工程]。 + + - 未被遮挡过:worst-posterior 选点器(保证接收盘内); + - 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘; + - 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起 + 每次 +45°。几何边界必须如实陈述:环上可见弧没有下界—— + 源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个 + 切点,固定步进环探可以全部漏过。梯子只提高提前命中概率, + 不构成接收或命中保证;完备性完全由后备清除兜底。 + """ + visibility = self._visibility_probe(region, channel) + if visibility is not None: + return visibility + blocked = self.blocked_probes.get(channel, []) + center, radius = region.mec() + if not blocked: + return select_measurement_point(region, self.client.state.position, + self.config).position + safe = safe_disk_radius(region, self.config) + first = blocked[0] + if len(blocked) == 1 and safe >= 0: + dx, dy = center[0] - first[0], center[1] - first[1] + norm = math.hypot(dx, dy) + if norm <= 1e-9: + # 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。 + return (center[0] + safe, center[1]) + scale = min(1.0, safe / norm) + # 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。 + return (center[0] + dx * scale, center[1] + dy * scale) + base = math.atan2(first[1] - center[1], first[0] - center[0]) + azimuth = base + math.radians(45.0 * (len(blocked) - 1)) + return (center[0] + radius * math.cos(azimuth), + center[1] + radius * math.sin(azimuth)) + + def _fallback_clear(self, channel: int, region: CandidateRegion) -> bool: + """定位停滞的后备:候选集有限圆盘覆盖逐格清除(格数即请求上界)。""" + search, session = self.config.search, self.config.session + width = max(p[0] for p in region.polygon) - min(p[0] for p in region.polygon) + height = max(p[1] for p in region.polygon) - min(p[1] for p in region.polygon) + cells = fallback_clear_cells(region.polygon, self.config) + entry = {"channel": channel, "cells": len(cells), + "bound": fallback_cell_bound(width, height, self.config), + "posterior_radius_m": round(region.mec()[1], 3)} + self.fallback_log.append(entry) + if len(cells) > search.fallback_max_cells: + # 宽候选集盲扫违背预算纪律:留待定位收敛,不得硬扫 [计划§4]。 + entry["outcome"] = "skipped_cell_cap" + self._anomaly("fallback_cell_cap_exceeded", channel, + cells=len(cells), cap=search.fallback_max_cells) + return False + # 现实预算核对:N 次请求 × 单请求高分位估计 + 退出余量,不足不启动。 + remaining = self.client.real_remaining_s + if remaining is None or len(cells) * session.real_rtt_p95_s \ + + session.reserve_exit_s > remaining: + entry["outcome"] = "skipped_budget" + return False + attempts = 0 + order = self._fallback_order(cells) + for cell in order: + if self._virtual_cap_reached(): + entry["outcome"] = "skipped_virtual_budget" + entry["attempts"] = attempts + return False + attempts += 1 + outcome = self.client.clear(cell, channel) + if outcome.result == CLEAR_SUCCESS: + self.belief.mark_cleared(channel) + self.cleared.append(channel) + entry["outcome"] = "cleared" + entry["attempts"] = attempts + return True + # 候选集保守包含真源,格心距格内任一点 <= 10*sqrt(2) < 20m:全部 + # 失败说明守恒性被打破,按异常留痕,不得据此删除候选。 + entry["outcome"] = "exhausted" + entry["attempts"] = attempts + self._anomaly("fallback_exhausted_without_success", channel, + cells=len(cells)) + return False +\end{lstlisting} + \endgroup diff --git a/docs/paper/figures/q4-plan.tex b/docs/paper/figures/q4-plan.tex index fd521ed..45597a9 100644 --- a/docs/paper/figures/q4-plan.tex +++ b/docs/paper/figures/q4-plan.tex @@ -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} diff --git a/docs/paper/main.pdf b/docs/paper/main.pdf index b625489..2315e8a 100644 Binary files a/docs/paper/main.pdf and b/docs/paper/main.pdf differ diff --git a/docs/paper/main.tex b/docs/paper/main.tex index 1bda8e2..e93677d 100644 --- a/docs/paper/main.tex +++ b/docs/paper/main.tex @@ -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 关键词:}有界误差;交会定位;最小包围圆;主动测量;定向覆盖 diff --git a/docs/paper/sections/appendix.tex b/docs/paper/sections/appendix.tex index 32cd847..d907c6d 100644 --- a/docs/paper/sections/appendix.tex +++ b/docs/paper/sections/appendix.tex @@ -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} diff --git a/docs/paper/sections/comparison.tex b/docs/paper/sections/comparison.tex index 017c25a..b15deb8 100644 --- a/docs/paper/sections/comparison.tex +++ b/docs/paper/sections/comparison.tex @@ -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$ 次,对应遮挡导致的定位停滞。 diff --git a/docs/paper/sections/q1.tex b/docs/paper/sections/q1.tex index 0a597e7..1da7b9f 100644 --- a/docs/paper/sections/q1.tex +++ b/docs/paper/sections/q1.tex @@ -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$ 角域构造出来。例如检测点 diff --git a/docs/paper/sections/q3.tex b/docs/paper/sections/q3.tex index e48a6de..2868276 100644 --- a/docs/paper/sections/q3.tex +++ b/docs/paper/sections/q3.tex @@ -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 diff --git a/docs/paper/sections/q4.tex b/docs/paper/sections/q4.tex index 9596f10..fd5e8ba 100644 --- a/docs/paper/sections/q4.tex +++ b/docs/paper/sections/q4.tex @@ -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 diff --git a/docs/paper/sections/results.tex b/docs/paper/sections/results.tex index 8f0dc84..696eed8 100644 --- a/docs/paper/sections/results.tex +++ b/docs/paper/sections/results.tex @@ -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{实验结果分析} diff --git a/mcm26b-核心代码.ipynb b/mcm26b-核心代码.ipynb new file mode 100644 index 0000000..85c5d15 --- /dev/null +++ b/mcm26b-核心代码.ipynb @@ -0,0 +1,6143 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9f704249", + "metadata": {}, + "source": [ + "# CUMCM 2026 B 题 · 无线电干扰源的快速自动定位与清除\n", + "## 单文件核心代码(问题一 ~ 问题四)\n", + "\n", + "本 notebook 是四问**生产实现**的自包含整理版:按求解流程顺序排列的算法内核,\n", + "不含评测机、命令行外壳、采集/基准/绘图脚本、单元测试,也不含未被采用的方案。\n", + "\n", + "### 1. 求解主线\n", + "\n", + "| 问 | 任务 | 本文档中的实现 |\n", + "| --- | --- | --- |\n", + "| 一 | 由 ±1° 有界误差示向度交会定位,求定位区域、直径、直径圆覆盖判定、最小包围圆与 20 m 清除证书(并给出直径圆不覆盖区域的等边三角形反例) | `Measurement` / `halfplanes` / `convex_hull` / `diameter` / `smallest_enclosing_circle` / `solve` |\n", + "| 二 | 选择第二个检测点,使目标必被接收且交会角最优 | `first_bearing_candidate_disk` / `CandidateRegion` / `safe_disk_radius` / `select_second_point` |\n", + "| 三 | 全向源:发现全部干扰源、逐一定位清除,最小化虚拟总时间 | `SevenBatch`(对照)与 `BayesLoop`(默认:网格粒子滤波 + 负观测 + 信息增益选点 + 滚动 2-opt) |\n", + "| 四 | 含定向发射源(半盘覆盖):不可见的静默必须与\"距离不足\"区分 | `DirectionalExtension`(定向覆盖最小命中集 + (位置×朝向) 联合信念 + ≤20 m 后备清除) |\n", + "\n", + "### 2. 公共层(四问共用)\n", + "\n", + "- **参数中心**(`ProblemConfig`):题面常量与工程参数的唯一字面量来源;\n", + "- **协议层**:字段表/结果码(`rules`)、纯计时模型(`timing`)、本地状态机(`state`)、\n", + " 传输抽象(`transport`)、会话日志(`log`)、串行幂等客户端(`SimulatorClient`);\n", + "- **信念层**:逐频道保守凸候选集(`CandidateRegion`)、网格幸存集(`ChannelParticles`)、\n", + " (位置×朝向) 联合可行集(`JointBelief`);\n", + "- **保证层**:覆盖账本(`CoverageLedger`)、覆盖计划与证书(`CoveragePlan`);\n", + "- **调度层**:开放路径排序(`nearest_neighbor_order` / `two_opt_open` / `improve_open_path`)。\n", + "\n", + "### 3. 三条硬约束(贯穿全部代码)\n", + "\n", + "1. **保守性**:真源(第四问含朝向)永远不被观测更新删掉——候选集用外切多边形外包圆盘、\n", + " 半平面裁剪带正容差、网格幸存集按 Voronoi 裕量内缩后再删格;\n", + "2. **幂等串行**:一个动作未决不发起下一个;网络失败时原样重发同一 `request_id` 与请求体;\n", + " `accepted=false` 不更新任何状态,其 `virtual_time_s=0` 不是当前时刻;\n", + "3. **计时一致**:预测(客户端)—执行(模拟器)—复算(日志)三方用同一计时模型,\n", + " 按整数微秒累计。\n", + "\n", + "### 4. 与仓库原文件的关系\n", + "\n", + "各代码单元逐字取自 `src/mcm26b/**`,仅做机械化改写(去包内相对导入、去文件 docstring、\n", + "去掉覆盖计划的 JSON 文件读取改为内嵌字面量、去掉未被生产路径调用的函数)。\n", + "本文件可独立运行:只依赖标准库与 `numpy`(可选加速,缺失时自动回退纯 Python)。\n" + ] + }, + { + "cell_type": "markdown", + "id": "dabe0eb2", + "metadata": {}, + "source": [ + "## 0. 环境与依赖\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "47afaab3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "numpy 加速后端: 启用\n" + ] + } + ], + "source": [ + "import json\n", + "import math\n", + "import os\n", + "import uuid\n", + "from dataclasses import dataclass, field, fields, replace\n", + "from enum import Enum\n", + "from functools import lru_cache\n", + "from itertools import combinations\n", + "from math import cos, dist, hypot, isfinite, radians, sin\n", + "from pathlib import Path\n", + "from random import Random\n", + "from time import monotonic, sleep\n", + "\n", + "try: # numpy 仅作可选加速后端;缺失时全部回退纯 Python\n", + " import numpy as _np\n", + "except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支\n", + " _np = None\n", + "\n", + "print(\"numpy 加速后端:\", \"启用\" if _np is not None else \"缺失(纯 Python 回退)\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "648b9bca", + "metadata": {}, + "source": [ + "# 第一部分 · 公共层\n", + "\n", + "## 1. 参数中心(`config.py`)\n", + "\n", + "题面常量标 `[题面]`,工程参数标 `[工程]`;后者可依演练实测调整,不代表官方规定。\n", + "任何魔法数字只在这里出现一次,其余模块通过 `ProblemConfig` 读取。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a77d00d5", + "metadata": {}, + "outputs": [], + "source": [ + "Vec2 = tuple[float, float]\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class FieldConfig:\n", + " \"\"\"题目物理世界与协议硬限制。[题面]\"\"\"\n", + "\n", + " omega_radius_m: float = 1800.0 # 目标区域半径,圆心为原点\n", + " channel_min: int = 1 # 有效频道范围\n", + " channel_max: int = 20\n", + " source_count_min: int = 10 # 一局源总数范围(接口不直接告知)\n", + " source_count_max: int = 16\n", + " r_eff_min_m: float = 1000.0 # 有效接收半径下界:no_signal 只能排除该圆盘\n", + " r_eff_max_m: float = 1500.0 # 有效接收半径上界:direction 隐含距离上界\n", + " near_radius_m: float = 5.0 # \"距离过近\"阈值,不返回示向度\n", + " clear_radius_m: float = 20.0 # 清除半径;与定向朝向无关\n", + " bearing_error_half_deg: float = 1.0 # 示向度误差幅度(题设模型)\n", + " bearing_decimals: int = 2 # 返回示向度小数位\n", + " coord_abs_limit_m: float = 2_000_000.0 # 提交坐标的绝对值上限(超出返回 HTTP 400)\n", + " directional_half_angle_deg: float = 90.0 # 定向源朝向两侧各90度覆盖(含边界)\n", + " max_virtual_duration_s: float = 360_000.0 # 虚拟世界限时(100小时)\n", + " max_real_duration_s: float = 1200.0 # /enter 成功后程序运行上限(20分钟)\n", + " test_window_s: float = 1500.0 # 确认开始后的测试窗口(25分钟)\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class CostConfig:\n", + " \"\"\"虚拟世界动作耗时模型。[题面] 附件1表1、附件2第4节。\"\"\"\n", + "\n", + " speed_mps: float = 5.0 # 移动速度;耗时=直线距离/speed\n", + " measure_s: float = 5.0 # 每次检测动作耗时(无论结果)\n", + " switch_s: float = 1.0 # 任意两频道间切换耗时,仅由合法 /measure 触发\n", + " clear_success_s: float = 5.0 # 清除成功耗时 = 光学定位3s + 激光清除2s\n", + " clear_fail_s: float = 3.0 # 未发现可清除目标 = 仅光学定位3s\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class SessionConfig:\n", + " \"\"\"会话与网络策略。arena/robot/request 规则为 [题面],其余为 [工程]。\"\"\"\n", + "\n", + " arena_id: str = \"default\" # [题面] 固定值\n", + " base_url: str = \"http://127.0.0.1:2026\" # [题面] 模拟器默认接口地址\n", + " robot_id_max_bytes: int = 64 # [题面] UTF-8 长度 1..64\n", + " request_id_max_bytes: int = 128 # [题面] UTF-8 长度 1..128\n", + " request_body_max_bytes: int = 65_536 # [题面] 超出返回 HTTP 413\n", + " connect_timeout_s: float = 5.0 # [工程] 连接超时\n", + " read_timeout_s: float = 15.0 # [工程] 读超时;覆盖\"服务器已处理但响应丢失\"\n", + " max_attempts: int = 3 # [工程] 单动作最多尝试次数(含首次)\n", + " retry_backoff_s: float = 0.25 # [工程] 重试退避基数,按 2^n 放大\n", + " reserve_exit_s: float = 60.0 # [工程] 预留退出与故障恢复的现实秒数\n", + " real_rtt_p95_s: float = 1.0 # [工程] 现实单请求高分位估计,预算核算用\n", + " # [工程] 自检/演练实机预算:题面正式测试程序运行上限 1200s 不可延长;\n", + " # 自检经 --real-budget-s 覆盖 LocalSimulator 返回的 remaining_real_duration_s,\n", + " # 默认 1800s(30 分钟实机),用于验证\"限时内持续运行直到找全\"的行为。\n", + " self_test_real_budget_s: float = 1800.0\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class SearchConfig:\n", + " \"\"\"搜索与覆盖规划参数。[工程] 推导依据见 docs/B题建模分析与实施计划.md。\"\"\"\n", + "\n", + " grid_step_m: float = 700.0 # P4 覆盖网格步长;单元对角 700*sqrt(2)≈989.95<=1000\n", + " grid_half_cells: int = 3 # i,j ∈ [-3,3],49 点覆盖 [-2100,2100]^2 ⊇ Ω\n", + " fallback_clear_grid_m: float = 20.0 # P5 后备清除网格;10*sqrt(2)<=20\n", + " # 舍入裕量:服务器对示向度保留两位小数,引入 <=0.005° 偏差 [题面];\n", + " # 不加裕量时合法真源可能被角域裁掉,故库默认与 CLI run 默认一致取 0.005 [工程]。\n", + " bearing_rounding_margin_deg: float = 0.005\n", + " # ---- P3/P4 在线定位与闭环参数 [工程] ----\n", + " polygon_sides: int = 192 # 圆盘外切多边形边数;Ω 近似误差 1800(1-cos(π/192))≈0.24m\n", + " witness_spacing_m: float = 60.0 # 最坏后验评估的边界采样间距;选定后再按 1/4 加密复核\n", + " candidate_rings: tuple = (0.0, 0.4, 0.75, 1.0) # 安全接收盘内候选点的半径比例\n", + " candidate_angles: int = 12 # 每环候选方向数\n", + " clear_margin_m: float = 0.5 # 清除证书的额外工程余量(多边形近似误差之外)\n", + " max_localize_steps: int = 8 # 单频道定位阶段允许的追加测量上限\n", + " max_anomalies_per_channel: int = 3 # 单频道异常记录上限,防止异常刷屏\n", + " # ---- P5 定向扩展参数 [工程] ----\n", + " max_blocked_probes: int = 4 # 定位单轮内被朝向遮挡的探点预算\n", + " # (首次选点+对跖复核+2 次环探;达限转后备清除)\n", + " fallback_max_cells: int = 400 # 后备清除格数硬顶;超过则不盲扫(宽候选集继续定位)\n", + " # ---- 覆盖计划(保证层)[工程,2026-09-12 架构优化 P0-2 / S2 硬化] ----\n", + " # 第四问发现点集:\n", + " # square49 旧的充分条件表(700m 方格,巡回 36.4km);\n", + " # q4_mincover_v1 75m/24 档贪心最小集(31 点,巡回 22.9km)——细网格复检\n", + " # 在 25m/48 档有 125 个 (p,h) 缺口,是采样过拟合解;\n", + " # q4_mincover_v2 **设计裕量**最小集(31 点,r_design=965m、前向角裕量 5°,\n", + " # 搜索分辨率 50m/48 档 + 证书分辨率精细剪枝),\n", + " # 15m/72 档(325.6 万 (p,h))缺口 0,最坏命中 990m,巡回 23.9km。\n", + " # 运行期按同一采样语义复核,未通过即拒绝启动。\n", + " q4_coverage_plan: str = \"q4_mincover_v2\"\n", + " verify_coverage_plan: bool = True # 运行期抽样校验(进程内缓存一次)\n", + " # ---- 第四问定向源联合信念 [工程,2026-09-12 架构优化 P0-3] ----\n", + " # 为已发现的定向频道维护 (位置 × 朝向) 联合可行集:用可见性概率选点,\n", + " # 取代固定 45° 阶梯探点;联合集空即给出缺席证明。安全性仍由凸/网格\n", + " # 清除证书与覆盖证明负责,本开关只影响选点顺序。\n", + " directional_joint_belief: bool = True\n", + " # ---- 第四问定向源探点值函数 [工程,2026-09-12 图论与 DP 方案 S3/D1] ----\n", + " # 旧评分 ``prob_detect/(移动+6)`` 只问\"这点看不看得见源\",不问\"测完剩多少\n", + " # 不确定度\",会反复选中同一个近处被遮挡点。D1 口径:先按可见概率过滤\n", + " # (只保留 prob_detect ≥ ratio×最大值的候选),再最小化\n", + " # E[后验 MEC 半径](q) + λ·(当前位置→q→MEC 圆心 的移动秒数 + 检测)\n", + " # 9 场景 core 配对(修正联合信念朝向规则之后):\n", + " # λ=0.05:后备 6.8 次 / T 9740 s(值函数主导,为信息多绕路)\n", + " # λ=5 :后备 4.3 次 / T 8604 s(移动项主导,可见性过滤 + 就近抵近)\n", + " # 仅移动项(nearest_visible):后备 4.3 次 / T 8602 s,墙钟最低\n", + " # 相对旧 visibility 口径(后备 125.6 次、T 9225 s)三者都更好,且 λ 越大\n", + " # 越优说明本题\"看得见 + 少走路\"比\"信息量最大\"更重要,故默认取极限口径。\n", + " probe_score_mode: str = \"nearest_visible\" # visibility | expected_radius | nearest_visible\n", + " probe_lambda_move_s: float = 5.0 # expected_radius 口径下的移动折算\n", + " # 可见概率过滤:只保留 prob_detect ≥ ratio×最大值的候选再比期望半径,\n", + " # 避免\"期望上有点信息、实际必被朝向遮挡\"的探点。\n", + " probe_visibility_ratio: float = 0.7\n", + " probe_exclude_blocked: bool = True # 已测过且被遮挡的探点不再重复选取\n", + " approach_fractions: tuple = () # D1 抵近点插值比例(0=圆心);默认关闭\n", + " # ---- 后备清除路径优化 [工程,2026-09-12 S4/D3] ----\n", + " # 实测反例:后备清除在命中真源时立即结束,\"整条走格路径最短\"并不是正确\n", + " # 目标——Or-opt 会把真源推到序列更后面。9 场景配对:最近邻 125.7 次失败\n", + " # vs Or-opt 161.4 次(T 8676 → 8940 s)。默认关闭,保留代码与开关。\n", + " fallback_optimize_tour: bool = False\n", + " # ---- 持续搜索 [工程,2026-09-11 用户决策修订] ----\n", + " # 虚拟时间即评测成绩口径(表1:平均定位清除时间):找全证明达成后\n", + " # 立即 /exit,不复扫。复扫保险模式(防缺席证明假设偏差)保留,经\n", + " # `mcm26b run --persistent` 显式开启:只复扫未清除频道,直到清除数\n", + " # 达 16、实机预算耗尽或虚拟安全余量不足。\n", + " persistent_search: bool = False\n", + " virtual_reserve_s: float = 10_000.0 # 虚拟时长安全余量:剩余不足时停止发起动作,\n", + " # 避免触及 100 小时上限被模拟器强制结束\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class BayesConfig:\n", + " \"\"\"P4b 贝叶斯网格滤波策略参数(问题3新策略 bayes)。[工程]\n", + "\n", + " 网格粒子滤波的严格性依赖 Voronoi 裕量:真实源所在格心与源的距离\n", + " <= sqrt(2)/2 * particle_spacing_m,所有 kill 判定都内缩该裕量,\n", + " 因此幸存集永远包含真源(定理见 docs/algo-3-bayes.md §2)。\n", + " \"\"\"\n", + "\n", + " particle_spacing_m: float = 14.65 # 网格间距;Voronoi 裕量 ≈ 10.35m;\n", + " # 全域约 4.7 万格/频道,查询走粗格索引\n", + " # ---- 信息增益评分 [工程,Optuna TPE 调参回填 2026-09-11] ----\n", + " # 原调参记录见 output/q3/bayes-tuning/;当前代码的配对验收记录见\n", + " # output/q3/optimization-20260911/,随机分布与信息评分仅为工程假设。\n", + " score_mode: str = \"entropy\" # entropy=期望 log 幸存占比(信息增益)|\n", + " # radius=期望后验半径(直指证书)\n", + " score_subsample: int = 800 # 期望幸存熵估计的粒子子采样规模\n", + " mc_representatives: int = 7 # 假设真值代表点数(均匀抽取幸存格)\n", + " lambda_move_s: float = 0.05 # 选点评分中移动时间的折算系数(分值/秒);\n", + " # 2026-09-12 配对复跑:0.05 优于 0.0105(20 场均值 −2.8%),\n", + " # entropy 口径下信息项为 O(1) 对数值,λ须小型但不可过小\n", + " # ---- 路线感知探点 [工程,2026-09-12 P2] ----\n", + " # 旧评分只看 current->q + q->MEC,容易选到“信息增益略高但要多绕几百米”\n", + " # 的探点。开启后,在候选评分中加入 q 替换当前位置后对当前任务路线代价\n", + " # 的增量;A/B 以冻结基准集配对验收。\n", + " # 2026-09-12 冻结基准集复跑(18 场景):关闭 3193.1 s vs 开启 3210.3 s,\n", + " # 移动 12339.8 m vs 12446.8 m —— 权重 1e-3 的路线项未带来净收益,默认关闭。\n", + " route_aware_probe: bool = False\n", + " route_aware_insert_weight: float = 0.001 # 路线代价增量的折算系数(小权重,仅微调)\n", + " route_aware_clear_weight: float = 0.0 # 额外 q->MEC 清除接近项(基础移动项已含)\n", + " wide_rings: tuple = (1.0, 1.5, 2.0) # 无保证接收盘时的探索环(× PF MEC 半径)\n", + " # ---- 站点与覆盖 [工程] ----\n", + " station_ring_radius_m: float = 1159.7 # 七点环半径;构造时检验全向覆盖解析上界\n", + " # ---- 发现-定位融合巡回 [工程,2026-09-12 P0-1,实测无效,默认关闭] ----\n", + " # 设想:站点扫频顺带取第二/第三条方位线,取代专用探点。\n", + " # 实测(output/suite/q3-fuse-ab*,20 场景配对,2026-09-12):\n", + " # 仅追加站点方位线:3454.6s vs 3426.9s(+0.8%,检测 +10.8 次,位移仅 −188m);\n", + " # 追加 + 抑制专用探点:4989.0s vs 3426.9s(+45.6%,20/20 场景更慢)。\n", + " # 原因:七站环上只有 2–3 个站能对同一源取得有效方位线,且站间交会角\n", + " # 不足以保证 20m 证书,专用信息增益探点仍然必要;抑制探点只会白等巡回。\n", + " # 代码保留以便复现与后续研究,成绩口径必须保持 False。\n", + " fuse_localize_into_tour: bool = False\n", + " # ---- 任务排序 [工程] ----\n", + " two_opt_enabled: bool = True # 开放路径 2-opt 改进(最近邻打底)\n", + " two_opt_passes: int = 4 # 2-opt 最大改进轮数\n", + " # ---- 精确任务序(Held-Karp,含换频状态)[工程,2026-09-12 S5/T1] ----\n", + " # 任务表典型 6–12 项:|T| ≤ exact_order_limit 时用精确 DP(状态=已访问集合\n", + " # × 结束时测向频道),换频代价精确计入;否则回退 NN + 2-opt。0 表示禁用。\n", + " # 实测(18 场景):精确 DP 3203.2 s vs 2-opt 3193.1 s —— 逐位最优的\"当前\n", + " # 任务表\"在滚动时域下反而略差(贪心排序的次优性起到探索作用),默认 0;\n", + " # 代码与对拍测试保留,可随时开启复核。\n", + " exact_order_limit: int = 0\n", + " # ---- PF 清除证书 [工程] ----\n", + " grid_cert_enabled: bool = True # 允许用幸存集 MEC 颁发清除证书(凸证书之外)\n", + " opportunistic_measure: bool = True # 已停靠位置顺路交会;仍逐频道计费\n", + " # 预测后验半径至少减少 (1−ratio) 才补测;0.5 优于 0.65(18 场景 3205.4 vs 3210.3 s)\n", + " opportunistic_radius_ratio: float = 0.5\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class ProblemConfig:\n", + " \"\"\"聚合配置。各模块函数的第一个默认参数,便于单测替换。\"\"\"\n", + "\n", + " field: FieldConfig = FieldConfig()\n", + " cost: CostConfig = CostConfig()\n", + " session: SessionConfig = SessionConfig()\n", + " search: SearchConfig = SearchConfig()\n", + " bayes: BayesConfig = BayesConfig()\n", + "\n", + "\n", + "DEFAULT_CONFIG = ProblemConfig()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "18c1702d", + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass(frozen=True)\n", + "class ProblemConfig:\n", + " \"\"\"聚合配置。各模块函数的第一个默认参数,便于单测替换。\"\"\"\n", + "\n", + " field: FieldConfig = FieldConfig()\n", + " cost: CostConfig = CostConfig()\n", + " session: SessionConfig = SessionConfig()\n", + " search: SearchConfig = SearchConfig()\n", + " bayes: BayesConfig = BayesConfig()\n", + "\n", + "\n", + "DEFAULT_CONFIG = ProblemConfig()\n" + ] + }, + { + "cell_type": "markdown", + "id": "eb41e595", + "metadata": {}, + "source": [ + "## 2. 协议层(`protocol/`)\n", + "\n", + "### 2.1 规则表与字段表(`rules.py`)\n", + "\n", + "四条指令的字段约束、结果码与 HTTP 错误语义,全部落成可执行数据:\n", + "客户端用它构造并预校验请求,模拟器用同一张表校验并拒绝未知字段。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "52874146", + "metadata": {}, + "outputs": [], + "source": [ + "# ---- 结果码 [题面] ----------------------------------------------------------\n", + "\n", + "NO_SIGNAL = \"no_signal\" # 收不到信号:无未清除源 / 超接收半径 / 定向覆盖角外\n", + "NEAR = \"near\" # 距离 <=5m 且在覆盖角内;不返回 svd_deg\n", + "DIRECTION = \"direction\" # 有信号;附 svd_deg(含 ±1° 误差,保留两位小数)\n", + "MEASURE_RESULTS = (NO_SIGNAL, NEAR, DIRECTION)\n", + "\n", + "CLEAR_SUCCESS = \"success\" # 20m 内发现并清除;耗时 5s\n", + "CLEAR_NO_TARGET = \"no_target_in_range\" # 未发现(无源/已清除/超20m);耗时 3s\n", + "CLEAR_RESULTS = (CLEAR_SUCCESS, CLEAR_NO_TARGET)\n", + "\n", + "EXIT_USER = \"user_exit\" # /exit 成功响应中的固定退出原因\n", + "\n", + "# accepted=false 的响应只含这 3 个字段;virtual_time_s 恒为 0,不是当前时刻 [题面]\n", + "BASE_RESPONSE_FIELDS = (\"accepted\", \"real_timestamp_ms\", \"virtual_time_s\")\n", + "\n", + "\n", + "# ---- 请求字段表 --------------------------------------------------------------\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class FieldSpec:\n", + " \"\"\"单个请求字段的约束。[题面] 附件2 5.1 节与各指令小节。\"\"\"\n", + "\n", + " kind: type # str / dict;数值字段由 sim 按整型/浮点细则单独校验\n", + " required: bool = True\n", + " max_bytes: int | None = None # 字符串字段的 UTF-8 长度上限\n", + "\n", + "\n", + "COMMON_FIELDS: dict[str, FieldSpec] = {\n", + " \"arena_id\": FieldSpec(str), # 必须为 \"default\"\n", + " \"robot_id\": FieldSpec(str, max_bytes=64), # 逐字节等于登录参赛队号\n", + " \"request_id\": FieldSpec(str, max_bytes=128), # 会话内幂等键,新动作用新 ID\n", + "}\n", + "\n", + "POSITION_FIELDS: dict[str, FieldSpec] = {\n", + " \"x\": FieldSpec(float), # 有限 JSON number,|x| <= 2_000_000\n", + " \"y\": FieldSpec(float),\n", + "}\n", + "\n", + "# 路径 -> 除公共字段外的动作字段;/enter 与 /exit 无附加字段\n", + "ACTION_FIELDS: dict[str, dict[str, FieldSpec]] = {\n", + " \"/enter\": {},\n", + " \"/measure\": {\"position\": FieldSpec(dict), \"channel\": FieldSpec(float)},\n", + " \"/clear\": {\"position\": FieldSpec(dict), \"channel\": FieldSpec(float)},\n", + " \"/exit\": {},\n", + "}\n", + "ACTION_PATHS = tuple(ACTION_FIELDS)\n", + "\n", + "# channel 写成 JSON number 1.0 且恰为整数也接受;1.5 返回 HTTP 400 [题面]\n", + "\n", + "\n", + "# ---- 响应字段表(accepted=true 时的附加字段) --------------------------------\n", + "\n", + "RESPONSE_FIELDS: dict[str, tuple[str, ...]] = {\n", + " \"/enter\": (\"max_virtual_duration_s\", \"max_real_duration_s\", \"remaining_real_duration_s\"),\n", + " \"/measure\": (\"measure_result\",), # direction 时另有 svd_deg\n", + " \"/clear\": (\"clear_result\",),\n", + " \"/exit\": (\"exit_reason\",),\n", + "}\n", + "\n", + "\n", + "# ---- 错误语义 [题面] 附件2 5.3 节 --------------------------------------------\n", + "\n", + "STATUS_OK = 200\n", + "STATUS_BAD_REQUEST = 400 # JSON 语法/重复键/缺字段/类型/频道或坐标范围\n", + "STATUS_CONFLICT = 409 # 同 request_id 对应不同动作,或并发不同动作\n", + "STATUS_RETRYABLE = (429, 500) # 流量保护/幂等记录上限、服务器内部错误:可原样重试\n", + "\n", + "\n", + "def http_status_meaning(status: int) -> str:\n", + " table = {\n", + " 200: \"动作已执行,或 JSON 合法但被业务拒绝(看 accepted)\",\n", + " 400: \"请求结构错误;不占用 request_id,修正后可复用该 ID\",\n", + " 404: \"路径未知或不精确(不接受尾随斜线与查询参数)\",\n", + " 405: \"已知路径使用了非 POST 方法\",\n", + " 409: \"request_id 幂等冲突或并发发送不同动作\",\n", + " 413: \"请求体超过 65536 字节\",\n", + " 415: \"Content-Type 或 Content-Encoding 不受支持\",\n", + " 429: \"连接/流量保护触发,或本局幂等记录达到上限\",\n", + " 500: \"模拟器内部错误\",\n", + " }\n", + " return table.get(status, \"未知状态码\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "624c4b89", + "metadata": {}, + "source": [ + "### 2.2 纯计时模型(`timing.py`)\n", + "\n", + "附件示例(105 / 111 / 194 / 199 秒)是唯一权威锚点:\n", + "一次合法动作 = 移动(直线距离 / 5 m/s)+ 换频道(仅检测,1 s)+ 动作本身(检测 5 s、\n", + "清除成功 5 s / 未发现 3 s),逐段量化后**按整数微秒累计**。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "bb132d37", + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass(frozen=True)\n", + "class CostBreakdown:\n", + " \"\"\"一次合法动作的三段耗时 [题面]:移动 + 换频道(仅检测)+ 动作本身。\"\"\"\n", + "\n", + " move_s: float\n", + " switch_s: float\n", + " action_s: float\n", + "\n", + " @property\n", + " def total_s(self) -> float:\n", + " return self.total_us / 1_000_000\n", + "\n", + " @property\n", + " def total_us(self) -> int:\n", + " \"\"\"逐段量化后按整数微秒累计,与官方虚拟时钟一致。\"\"\"\n", + " return sum(round(value * 1_000_000)\n", + " for value in (self.move_s, self.switch_s, self.action_s))\n", + "\n", + "\n", + "def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,\n", + " cost: CostConfig = CostConfig()) -> CostBreakdown:\n", + " \"\"\"合法 /measure 的耗时。移动按直线距离;换频道只看前后频道是否不同。\"\"\"\n", + " return CostBreakdown(\n", + " move_s=dist(prev_pos, pos) / cost.speed_mps,\n", + " switch_s=0.0 if channel == prev_channel else cost.switch_s,\n", + " action_s=cost.measure_s,\n", + " )\n", + "\n", + "\n", + "def clear_cost(prev_pos: Vec2, pos: Vec2, success: bool,\n", + " cost: CostConfig = CostConfig()) -> CostBreakdown:\n", + " \"\"\"合法 /clear 的耗时。\n", + "\n", + " /clear 的 channel 是目标干扰源频道,不切换测向机频道,也不产生换频道耗时 [题面]。\n", + " \"\"\"\n", + " return CostBreakdown(\n", + " move_s=dist(prev_pos, pos) / cost.speed_mps,\n", + " switch_s=0.0,\n", + " action_s=cost.clear_success_s if success else cost.clear_fail_s,\n", + " )\n", + "\n", + "\n", + "def move_time(a: Vec2, b: Vec2, cost: CostConfig = CostConfig()) -> float:\n", + " \"\"\"任意两点间的移动耗时(规划用)。\"\"\"\n", + " return dist(a, b) / cost.speed_mps\n" + ] + }, + { + "cell_type": "markdown", + "id": "0382155e", + "metadata": {}, + "source": [ + "### 2.3 本地状态机(`state.py`)\n", + "\n", + "位置 / 测向频道 / 虚拟时刻的唯一权威是最近一次 `accepted=true` 的响应;\n", + "`/clear` 不切换测向机频道,只有合法 `/measure` 才更新频道。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "01f62290", + "metadata": {}, + "outputs": [], + "source": [ + "class StateError(RuntimeError):\n", + " \"\"\"状态机被以非法顺序驱动(例如未 enter 就 apply_measure)。\"\"\"\n", + "\n", + "\n", + "@dataclass\n", + "class RobotState:\n", + " position: Vec2 = (0.0, 0.0) # /enter 成功后的初始位置 [题面]\n", + " channel: int = 1 # /enter 成功后的初始频道 [题面]\n", + " virtual_time_s: float = 0.0\n", + " entered: bool = False\n", + " exited: bool = False\n", + " action_count: int = 0\n", + " # 预测与实测的偏差记录;strict 模式下出现偏差会直接抛错。\n", + " mismatches: list = field(default_factory=list)\n", + "\n", + " # -------------------------------------------------------------- enter/exit\n", + " def apply_enter(self, response: dict, *, strict: bool = False) -> None:\n", + " self._require_accepted(response)\n", + " if strict and (self.entered or self.exited):\n", + " raise StateError(\"重复 /enter 或已退出后再 enter\")\n", + " self.entered = True\n", + " self.virtual_time_s = response[\"virtual_time_s\"]\n", + "\n", + " def apply_exit(self, response: dict) -> None:\n", + " self._require_accepted(response)\n", + " self.exited = True\n", + " self.virtual_time_s = response[\"virtual_time_s\"]\n", + "\n", + " # ----------------------------------------------------------------- measure\n", + " def apply_measure(self, request: dict, response: dict, *,\n", + " cost: CostConfig = CostConfig(),\n", + " strict: bool = False) -> CostBreakdown:\n", + " self._require_accepted(response)\n", + " if strict and (not self.entered or self.exited):\n", + " raise StateError(\"/measure 要求已 enter 且未退出\")\n", + " pos = (request[\"position\"][\"x\"], request[\"position\"][\"y\"])\n", + " channel = request[\"channel\"]\n", + " predicted = measure_cost(self.position, pos, self.channel, channel, cost)\n", + " predicted = self._advance(pos, channel, response, predicted, strict)\n", + " return predicted\n", + "\n", + " # ------------------------------------------------------------------- clear\n", + " def apply_clear(self, request: dict, response: dict, *,\n", + " cost: CostConfig = CostConfig(),\n", + " strict: bool = False) -> CostBreakdown:\n", + " self._require_accepted(response)\n", + " if strict and (not self.entered or self.exited):\n", + " raise StateError(\"/clear 要求已 enter 且未退出\")\n", + " pos = (request[\"position\"][\"x\"], request[\"position\"][\"y\"])\n", + " success = response[\"clear_result\"] == CLEAR_SUCCESS\n", + " predicted = clear_cost(self.position, pos, success, cost)\n", + " # 频道保持不变:/clear 不切换测向机频道 [题面]\n", + " return self._advance(pos, self.channel, response, predicted, strict)\n", + "\n", + " # ------------------------------------------------------------------ 共用\n", + " def _advance(self, pos: Vec2, channel: int, response: dict,\n", + " predicted: CostBreakdown, strict: bool) -> CostBreakdown:\n", + " # 先完成全部校验再落子:strict 模式下失败时不留半更新状态。\n", + " actual_delta = response[\"virtual_time_s\"] - self.virtual_time_s\n", + " if abs(actual_delta - predicted.total_s) > 1e-6:\n", + " message = (f\"耗时预测不一致: 预测 {predicted.total_s:.6f}s, \"\n", + " f\"实测 {actual_delta:.6f}s ({predicted})\")\n", + " if strict:\n", + " raise StateError(message)\n", + " # 非严格模式:状态仍以响应为准,偏差记入日志供复盘。\n", + " self.mismatches.append(\n", + " {\"action\": self.action_count + 1, \"predicted_s\": predicted.total_s,\n", + " \"actual_s\": actual_delta, \"message\": message})\n", + " self.position = pos\n", + " self.channel = channel\n", + " self.virtual_time_s = response[\"virtual_time_s\"]\n", + " self.action_count += 1\n", + " return predicted\n", + "\n", + " @staticmethod\n", + " def _require_accepted(response: dict) -> None:\n", + " if response.get(\"accepted\") is not True:\n", + " raise StateError(\"accepted=false 的响应不允许更新状态\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "fdea1f06", + "metadata": {}, + "source": [ + "### 2.4 传输抽象与会话日志(`transport.py` / `log.py`)\n", + "\n", + "HTTP 细节与协议客户端解耦:正式运行用 `RequestsTransport`,\n", + "本地评测 / 故障注入 / 日志回放各自替换 `Transport` 实现即可,客户端代码零改动。\n", + "会话日志逐条追加 JSONL(题面要求机器狗程序自行留档指令与响应)。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "75e9e40b", + "metadata": {}, + "outputs": [], + "source": [ + "class TransportError(Exception):\n", + " \"\"\"网络层失败:连接不上、读写超时。动作可能未送达,也可能结果未知。\"\"\"\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class TransportResponse:\n", + " status: int\n", + " body: bytes\n", + "\n", + "\n", + "class Transport:\n", + " \"\"\"最小 POST 接口。同步阻塞,一次一个请求(协议要求串行)。\"\"\"\n", + "\n", + " def post(self, url: str, body: bytes, headers: dict[str, str],\n", + " timeout: tuple[float, float]) -> TransportResponse:\n", + " raise NotImplementedError\n", + "\n", + "\n", + "class RequestsTransport(Transport):\n", + " \"\"\"基于 requests 的默认实现。timeout=(连接, 读取)。\"\"\"\n", + "\n", + " def post(self, url, body, headers, timeout):\n", + " import requests # 惰性导入:仅真正发起 HTTP 时才需要该依赖\n", + "\n", + " try:\n", + " response = requests.post(url, data=body, headers=headers, timeout=timeout)\n", + " except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:\n", + " raise TransportError(str(exc)) from exc\n", + " except requests.exceptions.RequestException as exc: # 其它请求级异常同样按网络失败处理\n", + " raise TransportError(str(exc)) from exc\n", + " return TransportResponse(response.status_code, response.content)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ce38cbf0", + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass\n", + "class SessionLog:\n", + " path: Path | None = None\n", + " events: list = field(default_factory=list)\n", + "\n", + " def __post_init__(self):\n", + " if self.path is not None:\n", + " self.path.parent.mkdir(parents=True, exist_ok=True)\n", + "\n", + " def append(self, event: dict) -> None:\n", + " event = {**event, \"logged_at_unix_s\": time()}\n", + " self.events.append(event)\n", + " if self.path is not None:\n", + " with self.path.open(\"a\", encoding=\"utf-8\") as handle:\n", + " handle.write(json.dumps(event, ensure_ascii=False, allow_nan=False) + \"\\n\")\n", + "\n", + " def action_events(self) -> list[dict]:\n", + " \"\"\"仅最终被接受的动作事件(每个 request_id 一条,含重试次数)。\"\"\"\n", + " return [event for event in self.events if event.get(\"response\") is not None\n", + " and event[\"response\"].get(\"accepted\") is True]\n", + "\n", + " def step_events(self) -> list[dict]:\n", + " \"\"\"每步计时事件(客户端 _log_step 写入,按发生顺序)。\"\"\"\n", + " return [event for event in self.events if event.get(\"event\") == \"step\"]\n" + ] + }, + { + "cell_type": "markdown", + "id": "00433092", + "metadata": {}, + "source": [ + "### 2.5 串行幂等客户端(`client.py`)\n", + "\n", + "- 每个新动作使用新的 `request_id`;仅在网络失败后重试**完全相同的动作**时复用原 ID 与\n", + " 原请求体(本类一次性序列化出 bytes 并原样重发);\n", + "- 必须同时检查 HTTP 状态与 `accepted`;`accepted=false` 不更新状态;\n", + "- 逐次等待响应、不得并发;本类完全同步;\n", + "- 现实截止用 `/enter` 的 `remaining_real_duration_s` 建立本地单调时钟并预留退出余量。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "9b89cf0b", + "metadata": {}, + "outputs": [], + "source": [ + "class ProtocolError(RuntimeError):\n", + " \"\"\"协议层失败,含 HTTP 状态与响应体;默认不重试(确定性错误)。\"\"\"\n", + "\n", + " def __init__(self, message: str, status: int | None = None, body=None):\n", + " super().__init__(message)\n", + " self.status = status\n", + " self.body = body\n", + "\n", + "\n", + "class ActionRejected(ProtocolError):\n", + " \"\"\"HTTP 200 但 accepted=false:动作未生效,状态不得更新。\"\"\"\n", + "\n", + " def __init__(self, message: str, body=None):\n", + " super().__init__(message, status=STATUS_OK, body=body)\n", + "\n", + "\n", + "class ProtocolViolation(ProtocolError):\n", + " \"\"\"响应与协议约定不符(字段缺失、值非法、虚拟时刻回退等)。\"\"\"\n", + "\n", + "\n", + "class DeadlineExceeded(RuntimeError):\n", + " \"\"\"现实预算不足:预留退出余量后剩余时间不够执行新动作。\"\"\"\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class MeasureOutcome:\n", + " result: str\n", + " svd_deg: float | None\n", + " virtual_time_s: float\n", + " predicted: CostBreakdown\n", + " raw: dict\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class ClearOutcome:\n", + " result: str\n", + " virtual_time_s: float\n", + " predicted: CostBreakdown\n", + " raw: dict\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class ExitInfo:\n", + " reason: str\n", + " virtual_time_s: float\n", + " raw: dict\n", + "\n", + "\n", + "def _validate_identifier(name: str, value: str, max_bytes: int) -> None:\n", + " if not isinstance(value, str) or not value:\n", + " raise ValueError(f\"{name} 必须是非空字符串\")\n", + " encoded = value.encode(\"utf-8\")\n", + " if not 1 <= len(encoded) <= max_bytes:\n", + " raise ValueError(f\"{name} 的 UTF-8 长度须在 1..{max_bytes} 字节\")\n", + " if any(code < 0x20 or code == 0x7F for code in encoded):\n", + " raise ValueError(f\"{name} 不能包含控制字符\")\n", + "\n", + "\n", + "def _validate_position(position: dict, limit: float) -> None:\n", + " if set(position) != set(POSITION_FIELDS):\n", + " raise ValueError(\"position 字段必须是且只能是 x、y\")\n", + " for key in (\"x\", \"y\"):\n", + " value = position[key]\n", + " if isinstance(value, bool) or not isinstance(value, (int, float)):\n", + " raise ValueError(f\"position.{key} 必须是数值\")\n", + " if not math.isfinite(value):\n", + " raise ValueError(f\"position.{key} 必须是有限数值\")\n", + " if abs(value) > limit:\n", + " raise ValueError(f\"position.{key} 绝对值不得超过 {limit} 米\")\n", + "\n", + "\n", + "def _validate_channel(channel) -> int:\n", + " # 写成 JSON number 1.0 且恰为整数也接受 [题面];1.5 拒绝。\n", + " if isinstance(channel, bool) or not isinstance(channel, (int, float)):\n", + " raise ValueError(\"channel 必须是数值\")\n", + " if isinstance(channel, float) and not channel.is_integer():\n", + " raise ValueError(\"channel 必须是整数\")\n", + " channel = int(channel)\n", + " if not 1 <= channel <= 20:\n", + " raise ValueError(\"channel 必须在 1..20 范围内\")\n", + " return channel\n", + "\n", + "\n", + "class SimulatorClient:\n", + " \"\"\"四条指令的同步客户端。状态只在 accepted=true 后推进。\"\"\"\n", + "\n", + " def __init__(self, robot_id: str, *,\n", + " config: ProblemConfig = ProblemConfig(),\n", + " transport: Transport | None = None,\n", + " log: SessionLog | None = None,\n", + " clock=monotonic):\n", + " _validate_identifier(\"robot_id\", robot_id, config.session.robot_id_max_bytes)\n", + " self.robot_id = robot_id\n", + " self.config = config\n", + " self.session = config.session\n", + " self.transport = transport if transport is not None else RequestsTransport()\n", + " self.log = log if log is not None else SessionLog()\n", + " self.clock = clock\n", + " self.state = RobotState()\n", + " self._prefix = uuid.uuid4().hex[:12]\n", + " self._seq = 0\n", + " self._deadline: float | None = None # 本地单调时钟截止\n", + " self._server_deadline_ms: float | None = None # 服务器时间戳交叉核对\n", + " self._enter_monotonic: float | None = None # 实机计时起点(/enter 成功)\n", + "\n", + " # ------------------------------------------------------------ 公共动作 API\n", + " def enter(self) -> dict:\n", + " started = self.clock()\n", + " payload = self._payload(\"/enter\")\n", + " response = self._call(\"/enter\", payload)\n", + " self.state.apply_enter(response)\n", + " remaining = response[\"remaining_real_duration_s\"]\n", + " if not isinstance(remaining, (int, float)) or remaining < 0:\n", + " raise ProtocolViolation(\"remaining_real_duration_s 缺失或非法\", body=response)\n", + " self._deadline = self.clock() + remaining\n", + " self._server_deadline_ms = (response[\"real_timestamp_ms\"]\n", + " + remaining * 1000.0)\n", + " self._enter_monotonic = self.clock()\n", + " self._log_step(\"/enter\", payload, response, started,\n", + " predicted=CostBreakdown(0.0, 0.0, 0.0))\n", + " return response\n", + "\n", + " def measure(self, position: Vec2, channel: int) -> MeasureOutcome:\n", + " started = self.clock()\n", + " payload = self._payload(\"/measure\", position=position, channel=channel)\n", + " response = self._call(\"/measure\", payload)\n", + " predicted = self.state.apply_measure(payload, response,\n", + " cost=self.config.cost)\n", + " result = response.get(\"measure_result\")\n", + " if result not in MEASURE_RESULTS:\n", + " raise ProtocolViolation(f\"measure_result 非法: {result!r}\", body=response)\n", + " svd = response.get(\"svd_deg\")\n", + " if result == DIRECTION:\n", + " if not isinstance(svd, (int, float)) or isinstance(svd, bool):\n", + " raise ProtocolViolation(\"direction 响应缺少数值 svd_deg\", body=response)\n", + " self._log_step(\"/measure\", payload, response, started, result=result,\n", + " predicted=predicted, channel=payload[\"channel\"])\n", + " return MeasureOutcome(result, float(svd) if svd is not None else None,\n", + " response[\"virtual_time_s\"], predicted, response)\n", + "\n", + " def clear(self, position: Vec2, channel: int) -> ClearOutcome:\n", + " started = self.clock()\n", + " payload = self._payload(\"/clear\", position=position, channel=channel)\n", + " response = self._call(\"/clear\", payload)\n", + " predicted = self.state.apply_clear(payload, response,\n", + " cost=self.config.cost)\n", + " result = response.get(\"clear_result\")\n", + " if result not in CLEAR_RESULTS:\n", + " raise ProtocolViolation(f\"clear_result 非法: {result!r}\", body=response)\n", + " self._log_step(\"/clear\", payload, response, started, result=result,\n", + " predicted=predicted, channel=payload[\"channel\"])\n", + " return ClearOutcome(result, response[\"virtual_time_s\"], predicted, response)\n", + "\n", + " def exit(self) -> ExitInfo:\n", + " started = self.clock()\n", + " payload = self._payload(\"/exit\")\n", + " response = self._call(\"/exit\", payload)\n", + " self.state.apply_exit(response)\n", + " reason = response.get(\"exit_reason\")\n", + " if reason != EXIT_USER:\n", + " raise ProtocolViolation(f\"exit_reason 非法: {reason!r}\", body=response)\n", + " self._log_step(\"/exit\", payload, response, started,\n", + " predicted=CostBreakdown(0.0, 0.0, 0.0))\n", + " return ExitInfo(reason, response[\"virtual_time_s\"], response)\n", + "\n", + " # ---------------------------------------------------------------- 预算控制\n", + " @property\n", + " def real_remaining_s(self) -> float | None:\n", + " \"\"\"按本地单调时钟估计的剩余现实秒数(未 enter 时为 None)。\"\"\"\n", + " return None if self._deadline is None else self._deadline - self.clock()\n", + "\n", + " def _log_step(self, path: str, payload: dict, response: dict,\n", + " started: float, *, result: str | None = None,\n", + " predicted: CostBreakdown, channel: int | None = None) -> None:\n", + " \"\"\"每步计时留档 [用户决策 2026-09-11]:实机墙钟耗时 + 比赛虚拟耗时分项\n", + " (移动=直线距离/5、切频道 1s、检测 5s、清除成功 5s/未发现 3s)。\n", + "\n", + " step 事件不含 response 字段,不会混入 action_events / 虚拟时刻复算。\n", + " \"\"\"\n", + " now = self.clock()\n", + " entry: dict = {\"event\": \"step\", \"path\": path,\n", + " \"request_id\": payload[\"request_id\"],\n", + " \"real_elapsed_s\": round(now - started, 6),\n", + " \"virtual_time_s\": response[\"virtual_time_s\"],\n", + " \"virtual_cost\": {\"move_s\": round(predicted.move_s, 6),\n", + " \"switch_s\": round(predicted.switch_s, 6),\n", + " \"action_s\": round(predicted.action_s, 6),\n", + " \"total_s\": round(predicted.total_s, 6)}}\n", + " if self._enter_monotonic is not None:\n", + " entry[\"real_since_enter_s\"] = round(now - self._enter_monotonic, 6)\n", + " if \"position\" in payload:\n", + " entry[\"position\"] = {\"x\": payload[\"position\"][\"x\"],\n", + " \"y\": payload[\"position\"][\"y\"]}\n", + " if channel is not None:\n", + " entry[\"channel\"] = channel\n", + " if result is not None:\n", + " entry[\"result\"] = result\n", + " self.log.append(entry)\n", + "\n", + " def _check_budget(self, path: str) -> None:\n", + " if path == \"/exit\" or self._deadline is None:\n", + " return\n", + " margin = self.session.reserve_exit_s\n", + " if self.real_remaining_s <= margin:\n", + " raise DeadlineExceeded(\n", + " f\"剩余现实时间 {self.real_remaining_s:.1f}s 不足以预留 {margin:.1f}s 退出余量\")\n", + "\n", + " # ---------------------------------------------------------------- 内部机制\n", + " def _payload(self, _path: str, *, position: Vec2 | None = None,\n", + " channel: int | None = None) -> dict:\n", + " payload = {\"arena_id\": self.session.arena_id,\n", + " \"robot_id\": self.robot_id,\n", + " \"request_id\": self._next_request_id()}\n", + " if position is not None:\n", + " x, y = float(position[0]), float(position[1])\n", + " _validate_position({\"x\": x, \"y\": y}, self.config.field.coord_abs_limit_m)\n", + " payload[\"position\"] = {\"x\": x, \"y\": y}\n", + " if channel is not None:\n", + " payload[\"channel\"] = _validate_channel(channel)\n", + " return payload\n", + "\n", + " def _next_request_id(self) -> str:\n", + " self._seq += 1\n", + " request_id = f\"{self._prefix}-{self._seq:04d}\"\n", + " _validate_identifier(\"request_id\", request_id,\n", + " self.session.request_id_max_bytes)\n", + " return request_id\n", + "\n", + " def _call(self, path: str, payload: dict) -> dict:\n", + " \"\"\"串行执行一个动作;网络失败原样重发,HTTP 确定性错误立即抛出。\"\"\"\n", + " self._check_budget(path)\n", + " body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode(\"utf-8\")\n", + " if len(body) > self.session.request_body_max_bytes:\n", + " raise ValueError(\"请求体超过 65536 字节\")\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + " url = self.session.base_url + path\n", + " attempts, last_error = 0, None\n", + " while attempts < self.session.max_attempts:\n", + " attempts += 1\n", + " started = self.clock()\n", + " self.log.append({\"event\": \"attempt\", \"path\": path,\n", + " \"request_id\": payload[\"request_id\"],\n", + " \"attempt\": attempts, \"request\": payload})\n", + " try:\n", + " result = self.transport.post(\n", + " url, body, headers,\n", + " timeout=(self.session.connect_timeout_s,\n", + " self.session.read_timeout_s))\n", + " except TransportError as exc:\n", + " last_error = exc\n", + " self.log.append({\"event\": \"transport_error\", \"path\": path,\n", + " \"request_id\": payload[\"request_id\"],\n", + " \"attempt\": attempts, \"error\": str(exc),\n", + " \"elapsed_s\": self.clock() - started})\n", + " if attempts < self.session.max_attempts:\n", + " sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))\n", + " continue\n", + " if result.status in STATUS_RETRYABLE:\n", + " self.log.append({\"event\": \"retryable_status\", \"path\": path,\n", + " \"request_id\": payload[\"request_id\"],\n", + " \"attempt\": attempts, \"status\": result.status})\n", + " if attempts < self.session.max_attempts:\n", + " sleep(self.session.retry_backoff_s * (2 ** (attempts - 1)))\n", + " continue\n", + " if result.status != STATUS_OK:\n", + " raise ProtocolError(\n", + " f\"{path} 返回 HTTP {result.status}\", status=result.status,\n", + " body=self._safe_json(result.body))\n", + " response = self._safe_json(result.body)\n", + " if response is None or response.get(\"accepted\") is not True:\n", + " raise ActionRejected(\n", + " f\"{path} 未被接受: {response}\", body=response)\n", + " self._verify_clock(response)\n", + " self.log.append({\"event\": \"accepted\", \"path\": path,\n", + " \"request_id\": payload[\"request_id\"],\n", + " \"request\": payload, \"response\": response,\n", + " \"attempts\": attempts,\n", + " \"elapsed_s\": self.clock() - started})\n", + " return response\n", + " raise TransportError(\n", + " f\"{path} 重试 {attempts} 次仍失败: {last_error}\") from last_error\n", + "\n", + " def _verify_clock(self, response: dict) -> None:\n", + " \"\"\"accepted=true 的虚拟时刻必须单调不减;现实时间戳用于漂移核对。\"\"\"\n", + " virtual = response.get(\"virtual_time_s\")\n", + " if not isinstance(virtual, (int, float)) or isinstance(virtual, bool):\n", + " raise ProtocolViolation(\"accepted 响应缺少 virtual_time_s\", body=response)\n", + " if virtual < self.state.virtual_time_s - 1e-9:\n", + " raise ProtocolViolation(\n", + " f\"虚拟时刻回退: {self.state.virtual_time_s} -> {virtual}\",\n", + " body=response)\n", + " if self._server_deadline_ms is not None:\n", + " drift = (self._server_deadline_ms - response[\"real_timestamp_ms\"]) / 1000.0\n", + " if drift < self.session.reserve_exit_s / 2:\n", + " # 服务器视角余量告急(本地时钟漂移或请求排队);仅记录,交由上层决策。\n", + " self.log.append({\"event\": \"server_time_warning\",\n", + " \"server_remaining_s\": drift})\n", + "\n", + " @staticmethod\n", + " def _safe_json(body: bytes):\n", + " try:\n", + " return json.loads(body.decode(\"utf-8\"))\n", + " except (UnicodeDecodeError, json.JSONDecodeError):\n", + " return None\n" + ] + }, + { + "cell_type": "markdown", + "id": "1b378ab3", + "metadata": {}, + "source": [ + "## 3. 几何内核(`geometry/`)\n", + "\n", + "### 3.1 角域交会定位(`localization.py`)\n", + "\n", + "每条示向度 = 三个半平面:角域下边界、上边界,以及\"只能在前向\"约束\n", + "(半角 < 90° 时交集是尖锥而非整条直线)。定位区域 = 全部半平面之交。\n", + "\n", + "对偶枚举任意两个半平面的交点即得全部候选顶点,再按可行性筛选——\n", + "有界性由\"是否存在非零衰退方向\"判定,因此**不需要人为加包围盒**:\n", + "\n", + "- 无交点 ⇒ 空集(数据矛盾或频道选错);\n", + "- 有非零衰退方向 ⇒ 无界,直径为 ∞;\n", + "- 否则顶点凸包即定位多边形,直径由顶点对枚举给出,最小包围圆用 Welzl 随机增量算法。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "3b895426", + "metadata": {}, + "outputs": [], + "source": [ + "Point = tuple[float, float]\n", + "EPS = 1e-9\n", + "PARALLEL_EPS = 1e-12\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class Measurement:\n", + " x: float\n", + " y: float\n", + " bearing_deg: float\n", + " half_angle_deg: float = 1.0\n", + "\n", + " def __post_init__(self):\n", + " values = (self.x, self.y, self.bearing_deg, self.half_angle_deg)\n", + " if any(isinstance(v, bool) or not isinstance(v, (int, float))\n", + " or not isfinite(v) for v in values):\n", + " raise ValueError(\"坐标、示向度和误差半角必须是有限数值\")\n", + " if not 0 <= self.half_angle_deg < 90:\n", + " raise ValueError(\"误差半角必须在 [0, 90) 度;题设为1度\")\n", + "\n", + "\n", + "def cross(a: Point, b: Point) -> float:\n", + " return a[0] * b[1] - a[1] * b[0]\n", + "\n", + "\n", + "def distance(a: Point, b: Point) -> float:\n", + " return hypot(a[0] - b[0], a[1] - b[1])\n", + "\n", + "\n", + "def halfplanes(m: Measurement) -> list[tuple[float, float, float]]:\n", + " \"\"\"Return a*x+b*y<=c with unit normals, including the forward constraint.\"\"\"\n", + " theta = m.bearing_deg % 360\n", + " lower, upper = radians(theta - m.half_angle_deg), radians(theta + m.half_angle_deg)\n", + " middle = radians(theta)\n", + " normals = [(sin(lower), -cos(lower)), (-sin(upper), cos(upper)),\n", + " (-cos(middle), -sin(middle))]\n", + " return [(a, b, a * m.x + b * m.y) for a, b in normals]\n", + "\n", + "\n", + "def convex_hull(points: list[Point], eps: float = EPS) -> list[Point]:\n", + " \"\"\"Monotone chain, CCW, no repeated final vertex; retain segment endpoints.\"\"\"\n", + " ordered = sorted(points)\n", + " unique = []\n", + " for p in ordered:\n", + " if not unique or distance(p, unique[-1]) > eps:\n", + " unique.append(p)\n", + " if len(unique) <= 2:\n", + " return unique\n", + "\n", + " def chain(seq):\n", + " out = []\n", + " for p in seq:\n", + " while len(out) >= 2:\n", + " a = (out[-1][0] - out[-2][0], out[-1][1] - out[-2][1])\n", + " b = (p[0] - out[-1][0], p[1] - out[-1][1])\n", + " # Distance-to-line tolerance, not an unscaled area tolerance.\n", + " if cross(a, b) > eps * max(hypot(*a), hypot(*b), eps):\n", + " break\n", + " out.pop()\n", + " out.append(p)\n", + " return out\n", + "\n", + " return chain(unique)[:-1] + chain(reversed(unique))[:-1]\n", + "\n", + "\n", + "def diameter(points: list[Point]):\n", + " if not points:\n", + " raise ValueError(\"空点集没有本程序定义下的直径\")\n", + " a = b = points[0]\n", + " value = 0.0\n", + " for p, q in combinations(points, 2):\n", + " d = distance(p, q)\n", + " if d > value:\n", + " value, a, b = d, p, q\n", + " return value, (a, b)\n", + "\n", + "\n", + "def _circumcircle(a: Point, b: Point, c: Point):\n", + " u = (b[0] - a[0], b[1] - a[1])\n", + " v = (c[0] - a[0], c[1] - a[1])\n", + " det = 2 * cross(u, v)\n", + " if abs(det) <= PARALLEL_EPS * max(hypot(*u) * hypot(*v), 1e-300):\n", + " return None\n", + " uu, vv = u[0] ** 2 + u[1] ** 2, v[0] ** 2 + v[1] ** 2\n", + " center = (a[0] + (uu * v[1] - vv * u[1]) / det,\n", + " a[1] + (u[0] * vv - v[0] * uu) / det)\n", + " return center, distance(center, a)\n", + "\n", + "\n", + "def _enclosing_circle_reference(points: list[Point], eps: float = EPS):\n", + " \"\"\"Enumerate all 1/2/3-point supports; O(m^4), appropriate for small m.\n", + "\n", + " Radius is recomputed against every vertex to preserve numerical containment.\n", + " Coordinates should be normalized when there are very large offsets.\n", + " \"\"\"\n", + " if not points:\n", + " raise ValueError(\"空集不计算最小包围圆\")\n", + " xs, ys = [p[0] for p in points], [p[1] for p in points]\n", + " best_c = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)\n", + " best_r = max(distance(best_c, p) for p in points)\n", + "\n", + " def consider(center, radius):\n", + " nonlocal best_c, best_r\n", + " if radius > best_r + eps:\n", + " return\n", + " actual = max(distance(center, p) for p in points)\n", + " if actual <= radius + eps and actual < best_r:\n", + " best_c, best_r = center, actual\n", + "\n", + " for p in points:\n", + " consider(p, 0.0)\n", + " for a, b in combinations(points, 2):\n", + " consider(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), distance(a, b) / 2)\n", + " for a, b, c in combinations(points, 3):\n", + " circle = _circumcircle(a, b, c)\n", + " if circle is not None:\n", + " consider(*circle)\n", + " return best_c, best_r\n", + "\n", + "\n", + "def _circle_covers(circle, point: Point, tolerance: float) -> bool:\n", + " \"\"\"Whether the circle already covers point; None means no circle yet.\"\"\"\n", + " if circle is None:\n", + " return False\n", + " return distance(circle[0], point) <= circle[1] + tolerance\n", + "\n", + "\n", + "def smallest_enclosing_circle(points: list[Point], eps: float = EPS):\n", + " \"\"\"Welzl's randomized incremental algorithm, using an explicit call stack.\n", + "\n", + " The fixed shuffle makes results reproducible. Expected linear work refers\n", + " to randomized input order, not a worst-case guarantee for this fixed seed.\n", + " Normalize internally so this public helper also handles translated inputs.\n", + " The exhaustive reference is retained for independent regression comparisons.\n", + " \"\"\"\n", + " if not points:\n", + " raise ValueError(\"空集不计算最小包围圆\")\n", + " if not isfinite(eps) or eps <= 0:\n", + " raise ValueError(\"eps必须为有限正数\")\n", + " if any(len(p) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float))\n", + " or not isfinite(x) for x in p) for p in points):\n", + " raise ValueError(\"点集必须由有限二维坐标组成\")\n", + " origin = points[0]\n", + " scale = max(distance(origin, p) for p in points)\n", + " if scale == 0:\n", + " return tuple(origin), 0.0\n", + " local = [((p[0]-origin[0])/scale, (p[1]-origin[1])/scale) for p in points]\n", + " tolerance = min(eps/scale, EPS)\n", + " Random(20260910).shuffle(local)\n", + " # Frame = (remaining prefix length, forced boundary, returning from child).\n", + " stack = [(len(local), (), False)]\n", + " circle = None\n", + " while stack:\n", + " n, boundary, returning = stack.pop()\n", + " if n == 0 or len(boundary) == 3:\n", + " # Forced support points must lie ON the boundary. For three points\n", + " # use their circumcircle, even when their unconstrained MEC is smaller.\n", + " circle = _circumcircle(*boundary) if len(boundary) == 3 else None\n", + " if circle is None and boundary:\n", + " circle = _enclosing_circle_reference(list(boundary), tolerance)\n", + " elif not returning:\n", + " stack.append((n, boundary, True))\n", + " stack.append((n-1, boundary, False))\n", + " elif not _circle_covers(circle, local[n-1], tolerance):\n", + " stack.append((n-1, boundary + (local[n-1],), False))\n", + " assert circle is not None\n", + " center = (origin[0]+circle[0][0]*scale, origin[1]+circle[0][1]*scale)\n", + " # Recompute after transforming back: returned circle contains all input points\n", + " # to distance() precision, including any roundoff from the large translation.\n", + " return center, max(distance(center, p) for p in points)\n", + "\n", + "\n", + "def solve(measurements: list[Measurement], eps: float = EPS) -> dict:\n", + " \"\"\"Solve Q1's pure angular intersection (no disk/range prior added).\n", + "\n", + " Nonempty intersections of <180-degree forward wedges are pointed polyhedra,\n", + " so they possess a vertex, even if unbounded. Feasibility follows from vertex\n", + " enumeration; unboundedness follows from a nonzero recession direction.\n", + " Empty diameter is null, unbounded diameter is null with diameter_infinite=true.\n", + "\n", + " Near-parallel plane pairs are intersected rather than skipped: their\n", + " intersection can be a legitimate vertex of a very long thin region\n", + " (e.g. stations 1cm apart with bearings 1 and 358.99999999999 deg give a\n", + " vertex ~5.7e10 m away). Only exactly-parallel pairs (det == 0) have no\n", + " intersection. The feasibility tolerance scales with the vertex magnitude,\n", + " since absolute residuals grow with coordinates.\n", + " \"\"\"\n", + " if not isfinite(eps) or eps <= 0:\n", + " raise ValueError(\"eps必须为有限正数\")\n", + " if not measurements:\n", + " return {\"status\": \"unbounded\", \"vertices\": [], \"diameter_m\": None,\n", + " \"diameter_infinite\": True, \"recession_direction\": [1.0, 0.0],\n", + " \"message\": \"没有观测,整个平面均可行\"}\n", + " if len(measurements) > 100:\n", + " raise ValueError(\"参考实现最多接收100个检测点;更大规模应使用半平面交算法\")\n", + " ox = sum(m.x for m in measurements) / len(measurements)\n", + " oy = sum(m.y for m in measurements) / len(measurements)\n", + " scale = max(1.0, max(hypot(m.x - ox, m.y - oy) for m in measurements))\n", + " local = [Measurement((m.x - ox) / scale, (m.y - oy) / scale,\n", + " m.bearing_deg, m.half_angle_deg) for m in measurements]\n", + " planes = [h for m in local for h in halfplanes(m)]\n", + "\n", + " def inside(p):\n", + " tol = eps * max(1.0, abs(p[0]), abs(p[1]))\n", + " return all(a * p[0] + b * p[1] <= c + tol for a, b, c in planes)\n", + "\n", + " feasible_vertices = []\n", + " for (a, b, c), (d, e, f) in combinations(planes, 2):\n", + " det = a * e - b * d\n", + " if det == 0.0:\n", + " continue # 精确平行:无交点(近平行对的交点是合法远端顶点,保留)\n", + " p = ((c * e - b * f) / det, (a * f - c * d) / det)\n", + " if isfinite(p[0]) and isfinite(p[1]) and inside(p):\n", + " feasible_vertices.append(p)\n", + "\n", + " if not feasible_vertices:\n", + " return {\"status\": \"empty\", \"vertices\": [], \"diameter_m\": None,\n", + " \"diameter_infinite\": False, \"tolerance_m\": eps * scale,\n", + " \"message\": \"测向角域没有公共点;检查频道、数据及误差界\"}\n", + "\n", + " # A nontrivial 2D recession cone has a ray tangent to an input halfplane.\n", + " # Use a much tighter angular threshold than the positional feasibility epsilon.\n", + " ray = None\n", + " for a, b, _ in planes:\n", + " for direction in ((b, -a), (-b, a)):\n", + " if all(d * direction[0] + e * direction[1] <= 2e-14\n", + " for d, e, _ in planes):\n", + " ray = direction\n", + " break\n", + " if ray is not None:\n", + " break\n", + "\n", + " hull = convex_hull(feasible_vertices, eps)\n", + "\n", + " def world(p):\n", + " return [ox + p[0] * scale, oy + p[1] * scale]\n", + "\n", + " result = {\"status\": \"unbounded\" if ray else\n", + " (\"point\" if len(hull) == 1 else \"segment\" if len(hull) == 2 else \"polygon\"),\n", + " \"vertices\": [world(p) for p in hull], \"tolerance_m\": eps * scale,\n", + " \"diameter_infinite\": ray is not None}\n", + " if ray is not None:\n", + " result.update(diameter_m=None, recession_direction=list(ray),\n", + " message=\"存在非零衰退方向,区域无界,直径为无穷大\")\n", + " return result\n", + "\n", + " d, pair = diameter(hull)\n", + " midpoint = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2)\n", + " distances = [distance(midpoint, p) for p in hull]\n", + " witness_index = max(range(len(hull)), key=distances.__getitem__)\n", + " max_distance = distances[witness_index]\n", + " center, radius = smallest_enclosing_circle(hull, eps)\n", + " area = abs(sum(cross(hull[i], hull[(i + 1) % len(hull)])\n", + " for i in range(len(hull)))) / 2\n", + " result.update(diameter_m=d * scale, diameter_pair=[world(p) for p in pair],\n", + " area_m2=area * scale ** 2,\n", + " diameter_circle={\"center\": world(midpoint), \"radius_m\": d * scale / 2,\n", + " \"max_vertex_distance_m\": max_distance * scale,\n", + " \"covers\": max_distance <= d / 2 + eps * 4,\n", + " \"coverage_tolerance_m\": 4 * eps * scale,\n", + " \"max_excess_m\": (max_distance-d/2) * scale,\n", + " \"outside_vertex_indices\": [i for i, value in enumerate(distances)\n", + " if value > d/2 + 4*eps],\n", + " \"witness_vertex\": world(hull[witness_index])\n", + " if max_distance > d/2 + 4*eps else None},\n", + " minimum_enclosing_circle={\"center\": world(center), \"radius_m\": radius * scale},\n", + " guaranteed_clearable_at_mec_center=radius * scale <= 20 - eps * scale * 4,\n", + " clearance_certificate={\"model\": \"angular_intersection_only\",\n", + " \"clear_radius_m\": 20.0,\n", + " \"numerical_margin_m\": 4*eps*scale,\n", + " \"radius_with_margin_m\": (radius+4*eps)*scale,\n", + " \"remaining_margin_m\": 20-(radius+4*eps)*scale,\n", + " \"center\": world(center)})\n", + " return result\n" + ] + }, + { + "cell_type": "markdown", + "id": "0e26ba66", + "metadata": {}, + "source": [ + "### 3.2 问题二的安全候选盘证书(`planning.py`)\n", + "\n", + "首次示向度只给出\"目标在角域 ∩ 距离 ≤ 1500 m 的扇形内\"。该扇形的**最小包围圆**\n", + "半径 a = R / (2 cos δ);再按保证接收半径(≥ 1000 m)内缩 s = 1000 − a 得到\n", + "保证接收盘 B(圆心, s)——盘内任意点到真源的距离 ≤ s + a = 1000 m,\n", + "因此第二点必收到 `direction`(或 `near`),不会浪费一次 `no_signal`。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "96ea4f0f", + "metadata": {}, + "outputs": [], + "source": [ + "def first_bearing_candidate_disk(measurement: Measurement,\n", + " max_range_m: float = 1500,\n", + " guaranteed_range_m: float = 1000) -> dict:\n", + " \"\"\"Enclose the entire truncated first-bearing sector, then erode its disk.\n", + "\n", + " For half-angle <=45 degrees the sector MEC has center distance and radius\n", + " a=R/(2*cos(delta)). Intersecting with Omega only shrinks the source set.\n", + " A missing sufficient disk is NOT proof that Q is empty. The certificate\n", + " guarantees distance only: directional transmitters may still be invisible.\n", + " \"\"\"\n", + " if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v)\n", + " or v <= 0 for v in (max_range_m, guaranteed_range_m)):\n", + " raise ValueError(\"接收半径必须为有限正数\")\n", + " if measurement.half_angle_deg > 45:\n", + " raise ValueError(\"此窄角域证书仅支持半角不超过45度;题设为1度\")\n", + " delta, theta = radians(measurement.half_angle_deg), radians(measurement.bearing_deg % 360)\n", + " a = max_range_m/(2*cos(delta))\n", + " center = [measurement.x+a*cos(theta), measurement.y+a*sin(theta)]\n", + " available = guaranteed_range_m-a\n", + " return {\"source_model\": \"first_bearing_sector_with_range_upper_bound\",\n", + " \"sector_enclosing_circle\": {\"center\": center, \"radius_m\": a},\n", + " \"sufficient_candidate_disk\": {\"center\": center, \"radius_m\": available}\n", + " if available >= 0 else None,\n", + " \"candidate_nonempty_certified\": available >= 0,\n", + " \"distance_only\": True,\n", + " \"max_range_m\": max_range_m, \"guaranteed_range_m\": guaranteed_range_m}\n" + ] + }, + { + "cell_type": "markdown", + "id": "15dfa096", + "metadata": {}, + "source": [ + "## 4. 信念层与保证层\n", + "\n", + "### 4.1 逐频道保守凸候选集(`belief/__init__.py`)\n", + "\n", + "候选集 K_c 用**圆盘外切多边形 ∩ 测向角域半平面**表示。两条不变式:\n", + "\n", + "- 外切多边形严格包含圆盘、半平面裁剪带正容差 ⇒ 更新只会放大不会缩小真实候选集;\n", + "- `direction` 读数给出 K_c ⊆ Ω ∩ W(s, θ±δ_eff) ∩ B(s, 1500),δ_eff 含示向度两位小数的\n", + " 舍入裕量 0.005°;\n", + "- `no_signal` **不删空间候选**(对定向源它只说明被遮挡,见第四问),\n", + " \"该频道无源\"的证明由覆盖账本或网格幸存集给出;\n", + "- `near` 读数把候选集收缩到 B(s, 5),可直接产生清除证书。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "48977ca8", + "metadata": {}, + "outputs": [], + "source": [ + "# 半平面裁剪的正容差(米)。坐标量级 ~2100,浮点误差远小于它;\n", + "# 容差只向\"保留更多候选\"的方向倾斜,不破坏保守性。\n", + "_CLIP_TOL_M = 1e-6\n", + "_DEDUPE_TOL_M = 1e-7\n", + "\n", + "\n", + "class InconsistentBelief(RuntimeError):\n", + " \"\"\"读数把候选集剪空等违背保守性的情形;须停机核查而非继续跑。\"\"\"\n", + "\n", + "\n", + "class ChannelStatus(Enum):\n", + " UNKNOWN = \"unknown\" # 尚无有效证据\n", + " DETECTED = \"detected\" # 收到过 direction/near,待定位\n", + " LOCATED = \"located\" # 候选集包围圆半径 <= 20,可清除\n", + " CLEARED = \"cleared\" # 清除成功\n", + " ABSENT = \"absent\" # 覆盖证明不存在(P4)\n", + "\n", + "\n", + "def circumscribed_polygon(center: Vec2, radius_m: float, sides: int) -> list[Vec2]:\n", + " \"\"\"圆的外切正多边形顶点(外接半径 r/cos(π/n)),严格包含圆盘。\n", + "\n", + " 近似误差(外接圆超出圆盘的部分)为 r(1/cos(π/n) - 1) ≤ r(1-cos(π/n))·sec²,\n", + " 调用方用 (1-cos(π/n)) 口径记账即可(略保守偏大)。\n", + " \"\"\"\n", + " if sides < 8:\n", + " raise ValueError(\"外切多边形边数过少,近似误差不可控\")\n", + " circumradius = radius_m / math.cos(math.pi / sides)\n", + " step = 2 * math.pi / sides\n", + " return [(center[0] + circumradius * math.cos(step * i + step / 2),\n", + " center[1] + circumradius * math.sin(step * i + step / 2))\n", + " for i in range(sides)]\n", + "\n", + "\n", + "def polygon_halfplanes(polygon: list[Vec2]) -> list[tuple[float, float, float]]:\n", + " \"\"\"CCW 多边形 -> 外法线半平面列表 (a, b, c),含义 a·x + b·y <= c。\"\"\"\n", + " planes = []\n", + " count = len(polygon)\n", + " for index in range(count):\n", + " p, q = polygon[index], polygon[(index + 1) % count]\n", + " dx, dy = q[0] - p[0], q[1] - p[1]\n", + " norm = math.hypot(dx, dy)\n", + " if norm <= 1e-12:\n", + " continue\n", + " a, b = dy / norm, -dx / norm # CCW 的右法线朝外\n", + " planes.append((a, b, a * p[0] + b * p[1]))\n", + " return planes\n", + "\n", + "\n", + "def clip_halfplane(polygon: list[Vec2], a: float, b: float, c: float,\n", + " tol: float = _CLIP_TOL_M) -> list[Vec2]:\n", + " \"\"\"Sutherland–Hodgman 凸多边形半平面裁剪,保留 a·x + b·y <= c + tol。\n", + "\n", + " 边界交点取在无容差直线上;进入判定带正容差,保证\"贴边\"的真点不丢。\n", + " \"\"\"\n", + " if not polygon:\n", + " return []\n", + " output: list[Vec2] = []\n", + " count = len(polygon)\n", + " for index in range(count):\n", + " p, q = polygon[index], polygon[(index + 1) % count]\n", + " fp = a * p[0] + b * p[1] - c\n", + " fq = a * q[0] + b * q[1] - c\n", + " p_in, q_in = fp <= tol, fq <= tol\n", + " if p_in:\n", + " output.append(p)\n", + " if p_in != q_in:\n", + " ratio = fp / (fp - fq)\n", + " output.append((p[0] + ratio * (q[0] - p[0]),\n", + " p[1] + ratio * (q[1] - p[1])))\n", + " return _dedupe(output)\n", + "\n", + "\n", + "def _dedupe(points: list[Vec2], tol: float = _DEDUPE_TOL_M) -> list[Vec2]:\n", + " unique: list[Vec2] = []\n", + " for point in points:\n", + " if not unique or math.dist(point, unique[-1]) > tol:\n", + " unique.append(point)\n", + " if len(unique) > 1 and math.dist(unique[0], unique[-1]) <= tol:\n", + " unique.pop()\n", + " return unique\n", + "\n", + "\n", + "class CandidateRegion:\n", + " \"\"\"某频道候选集合的保守凸外包多边形(顶点按 CCW;允许退化为 1-2 点)。\"\"\"\n", + "\n", + " def __init__(self, polygon: list[Vec2], slack_m: float = 0.0):\n", + " self.polygon = list(polygon)\n", + " self.slack_m = float(slack_m) # 外包近似引入的半径向误差记账\n", + " self._mec: tuple[Vec2, float] | None = None\n", + "\n", + " # ------------------------------------------------------------ 构造\n", + " @classmethod\n", + " def disk(cls, center: Vec2, radius_m: float, sides: int,\n", + " slack_m: float | None = None) -> \"CandidateRegion\":\n", + " if slack_m is None:\n", + " slack_m = radius_m * (1 - math.cos(math.pi / sides))\n", + " return cls(circumscribed_polygon(center, radius_m, sides), slack_m)\n", + "\n", + " @classmethod\n", + " def initial(cls, station: Vec2, config: ProblemConfig) -> \"CandidateRegion\":\n", + " \"\"\"首个 direction 后的基础集:Ω ∩ B(station, r_max) 的保守外包。\"\"\"\n", + " field_cfg, sides = config.field, config.search.polygon_sides\n", + " slack = field_cfg.omega_radius_m * (1 - math.cos(math.pi / sides))\n", + " region = cls.disk((0.0, 0.0), field_cfg.omega_radius_m, sides, slack)\n", + " region.clip_disk(station, field_cfg.r_eff_max_m, sides)\n", + " return region\n", + "\n", + " # ------------------------------------------------------------ 收缩\n", + " def _clip(self, a: float, b: float, c: float) -> None:\n", + " polygon = clip_halfplane(self.polygon, a, b, c)\n", + " if not polygon:\n", + " raise InconsistentBelief(\n", + " f\"候选区域被半平面 ({a:.6f},{b:.6f},{c:.6f}) 剪空:读数与保守集矛盾\")\n", + " self.polygon = polygon\n", + " self._mec = None\n", + "\n", + " def clip_disk(self, center: Vec2, radius_m: float, sides: int) -> None:\n", + " \"\"\"与圆盘 B(center, radius) 的外切多边形求交(近似误差入账)。\"\"\"\n", + " for a, b, c in polygon_halfplanes(circumscribed_polygon(center, radius_m, sides)):\n", + " self._clip(a, b, c)\n", + " self.slack_m += radius_m * (1 - math.cos(math.pi / sides))\n", + "\n", + " def clip_wedge(self, station: Vec2, bearing_deg: float,\n", + " half_angle_deg: float) -> None:\n", + " \"\"\"与测向角域 W(station, bearing±half_angle) 求交(半角须 <90°)。\n", + "\n", + " 两个边界半平面与 localization.halfplanes 同口径,0°/360° 跨界由\n", + " sin/cos 直接处理,无需分支。\n", + " \"\"\"\n", + " if not 0 < half_angle_deg < 90:\n", + " raise ValueError(\"角域半角须在 (0, 90) 度\")\n", + " lower = math.radians(bearing_deg - half_angle_deg)\n", + " upper = math.radians(bearing_deg + half_angle_deg)\n", + " sx, sy = float(station[0]), float(station[1])\n", + " self._clip(math.sin(lower), -math.cos(lower),\n", + " math.sin(lower) * sx - math.cos(lower) * sy)\n", + " self._clip(-math.sin(upper), math.cos(upper),\n", + " -math.sin(upper) * sx + math.cos(upper) * sy)\n", + "\n", + " # ------------------------------------------------------------ 查询\n", + " def mec(self) -> tuple[Vec2, float]:\n", + " \"\"\"最小包围圆(圆心, 半径)。半径是真实候选集 ρ 的上界。\"\"\"\n", + " if self._mec is None:\n", + " if not self.polygon:\n", + " raise InconsistentBelief(\"候选区域为空\")\n", + " self._mec = smallest_enclosing_circle(self.polygon)\n", + " return self._mec\n", + "\n", + " def witnesses(self, spacing_m: float) -> list[Vec2]:\n", + " \"\"\"顶点 + 边界按间距细分的采样点(最坏后验评估用)。\"\"\"\n", + " if len(self.polygon) < 3:\n", + " return list(self.polygon)\n", + " points = list(self.polygon)\n", + " count = len(self.polygon)\n", + " for index in range(count):\n", + " p, q = self.polygon[index], self.polygon[(index + 1) % count]\n", + " length = math.dist(p, q)\n", + " steps = int(length // spacing_m)\n", + " for k in range(1, steps + 1):\n", + " ratio = k / (steps + 1)\n", + " points.append((p[0] + ratio * (q[0] - p[0]),\n", + " p[1] + ratio * (q[1] - p[1])))\n", + " return _dedupe(points, tol=1e-9)\n", + "\n", + " def contains(self, point: Vec2, tol_m: float = 0.05) -> bool:\n", + " \"\"\"点是否在候选集内(带容差;用于真源保留性检验,不做删点依据)。\"\"\"\n", + " if not self.polygon:\n", + " return False\n", + " if len(self.polygon) == 1:\n", + " return math.dist(point, self.polygon[0]) <= tol_m + self.slack_m\n", + " if len(self.polygon) == 2:\n", + " p, q = self.polygon\n", + " return _point_segment_distance(point, p, q) <= tol_m + self.slack_m\n", + " for index, p in enumerate(self.polygon):\n", + " q = self.polygon[(index + 1) % len(self.polygon)]\n", + " if (q[0] - p[0]) * (point[1] - p[1]) - (q[1] - p[1]) * (point[0] - p[0]) \\\n", + " < -tol_m - self.slack_m:\n", + " return False\n", + " return True\n", + "\n", + "\n", + "def _point_segment_distance(point: Vec2, p: Vec2, q: Vec2) -> float:\n", + " vx, vy = q[0] - p[0], q[1] - p[1]\n", + " length_sq = vx * vx + vy * vy\n", + " if length_sq <= 1e-18:\n", + " return math.dist(point, p)\n", + " t = max(0.0, min(1.0, ((point[0] - p[0]) * vx + (point[1] - p[1]) * vy) / length_sq))\n", + " return math.dist(point, (p[0] + t * vx, p[1] + t * vy))\n", + "\n", + "\n", + "@dataclass\n", + "class ChannelBelief:\n", + " channel: int\n", + " status: ChannelStatus = ChannelStatus.UNKNOWN\n", + " region: CandidateRegion | None = None\n", + " measurements: list = field(default_factory=list) # 逐步观测记录(审计用)\n", + "\n", + "\n", + "class BeliefState:\n", + " \"\"\"全部 20 个频道的信念集合。策略端唯一的事实来源。\"\"\"\n", + "\n", + " def __init__(self, config: ProblemConfig = ProblemConfig()):\n", + " self.config = config\n", + " self.channels = {channel: ChannelBelief(channel)\n", + " for channel in range(config.field.channel_min,\n", + " config.field.channel_max + 1)}\n", + "\n", + " @property\n", + " def effective_half_angle_deg(self) -> float:\n", + " \"\"\"示向度误差半角 + 舍入裕量:服务器保留两位小数引入 ≤0.005° 偏差。\"\"\"\n", + " return (self.config.field.bearing_error_half_deg\n", + " + self.config.search.bearing_rounding_margin_deg)\n", + "\n", + " def update_measure(self, channel: int, position: Vec2, result: str,\n", + " svd_deg: float | None) -> None:\n", + " \"\"\"按读数收敛候选集合;真源保留性由保守表示保证。\"\"\"\n", + " belief = self.channels[channel]\n", + " belief.measurements.append({\"position\": tuple(position), \"result\": result,\n", + " \"svd_deg\": svd_deg})\n", + " if result == DIRECTION:\n", + " if svd_deg is None:\n", + " raise ValueError(\"direction 读数必须携带 svd_deg\")\n", + " sides = self.config.search.polygon_sides\n", + " if belief.region is None:\n", + " belief.region = CandidateRegion.initial(position, self.config)\n", + " else:\n", + " # 后续 direction 同样隐含距离 <= r_max [题面]\n", + " belief.region.clip_disk(position, self.config.field.r_eff_max_m, sides)\n", + " belief.region.clip_wedge(position, svd_deg, self.effective_half_angle_deg)\n", + " belief.status = ChannelStatus.DETECTED\n", + " elif result == NEAR:\n", + " sides = self.config.search.polygon_sides\n", + " if belief.region is None:\n", + " belief.region = CandidateRegion.disk(\n", + " tuple(position), self.config.field.near_radius_m, sides)\n", + " else:\n", + " belief.region.clip_disk(position, self.config.field.near_radius_m, sides)\n", + " belief.status = ChannelStatus.DETECTED\n", + " elif result == NO_SIGNAL:\n", + " # P3/P4 基线:不据此删空间候选(缺席证明由覆盖账本给出)。\n", + " pass\n", + " else:\n", + " raise ValueError(f\"未知测向结果: {result!r}\")\n", + "\n", + " def candidate_set(self, channel: int) -> CandidateRegion | None:\n", + " \"\"\"该频道当前候选集合(保守外包形式);尚无 direction/near 时为 None。\"\"\"\n", + " return self.channels[channel].region\n", + "\n", + " def mark_cleared(self, channel: int) -> None:\n", + " self.channels[channel].status = ChannelStatus.CLEARED\n", + "\n", + " def mark_absent(self, channel: int) -> None:\n", + " self.channels[channel].status = ChannelStatus.ABSENT\n", + "\n", + " def channels_by_status(self, *statuses: ChannelStatus) -> list[int]:\n", + " return sorted(channel for channel, belief in self.channels.items()\n", + " if belief.status in statuses)\n" + ] + }, + { + "cell_type": "markdown", + "id": "d5ebeb37", + "metadata": {}, + "source": [ + "### 4.2 逐频道覆盖账本(`coverage/__init__.py`)\n", + "\n", + "\"路过某点\"不算\"测过某频道\":覆盖必须按 (频道, 点) 二维记账,\n", + "这是\"全点皆 `no_signal` ⇒ 该频道不存在源\"这一缺席证明的唯一依据。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "83ef8c99", + "metadata": {}, + "outputs": [], + "source": [ + "def coverage_points(config: ProblemConfig = ProblemConfig()) -> list[Vec2]:\n", + " \"\"\"49 点覆盖网格生成(纯几何,可独立测试)。\"\"\"\n", + " step = config.search.grid_step_m\n", + " half = config.search.grid_half_cells\n", + " return [(step * i, step * j)\n", + " for j in range(half, -half - 1, -1)\n", + " for i in range(-half, half + 1)]\n", + "\n", + "\n", + "@dataclass\n", + "class CoverageLedger:\n", + " \"\"\"逐频道覆盖账本:(频道, 网格点) -> 是否已测。\"\"\"\n", + "\n", + " config: ProblemConfig = field(default_factory=ProblemConfig)\n", + " points: list[Vec2] = field(default_factory=coverage_points)\n", + " measured: set = field(default_factory=set) # {(channel, point_index)}\n", + " skipped: dict = field(default_factory=dict) # channel -> 原因\n", + "\n", + " def mark_measured(self, channel: int, point_index: int) -> None:\n", + " self.measured.add((channel, point_index))\n", + "\n", + " def measured_at(self, channel: int, point_index: int) -> bool:\n", + " return (channel, point_index) in self.measured\n", + "\n", + " def skip_channel(self, channel: int, reason: str) -> None:\n", + " \"\"\"记录整频道跳过原因(如 already_cleared / located_pending_clear)。\"\"\"\n", + " self.skipped[channel] = reason\n", + "\n", + " def pending_points(self, channel: int) -> list[int]:\n", + " \"\"\"该频道尚未测量的网格点下标。扫描调度策略未实现(P4)。\"\"\"\n", + " return [index for index in range(len(self.points))\n", + " if not self.measured_at(channel, index)]\n", + "\n", + " def coverage_complete(self, channel: int) -> bool:\n", + " \"\"\"该频道覆盖是否完成(含显式跳过)。\"\"\"\n", + " return channel in self.skipped or not self.pending_points(channel)\n" + ] + }, + { + "cell_type": "markdown", + "id": "2797039e", + "metadata": {}, + "source": [ + "### 4.3 覆盖计划与证书(`guarantee/cover.py`)\n", + "\n", + "两类覆盖语义(题面约束不同,绝不可混用):\n", + "\n", + "| 语义 | 适用 | 缺席条件 |\n", + "| --- | --- | --- |\n", + "| `full_disk` | 第三问全向源 | ∪ B(c, 1000) ⊇ Ω,即 Ω 内每点都落在某个被访点的最小接收圆盘内 |\n", + "| `forward_half_disk` | 第四问定向源 | ∀(p, h) ∈ Ω × S¹,存在被访点 c 使 \\|c−p\\| ≤ 1000 且 (c−p)·u(h) ≥ 0 |\n", + "\n", + "**保证 = 离线搜索 + 运行期校验**:点表与证书一起落盘并校验采样语义、缺口数、\n", + "最坏命中距离与生成参数哈希,运行期用同一套采样语义重新校核,未通过即拒绝启动。\n", + "证书硬化把\"采样格覆盖\"提升为\"连续空间覆盖\":设计裕量 r_eff + step·√2/2 ≤ 1000\n", + "且角裕量 ≥ 180/headings/2。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "9fe42d17", + "metadata": {}, + "outputs": [], + "source": [ + "R_EFF_MIN_M = 1000.0 # [题面] 最小有效接收半径\n", + "OMEGA_RADIUS_M = 1800.0 # [题面] 目标圆域半径\n", + "SPEED_MPS = 5.0 # [题面] 用于把巡回长度折算成虚拟移动秒数\n", + "\n", + "# 采样分辨率。**校核必须与证书同分辨率**:更粗的采样会放过\"删掉一个关键点\n", + "# 仍然看似覆盖\"的点集,使校核退化为装饰(实测 100 m/12 档即漏判)。\n", + "# 75 m / 24 档下一次全域校核约 1 s,且按计划名缓存,每进程只付一次。\n", + "CERTIFY_STEP_M = 75.0\n", + "CERTIFY_HEADINGS = 24\n", + "VERIFY_STEP_M = CERTIFY_STEP_M\n", + "VERIFY_HEADINGS = CERTIFY_HEADINGS\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class CoverageCertificate:\n", + " \"\"\"覆盖证书:采样语义 + 缺口 + 最坏命中距离。\"\"\"\n", + "\n", + " kind: str # \"full_disk\" | \"forward_half_disk\"\n", + " target_step_m: float\n", + " headings: int\n", + " r_eff_min_m: float\n", + " targets: int\n", + " uncovered: int\n", + " worst_hit_distance_m: float\n", + " nn_tour_m: float\n", + " generator_sha256: str = \"\"\n", + " # 证书硬化:校核时使用的前向角裕量(度)。与 r_eff_min_m 的设计裕量\n", + " # 一起把\"采样格覆盖\"提升为\"连续空间覆盖\",见 verify_forward_half_disk。\n", + " verify_heading_margin_deg: float = 0.0\n", + "\n", + " @property\n", + " def valid(self) -> bool:\n", + " return self.uncovered == 0 and self.targets > 0\n", + "\n", + " def as_dict(self) -> dict:\n", + " return {\"kind\": self.kind, \"target_step_m\": self.target_step_m,\n", + " \"headings\": self.headings, \"r_eff_min_m\": self.r_eff_min_m,\n", + " \"targets\": self.targets, \"uncovered\": self.uncovered,\n", + " \"worst_hit_distance_m\": round(self.worst_hit_distance_m, 3),\n", + " \"nn_tour_m\": round(self.nn_tour_m, 1),\n", + " \"nn_tour_virtual_s\": round(self.nn_tour_m / SPEED_MPS, 1),\n", + " \"generator_sha256\": self.generator_sha256,\n", + " \"verify_heading_margin_deg\": self.verify_heading_margin_deg}\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class CoveragePlan:\n", + " \"\"\"一份覆盖计划:点集 + 语义 + 证书(可为解析证明)。\"\"\"\n", + "\n", + " name: str\n", + " problem: int\n", + " points: tuple[Vec2, ...]\n", + " kind: str # \"analytic\" | \"certified\"\n", + " coverage: str # \"full_disk\" | \"forward_half_disk\"\n", + " note: str = \"\"\n", + " certificate: CoverageCertificate | None = None\n", + " generator: dict = field(default_factory=dict)\n", + "\n", + " def __len__(self) -> int:\n", + " return len(self.points)\n", + "\n", + " def as_dict(self) -> dict:\n", + " return {\"name\": self.name, \"problem\": self.problem, \"kind\": self.kind,\n", + " \"coverage\": self.coverage, \"note\": self.note,\n", + " \"generator\": self.generator,\n", + " \"certificate\": (self.certificate.as_dict()\n", + " if self.certificate is not None else None),\n", + " \"points\": [[p[0], p[1]] for p in self.points]}\n", + "\n", + "\n", + "# --------------------------------------------------------------------- 采样\n", + "def omega_positions(step_m: float) -> list[Vec2]:\n", + " \"\"\"Ω 内均匀方格位置采样。\"\"\"\n", + " span = int(OMEGA_RADIUS_M / step_m) + 1\n", + " return [(step_m * i, step_m * j)\n", + " for i in range(-span, span + 1) for j in range(-span, span + 1)\n", + " if (step_m * i) ** 2 + (step_m * j) ** 2 <= OMEGA_RADIUS_M ** 2]\n", + "\n", + "\n", + "def forward_targets(step_m: float, headings: int) -> list[tuple[float, float, float]]:\n", + " \"\"\"(p, h) 目标采样:位置在 Ω 内,朝向均分整圆。\"\"\"\n", + " return [(x, y, 2.0 * math.pi * k / headings)\n", + " for (x, y) in omega_positions(step_m) for k in range(headings)]\n", + "\n", + "\n", + "def candidate_lattice(spacing_m: float, radius_m: float) -> list[Vec2]:\n", + " \"\"\"半径 radius_m 圆盘内的方格候选点(离线搜索用)。\"\"\"\n", + " span = int(radius_m / spacing_m) + 1\n", + " return [(spacing_m * i, spacing_m * j)\n", + " for i in range(-span, span + 1) for j in range(-span, span + 1)\n", + " if (spacing_m * i) ** 2 + (spacing_m * j) ** 2 <= radius_m ** 2]\n", + "\n", + "\n", + "def _bucket(points: list[Vec2], cell: float) -> dict[tuple[int, int], list[int]]:\n", + " table: dict[tuple[int, int], list[int]] = {}\n", + " for index, (x, y) in enumerate(points):\n", + " table.setdefault((int(x // cell), int(y // cell)), []).append(index)\n", + " return table\n", + "\n", + "\n", + "def coverage_masks(targets, candidates: list[Vec2],\n", + " r_eff: float = R_EFF_MIN_M,\n", + " heading_margin_deg: float = 0.0) -> list[int]:\n", + " \"\"\"每个候选点覆盖的目标位掩码(Python 大整数,按位)。\n", + "\n", + " 只在与候选点空间桶相邻的格内做距离/前向判定,避免 |T|·|C| 笛卡尔积。\n", + "\n", + " ``r_eff`` / ``heading_margin_deg`` 是**设计裕量**:离线搜索时把\"命中\"\n", + " 定义得比物理要求更严(距离 ≤ r_eff、前向夹角 ≤ 90°−margin),使采样格\n", + " 上的覆盖自动蕴含相邻未采样 (p,h) 也被覆盖(S2 证书硬化)。\n", + " \"\"\"\n", + " table = _bucket(candidates, r_eff)\n", + " masks = [0] * len(candidates)\n", + " sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))\n", + " for t_index, (px, py, heading) in enumerate(targets):\n", + " ux, uy = math.cos(heading), math.sin(heading)\n", + " ci, cj = int(px // r_eff), int(py // r_eff)\n", + " bit = 1 << t_index\n", + " for di in (-1, 0, 1):\n", + " for dj in (-1, 0, 1):\n", + " for c_index in table.get((ci + di, cj + dj), ()):\n", + " cx, cy = candidates[c_index]\n", + " dx, dy = cx - px, cy - py\n", + " distance_sq = dx * dx + dy * dy\n", + " if distance_sq > r_eff * r_eff:\n", + " continue\n", + " if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:\n", + " continue\n", + " masks[c_index] |= bit\n", + " return masks\n", + "\n", + "\n", + "def greedy_cover(masks: list[int], n_targets: int) -> list[int]:\n", + " \"\"\"贪心集合覆盖:每次取\"新增覆盖目标数\"最大的候选,返回索引表。\"\"\"\n", + " full = (1 << n_targets) - 1\n", + " covered = 0\n", + " chosen: list[int] = []\n", + " remaining = list(range(len(masks)))\n", + " while covered != full and remaining:\n", + " best_index, best_gain = -1, 0\n", + " for c_index in remaining:\n", + " gain = (masks[c_index] & ~covered).bit_count()\n", + " if gain > best_gain:\n", + " best_index, best_gain = c_index, gain\n", + " if best_index < 0:\n", + " break\n", + " chosen.append(best_index)\n", + " covered |= masks[best_index]\n", + " remaining.remove(best_index)\n", + " return chosen\n", + "\n", + "\n", + "def nn_tour_length(points: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:\n", + " \"\"\"最近邻开放路径长度(巡回长度上界)。\"\"\"\n", + " remaining = list(points)\n", + " position = start\n", + " total = 0.0\n", + " while remaining:\n", + " nxt = min(remaining, key=lambda q: (q[0] - position[0]) ** 2\n", + " + (q[1] - position[1]) ** 2)\n", + " remaining.remove(nxt)\n", + " total += math.dist(position, nxt)\n", + " position = nxt\n", + " return total\n", + "\n", + "\n", + "# --------------------------------------------------------------------- 校验\n", + "def _hit_distance(points: list[Vec2], position: Vec2, heading: float,\n", + " r_eff: float) -> float | None:\n", + " \"\"\"最近的前向命中距离;没有任何前向命中点则返回 None。\"\"\"\n", + " ux, uy = math.cos(heading), math.sin(heading)\n", + " best = None\n", + " for (cx, cy) in points:\n", + " dx, dy = cx - position[0], cy - position[1]\n", + " if dx * dx + dy * dy > r_eff * r_eff:\n", + " continue\n", + " if dx * ux + dy * uy < -1e-9:\n", + " continue\n", + " distance = math.hypot(dx, dy)\n", + " if best is None or distance < best:\n", + " best = distance\n", + " return best\n", + "\n", + "\n", + "def _nearest_distance(points: list[Vec2], position: Vec2,\n", + " r_eff: float) -> float | None:\n", + " \"\"\"最近距离(不要求前向);超过 r_eff 视作未命中。\"\"\"\n", + " best = None\n", + " for (cx, cy) in points:\n", + " distance = math.hypot(cx - position[0], cy - position[1])\n", + " if distance <= r_eff and (best is None or distance < best):\n", + " best = distance\n", + " return best\n", + "\n", + "\n", + "def verify_forward_half_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,\n", + " headings: int = VERIFY_HEADINGS,\n", + " r_eff: float = R_EFF_MIN_M,\n", + " heading_margin_deg: float = 0.0) -> CoverageCertificate:\n", + " \"\"\"第四问语义:∀(p,h) 存在前向半盘内的被访点。\n", + "\n", + " ``heading_margin_deg`` 是**证书硬化**用的前向角裕量:把\"命中\"收紧为\n", + " \"前向夹角 ≤ 90° − margin\"。与 ``r_eff`` 的设计裕量合用时,采样格上的\n", + " 覆盖即可推出**连续空间**覆盖:真实 (p',h') 距采样格 ≤ step·√2/2、\n", + " 方位差 ≤ 180/headings 度,只要\n", + "\n", + " r_eff + step·√2/2 ≤ 1000 且 margin ≥ 180/headings/2\n", + "\n", + " 就能保证 |c−p'| ≤ 1000 且 (c−p')·u(h') ≥ 0。这正是 S2 取代\"粗采样\n", + " 假通过\"的方式。默认 0 表示按物理条件校核。\n", + " \"\"\"\n", + " points = list(points)\n", + " targets = forward_targets(step_m, headings)\n", + " table = _bucket(points, r_eff)\n", + " missing, worst = 0, 0.0\n", + " sin_margin = math.sin(math.radians(max(heading_margin_deg, 0.0)))\n", + " for (px, py, heading) in targets:\n", + " ux, uy = math.cos(heading), math.sin(heading)\n", + " ci, cj = int(px // r_eff), int(py // r_eff)\n", + " best = None\n", + " for di in (-1, 0, 1):\n", + " for dj in (-1, 0, 1):\n", + " for c_index in table.get((ci + di, cj + dj), ()):\n", + " cx, cy = points[c_index]\n", + " dx, dy = cx - px, cy - py\n", + " distance_sq = dx * dx + dy * dy\n", + " if distance_sq > r_eff * r_eff:\n", + " continue\n", + " if dx * ux + dy * uy < sin_margin * math.sqrt(distance_sq) - 1e-9:\n", + " continue\n", + " distance = math.sqrt(distance_sq)\n", + " if best is None or distance < best:\n", + " best = distance\n", + " if best is None:\n", + " missing += 1\n", + " else:\n", + " worst = max(worst, best)\n", + " return CoverageCertificate(\"forward_half_disk\", step_m, headings, r_eff,\n", + " len(targets), missing, worst,\n", + " nn_tour_length(points))\n", + "\n", + "\n", + "def verify_full_disk(points: list[Vec2], step_m: float = VERIFY_STEP_M,\n", + " r_eff: float = R_EFF_MIN_M) -> CoverageCertificate:\n", + " \"\"\"第三问语义:Ω 内每点都在某个被访点的 r_eff 圆盘内。\"\"\"\n", + " points = list(points)\n", + " positions = omega_positions(step_m)\n", + " table = _bucket(points, r_eff)\n", + " missing, worst = 0, 0.0\n", + " for (px, py) in positions:\n", + " ci, cj = int(px // r_eff), int(py // r_eff)\n", + " best = None\n", + " for di in (-1, 0, 1):\n", + " for dj in (-1, 0, 1):\n", + " for c_index in table.get((ci + di, cj + dj), ()):\n", + " cx, cy = points[c_index]\n", + " distance = math.hypot(cx - px, cy - py)\n", + " if distance <= r_eff and (best is None or distance < best):\n", + " best = distance\n", + " if best is None:\n", + " missing += 1\n", + " else:\n", + " worst = max(worst, best)\n", + " return CoverageCertificate(\"full_disk\", step_m, 1, r_eff, len(positions),\n", + " missing, worst, nn_tour_length(points))\n", + "\n", + "\n", + "def verify_plan(plan: CoveragePlan, step_m: float | None = None,\n", + " headings: int | None = None) -> CoverageCertificate:\n", + " \"\"\"按计划声明的覆盖语义校核。\n", + "\n", + " 默认使用**证书自带的采样分辨率**(离线 75 m / 24 档):更粗的运行期采样\n", + " 会放过\"删掉一个关键点仍看似覆盖\"的点集,使校核失去意义。校核按计划名\n", + " 缓存(``load_plan`` 的 lru_cache),每进程只付一次成本。\n", + " \"\"\"\n", + " certificate = plan.certificate\n", + " if step_m is None:\n", + " step_m = certificate.target_step_m if certificate else VERIFY_STEP_M\n", + " if headings is None:\n", + " headings = (certificate.headings if certificate\n", + " else VERIFY_HEADINGS)\n", + " if plan.coverage == \"forward_half_disk\":\n", + " # 硬化证书:按证书自带的距离/角度设计裕量重新校核(旧证书默认\n", + " # r_eff=1000、margin=0,与历史口径逐位一致)。\n", + " return verify_forward_half_disk(\n", + " plan.points, step_m, headings,\n", + " r_eff=(certificate.r_eff_min_m if certificate else R_EFF_MIN_M),\n", + " heading_margin_deg=(certificate.verify_heading_margin_deg\n", + " if certificate else 0.0))\n", + " return verify_full_disk(plan.points, step_m, certificate.r_eff_min_m\n", + " if certificate else R_EFF_MIN_M)\n", + "\n", + "\n", + "# --------------------------------------------------------------------- 计划\n", + "def seven_points(ring_radius_m: float) -> list[Vec2]:\n", + " \"\"\"解析七站:原点 + 环上 6 等分点。\"\"\"\n", + " return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n", + " ring_radius_m * math.sin(k * math.pi / 3))\n", + " for k in range(6)]\n", + "\n", + "\n", + "def seven_plan(ring_radius_m: float) -> CoveragePlan:\n", + " \"\"\"第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。\"\"\"\n", + " return CoveragePlan(\n", + " name=f\"seven-r{ring_radius_m:.1f}\", problem=3, kind=\"analytic\",\n", + " coverage=\"full_disk\",\n", + " note=(\"解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;\"\n", + " \"r∈[1122.9, 1732] 时成立,巡检半径越小巡回越短\"),\n", + " points=tuple(seven_points(ring_radius_m)))\n", + "\n", + "\n", + "def square_49_plan() -> CoveragePlan:\n", + " \"\"\"第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。\"\"\"\n", + " return CoveragePlan(\n", + " name=\"square49\", problem=4, kind=\"certified\",\n", + " coverage=\"forward_half_disk\",\n", + " note=\"单元对角 700√2≈989.95≤1000 的充分条件;点数为最小命中集的 1.6 倍\",\n", + " # 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史\n", + " # 基线 grid49_immediate 的逐位可复现。\n", + " points=tuple((700.0 * i, 700.0 * j)\n", + " for j in range(3, -4, -1) for i in range(-3, 4)))\n", + "\n", + "\n", + "def plan_instance(name: str, verify: bool = True) -> CoveragePlan:\n", + " \"\"\"统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。\"\"\"\n", + " if name == \"square49\":\n", + " return square_49_plan()\n", + " if name == \"seven\":\n", + " return seven_plan(1200.0)\n", + " if name not in EMBEDDED_PLANS:\n", + " raise FileNotFoundError(f\"未找到覆盖计划 {name!r};现有:{plan_names()}\")\n", + " plan = embedded_plan(name)\n", + " if verify and plan.kind == \"certified\":\n", + " certificate = verify_plan(plan)\n", + " if not certificate.valid:\n", + " raise ValueError(\n", + " f\"覆盖计划 {name!r} 运行期校验失败:\"\n", + " f\"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖\")\n", + " plan = CoveragePlan(\n", + " name=plan.name, problem=plan.problem, kind=plan.kind,\n", + " coverage=plan.coverage, note=plan.note, generator=plan.generator,\n", + " certificate=CoverageCertificate(\n", + " kind=certificate.kind, target_step_m=certificate.target_step_m,\n", + " headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,\n", + " targets=certificate.targets, uncovered=certificate.uncovered,\n", + " worst_hit_distance_m=certificate.worst_hit_distance_m,\n", + " nn_tour_m=certificate.nn_tour_m,\n", + " generator_sha256=(plan.certificate.generator_sha256\n", + " if plan.certificate else \"\")),\n", + " points=plan.points)\n", + " return plan\n", + "\n", + "\n", + "def plan_names() -> list[str]:\n", + " \"\"\"全部可加载的计划名(内置解析计划 + 内嵌证书计划)。\"\"\"\n", + " return [\"square49\", \"seven\"] + sorted(EMBEDDED_PLANS)\n" + ] + }, + { + "cell_type": "markdown", + "id": "b6709645", + "metadata": {}, + "source": [ + "### 4.4 第四问最小命中集(内嵌计划)\n", + "\n", + "现役计划 `q4_mincover_v2`:31 点(700 m 方格 49 点的充分条件可压到 31 点),\n", + "设计裕量 r_design = 965 m、前向角裕量 5°,按 15 m / 72 档共 3 256 200 个 (p, h)\n", + "复核缺口为 0、最坏命中 990.05 m、最近邻巡回 23.88 km。\n", + "`q4_mincover_v1` 是同点数的粗采样(75 m / 24 档)解,细网格复检有 125 个缺口——\n", + "保留它正是\"粗采样假通过\"的反例。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "2892e247", + "metadata": {}, + "outputs": [], + "source": [ + "def seven_points(ring_radius_m: float) -> list[Vec2]:\n", + " \"\"\"解析七站:原点 + 环上 6 等分点。\"\"\"\n", + " return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n", + " ring_radius_m * math.sin(k * math.pi / 3))\n", + " for k in range(6)]\n", + "\n", + "\n", + "def seven_plan(ring_radius_m: float) -> CoveragePlan:\n", + " \"\"\"第三问解析计划(覆盖证明由 validate_seven_coverage 给出)。\"\"\"\n", + " return CoveragePlan(\n", + " name=f\"seven-r{ring_radius_m:.1f}\", problem=3, kind=\"analytic\",\n", + " coverage=\"full_disk\",\n", + " note=(\"解析证明 max(r/√3, sqrt(R²+r²−√3Rr)) < 1000;\"\n", + " \"r∈[1122.9, 1732] 时成立,巡检半径越小巡回越短\"),\n", + " points=tuple(seven_points(ring_radius_m)))\n", + "\n", + "\n", + "def square_49_plan() -> CoveragePlan:\n", + " \"\"\"第四问旧计划:700 m 方格 49 点(充分条件,保留作回退与对照)。\"\"\"\n", + " return CoveragePlan(\n", + " name=\"square49\", problem=4, kind=\"certified\",\n", + " coverage=\"forward_half_disk\",\n", + " note=\"单元对角 700√2≈989.95≤1000 的充分条件;点数为最小命中集的 1.6 倍\",\n", + " # 顺序与 coverage.coverage_points() 完全一致(y 由大到小),保持历史\n", + " # 基线 grid49_immediate 的逐位可复现。\n", + " points=tuple((700.0 * i, 700.0 * j)\n", + " for j in range(3, -4, -1) for i in range(-3, 4)))\n", + "\n", + "\n", + "def plan_instance(name: str, verify: bool = True) -> CoveragePlan:\n", + " \"\"\"统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。\"\"\"\n", + " if name == \"square49\":\n", + " return square_49_plan()\n", + " if name == \"seven\":\n", + " return seven_plan(1200.0)\n", + " if name not in EMBEDDED_PLANS:\n", + " raise FileNotFoundError(f\"未找到覆盖计划 {name!r};现有:{plan_names()}\")\n", + " plan = embedded_plan(name)\n", + " if verify and plan.kind == \"certified\":\n", + " certificate = verify_plan(plan)\n", + " if not certificate.valid:\n", + " raise ValueError(\n", + " f\"覆盖计划 {name!r} 运行期校验失败:\"\n", + " f\"{certificate.uncovered}/{certificate.targets} 个 (p,h) 未被覆盖\")\n", + " plan = CoveragePlan(\n", + " name=plan.name, problem=plan.problem, kind=plan.kind,\n", + " coverage=plan.coverage, note=plan.note, generator=plan.generator,\n", + " certificate=CoverageCertificate(\n", + " kind=certificate.kind, target_step_m=certificate.target_step_m,\n", + " headings=certificate.headings, r_eff_min_m=certificate.r_eff_min_m,\n", + " targets=certificate.targets, uncovered=certificate.uncovered,\n", + " worst_hit_distance_m=certificate.worst_hit_distance_m,\n", + " nn_tour_m=certificate.nn_tour_m,\n", + " generator_sha256=(plan.certificate.generator_sha256\n", + " if plan.certificate else \"\")),\n", + " points=plan.points)\n", + " return plan\n", + "\n", + "\n", + "def plan_names() -> list[str]:\n", + " \"\"\"全部可加载的计划名(内置解析计划 + 内嵌证书计划)。\"\"\"\n", + " return [\"square49\", \"seven\"] + sorted(EMBEDDED_PLANS)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "51d5ac79", + "metadata": {}, + "outputs": [], + "source": [ + "# 覆盖计划以字面量内嵌(原实现从 guarantee/plans/*.json 读取;数值逐位一致)。\n", + "# v2 是现役计划:31 点、设计裕量 r_design=965 m / 前向角裕量 5°,\n", + "# 证书按 15 m / 72 档复核 3 256 200 个 (p, h) 目标,缺口 0、最坏命中 990.05 m。\n", + "# v1(75 m/24 档采样过拟合解)保留作对照,说明\"粗采样假通过\"的风险。\n", + "Q4_MINCOVER_V2 = {\n", + " \"name\": \"q4_mincover_v2\",\n", + " \"problem\": 4,\n", + " \"kind\": \"certified\",\n", + " \"coverage\": \"forward_half_disk\",\n", + " \"note\": \"设计裕量最小命中集:31 点,r_design=965 m / 前向角裕量 5.0°;证书按 15 m/72 档、r_verify=1000 m、角裕量 0.0° 校核缺口 0(同一证书口径复核,含精细剪枝);最坏命中 990 m,NN 巡回 23.9 km\",\n", + " \"generator\": {\n", + " \"script\": \"scripts/verify_q4_cover.py\",\n", + " \"spacing_m\": 100.0,\n", + " \"radius_m\": 2850.0,\n", + " \"target_step_m\": 50.0,\n", + " \"headings\": 48,\n", + " \"design_r_eff_m\": 965.0,\n", + " \"heading_margin_deg\": 5.0,\n", + " \"pruned\": True,\n", + " \"verify_step_m\": 15.0,\n", + " \"verify_headings\": 72,\n", + " \"verify_r_eff_m\": 1000.0,\n", + " \"verify_heading_margin_deg\": 0.0,\n", + " \"code_sha256\": \"adf4fe6e9853c47314fac2c630fc6846ea6922102c2e83b507e5d397434c0e1d\"\n", + " },\n", + " \"certificate\": {\n", + " \"kind\": \"forward_half_disk\",\n", + " \"target_step_m\": 15.0,\n", + " \"headings\": 72,\n", + " \"r_eff_min_m\": 1000.0,\n", + " \"targets\": 3256200,\n", + " \"uncovered\": 0,\n", + " \"worst_hit_distance_m\": 990.051,\n", + " \"nn_tour_m\": 23883.8,\n", + " \"nn_tour_virtual_s\": 4776.8,\n", + " \"generator_sha256\": \"adf4fe6e9853c47314fac2c630fc6846ea6922102c2e83b507e5d397434c0e1d\",\n", + " \"verify_heading_margin_deg\": 0.0\n", + " },\n", + " \"points\": [\n", + " (-800, -200), (800, 200), (-200, 900), (200, -900), (-1200, 700), (1200, -700),\n", + " (800, 1200), (-1800, -300), (1800, 300), (-400, 1800), (0, 0), (1400, -1400),\n", + " (-1500, -1300), (1500, 1300), (-400, -1800), (400, 1800), (-1800, 400), (1800, -400),\n", + " (-500, 700), (-700, -700), (700, 700), (1300, 1100), (-1300, 1500), (500, -1800),\n", + " (-900, -2100), (600, 1900), (-2500, 500), (1900, -900), (-1700, -500), (-700, -1200),\n", + " (900, -700),\n", + " ],\n", + "}\n", + "Q4_MINCOVER_V1 = {\n", + " \"name\": \"q4_mincover_v1\",\n", + " \"problem\": 4,\n", + " \"kind\": \"certified\",\n", + " \"coverage\": \"forward_half_disk\",\n", + " \"note\": \"贪心击中集等价覆盖:31 点 vs 现役 49 点,NN 巡回 22.9 km vs 36.4 km\",\n", + " \"generator\": {\n", + " \"script\": \"scripts/verify_q4_cover.py\",\n", + " \"spacing_m\": 200.0,\n", + " \"radius_m\": 2850.0,\n", + " \"target_step_m\": 75.0,\n", + " \"headings\": 24,\n", + " \"code_sha256\": \"dce9f0e189439bbea26f6bcbebf7e7f7bb99021a9035fca0c8d67bb67a3a529a\"\n", + " },\n", + " \"certificate\": None,\n", + " \"points\": [\n", + " (-800, 0), (800, 0), (0, -1000), (0, 1000), (-1200, -1000), (-1000, 1000),\n", + " (1000, -1000), (1000, 1000), (1800, 0), (-1800, 200), (-400, -1800), (-200, 1800),\n", + " (0, 0), (800, -1800), (1200, 1600), (-1400, 1400), (1800, -800), (-1800, -800),\n", + " (1800, 600), (-600, -1000), (400, 1800), (-1400, -1800), (-1200, 800), (-1200, 1800),\n", + " (400, -800), (-200, 400), (600, -1400), (-2200, 600), (1000, -1600), (-2000, -400),\n", + " (-200, -2200),\n", + " ],\n", + "}\n", + "\n", + "EMBEDDED_PLANS = {\"q4_mincover_v1\": Q4_MINCOVER_V1, \"q4_mincover_v2\": Q4_MINCOVER_V2}\n", + "\n", + "\n", + "def embedded_plan(name: str) -> CoveragePlan:\n", + " \"\"\"把内嵌计划字典还原成 :class:`CoveragePlan`(证书字段按数据类取值)。\"\"\"\n", + " payload = EMBEDDED_PLANS[name]\n", + " raw_cert = payload.get(\"certificate\") or {}\n", + " known = {item.name for item in fields(CoverageCertificate)}\n", + " certificate = (CoverageCertificate(**{key: value for key, value in raw_cert.items()\n", + " if key in known}) if raw_cert else None)\n", + " return CoveragePlan(\n", + " name=payload[\"name\"], problem=int(payload[\"problem\"]),\n", + " kind=payload.get(\"kind\", \"certified\"),\n", + " coverage=payload.get(\"coverage\", \"forward_half_disk\"),\n", + " note=payload.get(\"note\", \"\"), certificate=certificate,\n", + " generator=payload.get(\"generator\", {}),\n", + " points=tuple((float(x), float(y)) for x, y in payload[\"points\"]))\n" + ] + }, + { + "cell_type": "markdown", + "id": "5fdc48f7", + "metadata": {}, + "source": [ + "### 4.5 第三问解析七站计划(`planner/seven.py` 覆盖部分)\n", + "\n", + "原点 + 半径 r 的 6 等分环:域半径 R、环半径 r 时,域内任一点到最近站的距离\n", + "≤ max(r/√3, √(R² + r² − √3Rr)),只要该上界 < 1000 m(最小有效接收半径),\n", + "\"7 站全 `no_signal`\"就是严格的缺席证明。r ∈ [1122.9, 1732] 可行,巡检半径越小巡回越短。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "62b4aec6", + "metadata": {}, + "outputs": [], + "source": [ + "RING_RADIUS_M = 1200.0 # [工程] 解析证明见 docs/algo-3.md §5 与 geometry_check\n", + "\n", + "\n", + "def seven_points(ring_radius_m: float = RING_RADIUS_M) -> list[tuple[float, float]]:\n", + " \"\"\"原点 + 环上 6 等分点的七站覆盖集。\"\"\"\n", + " return [(0.0, 0.0)] + [(ring_radius_m * math.cos(k * math.pi / 3),\n", + " ring_radius_m * math.sin(k * math.pi / 3)) for k in range(6)]\n", + "\n", + "\n", + "def validate_seven_coverage(ring_radius_m: float, config: ProblemConfig) -> float:\n", + " \"\"\"六个60°扇区内的最近站最远距离上界;仅证明全向源覆盖。\n", + "\n", + " 原点/环站Voronoi交点给出r/√3,目标圆边界的扇区接缝给出另一项。\n", + " 环半径满足0 config.field.r_eff_min_m - 1e-6:\n", + " raise ValueError(\"七站未覆盖整个目标域,不能据此证明频道缺席\")\n", + " return bound\n" + ] + }, + { + "cell_type": "markdown", + "id": "d0db208e", + "metadata": {}, + "source": [ + "### 4.6 网格幸存集粒子滤波(`particles.py`)\n", + "\n", + "保守凸外包无法表达\"角域 ∩ 距离盘 ∩ 全部 no_signal 排除盘\"这类**非凸(挖洞)**可行集。\n", + "网格幸存集可以,并额外买到三件事:\n", + "\n", + "1. **缺席证明**:幸存集为空 ⇒ 可行集为空 ⇒ 该频道无源(严格,不必等覆盖账本跑完);\n", + "2. **跳测规则**:幸存格到测点的最小距离 > r_max ⇒ 该次测量必 `no_signal` 且不产生新排除,\n", + " 直接跳过(省 5 s 检测 + 1 s 换频);\n", + "3. **负观测更新**:`no_signal` 直接删掉 B(s, 1000) 内的整格。\n", + "\n", + "严格性由 **Voronoi 裕量**保证:格距 h 的裕量 m = √2/2·h 是格心到格内任一点的最大距离,\n", + "所有删除判定都内缩 m(\"整格都在可行集外才删\"),因此真源所在格永不被删。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "63499de6", + "metadata": {}, + "outputs": [], + "source": [ + "_ANG_EPS_DEG = 1e-9 # 角向 kill 比较的浮点余量\n", + "_DIST_EPS_M = 1e-9\n", + "_COARSE_FACTOR = 4 # 粗格每边聚合的细格数\n", + "\n", + "try: # numpy 仅作**可选**加速后端;缺失时全部回退纯 Python\n", + " import numpy as _np\n", + "except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支\n", + " _np = None\n", + "if os.environ.get(\"MCM26B_NO_NUMPY\"): # 强制纯 Python:对拍与回退验证用\n", + " _np = None\n", + "\n", + "\n", + "def angular_gap(a_deg: float, b_deg: float) -> float:\n", + " \"\"\"两个方位角(度)的最小夹角。\"\"\"\n", + " return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)\n", + "\n", + "\n", + "def as_sample_array(samples):\n", + " \"\"\"把 [(x,y), ...] 采样转成 (n,2) float64 数组;numpy 缺失时原样返回。\n", + "\n", + " 评分为 O(代表点 × 子采样) 的向量运算,是单局 CPU 的主要开销\n", + " (实测占 72%)。调用方在每次选点前转换一次,评分函数按类型分派,\n", + " 两条实现(numpy / 纯 Python)语义逐位对齐,见 tests/test_particles.py。\n", + " \"\"\"\n", + " if _np is None or isinstance(samples, _np.ndarray) or len(samples) == 0:\n", + " return samples\n", + " return _np.asarray(samples, dtype=float)\n", + "\n", + "\n", + "def _branch_radius_array(members) -> float:\n", + " \"\"\"分支后验半径的数组版:到质心的最大距离。\"\"\"\n", + " centroid = members.mean(axis=0)\n", + " return float(_np.max(_np.hypot(members[:, 0] - centroid[0],\n", + " members[:, 1] - centroid[1])))\n", + "\n", + "\n", + "class ChannelParticles:\n", + " \"\"\"单个频道的网格幸存集。\n", + "\n", + " 网格为覆盖 Ω 外扩一格的轴对齐方格(边长 h),索引 ``i*W + j``;\n", + " ``alive`` 是幸存格索引集合。初始集 = 格心距原点 <= Ω 半径 + 裕量的\n", + " 全部格子,因此真源(|p| <= 1800 [题面])所在格必在初始集内。\n", + " ``version`` 随每次删除自增,调用方可据此缓存派生量(MEC、跳测判定)。\n", + " \"\"\"\n", + "\n", + " def __init__(self, config: ProblemConfig = ProblemConfig(),\n", + " spacing_m: float | None = None):\n", + " field = config.field\n", + " self.config = config\n", + " self.spacing_m = float(spacing_m if spacing_m is not None\n", + " else config.bayes.particle_spacing_m)\n", + " if not 1.0 <= self.spacing_m <= 200.0:\n", + " raise ValueError(\"网格间距须在 [1, 200] 米\")\n", + " self.margin_m = self.spacing_m * math.sqrt(2) / 2 # Voronoi 裕量\n", + " self.r_eff_min_m = field.r_eff_min_m\n", + " self.r_eff_max_m = field.r_eff_max_m\n", + " self.near_radius_m = field.near_radius_m\n", + " self.half_angle_deg = (field.bearing_error_half_deg\n", + " + config.search.bearing_rounding_margin_deg)\n", + " # 覆盖 [-R-m, R+m]² 的格点阵列;格 (i,j) 的中心在\n", + " # (x0 + (i+0.5)h, x0 + (j+0.5)h),中心到格内任一点 <= h·√2/2 = 裕量\n", + " span = field.omega_radius_m + self.margin_m\n", + " self.x0 = -span\n", + " self.width = int(math.ceil(2 * span / self.spacing_m)) + 1\n", + " limit_sq = (span + 1e-9) ** 2\n", + " self.alive: set[int] = set()\n", + " self._coarse: dict[int, int] = {} # 粗格索引 -> 幸存细格数\n", + " coarse_w = self._coarse_width()\n", + " for i in range(self.width):\n", + " x = self.x0 + (i + 0.5) * self.spacing_m\n", + " for j in range(self.width):\n", + " if x * x + (self.x0 + (j + 0.5) * self.spacing_m) ** 2 <= limit_sq:\n", + " index = i * self.width + j\n", + " self.alive.add(index)\n", + " key = (i // _COARSE_FACTOR) * coarse_w + j // _COARSE_FACTOR\n", + " self._coarse[key] = self._coarse.get(key, 0) + 1\n", + " self.version = 0\n", + " self._mec: tuple[Vec2, float] | None = None\n", + "\n", + " # ------------------------------------------------------------ 索引与坐标\n", + " def _coarse_width(self) -> int:\n", + " return (self.width + _COARSE_FACTOR - 1) // _COARSE_FACTOR\n", + "\n", + " def cell_center(self, index: int) -> Vec2:\n", + " i, j = divmod(index, self.width)\n", + " return (self.x0 + (i + 0.5) * self.spacing_m,\n", + " self.x0 + (j + 0.5) * self.spacing_m)\n", + "\n", + " def discard(self, indices: list[int]) -> None:\n", + " \"\"\"公开删除入口:联合信念把\"朝向不可能\"的格一并删除时使用。\"\"\"\n", + " self._discard(indices)\n", + "\n", + " def _discard(self, indices: list[int]) -> None:\n", + " if not indices:\n", + " return\n", + " coarse_w = self._coarse_width()\n", + " for index in indices:\n", + " if index not in self.alive:\n", + " continue\n", + " self.alive.discard(index)\n", + " key = (index // self.width // _COARSE_FACTOR) * coarse_w \\\n", + " + (index % self.width) // _COARSE_FACTOR\n", + " count = self._coarse.get(key, 0) - 1\n", + " if count <= 0:\n", + " self._coarse.pop(key, None)\n", + " else:\n", + " self._coarse[key] = count\n", + " self.version += 1\n", + " self._mec = None\n", + "\n", + " # ------------------------------------------------------------ 观测更新\n", + " def _kill_disk(self, center: Vec2, radius_m: float) -> None:\n", + " \"\"\"删除格心距 center <= radius_m 的全部幸存格(整格判据见模块注释)。\"\"\"\n", + " if not self.alive:\n", + " return\n", + " reach = radius_m + _DIST_EPS_M\n", + " ilo = max(0, int(math.floor((center[0] - reach - self.x0) / self.spacing_m)))\n", + " ihi = min(self.width - 1,\n", + " int(math.ceil((center[0] + reach - self.x0) / self.spacing_m)))\n", + " jlo = max(0, int(math.floor((center[1] - reach - self.x0) / self.spacing_m)))\n", + " jhi = min(self.width - 1,\n", + " int(math.ceil((center[1] + reach - self.x0) / self.spacing_m)))\n", + " limit_sq = reach * reach\n", + " doomed = []\n", + " for i in range(ilo, ihi + 1):\n", + " dx = self.x0 + (i + 0.5) * self.spacing_m - center[0]\n", + " base = i * self.width\n", + " for j in range(jlo, jhi + 1):\n", + " index = base + j\n", + " if index not in self.alive:\n", + " continue\n", + " dy = self.x0 + (j + 0.5) * self.spacing_m - center[1]\n", + " if dx * dx + dy * dy <= limit_sq:\n", + " doomed.append(index)\n", + " self._discard(doomed)\n", + "\n", + " def _kill_outside_disk(self, center: Vec2, radius_m: float) -> None:\n", + " \"\"\"删除格心距 center > radius_m 的全部幸存格(保留盘内)。\"\"\"\n", + " if not self.alive:\n", + " return\n", + " reach = radius_m + _DIST_EPS_M\n", + " doomed = [index for index in self.alive\n", + " if math.dist(self.cell_center(index), center) > reach]\n", + " self._discard(doomed)\n", + "\n", + " def _kill_outside_wedge(self, station: Vec2, reading_deg: float) -> None:\n", + " \"\"\"删除角向偏离读数超出 δ_eff + 角向格裕量的幸存格。\n", + "\n", + " 角向格裕量 = asin(m / max(dist−m, 1)):格内任一点相对格心的方位\n", + " 偏差不超过它(点距测点还可再近 m,故分母再内缩 m)。\n", + " \"\"\"\n", + " if not self.alive:\n", + " return\n", + " sx, sy = float(station[0]), float(station[1])\n", + " doomed = []\n", + " for index in self.alive:\n", + " gx, gy = self.cell_center(index)\n", + " dist = math.hypot(gx - sx, gy - sy)\n", + " if dist <= self.margin_m + 1.0:\n", + " continue # 格贴测点:格内方位可指向任意方向,不可删\n", + " angular_margin = math.degrees(\n", + " math.asin(min(0.9999, self.margin_m / (dist - self.margin_m))))\n", + " gap = angular_gap(math.degrees(math.atan2(gy - sy, gx - sx)), reading_deg)\n", + " if gap > self.half_angle_deg + angular_margin + _ANG_EPS_DEG:\n", + " doomed.append(index)\n", + " self._discard(doomed)\n", + "\n", + " def apply_measure(self, station: Vec2, result: str,\n", + " svd_deg: float | None = None, *,\n", + " directional: bool = False) -> None:\n", + " \"\"\"按读数更新幸存集。真源保留性由 Voronoi 裕量保证(模块注释)。\n", + "\n", + " ``directional=True``(第四问):no_signal **不删空间候选**——定向源\n", + " 的静默还可能来自朝向遮挡,单独删空间候选会误删真源;朝向维度由\n", + " :class:`~mcm26b.belief.joint.JointBelief` 维护。\n", + " \"\"\"\n", + " if result == NO_SIGNAL:\n", + " if directional:\n", + " return\n", + " # no_signal ⟹ dist > r_eff >= 1000:整格落入 B(s, 1000−m) 的删。\n", + " self._kill_disk(station, self.r_eff_min_m - self.margin_m)\n", + " elif result == NEAR:\n", + " self._kill_outside_disk(station, self.near_radius_m + self.margin_m)\n", + " elif result == DIRECTION:\n", + " if svd_deg is None:\n", + " raise ValueError(\"direction 读数必须携带 svd_deg\")\n", + " # direction 隐含 dist <= r_max [题面]\n", + " self._kill_outside_disk(station, self.r_eff_max_m + self.margin_m)\n", + " self._kill_outside_wedge(station, svd_deg)\n", + " else:\n", + " raise ValueError(f\"未知测向结果: {result!r}\")\n", + "\n", + " # ------------------------------------------------------------ 查询\n", + " def __len__(self) -> int:\n", + " return len(self.alive)\n", + "\n", + " def alive_points(self, limit: int | None = None) -> list[Vec2]:\n", + " sorted_alive = sorted(self.alive)\n", + " if limit is not None and len(sorted_alive) > limit:\n", + " step = len(sorted_alive) / limit\n", + " indices = {sorted_alive[int(k * step) % len(sorted_alive)]\n", + " for k in range(limit)}\n", + " indices = sorted(indices)\n", + " else:\n", + " indices = sorted_alive\n", + " return [self.cell_center(index) for index in indices]\n", + "\n", + " def nearest_alive_distance(self, point: Vec2, limit_m: float) -> float | None:\n", + " \"\"\"最近幸存格心距离;> limit_m 时返回 None(粗格环扫 + 下界剪枝)。\n", + "\n", + " 返回值语义:恰为最近幸存格心距离(若 <= limit_m);None 表示所有\n", + " 幸存格心都超出 limit_m。粗格聚合半径已计入扫描范围,不漏判。\n", + " \"\"\"\n", + " if not self.alive:\n", + " return None\n", + " coarse_cell_m = self.spacing_m * _COARSE_FACTOR\n", + " coarse_reach = coarse_cell_m * math.sqrt(2) / 2\n", + " cw = self._coarse_width()\n", + " ci_base = int(math.floor((point[0] - self.x0) / coarse_cell_m))\n", + " cj_base = int(math.floor((point[1] - self.x0) / coarse_cell_m))\n", + " ring_max = int(math.ceil((limit_m + coarse_reach) / coarse_cell_m)) + 1\n", + " best: float | None = None\n", + " for ring in range(ring_max + 1):\n", + " lower_bound = max(0.0, ring - 1) * coarse_cell_m - coarse_reach\n", + " if best is not None and lower_bound >= best:\n", + " break # 更外环的所有格心都 >= 下界 >= 当前最优\n", + " for ci in range(max(0, ci_base - ring), min(cw - 1, ci_base + ring) + 1):\n", + " for cj in range(max(0, cj_base - ring), min(cw - 1, cj_base + ring) + 1):\n", + " if max(abs(ci - ci_base), abs(cj - cj_base)) != ring:\n", + " continue # 只扫当前环(Chebyshev 环)\n", + " if (ci * cw + cj) not in self._coarse:\n", + " continue\n", + " ccx = self.x0 + (ci + 0.5) * coarse_cell_m\n", + " ccy = self.x0 + (cj + 0.5) * coarse_cell_m\n", + " if math.hypot(ccx - point[0], ccy - point[1]) \\\n", + " > limit_m + coarse_reach:\n", + " continue\n", + " i0, j0 = ci * _COARSE_FACTOR, cj * _COARSE_FACTOR\n", + " for i in range(i0, min(i0 + _COARSE_FACTOR, self.width)):\n", + " base = i * self.width\n", + " for j in range(j0, min(j0 + _COARSE_FACTOR, self.width)):\n", + " index = base + j\n", + " if index not in self.alive:\n", + " continue\n", + " dist = math.dist(self.cell_center(index), point)\n", + " if dist <= limit_m and (best is None or dist < best):\n", + " best = dist\n", + " return best\n", + "\n", + " def guaranteed_no_signal(self, station: Vec2) -> bool:\n", + " \"\"\"真源与该测点距离必 > r_max(>= r_eff)⟹ 必 no_signal,测了也无信息。\"\"\"\n", + " return self.nearest_alive_distance(\n", + " station, self.r_eff_max_m + self.margin_m) is None\n", + "\n", + " def mec(self) -> tuple[Vec2, float]:\n", + " \"\"\"幸存集最小包围圆(圆心, 半径);按 version 缓存。\"\"\"\n", + " if self._mec is None:\n", + " if not self.alive:\n", + " raise ValueError(\"幸存集为空(该频道已可证明缺席)\")\n", + " points = [self.cell_center(index) for index in self.alive]\n", + " self._mec = smallest_enclosing_circle(points)\n", + " return self._mec\n", + "\n", + " # ------------------------------------------------------------ 信息增益\n", + " def subsample(self, size: int) -> list[Vec2]:\n", + " \"\"\"按索引序均匀抽取的子采样(索引序≈空间序,代表点空间铺开)。\"\"\"\n", + " return self.alive_points(limit=size)\n", + "\n", + " # ---------------------------------------------------- 向量化评分(可选)\n", + " def _expected_survival_vector(self, point: Vec2, sub, representatives) -> float:\n", + " \"\"\"``expected_survival`` 的向量化实现(与纯 Python 版逐分支同语义)。\"\"\"\n", + " count = len(sub)\n", + " eps = 1.0 / count\n", + " dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]\n", + " dist = _np.hypot(dx, dy)\n", + " within = int(_np.count_nonzero(\n", + " dist <= self.r_eff_min_m - self.margin_m + _DIST_EPS_M))\n", + " log_keep_ns = math.log(max(1.0 - within / count, eps))\n", + " bearing = _np.degrees(_np.arctan2(dy, dx))\n", + " in_range = dist <= self.r_eff_max_m + self.margin_m\n", + " rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]\n", + " rdist = _np.hypot(rdx, rdy)\n", + " rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n", + " gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n", + " survivors = _np.count_nonzero(\n", + " (gaps <= self.half_angle_deg + _ANG_EPS_DEG) & in_range[None, :], axis=1)\n", + " p_ns = _np.clip((rdist - self.r_eff_min_m)\n", + " / (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)\n", + " keep_dir = _np.maximum(survivors / count, eps)\n", + " values = p_ns * log_keep_ns + (1.0 - p_ns) * _np.log(keep_dir)\n", + " near = rdist <= self.near_radius_m + self.margin_m\n", + " values = _np.where(near, math.log(eps), values)\n", + " return float(values.mean())\n", + "\n", + " def _expected_posterior_radius_vector(self, point: Vec2, sub,\n", + " representatives) -> float:\n", + " \"\"\"``expected_posterior_radius`` 的向量化实现。\"\"\"\n", + " eps_radius = self.spacing_m / 2.0\n", + " dx, dy = sub[:, 0] - point[0], sub[:, 1] - point[1]\n", + " dist = _np.hypot(dx, dy)\n", + " bearing = _np.degrees(_np.arctan2(dy, dx))\n", + " keep_ns = dist > self.r_eff_min_m - self.margin_m\n", + " r_ns = (_branch_radius_array(sub[keep_ns]) if keep_ns.any()\n", + " else eps_radius)\n", + " rdx, rdy = representatives[:, 0] - point[0], representatives[:, 1] - point[1]\n", + " rdist = _np.hypot(rdx, rdy)\n", + " rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n", + " gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n", + " dir_mask = ((gaps <= self.half_angle_deg + _ANG_EPS_DEG)\n", + " & (dist[None, :] <= self.r_eff_max_m + self.margin_m))\n", + " r_dir = _np.array([_branch_radius_array(sub[row]) if row.any()\n", + " else eps_radius for row in dir_mask])\n", + " p_ns = _np.clip((rdist - self.r_eff_min_m)\n", + " / (self.r_eff_max_m - self.r_eff_min_m), 0.0, 1.0)\n", + " near = rdist <= self.near_radius_m + self.margin_m\n", + " values = _np.where(near, self.near_radius_m + self.margin_m,\n", + " p_ns * r_ns + (1.0 - p_ns) * r_dir)\n", + " return float(values.mean())\n", + "\n", + " def expected_survival(self, point: Vec2, subsample,\n", + " representatives) -> float:\n", + " \"\"\"E[log 幸存占比]:越小该测点的期望信息增益越大。\n", + "\n", + " r_eff 不确定性解析积分:dist∈(1000,1500] 时按 r_eff~U(1000,1500)\n", + " 取 p_ns=(dist−1000)/500 为 no_signal 分支概率。\n", + " - no_signal 分支:删 B(point, 1000−m),幸存 = 子采样中盘外比例;\n", + " - direction 分支:以代表点方位为期望读数,角域 ∩ B(point,1500+m) 内幸存;\n", + " - near 分支(dist <= 5+m):可行集收缩到 5m 盘,幸存占比趋近 0。\n", + " \"\"\"\n", + " if not self.alive or len(subsample) == 0 or len(representatives) == 0:\n", + " return 0.0\n", + " if _np is not None and isinstance(subsample, _np.ndarray) \\\n", + " and isinstance(representatives, _np.ndarray):\n", + " return self._expected_survival_vector(point, subsample, representatives)\n", + " eps = 1.0 / len(subsample)\n", + " keep_ns = max(1.0 - self.fraction_within(subsample, point,\n", + " self.r_eff_min_m - self.margin_m),\n", + " eps)\n", + " log_keep_ns = math.log(keep_ns)\n", + " total = 0.0\n", + " for rep in representatives:\n", + " dist = math.hypot(rep[0] - point[0], rep[1] - point[1])\n", + " if dist <= self.near_radius_m + self.margin_m:\n", + " total += math.log(eps)\n", + " continue\n", + " p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)\n", + " / (self.r_eff_max_m - self.r_eff_min_m)))\n", + " reading = math.atan2(rep[1] - point[1], rep[0] - point[0])\n", + " survivors = 0\n", + " for p in subsample:\n", + " if math.dist(p, point) > self.r_eff_max_m + self.margin_m:\n", + " continue\n", + " gap = angular_gap(math.degrees(\n", + " math.atan2(p[1] - point[1], p[0] - point[0])),\n", + " math.degrees(reading))\n", + " if gap <= self.half_angle_deg + _ANG_EPS_DEG:\n", + " survivors += 1\n", + " keep_dir = max(survivors / len(subsample), eps)\n", + " total += p_ns * log_keep_ns + (1.0 - p_ns) * math.log(keep_dir)\n", + " return total / max(len(representatives), 1)\n", + "\n", + " def fraction_within(self, samples, point: Vec2, radius_m: float) -> float:\n", + " if len(samples) == 0:\n", + " return 0.0\n", + " reach = radius_m + _DIST_EPS_M\n", + " if _np is not None and isinstance(samples, _np.ndarray):\n", + " distance = _np.hypot(samples[:, 0] - point[0], samples[:, 1] - point[1])\n", + " return float(_np.count_nonzero(distance <= reach)) / len(samples)\n", + " count = sum(1 for p in samples if math.dist(p, point) <= reach)\n", + " return count / len(samples)\n", + "\n", + " def _branch_radius(self, members) -> float:\n", + " \"\"\"分支后验的半径代理:幸存子采样到其质心的最大距离(O(n),免 Welzl)。\"\"\"\n", + " if len(members) == 0:\n", + " return 0.0\n", + " if _np is not None and isinstance(members, _np.ndarray):\n", + " return _branch_radius_array(members)\n", + " mx = sum(p[0] for p in members) / len(members)\n", + " my = sum(p[1] for p in members) / len(members)\n", + " return max(math.hypot(p[0] - mx, p[1] - my) for p in members)\n", + "\n", + " def expected_posterior_radius(self, point: Vec2, subsample,\n", + " representatives) -> float:\n", + " \"\"\"一次测量后期望后验半径(米):选点主指标,直指清除证书阈值。\n", + "\n", + " 对每个代表假设(真值候选)按 r_eff~U(1000,1500) 解析积分\n", + " no_signal/direction 分支,near 分支(dist <= 5+m)后验为 5m 盘;\n", + " 各分支半径用幸存子采样的质心最大距离近似(偏差 << 格距,排序用)。\n", + " 与几何版 worst-posterior 同族,但作用于含负观测的非凸幸存集,\n", + " 且以期望代替最坏(代表点覆盖幸存集主轴)。\n", + " \"\"\"\n", + " if not self.alive or len(subsample) == 0 or len(representatives) == 0:\n", + " return 0.0\n", + " if _np is not None and isinstance(subsample, _np.ndarray) \\\n", + " and isinstance(representatives, _np.ndarray):\n", + " return self._expected_posterior_radius_vector(\n", + " point, subsample, representatives)\n", + " eps_radius = self.spacing_m / 2.0\n", + " total = 0.0\n", + " for rep in representatives:\n", + " dist = math.hypot(rep[0] - point[0], rep[1] - point[1])\n", + " if dist <= self.near_radius_m + self.margin_m:\n", + " total += self.near_radius_m + self.margin_m\n", + " continue\n", + " p_ns = min(1.0, max(0.0, (dist - self.r_eff_min_m)\n", + " / (self.r_eff_max_m - self.r_eff_min_m)))\n", + " members_ns = [p for p in subsample\n", + " if math.dist(p, point) > self.r_eff_min_m - self.margin_m]\n", + " reading = math.atan2(rep[1] - point[1], rep[0] - point[0])\n", + " members_dir = []\n", + " for p in subsample:\n", + " if math.dist(p, point) > self.r_eff_max_m + self.margin_m:\n", + " continue\n", + " gap = angular_gap(math.degrees(\n", + " math.atan2(p[1] - point[1], p[0] - point[0])),\n", + " math.degrees(reading))\n", + " if gap <= self.half_angle_deg + _ANG_EPS_DEG:\n", + " members_dir.append(p)\n", + " r_ns = self._branch_radius(members_ns) if members_ns else eps_radius\n", + " r_dir = self._branch_radius(members_dir) if members_dir else eps_radius\n", + " total += p_ns * r_ns + (1.0 - p_ns) * r_dir\n", + " return total / max(len(representatives), 1)\n" + ] + }, + { + "cell_type": "markdown", + "id": "7cfa64a2", + "metadata": {}, + "source": [ + "### 4.7 (位置 × 朝向) 联合可行集(`belief/joint.py`)\n", + "\n", + "第四问的失败全部来自定向源:策略知道\"源可能在哪个空间区域\",却不知道\"它朝着哪边\"。\n", + "本模块给每个幸存格附一个 24 档(15°)朝向可行性掩码,把\"看不看得见\"变成可推理的量。\n", + "更新规则全部取保守侧并计入**格内角裕量** `asin(margin / max(dist − margin, 1))`\n", + "(格内任一点相对格心方位偏差的上界):\n", + "\n", + "| 观测 | 位置 | 朝向 |\n", + "| --- | --- | --- |\n", + "| `direction` @ s | 距离 ≤ 1500、角向 \\|gap\\| ≤ δ_eff + 格裕量 | 必须**可能**覆盖 s |\n", + "| `near` @ s | 距离 ≤ 5 | 必须**可能**覆盖 s(near 同样要求可见) |\n", + "| `no_signal` @ s | **不删**(可能是遮挡,也可能是距离不足) | 仅在整格必落在 1000 m 内时,删去\"必然看得见\"的档 |\n", + "\n", + "由此得到二维真源保留定理:真源 (位置, 朝向) 永不被删。`prob_detect` 用可见性概率选点,\n", + "取代固定的 45° 阶梯试探;联合集为空同样就是缺席证明。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "3e8eb944", + "metadata": {}, + "outputs": [], + "source": [ + "HEADING_BINS = 24 # 15° 一档\n", + "_BIN_HALF_DEG = 180.0 / HEADING_BINS # 7.5°\n", + "_FULL_MASK = (1 << HEADING_BINS) - 1\n", + "_GRID_DEG = 3.0 # 角度查表分辨率\n", + "_GRID_N = int(round(360.0 / _GRID_DEG))\n", + "\n", + "# numpy 仅作可选加速后端(与 particles 同一开关:无 numpy 时走纯 Python 分支)\n", + "_np = _np\n", + "\n", + "\n", + "def heading_gap(a_deg: float, b_deg: float) -> float:\n", + " \"\"\"两个朝向角的最小夹角(度)。\"\"\"\n", + " return abs((a_deg - b_deg + 180.0) % 360.0 - 180.0)\n", + "\n", + "\n", + "def _cover_mask(bearing_deg: float, half_deg: float) -> int:\n", + " \"\"\"与该方位夹角不超过 ``half_deg`` 的朝向档掩码。\"\"\"\n", + " mask = 0\n", + " for k in range(HEADING_BINS):\n", + " if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) <= half_deg:\n", + " mask |= 1 << k\n", + " return mask\n", + "\n", + "\n", + "# 查表:以 bearing 为轴、半角 90° 的可见半平面。\n", + "# - COVER:**可能**覆盖该方位(含半档与查表舍入的保守侧);\n", + "# - SILENT:**确定**覆盖该方位(内缩半档与舍入)——离散对照表,供审计与\n", + "# 文档比对;实际裁剪走 _cover_mask / _far_mask,因为还必须逐格计入\n", + "# \"格内角裕量\"(格心到格内任一点的方位偏差上界),无法预先制表。\n", + "_COVER_TABLE = tuple(\n", + " _cover_mask(_GRID_DEG * i, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2)\n", + " for i in range(_GRID_N))\n", + "_SILENT_TABLE = tuple(\n", + " _cover_mask(_GRID_DEG * i, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2)\n", + " for i in range(_GRID_N))\n", + "\n", + "\n", + "def _far_mask(bearing_deg: float, exclude_half_deg: float) -> int:\n", + " \"\"\"与方位夹角**大于** ``exclude_half_deg`` 的朝向档掩码(no_signal 用)。\n", + "\n", + " 与 :func:`_cover_mask` 互补:no_signal 只允许保留\"确定看不见\"的朝向档。\n", + " \"\"\"\n", + " mask = 0\n", + " for k in range(HEADING_BINS):\n", + " if heading_gap(360.0 * k / HEADING_BINS, bearing_deg) > exclude_half_deg:\n", + " mask |= 1 << k\n", + " return mask\n", + "\n", + "if _np is not None: # 12 位半字 popcount 查表(朝向掩码 24 位)与半平面查表数组\n", + " _POP16 = _np.array([bin(value).count(\"1\") for value in range(1 << 12)],\n", + " dtype=_np.int64)\n", + " _COVER_ARR = _np.array(_COVER_TABLE, dtype=_np.int64)\n", + "else: # pragma: no cover - 无 numpy 分支\n", + " _POP16 = _COVER_ARR = None\n", + "\n", + "\n", + "def _popcount(mask: int) -> int:\n", + " return bin(mask).count(\"1\")\n", + "\n", + "\n", + "def _table_index(bearing_deg: float) -> int:\n", + " return int(round((bearing_deg % 360.0) / _GRID_DEG)) % _GRID_N\n", + "\n", + "\n", + "class JointBelief:\n", + " \"\"\"单频道 (位置 × 朝向) 联合可行集。\"\"\"\n", + "\n", + " def __init__(self, config: ProblemConfig = ProblemConfig()):\n", + " self.config = config\n", + " self.field = config.field\n", + " self.position = ChannelParticles(config)\n", + " self.masks: dict[int, int] = {} # 格索引 -> 朝向掩码(缺省为全档)\n", + " self._sample_cache: tuple[int, list[int]] | None = None\n", + "\n", + " # ------------------------------------------------------------ 查询\n", + " def __len__(self) -> int:\n", + " return len(self.position)\n", + "\n", + " def is_absent(self) -> bool:\n", + " \"\"\"联合集空 ⟹ 该频道不存在源(真源保留定理的二维推广)。\"\"\"\n", + " return not self.position.alive\n", + "\n", + " def _mask(self, index: int) -> int:\n", + " return self.masks.get(index, _FULL_MASK)\n", + "\n", + " def sample(self, size: int = 256) -> list[int]:\n", + " \"\"\"均匀抽样幸存格(评分用;证书始终用完整集合)。\"\"\"\n", + " cached = self._sample_cache\n", + " if cached is not None and cached[0] == self.position.version:\n", + " return cached[1]\n", + " alive = sorted(self.position.alive)\n", + " if len(alive) > size:\n", + " step = len(alive) / size\n", + " alive = [alive[int(k * step) % len(alive)] for k in range(size)]\n", + " self._sample_cache = (self.position.version, alive)\n", + " return alive\n", + "\n", + " def _mask_popcount(self, index: int) -> int:\n", + " return _popcount(self._mask(index))\n", + "\n", + " def expected_posterior_radius(self, reader: Vec2, size: int | None = None) -> float:\n", + " \"\"\"一次测量后的期望后验 MEC 半径(米);D1 值函数。\n", + "\n", + " 与 :meth:`ChannelParticles.expected_posterior_radius` 同族,差别只在\n", + " no_signal 分支概率:定向源不仅会因距离 > r_eff 静默,还会因朝向遮挡\n", + " 静默,因此\n", + "\n", + " p_vis(p) = P(r_eff ≥ |p−q|) × cover_ratio(p, q)\n", + "\n", + " 其中 ``cover_ratio`` 是该格朝向掩码中\"可能覆盖 q 方位\"的档位占比。\n", + " 分支半径语义:\n", + "\n", + " - direction(可见,非 near):读数为 q 处的方位 ±δ,后验收缩到该方位\n", + " 楔形与当前可行集之交,半径取幸存子采样的质心最大距离;\n", + " - no_signal:定向源的静默**不删空间候选**,位置后验半径保持当前值;\n", + " - near:后验为 5m 盘。\n", + "\n", + " 返回值只用于**选点排序**,不参与任何安全判定;证书仍由凸/网格口径给出。\n", + " \"\"\"\n", + " pf = self.position\n", + " if not pf.alive:\n", + " return 0.0\n", + " if size is None:\n", + " size = self.config.bayes.score_subsample\n", + " indices = self.sample(max(2, size))\n", + " if not indices:\n", + " return 0.0\n", + " stride = max(len(indices) // max(self.config.bayes.mc_representatives, 1), 1)\n", + " representatives = indices[::stride]\n", + " if _np is not None:\n", + " return self._epr_vector(reader, indices, representatives)\n", + " return self._epr_python(reader, indices, representatives) # pragma: no cover\n", + "\n", + " def _epr_vector(self, reader: Vec2, indices: list[int],\n", + " representatives: list[int]) -> float:\n", + " pf = self.position\n", + " points = _np.array([pf.cell_center(index) for index in indices], dtype=float)\n", + " masks = _np.array([self._mask(index) for index in indices], dtype=_np.int64)\n", + " reps = _np.array([pf.cell_center(index) for index in representatives],\n", + " dtype=float)\n", + " rep_masks = _np.array([self._mask(index) for index in representatives],\n", + " dtype=_np.int64)\n", + " dx, dy = points[:, 0] - reader[0], points[:, 1] - reader[1]\n", + " dist = _np.hypot(dx, dy)\n", + " bearing = _np.degrees(_np.arctan2(dy, dx))\n", + " rdx, rdy = reps[:, 0] - reader[0], reps[:, 1] - reader[1]\n", + " rdist = _np.hypot(rdx, rdy)\n", + " rbearing = _np.degrees(_np.arctan2(rdy, rdx))\n", + " cover = _COVER_ARR[_np.round(_np.mod(rbearing, 360.0) / _GRID_DEG)\n", + " .astype(_np.int64) % _GRID_N]\n", + " visible_mask = rep_masks & cover\n", + " bits = (_POP16[visible_mask & 0xFFF]\n", + " + _POP16[(visible_mask >> 12) & 0xFFF])\n", + " cover_ratio = bits / float(HEADING_BINS)\n", + " p_silent_range = _np.clip((rdist - self.field.r_eff_min_m)\n", + " / (self.field.r_eff_max_m\n", + " - self.field.r_eff_min_m), 0.0, 1.0)\n", + " p_vis = (1.0 - p_silent_range) * cover_ratio\n", + " p_ns = 1.0 - p_vis\n", + " gaps = _np.abs((bearing[None, :] - rbearing[:, None] + 180.0) % 360.0 - 180.0)\n", + " in_wedge = ((gaps <= self.config.field.bearing_error_half_deg\n", + " + self.config.search.bearing_rounding_margin_deg + 1e-9)\n", + " & (dist[None, :] <= self.field.r_eff_max_m + pf.margin_m))\n", + " eps_radius = pf.spacing_m / 2.0\n", + " radii = []\n", + " for row in in_wedge:\n", + " member = points[row]\n", + " radii.append(_branch_radius_array(member)\n", + " if len(member) else eps_radius)\n", + " radii = _np.array(radii, dtype=float)\n", + " current = pf.mec()[1]\n", + " near = rdist <= self.field.near_radius_m + pf.margin_m\n", + " values = _np.where(near, self.field.near_radius_m + pf.margin_m,\n", + " p_ns * current + p_vis * radii)\n", + " return float(values.mean())\n", + "\n", + " def _epr_python(self, reader: Vec2, indices: list[int],\n", + " representatives: list[int]) -> float: # pragma: no cover\n", + " \"\"\"纯 Python 回退(与向量版逐分支同语义,供无 numpy 环境与对拍)。\"\"\"\n", + " pf = self.position\n", + " points = [pf.cell_center(index) for index in indices]\n", + " masks = [self._mask(index) for index in indices]\n", + " eps_radius = pf.spacing_m / 2.0\n", + " current = pf.mec()[1]\n", + " half_angle = (self.config.field.bearing_error_half_deg\n", + " + self.config.search.bearing_rounding_margin_deg)\n", + " total = 0.0\n", + " for rep_index in representatives:\n", + " rx, ry = pf.cell_center(rep_index)\n", + " distance = math.hypot(rx - reader[0], ry - reader[1])\n", + " if distance <= self.field.near_radius_m + pf.margin_m:\n", + " total += self.field.near_radius_m + pf.margin_m\n", + " continue\n", + " reading = math.degrees(math.atan2(ry - reader[1], rx - reader[0]))\n", + " cover = _COVER_TABLE[_table_index(reading)]\n", + " bits = _popcount(self._mask(rep_index) & cover)\n", + " ratio = bits / float(HEADING_BINS)\n", + " if distance <= self.field.r_eff_min_m:\n", + " p_range = 1.0\n", + " elif distance >= self.field.r_eff_max_m:\n", + " p_range = 0.0\n", + " else:\n", + " p_range = ((self.field.r_eff_max_m - distance)\n", + " / (self.field.r_eff_max_m - self.field.r_eff_min_m))\n", + " p_vis = p_range * ratio\n", + " members = []\n", + " for point, mask in zip(points, masks):\n", + " if math.dist(point, reader) > self.field.r_eff_max_m + pf.margin_m:\n", + " continue\n", + " bearing = math.degrees(math.atan2(point[1] - reader[1],\n", + " point[0] - reader[0]))\n", + " if heading_gap(bearing, reading) <= half_angle + 1e-9:\n", + " members.append(point)\n", + " if members:\n", + " mx = sum(p[0] for p in members) / len(members)\n", + " my = sum(p[1] for p in members) / len(members)\n", + " radius = max(math.hypot(p[0] - mx, p[1] - my) for p in members)\n", + " else:\n", + " radius = eps_radius\n", + " total += (1.0 - p_vis) * current + p_vis * radius\n", + " return total / max(len(representatives), 1)\n", + "\n", + "\n", + " def prob_detect(self, reader: Vec2, size: int = 256) -> float:\n", + " \"\"\"在 ``reader`` 处能看见任一可行 (位置, 朝向) 的概率质量(启发式)。\n", + "\n", + " r_eff ~ U[1000, 1500] 解析积分;朝向按可行档位占比计权。\n", + " 只用于选点排序,不参与任何安全判定。\n", + " \"\"\"\n", + " cells = self.sample(size)\n", + " if not cells:\n", + " return 0.0\n", + " total = 0.0\n", + " span = self.field.r_eff_max_m - self.field.r_eff_min_m\n", + " for index in cells:\n", + " gx, gy = self.position.cell_center(index)\n", + " distance = math.hypot(reader[0] - gx, reader[1] - gy)\n", + " if distance > self.field.r_eff_max_m:\n", + " continue\n", + " if distance <= self.field.r_eff_min_m:\n", + " p_range = 1.0\n", + " else:\n", + " p_range = (self.field.r_eff_max_m - distance) / span\n", + " if p_range <= 0.0:\n", + " continue\n", + " bearing = math.degrees(math.atan2(reader[1] - gy, reader[0] - gx))\n", + " cover = _COVER_TABLE[_table_index(bearing)]\n", + " bits = bin(self._mask(index) & cover).count(\"1\")\n", + " if bits:\n", + " total += p_range * bits / HEADING_BINS\n", + " return total / len(cells)\n", + "\n", + " # ------------------------------------------------------------ 观测更新\n", + " def _apply_heading(self, station: Vec2, keep: bool,\n", + " within_m: float | None = None) -> None:\n", + " \"\"\"按可见性约束裁剪朝向档(保守:只删\"必然不可能\"的档)。\n", + "\n", + " ``keep=True``(direction / near)⟹ 朝向必须**可能**覆盖 station:\n", + " 保留与方位夹角 ≤ 90° + 半档 + 查表舍入 + 格内角裕量的档。\n", + "\n", + " ``keep=False``(no_signal 且整格必在 r_eff_min 内)⟹ 朝向必须\n", + " **确定不覆盖** station:只保留与方位夹角 > 90° − 半档 − 查表舍入\n", + " − 格内角裕量的档。\n", + "\n", + " ``格内角裕量 = asin(margin / max(dist − margin, 1))``:格内任一点\n", + " 相对格心的方位偏差上界。两个方向都必须计入它,否则会误删真源的\n", + " 朝向档(keep=True 漏计)或保留必然可见的档(keep=False 漏计)。\n", + " \"\"\"\n", + " margin_m = self.position.margin_m\n", + " doomed: list[int] = []\n", + " for index in self.position.alive:\n", + " gx, gy = self.position.cell_center(index)\n", + " dx, dy = station[0] - gx, station[1] - gy\n", + " distance = math.hypot(dx, dy)\n", + " if within_m is not None and distance > within_m:\n", + " continue\n", + " bearing = math.degrees(math.atan2(dy, dx))\n", + " if distance <= margin_m + 1.0:\n", + " # 格贴测点:格内方位可指向任意方向,任何一侧都不可据此裁剪\n", + " continue\n", + " angular_margin = math.degrees(\n", + " math.asin(min(0.9999, margin_m / (distance - margin_m))))\n", + " if keep:\n", + " updated = self._mask(index) & _cover_mask(\n", + " bearing, 90.0 + _BIN_HALF_DEG + _GRID_DEG / 2 + angular_margin)\n", + " else:\n", + " updated = self._mask(index) & _far_mask(\n", + " bearing, 90.0 - _BIN_HALF_DEG - _GRID_DEG / 2 - angular_margin)\n", + " if updated == 0:\n", + " doomed.append(index)\n", + " self.masks.pop(index, None)\n", + " elif updated != _FULL_MASK:\n", + " self.masks[index] = updated\n", + " if doomed:\n", + " self.position.discard(doomed)\n", + "\n", + " def apply(self, station: Vec2, result: str,\n", + " svd_deg: float | None = None) -> None:\n", + " \"\"\"按读数更新联合可行集(保守,真源 (位置, 朝向) 永不被删)。\"\"\"\n", + " if result == DIRECTION:\n", + " self.position.apply_measure(station, DIRECTION, svd_deg,\n", + " directional=True)\n", + " self._apply_heading(station, keep=True)\n", + " elif result == NEAR:\n", + " # near 同样要求可见(评测机先判朝向再判距离)\n", + " self.position.apply_measure(station, NEAR, directional=True)\n", + " self._apply_heading(station, keep=True)\n", + " elif result == NO_SIGNAL:\n", + " # 距离阈值必须取**保守侧**:只有\"整格都必然落在 r_eff_min 圆盘内\"\n", + " # 时,no_signal 才一定由朝向遮挡解释。格心到格内任一点 ≤ margin,\n", + " # 故阈值取 r_eff_min − margin;取 + margin 会把 1000~1010m 的模糊带\n", + " # 也当成\"必须不覆盖\",从而可能删掉真源的朝向档(破坏真源保留性)。\n", + " self._apply_heading(station, keep=False,\n", + " within_m=max(\n", + " 0.0, self.field.r_eff_min_m\n", + " - self.position.margin_m))\n", + " else:\n", + " raise ValueError(f\"未知测向结果: {result!r}\")\n", + "\n", + " # ------------------------------------------------------------ 诊断\n", + " def summary(self) -> dict:\n", + " \"\"\"联合集规模快照(写入策略 summary,供审计与论文复算)。\"\"\"\n", + " cells = self.sample(256)\n", + " bins = [bin(self._mask(index)).count(\"1\") for index in cells]\n", + " return {\"cells\": len(self.position),\n", + " \"sampled_mean_heading_bins\": (round(sum(bins) / len(bins), 2)\n", + " if bins else 0.0),\n", + " \"heading_bins_total\": HEADING_BINS}\n" + ] + }, + { + "cell_type": "markdown", + "id": "63720d6b", + "metadata": {}, + "source": [ + "## 5. 调度层:开放路径排序(`routing.py`)\n", + "\n", + "本题的真实图问题是**开放路径**:起点固定在机器人当前位置、终点自由,\n", + "代价 = Σ 移动时间 + Σ 动作耗时。后备清除的走格顺序用最近邻打底 + 2-opt 改进\n", + "(O(1) 增量代价评估,终点自由故只反转内部片段、不做首尾相接)。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "f2e043c8", + "metadata": {}, + "outputs": [], + "source": [ + "import math\n", + "\n", + "Vec2 = tuple[float, float]\n", + "\n", + "\n", + "def path_length(order: list[Vec2], start: Vec2 = (0.0, 0.0)) -> float:\n", + " \"\"\"开放路径长度(起点 start,终点自由)。\"\"\"\n", + " total = 0.0\n", + " position = start\n", + " for point in order:\n", + " total += math.dist(position, point)\n", + " position = point\n", + " return total\n", + "\n", + "\n", + "def nearest_neighbor_order(points: list[Vec2],\n", + " start: Vec2 = (0.0, 0.0)) -> list[Vec2]:\n", + " \"\"\"最近邻开放路径(O(n²),启发式上界)。\"\"\"\n", + " remaining = list(points)\n", + " order: list[Vec2] = []\n", + " position = start\n", + " while remaining:\n", + " nxt = min(remaining, key=lambda q: math.dist(position, q))\n", + " remaining.remove(nxt)\n", + " order.append(nxt)\n", + " position = nxt\n", + " return order\n", + "\n", + "\n", + "def two_opt_open(order: list[Vec2], start: Vec2 = (0.0, 0.0),\n", + " max_passes: int = 8) -> list[Vec2]:\n", + " \"\"\"开放路径 2-opt(终点自由:只反转内部片段,不做首尾相接)。\n", + "\n", + " 增量代价 O(1):反转 [i..j] 只影响 (prev, i) 与 (j, next) 两条边。\n", + " \"\"\"\n", + " best = list(order)\n", + " n = len(best)\n", + " if n < 3:\n", + " return best\n", + " for _ in range(max_passes):\n", + " improved = False\n", + " for i in range(n - 1):\n", + " prev = best[i - 1] if i > 0 else start\n", + " pi = best[i]\n", + " d_prev_i = math.dist(prev, pi)\n", + " for j in range(i + 1, n):\n", + " pj = best[j]\n", + " nxt = best[j + 1] if j + 1 < n else None\n", + " d_prev_j = math.dist(prev, pj)\n", + " removed = d_prev_i\n", + " added = d_prev_j\n", + " if nxt is not None:\n", + " removed += math.dist(pj, nxt)\n", + " added += math.dist(pi, nxt)\n", + " if added < removed - 1e-9:\n", + " best[i:j + 1] = best[i:j + 1][::-1]\n", + " improved = True\n", + " pi = best[i]\n", + " d_prev_i = math.dist(prev, pi)\n", + " if not improved:\n", + " break\n", + " return best\n", + "\n", + "\n", + "def improve_open_path(points: list[Vec2], start: Vec2 = (0.0, 0.0),\n", + " max_passes: int = 8) -> list[Vec2]:\n", + " \"\"\"最近邻打底 + 2-opt 改进的开放路径(后备清除走格顺序用)。\"\"\"\n", + " order = nearest_neighbor_order(points, start)\n", + " return two_opt_open(order, start, max_passes)\n" + ] + }, + { + "cell_type": "markdown", + "id": "9325502c", + "metadata": {}, + "source": [ + "# 第二部分 · 策略层(`planner/`)\n", + "\n", + "## 6. 问题一/二/三共用的选点器与全向闭环\n", + "\n", + "- **问题一**在几何内核里直接求解(第三部分给出算例);\n", + "- **问题二**用 `select_second_point`:在保证接收盘内环形采样,以\"最坏后验包围圆半径\"\n", + " 为主指标、移动代价为次指标;\n", + "- **问题三**用 `OmnidirectionalLoop` 骨架:覆盖 → 定位 → 清除 → 收尾,\n", + " 子类只替换覆盖计划与调度细节。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "f4e91fbc", + "metadata": {}, + "outputs": [], + "source": [ + "def bearing_deg(a: Vec2, b: Vec2) -> float:\n", + " \"\"\"从 a 指向 b 的方位角(0-360,0 为 x 正方向,逆时针)。\"\"\"\n", + " return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 360.0\n", + "\n", + "\n", + "def _error_brief(exc: BaseException) -> dict:\n", + " \"\"\"异常摘要(summary 留档用):类型名 + 截断消息。\"\"\"\n", + " return {\"type\": type(exc).__name__, \"message\": str(exc)[:500]}\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class SecondPointPlan:\n", + " \"\"\"下一检测点的候选与依据(P3 输出)。\"\"\"\n", + "\n", + " position: Vec2\n", + " worst_posterior_radius_m: float\n", + " move_cost_s: float\n", + " reason: str\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class ClearCertificate:\n", + " \"\"\"清除证书:候选集外包半径(含近似误差)不超过 20m 阈值。\"\"\"\n", + "\n", + " channel: int\n", + " center: Vec2\n", + " radius_m: float\n", + " limit_m: float\n", + " certified: bool\n", + "\n", + "\n", + "def clear_certificate(region: CandidateRegion, channel: int,\n", + " config: ProblemConfig = ProblemConfig()) -> ClearCertificate:\n", + " \"\"\"ρ_poly <= 20 - 外包近似误差 - 工程余量 才允许发起清除。\"\"\"\n", + " center, radius = region.mec()\n", + " limit = (config.field.clear_radius_m - region.slack_m\n", + " - config.search.clear_margin_m)\n", + " return ClearCertificate(channel, center, radius, limit, radius <= limit)\n", + "\n", + "\n", + "def candidate_points(center: Vec2, safe_radius_m: float, rings, angles: int) -> list[Vec2]:\n", + " \"\"\"保证接收盘内的候选点:盘心 + 各比例半径 × 均匀方向。\"\"\"\n", + " points = [tuple(center)]\n", + " for fraction in rings:\n", + " radius = fraction * safe_radius_m\n", + " if radius <= 1e-9:\n", + " continue\n", + " step = 2 * math.pi / angles\n", + " for k in range(angles):\n", + " angle = step * k + step / 2\n", + " points.append((center[0] + radius * math.cos(angle),\n", + " center[1] + radius * math.sin(angle)))\n", + " return points\n", + "\n", + "\n", + "def _worst_posterior_radius(region: CandidateRegion, q: Vec2,\n", + " witnesses: list[Vec2], half_angle_deg: float,\n", + " near_radius_m: float, disk_sides: int) -> float:\n", + " \"\"\"对候选点 q,遍历\"目标边界点 × 读数分支\"的最坏后验包围半径。\n", + "\n", + " 读数分支:direction 的区间端点 φ(g)±δ(端点是每个 g 的最不利读数),\n", + " 以及 near——源落在 q 的 near_radius_m 内时后验为 B(q, near)∩候选集。\n", + " 保证接收盘内 no_signal 不可能发生,故不枚举该分支。\n", + " 这是启发式近似,不构成最优性证明 [工程]。\n", + " \"\"\"\n", + " posterior_radii = []\n", + " for g in witnesses:\n", + " phi = bearing_deg(q, g)\n", + " for reading in (phi - half_angle_deg, phi + half_angle_deg):\n", + " probe = CandidateRegion(list(region.polygon), region.slack_m)\n", + " probe.clip_wedge(q, reading, half_angle_deg)\n", + " if probe.polygon:\n", + " posterior_radii.append(probe.mec()[1])\n", + " near_probe = CandidateRegion(list(region.polygon), region.slack_m)\n", + " try:\n", + " near_probe.clip_disk(q, near_radius_m, disk_sides)\n", + " except InconsistentBelief:\n", + " pass # B(q, near) 与候选集不相交:near 分支不可能发生,跳过\n", + " else:\n", + " if near_probe.polygon:\n", + " posterior_radii.append(near_probe.mec()[1])\n", + " if not posterior_radii:\n", + " return region.mec()[1] # 无有效采样点时退回当前半径\n", + " return max(posterior_radii)\n", + "\n", + "\n", + "def safe_disk_radius(region: CandidateRegion,\n", + " config: ProblemConfig = ProblemConfig()) -> float:\n", + " \"\"\"保证接收盘 B(mec, safe) 的半径;<0 表示无保证(ρ 超过最小接收半径)。\n", + "\n", + " 盘内任意点到候选集每点距离 <= safe + ρ + 1e-3 < 1000 <= 最小接收半径,\n", + " 故必然收到 direction/near。1mm 数值余量避免浮点贴边被 r_eff=1000 的源\n", + " 判为 no_signal。\n", + " \"\"\"\n", + " return config.field.r_eff_min_m - region.mec()[1] - 1e-3\n", + "\n", + "\n", + "def select_measurement_point(region: CandidateRegion, from_position: Vec2,\n", + " config: ProblemConfig = ProblemConfig()) -> SecondPointPlan:\n", + " \"\"\"在保证接收盘内选择下一检测点(P3 核心;对任意候选集通用)。\n", + "\n", + " 保证接收盘 B(mec_center, 1000-ρ):盘内任意点到候选集每点距离\n", + " <= (1000-ρ) + ρ = 1000 <= 最小接收半径,故必然收到 direction/near,\n", + " 不会浪费一次 no_signal。ρ>1000 时退化为探索 MEC 圆心(可能 no_signal)。\n", + " \"\"\"\n", + " field, search = config.field, config.search\n", + " center, radius = region.mec()\n", + " half_angle = field.bearing_error_half_deg + search.bearing_rounding_margin_deg\n", + "\n", + " def move_cost(point: Vec2) -> float:\n", + " return move_time(from_position, point, config.cost)\n", + "\n", + " # 1mm 数值余量在 safe_disk_radius 内记账。\n", + " safe_radius = safe_disk_radius(region, config)\n", + " if safe_radius < 0:\n", + " return SecondPointPlan(tuple(center), radius, move_cost(center),\n", + " \"无保证接收盘(ρ>1000):探索 MEC 圆心,可能 no_signal\")\n", + " coarse = region.witnesses(search.witness_spacing_m)\n", + " samples = candidate_points(center, safe_radius,\n", + " search.candidate_rings, search.candidate_angles)\n", + " near_radius, sides = field.near_radius_m, search.polygon_sides\n", + " best_key, best_point, best_worst = None, None, None\n", + " for point in samples:\n", + " worst = _worst_posterior_radius(region, point, coarse, half_angle,\n", + " near_radius, sides)\n", + " key = (round(worst, 6), round(move_cost(point), 6))\n", + " if best_key is None or key < best_key:\n", + " best_key, best_point, best_worst = key, point, worst\n", + " dense = _worst_posterior_radius(region, best_point,\n", + " region.witnesses(search.witness_spacing_m / 4),\n", + " half_angle, near_radius, sides)\n", + " reason = (f\"安全盘 B(centre,{safe_radius:.0f}m) 内 {len(samples)} 候选;\"\n", + " f\"粗采样最坏后验 {best_worst:.1f}m,1/4 间距加密复核 {dense:.1f}m\")\n", + " return SecondPointPlan(tuple(best_point), dense, move_cost(best_point), reason)\n", + "\n", + "\n", + "def select_second_point(client: SimulatorClient, first_position: Vec2,\n", + " first_bearing_deg: float,\n", + " config: ProblemConfig = ProblemConfig()) -> SecondPointPlan:\n", + " \"\"\"P3 入口:由首次 direction 构造 K1,返回保证接收的第二检测点方案。\"\"\"\n", + " region = CandidateRegion.initial(first_position, config)\n", + " half_angle = config.field.bearing_error_half_deg \\\n", + " + config.search.bearing_rounding_margin_deg\n", + " region.clip_wedge(first_position, first_bearing_deg, half_angle)\n", + " return select_measurement_point(region, client.state.position, config)\n" + ] + }, + { + "cell_type": "markdown", + "id": "76749d32", + "metadata": {}, + "source": [ + "### 6.1 全向闭环骨架(`OmnidirectionalLoop`)\n", + "\n", + "发现(覆盖账本)→ 定位(候选集收敛到 20 m 内)→ 清除 → 收尾 `/exit`。\n", + "任何异常都必须尽力 `/exit` 并产出 summary,不得静默丢会话:\n", + "断连、协议错误、守恒性破坏(候选集被剪空 / 保证接收盘内 `no_signal` /\n", + "清除证书失效)都会记入 `stop_reason` 与异常表。\n", + "`completed` 表示任务在证明意义上完成(每频道 CLEARED 或 ABSENT),\n", + "`accounted_virtual_time_s` 记录首次\"找全证明\"的虚拟时刻。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "22efcd41", + "metadata": {}, + "outputs": [], + "source": [ + "class OmnidirectionalLoop:\n", + " \"\"\"问题3全向\"发现—定位—清除—持续复扫\"闭环(P4 基线 + 持续搜索)。\n", + "\n", + " 串行驱动 SimulatorClient:覆盖阶段在 49 点网格上对每个 UNKNOWN 频道\n", + " 检测;发现 direction 即进入该频道的定位子循环(选点→测量→收敛→\n", + " 证书清除),near 直接清除。全部频道\"已清除或证明不存在\"后按\n", + " ``search.persistent_search``(默认关闭)可选择只复扫\n", + " 未清除频道,直到清除数达 16、实机预算耗尽或虚拟安全余量不足。\n", + " stop_reason 记录循环结束原因;``completed`` 表示任务证明完成\n", + " (结束时每频道均 CLEARED/ABSENT 且无中断),与\"复扫到限时才停\"\n", + " 相互独立:accounted_virtual_time_s 记录首次证明找全的虚拟时刻。\n", + " \"\"\"\n", + "\n", + " def __init__(self, client: SimulatorClient,\n", + " config: ProblemConfig = ProblemConfig()):\n", + " self.client = client\n", + " self.config = config\n", + " self.belief = BeliefState(config)\n", + " # 覆盖计划是\"可离线搜索、可在运行期校验\"的一等对象;基类默认保持\n", + " # 历史 49 点表(grid49_immediate 基线口径不变),各问题策略自行覆盖。\n", + " self.coverage_plan = square_49_plan()\n", + " self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points))\n", + " self.cleared: list[int] = []\n", + " self.anomalies: list[dict] = []\n", + " self.stop_reason: str | None = None\n", + " # 逐频道缺席证据来源(pf_empty / coverage),供审计与论文逐条追溯。\n", + " self.absence_evidence: dict[int, str] = {}\n", + " # \"同一 0.5m 测点 × 同一频道\"的重复检测计数:历史上一次官方演练\n", + " # 有 98.84% 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。\n", + " self.redundant_rescan_measures = 0\n", + " # 首次达成\"每频道均已清除或证明不存在\"的虚拟时刻(找全证明)。\n", + " self.accounted_virtual_time_s: float | None = None\n", + " # 证书有效但 /clear 失败后必须先补测再重试,避免在同一证书上\n", + " # 反复消耗 3 秒/次的失败清除。\n", + " self._may_clear: set[int] = set()\n", + " # 断连/协议错误/守恒性破坏等中断:留档异常,收尾时写入 summary。\n", + " self._abort_detail: BaseException | None = None\n", + "\n", + " # ------------------------------------------------------------ 主流程\n", + " def run(self) -> dict:\n", + " \"\"\"主闭环。任何异常都必须尽力 /exit 并产出 summary,不得静默丢会话。\"\"\"\n", + " try:\n", + " self.client.enter()\n", + " self._coverage_phase()\n", + " if self.stop_reason is None:\n", + " self._mark_absent_after_coverage()\n", + " self._resolve_detected()\n", + " self._record_accounted()\n", + " if self.config.search.persistent_search:\n", + " self._persistent_phase()\n", + " if self.stop_reason is None:\n", + " if len(self.cleared) >= self.config.field.source_count_max:\n", + " self.stop_reason = \"sixteen_cleared\"\n", + " elif self._all_accounted():\n", + " self.stop_reason = \"all_resolved\"\n", + " else:\n", + " self.stop_reason = \"unresolved_channels\"\n", + " except DeadlineExceeded:\n", + " self.stop_reason = \"real_budget_exhausted\"\n", + " except (TransportError, ProtocolError, InconsistentBelief) as exc:\n", + " self._abort_detail = exc\n", + " if isinstance(exc, TransportError):\n", + " self.stop_reason = \"transport_failure\"\n", + " elif isinstance(exc, InconsistentBelief):\n", + " self.stop_reason = \"inconsistent_belief\"\n", + " else:\n", + " self.stop_reason = \"protocol_error\"\n", + " except Exception as exc: # 意外异常同样收尾,不阻断 /exit 与结果汇总\n", + " self._abort_detail = exc\n", + " self.stop_reason = \"unexpected_error\"\n", + " info = None\n", + " exit_error = None\n", + " try:\n", + " info = self.client.exit()\n", + " except Exception as exc: # 接口不可用:记录后仍产出未完成状态\n", + " exit_error = exc\n", + " summary = self._summary(info)\n", + " if exit_error is not None:\n", + " summary[\"exit_error\"] = _error_brief(exit_error)\n", + " return summary\n", + "\n", + " # ------------------------------------------------------------ 预算与证明\n", + " def _min_virtual_reserve(self) -> float:\n", + " \"\"\"单动作耗时上界的保守估计,用于虚拟安全余量。\n", + "\n", + " 上界 = 2 × 最远站点距离 / 速度 + 检测 + 换频(机器人不会跑到覆盖\n", + " 计划包围区之外执行任务)。配置值通常更宽松;本函数保证即使配置被\n", + " 调小,余量也足以完成最后一个动作而不触及 100 小时上限。\n", + " \"\"\"\n", + " reach = max((math.dist((0.0, 0.0), point) for point in self.ledger.points),\n", + " default=0.0)\n", + " action_bound = (2.0 * reach / self.config.cost.speed_mps\n", + " + self.config.cost.measure_s + self.config.cost.switch_s)\n", + " return max(self.config.search.virtual_reserve_s, action_bound)\n", + "\n", + " def _virtual_cap_reached(self) -> bool:\n", + " \"\"\"虚拟安全余量不足:置 stop_reason 并返回 True,调用方立即收尾。\n", + "\n", + " 余量须大于单次动作耗时上界(见 :meth:`_min_virtual_reserve`),\n", + " `search.virtual_reserve_s=10000s` [工程] 提供充分裕度,避免触及\n", + " 附件2 §4.5 的 100 小时上限被模拟器强制结束。\n", + " \"\"\"\n", + " if (self.client.state.virtual_time_s + self._min_virtual_reserve()\n", + " < self.config.field.max_virtual_duration_s):\n", + " return False\n", + " self.stop_reason = \"virtual_time_cap\"\n", + " return True\n", + "\n", + " def _all_accounted(self) -> bool:\n", + " return all(belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)\n", + " for belief in self.belief.channels.values())\n", + "\n", + " def _record_accounted(self) -> None:\n", + " \"\"\"首次\"每频道均已清除或证明不存在\"的时刻留档(找全证明)。\"\"\"\n", + " if self.accounted_virtual_time_s is None and self._all_accounted():\n", + " self.accounted_virtual_time_s = self.client.state.virtual_time_s\n", + "\n", + " # ------------------------------------------------------------ 覆盖阶段\n", + " def _pending_channels_at(self, index: int) -> list[int]:\n", + " return [channel for channel, belief in self.belief.channels.items()\n", + " if belief.status is ChannelStatus.UNKNOWN\n", + " and not self.ledger.measured_at(channel, index)]\n", + "\n", + " def _next_point_index(self) -> int | None:\n", + " \"\"\"尚有 UNKNOWN 频道待测的最近网格点(贪心最近邻 [工程])。\"\"\"\n", + " position = self.client.state.position\n", + " best_index, best_distance = None, 0.0\n", + " for index, point in enumerate(self.ledger.points):\n", + " if not self._pending_channels_at(index):\n", + " continue\n", + " distance = math.dist(position, point)\n", + " if best_index is None or distance < best_distance:\n", + " best_index, best_distance = index, distance\n", + " return best_index\n", + "\n", + " def _coverage_phase(self) -> None:\n", + " while True:\n", + " if self._count_limit_reached():\n", + " return\n", + " index = self._next_point_index()\n", + " if index is None:\n", + " return\n", + " point = self.ledger.points[index]\n", + " for channel in self._pending_channels_at(index):\n", + " if self._virtual_cap_reached():\n", + " return\n", + " outcome = self.client.measure(point, channel)\n", + " self.ledger.mark_measured(channel, index)\n", + " self.belief.update_measure(channel, point, outcome.result,\n", + " outcome.svd_deg)\n", + " if outcome.result == DIRECTION:\n", + " self._localize_and_clear(channel)\n", + " elif outcome.result == NEAR:\n", + " region = self.belief.candidate_set(channel)\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified:\n", + " self._attempt_clear(channel, cert.center, cert)\n", + " else: # 理论上不发生(near 候选半径 5m);保守记录\n", + " self._anomaly(\"near_without_certificate\", channel,\n", + " position=point)\n", + " if self.stop_reason is not None or self._count_limit_reached():\n", + " return\n", + "\n", + " # ------------------------------------------------------------ 持续复扫\n", + " def _persistent_phase(self) -> None:\n", + " \"\"\"全部频道已清除/证明不存在后的持续复扫 [用户决策 2026-09-11]。\n", + "\n", + " 只扫未清除频道(DETECTED 继续定位,ABSENT 全网格复测,防模拟器\n", + " 或覆盖证明假设偏差),直到清除数达 16 或虚拟安全余量不足;\n", + " 实机预算由客户端 DeadlineExceeded 兜底(扫到限时为止)。\n", + " \"\"\"\n", + " while self.stop_reason is None:\n", + " if self._count_limit_reached():\n", + " return\n", + " if self._virtual_cap_reached():\n", + " return\n", + " self._persistent_round()\n", + "\n", + " def _persistent_round(self) -> None:\n", + " \"\"\"一轮全网格复扫:网格点最近邻序,逐点测量全部未清除频道。\"\"\"\n", + " measured: set[tuple[int, int]] = set()\n", + " while self.stop_reason is None:\n", + " index = self._next_resweep_point(measured)\n", + " if index is None:\n", + " return\n", + " point = self.ledger.points[index]\n", + " for channel in self._resweep_channels_at(index, measured):\n", + " if self._virtual_cap_reached():\n", + " return\n", + " measured.add((channel, index))\n", + " was_absent = (self.belief.channels[channel].status\n", + " is ChannelStatus.ABSENT)\n", + " outcome = self.client.measure(point, channel)\n", + " self.belief.update_measure(channel, point, outcome.result,\n", + " outcome.svd_deg)\n", + " if was_absent and outcome.result in (DIRECTION, NEAR):\n", + " # 复测推翻了覆盖缺席证明:守恒性留痕,按新证据定位清除。\n", + " self._anomaly(\"absent_detected_on_resweep\", channel,\n", + " position=point, result=outcome.result)\n", + " if outcome.result == DIRECTION:\n", + " self._localize_and_clear(channel)\n", + " elif outcome.result == NEAR:\n", + " region = self.belief.candidate_set(channel)\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified:\n", + " self._attempt_clear(channel, cert.center, cert)\n", + " else:\n", + " self._anomaly(\"near_without_certificate\", channel,\n", + " position=point)\n", + " if self.stop_reason is not None:\n", + " return\n", + "\n", + " def _next_resweep_point(self, measured: set[tuple[int, int]]) -> int | None:\n", + " \"\"\"尚有未清除频道待复测的最近网格点(贪心最近邻 [工程])。\"\"\"\n", + " position = self.client.state.position\n", + " best_index, best_distance = None, 0.0\n", + " for index, point in enumerate(self.ledger.points):\n", + " if not self._resweep_channels_at(index, measured):\n", + " continue\n", + " distance = math.dist(position, point)\n", + " if best_index is None or distance < best_distance:\n", + " best_index, best_distance = index, distance\n", + " return best_index\n", + "\n", + " def _resweep_channels_at(self, index: int,\n", + " measured: set[tuple[int, int]]) -> list[int]:\n", + " return [channel for channel, belief in self.belief.channels.items()\n", + " if belief.status is not ChannelStatus.CLEARED\n", + " and (channel, index) not in measured]\n", + "\n", + " def _count_limit_reached(self) -> bool:\n", + " if len(self.cleared) >= self.config.field.source_count_max:\n", + " self.stop_reason = \"sixteen_cleared\"\n", + " return True\n", + " return False\n", + "\n", + " # ------------------------------------------------------------ 定位与清除\n", + " def _localize_and_clear(self, channel: int) -> bool:\n", + " \"\"\"定位子循环:选点测量收敛候选集,拿到清除证书后清除。\n", + "\n", + " 返回是否已清除;步数耗尽仍未收敛则保持 DETECTED 留待二轮。\n", + " 进入本函数前该频道刚有一次 direction 读数,允许立即清除。\n", + " \"\"\"\n", + " search = self.config.search\n", + " self._may_clear.add(channel)\n", + " for _ in range(search.max_localize_steps):\n", + " if self._virtual_cap_reached():\n", + " return False\n", + " region = self.belief.candidate_set(channel)\n", + " if region is None:\n", + " return False\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified and channel in self._may_clear:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " return True\n", + " self._may_clear.discard(channel)\n", + " continue\n", + " plan = select_measurement_point(region, self.client.state.position,\n", + " self.config)\n", + " outcome = self.client.measure(plan.position, channel)\n", + " self.belief.update_measure(channel, plan.position, outcome.result,\n", + " outcome.svd_deg)\n", + " self._may_clear.add(channel)\n", + " if outcome.result == NO_SIGNAL:\n", + " self._anomaly(\"no_signal_in_safe_disk\", channel,\n", + " position=plan.position, detail=plan.reason)\n", + " return self.belief.channels[channel].status is ChannelStatus.CLEARED\n", + "\n", + " def _attempt_clear(self, channel: int, target: Vec2,\n", + " cert: ClearCertificate) -> bool:\n", + " outcome = self.client.clear(target, channel)\n", + " if outcome.result == CLEAR_SUCCESS:\n", + " self.belief.mark_cleared(channel)\n", + " self.cleared.append(channel)\n", + " return True\n", + " if cert.certified:\n", + " self._anomaly(\"clear_failed_with_certificate\", channel, position=target,\n", + " radius_m=round(cert.radius_m, 3), limit_m=cert.limit_m)\n", + " return False\n", + "\n", + " def _anomaly(self, kind: str, channel: int, **detail) -> None:\n", + " \"\"\"异常只在守恒性被打破或证书失效时出现;每频道限量记录防刷屏。\"\"\"\n", + " recorded = sum(1 for item in self.anomalies if item[\"channel\"] == channel)\n", + " if recorded >= self.config.search.max_anomalies_per_channel:\n", + " return\n", + " self.anomalies.append({\"kind\": kind, \"channel\": channel, **detail})\n", + "\n", + " # ------------------------------------------------------------ 收尾\n", + " def _mark_absent_after_coverage(self) -> None:\n", + " \"\"\"全向源缺席证明:全网格无非 no_signal 读数的频道不存在源 [题面]。\"\"\"\n", + " for channel, belief in self.belief.channels.items():\n", + " if belief.status is ChannelStatus.UNKNOWN \\\n", + " and self.ledger.coverage_complete(channel):\n", + " self.belief.mark_absent(channel)\n", + " self.absence_evidence[channel] = \"coverage\"\n", + "\n", + " def _resolve_detected(self) -> None:\n", + " \"\"\"覆盖完成后,对仍未清除的 DETECTED 频道做第二轮定位。\n", + "\n", + " 不再在此决定停止:持续搜索模式下由 _persistent_phase 接力,\n", + " 非持续模式由 run() 依据 _all_accounted 判定 all_resolved。\n", + " \"\"\"\n", + " for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n", + " if self._count_limit_reached() or self._virtual_cap_reached():\n", + " return\n", + " self._localize_and_clear(channel)\n", + " if self.stop_reason is not None:\n", + " return\n", + "\n", + " def _summary(self, info) -> dict:\n", + " \"\"\"结果汇总。info 为 None 表示 /exit 未能完成(断连等),按未完成上报。\n", + "\n", + " completed = 无中断且结束时每频道均 CLEARED/ABSENT(任务证明完成);\n", + " stop_reason 另行记录循环结束原因(持续复扫模式下通常是\n", + " virtual_time_cap / real_budget_exhausted / sixteen_cleared)。\n", + " \"\"\"\n", + " events = self.client.log.action_events()\n", + " stats = summarize(events)\n", + " report = recompute_virtual_time(events)\n", + " steps = self._step_report()\n", + " cleared = self.belief.channels_by_status(ChannelStatus.CLEARED)\n", + " absent = self.belief.channels_by_status(ChannelStatus.ABSENT)\n", + " resolved = set(cleared) | set(absent)\n", + " unresolved = [channel for channel in sorted(self.belief.channels)\n", + " if channel not in resolved]\n", + " virtual_time_s = round((info.virtual_time_s if info is not None\n", + " else self.client.state.virtual_time_s), 3)\n", + " # 清除数达 16 即数量上界证明(其余频道无论状态如何都不可能有源);\n", + " # 否则要求每频道均 CLEARED/ABSENT。\n", + " completed = (self._abort_detail is None\n", + " and (len(self.cleared) >= self.config.field.source_count_max\n", + " or not unresolved))\n", + " # 完成依据必须显式区分\"数量上界\"与\"逐频道缺席/清除证明\",\n", + " # 否则论文与审计会把 16 源截断误读为逐频道证明。\n", + " if self._abort_detail is not None:\n", + " completion_basis = \"incomplete\"\n", + " elif len(self.cleared) >= self.config.field.source_count_max:\n", + " completion_basis = \"count_cap\"\n", + " elif not unresolved:\n", + " completion_basis = \"per_channel_proof\"\n", + " else:\n", + " completion_basis = \"incomplete\"\n", + " summary = {\n", + " \"completed\": completed,\n", + " \"completion_basis\": completion_basis,\n", + " \"stop_reason\": self.stop_reason,\n", + " \"exit_reason\": \"exit_unreachable\" if info is None else info.reason,\n", + " \"virtual_time_s\": virtual_time_s,\n", + " \"accounted_virtual_time_s\": (round(self.accounted_virtual_time_s, 3)\n", + " if self.accounted_virtual_time_s is not None\n", + " else None),\n", + " \"persistent_search\": self.config.search.persistent_search,\n", + " \"virtual_reserve_s\": round(self._min_virtual_reserve(), 3),\n", + " \"coverage_plan\": self.coverage_plan.name,\n", + " \"coverage_points\": len(self.coverage_plan.points),\n", + " \"coverage_plan_points\": [[p[0], p[1]] for p in self.coverage_plan.points],\n", + " \"coverage_plan_certificate\": (\n", + " self.coverage_plan.certificate.as_dict()\n", + " if self.coverage_plan.certificate is not None else None),\n", + " \"cleared_channels\": cleared,\n", + " \"absent_channels\": absent,\n", + " \"unresolved_channels\": unresolved,\n", + " \"absence_evidence\": {str(channel): self.absence_evidence[channel]\n", + " for channel in sorted(absent)},\n", + " \"redundant_rescan_measures\": self.redundant_rescan_measures,\n", + " \"measures\": stats[\"measures\"],\n", + " \"clears\": stats[\"clears\"],\n", + " \"clear_success\": stats[\"cleared\"],\n", + " \"total_requests\": stats[\"measures\"] + stats[\"clears\"],\n", + " \"move_distance_m\": round(stats[\"distance_m\"], 1),\n", + " # 题面口径:定位清除总时间(全程虚拟时长)/ 被清除干扰源个数。\n", + " \"avg_locate_clear_time_s\": (round(virtual_time_s / len(cleared), 3)\n", + " if cleared else None),\n", + " \"step_totals\": steps[\"totals\"],\n", + " \"steps\": steps[\"rows\"],\n", + " \"anomalies\": self.anomalies,\n", + " \"virtual_recompute_mismatches\": len(report[\"mismatches\"]),\n", + " \"channel_status\": self._channel_status_snapshot(),\n", + " }\n", + " if self._abort_detail is not None:\n", + " summary[\"abort_detail\"] = _error_brief(self._abort_detail)\n", + " return summary\n", + "\n", + " def _step_report(self) -> dict:\n", + " \"\"\"每步计时汇总 [用户决策 2026-09-11]:实机墙钟 + 比赛虚拟耗时分项。\"\"\"\n", + " totals = {\"count\": 0, \"real_elapsed_total_s\": 0.0,\n", + " \"real_since_enter_total_s\": 0.0,\n", + " \"virtual_move_s\": 0.0, \"virtual_switch_s\": 0.0,\n", + " \"virtual_measure_action_s\": 0.0, \"virtual_clear_action_s\": 0.0,\n", + " \"virtual_total_s\": 0.0}\n", + " rows = []\n", + " for index, step in enumerate(self.client.log.step_events()):\n", + " cost = step[\"virtual_cost\"]\n", + " totals[\"count\"] += 1\n", + " totals[\"real_elapsed_total_s\"] += step[\"real_elapsed_s\"]\n", + " totals[\"real_since_enter_total_s\"] = step.get(\n", + " \"real_since_enter_s\", totals[\"real_since_enter_total_s\"])\n", + " totals[\"virtual_move_s\"] += cost[\"move_s\"]\n", + " totals[\"virtual_switch_s\"] += cost[\"switch_s\"]\n", + " if step[\"path\"] == \"/measure\":\n", + " totals[\"virtual_measure_action_s\"] += cost[\"action_s\"]\n", + " elif step[\"path\"] == \"/clear\":\n", + " totals[\"virtual_clear_action_s\"] += cost[\"action_s\"]\n", + " totals[\"virtual_total_s\"] += cost[\"total_s\"]\n", + " rows.append({\"index\": index, \"action\": step[\"path\"].lstrip(\"/\"),\n", + " \"result\": step.get(\"result\"),\n", + " \"channel\": step.get(\"channel\"),\n", + " \"position\": step.get(\"position\"),\n", + " \"real_elapsed_ms\": round(step[\"real_elapsed_s\"] * 1000.0, 3),\n", + " \"real_since_enter_s\": step.get(\"real_since_enter_s\"),\n", + " \"move_s\": round(cost[\"move_s\"], 3),\n", + " \"switch_s\": round(cost[\"switch_s\"], 3),\n", + " \"action_s\": round(cost[\"action_s\"], 3),\n", + " \"step_virtual_s\": round(cost[\"total_s\"], 3),\n", + " \"virtual_time_s\": step[\"virtual_time_s\"]})\n", + " for key, value in totals.items():\n", + " if isinstance(value, float):\n", + " totals[key] = round(value, 3)\n", + " return {\"totals\": totals, \"rows\": rows}\n", + "\n", + " def _channel_status_snapshot(self) -> dict:\n", + " \"\"\"逐频道状态快照:中断收尾时的未完成状态留档(每频道一条)。\"\"\"\n", + " snapshot = {}\n", + " for channel, belief in sorted(self.belief.channels.items()):\n", + " entry = {\"status\": belief.status.value}\n", + " if belief.region is not None:\n", + " entry[\"posterior_radius_m\"] = round(belief.region.mec()[1], 3)\n", + " snapshot[str(channel)] = entry\n", + " return snapshot\n" + ] + }, + { + "cell_type": "markdown", + "id": "1515ae48", + "metadata": {}, + "source": [ + "### 6.2 第四问定向扩展(`DirectionalExtension`)\n", + "\n", + "与第三问的三点差异:\n", + "\n", + "1. 覆盖阶段换成定向覆盖最小命中集(`q4_mincover_v2`),发现保证对定向源同样成立;\n", + "2. 保证接收盘内的 `no_signal` 只能由**朝向遮挡**解释:该频道记为\"观测到定向\",\n", + " 对 MEC 圆心取对跖探点复核,并用联合信念按可见概率选点;\n", + "3. 定位停滞时对**有界**候选集构造 ≤20 m 有限圆盘覆盖,逐格 `/clear`\n", + " (清除与朝向无关,是完备后备);格数超硬顶或预算不足即跳过并留痕,绝不盲扫。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "c2acad85", + "metadata": {}, + "outputs": [], + "source": [ + "class DirectionalExtension(OmnidirectionalLoop):\n", + " \"\"\"问题4混合源闭环(P5):全向基线 + 定向观测处理 + 后备清除。\n", + "\n", + " 与 P4 的三点差异(协议衔接见 docs/协议规则表与状态机.md §7):\n", + " - 49 点覆盖的发现保证对定向源同样成立,覆盖阶段不区分源类型;\n", + " - 保证接收盘内的 no_signal 只能由朝向遮挡解释 [观测推理]:该频道\n", + " 记为\"观测到定向\",随后对 MEC 圆心取对跖探点复核;联合约束基线\n", + " 不据此删除空间候选(真源不误删);\n", + " - 定位停滞(候选集有界但未达清除证书)时构造 <=20m 有限圆盘覆盖,\n", + " 预算核对后逐格 /clear:清除与朝向无关 [题面],是定向源不依赖\n", + " 可见性的完备后备。格数超硬顶或现实预算不足则跳过并留痕,绝不盲扫。\n", + " \"\"\"\n", + "\n", + " def __init__(self, client: SimulatorClient,\n", + " config: ProblemConfig = ProblemConfig()):\n", + " super().__init__(client, config)\n", + " # P0-2:第四问切换到同一保证下的最小命中集(默认 q4_mincover_v1)。\n", + " self.coverage_plan = plan_instance(config.search.q4_coverage_plan,\n", + " verify=config.search.verify_coverage_plan)\n", + " self.ledger = CoverageLedger(config, points=list(self.coverage_plan.points))\n", + " self.directional_observed: list[int] = [] # 观测到定向性的频道\n", + " self.blocked_probes: dict[int, list[Vec2]] = {}\n", + " self.fallback_log: list[dict] = [] # 后备清除逐次留痕\n", + " # P0-3:(位置 × 朝向) 联合可行集,仅在频道首次被看见时惰性建立,\n", + " # 使其初始化与更新成本只落在\"确实存在定向源\"的频道上。\n", + " self.joint: dict[int, JointBelief] = {}\n", + " self.joint_absent_channels: list[int] = []\n", + " self.visibility_probes = 0 # 由联合信念选出的探点数\n", + "\n", + " def _coverage_phase(self) -> None:\n", + " \"\"\"49站的定向覆盖证明不变;每站先串行扫频,再就近服务已发现源。\n", + "\n", + " 避免检测一个源后远行清除,再返回同站检测下一个频道。\n", + " no_signal不用于删空间候选,也不使用第三问的七站缺席证明。\n", + " \"\"\"\n", + " while not self._count_limit_reached() and self.stop_reason is None:\n", + " index = self._next_point_index()\n", + " if index is None:\n", + " return\n", + " point = self.ledger.points[index]\n", + " pending = self._pending_channels_at(index)\n", + " pending.sort(key=lambda c: (c != self.client.state.channel, c))\n", + " for channel in pending:\n", + " if self._virtual_cap_reached():\n", + " return\n", + " outcome = self.client.measure(point, channel)\n", + " self.ledger.mark_measured(channel, index)\n", + " self._observe(channel, point, outcome.result, outcome.svd_deg)\n", + " if outcome.result == NEAR:\n", + " cert = clear_certificate(\n", + " self.belief.candidate_set(channel), channel, self.config)\n", + " if cert.certified:\n", + " self._attempt_clear(channel, point, cert)\n", + " if self._count_limit_reached():\n", + " return\n", + " queue = self.belief.channels_by_status(ChannelStatus.DETECTED)\n", + " while queue and self.stop_reason is None:\n", + " channel = min(queue, key=lambda c: (\n", + " math.dist(self.client.state.position,\n", + " self.belief.candidate_set(c).mec()[0]), c))\n", + " queue.remove(channel)\n", + " self._localize_and_clear(channel)\n", + " if self._count_limit_reached():\n", + " return\n", + "\n", + " # ------------------------------------------------------------ 定位与清除\n", + " def _localize_and_clear(self, channel: int) -> bool:\n", + " \"\"\"定位子循环:选点测量收敛候选集,拿到清除证书后清除。\n", + "\n", + " 与 P4 的差异:保证接收盘内的 no_signal 说明探点被朝向遮挡\n", + " [观测推理],走\"对跖复核 -> 环探\"升级梯子(不计入定位步数,\n", + " 由 max_blocked_probes 限流),之后仍未收敛再转后备清除。\n", + " \"\"\"\n", + " search = self.config.search\n", + " self._may_clear.add(channel)\n", + " steps = 0\n", + " while steps < search.max_localize_steps:\n", + " if self._virtual_cap_reached():\n", + " return False\n", + " region = self.belief.candidate_set(channel)\n", + " if region is None:\n", + " return False\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified and channel in self._may_clear:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " return True\n", + " self._may_clear.discard(channel) # 证书失败:补测后才可重试\n", + " continue\n", + " if len(self.blocked_probes.get(channel, [])) >= search.max_blocked_probes:\n", + " break # 梯子用尽仍被遮挡:定位停滞,转后备清除\n", + " point = self._next_probe(region, channel)\n", + " within_range = (math.dist(point, region.mec()[0])\n", + " <= safe_disk_radius(region, self.config))\n", + " outcome = self.client.measure(point, channel)\n", + " self._observe(channel, point, outcome.result, outcome.svd_deg)\n", + " self._may_clear.add(channel)\n", + " if outcome.result == NO_SIGNAL:\n", + " if within_range and channel not in self.directional_observed:\n", + " self.directional_observed.append(channel)\n", + " self.blocked_probes.setdefault(channel, []).append(point)\n", + " continue # 盘外无信号也可能是距离不足,不据此断言定向\n", + " self.blocked_probes.pop(channel, None)\n", + " steps += 1\n", + " if self.belief.channels[channel].status is ChannelStatus.CLEARED:\n", + " return True\n", + " region = self.belief.candidate_set(channel)\n", + " if region is None:\n", + " return False\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified and channel in self._may_clear:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " return True\n", + " self._may_clear.discard(channel)\n", + " return self._fallback_clear(channel, region)\n", + "\n", + " def _observe(self, channel: int, point: Vec2, result: str,\n", + " svd_deg: float | None) -> None:\n", + " \"\"\"同步更新凸候选集与 (位置 × 朝向) 联合可行集。\n", + "\n", + " 联合集只在频道首次被看见(direction/near)时建立:从未被看见的\n", + " 频道由覆盖证明负责缺席,不需要朝向维度。联合集一旦为空,直接给出\n", + " 该频道的缺席证明(严格,与覆盖证明同一条件),可提前停止对其检测。\n", + " \"\"\"\n", + " self.belief.update_measure(channel, point, result, svd_deg)\n", + " if not self.config.search.directional_joint_belief:\n", + " return\n", + " joint = self.joint.get(channel)\n", + " if joint is None:\n", + " if result not in (DIRECTION, NEAR):\n", + " return\n", + " joint = JointBelief(self.config)\n", + " self.joint[channel] = joint\n", + " joint.apply(point, result, svd_deg)\n", + " if joint.is_absent() and self.belief.channels[channel].status \\\n", + " is not ChannelStatus.CLEARED:\n", + " self.belief.mark_absent(channel)\n", + " self.absence_evidence[channel] = \"joint_empty\"\n", + " if channel not in self.joint_absent_channels:\n", + " self.joint_absent_channels.append(channel)\n", + "\n", + " def _probe_candidates(self, region: CandidateRegion, channel: int) -> list[Vec2]:\n", + " \"\"\"可见性选点的候选点族:保证接收点 + 对跖点 + 环探 + **抵近点**。\n", + "\n", + " D1 关键结构性改动:旧点族全部围绕 MEC 圆心固定环采样,远距离不收敛时\n", + " 没有\"主动靠近\"这一动作,只能退化到 20m 网格盲清。现在补上\n", + " ``center + α·(当前位置 − center)``(α∈approach_fractions,α=0 即圆心)\n", + " 的抵近序列,使策略可以在保持可见的前提下逐次逼近真源,最终以正常\n", + " 角域证书或 near 收尾。\n", + " \"\"\"\n", + " center, radius = region.mec()\n", + " position = self.client.state.position\n", + " candidates = [select_measurement_point(\n", + " region, position, self.config).position]\n", + " safe = safe_disk_radius(region, self.config)\n", + " blocked = self.blocked_probes.get(channel, [])\n", + " if blocked and safe >= 0:\n", + " dx, dy = center[0] - blocked[0][0], center[1] - blocked[0][1]\n", + " norm = math.hypot(dx, dy)\n", + " if norm > 1e-9:\n", + " scale = min(1.0, safe / norm)\n", + " candidates.append((center[0] + dx * scale, center[1] + dy * scale))\n", + " # 抵近点:从 MEC 圆心到当前点的插值序列(含圆心本身)。\n", + " for fraction in self.config.search.approach_fractions:\n", + " candidates.append((center[0] + (position[0] - center[0]) * fraction,\n", + " center[1] + (position[1] - center[1]) * fraction))\n", + " for factor in (0.5, 1.0, 1.5):\n", + " ring = max(radius * factor, 2.0 * self.config.field.near_radius_m)\n", + " for step in range(8):\n", + " angle = math.pi * step / 4.0\n", + " candidates.append((center[0] + ring * math.cos(angle),\n", + " center[1] + ring * math.sin(angle)))\n", + " unique: list[Vec2] = []\n", + " seen: set[tuple[int, int]] = set()\n", + " blocked_keys = {(int(round(point[0] * 2)), int(round(point[1] * 2)))\n", + " for point in blocked} \\\n", + " if self.config.search.probe_exclude_blocked else set()\n", + " for point in candidates:\n", + " key = (int(round(point[0] * 2)), int(round(point[1] * 2)))\n", + " if key in seen or key in blocked_keys:\n", + " continue # 已测过且被遮挡的点:再测不会有任何新信息\n", + " seen.add(key)\n", + " unique.append(point)\n", + " return unique\n", + "\n", + " def _visibility_probe(self, region: CandidateRegion, channel: int) -> Vec2 | None:\n", + " \"\"\"按联合信念选探点;联合信念不可用时返回 None(退回梯子)。\n", + "\n", + " 三种口径(只影响选点顺序,不承担任何安全责任):\n", + "\n", + " - ``nearest_visible``(默认):先按可见概率过滤(只保留\n", + " ``prob_detect ≥ ratio × max`` 的候选,避免选中\"实际必然被朝向遮挡\"的\n", + " 点),再取 ``当前位置→q→MEC 圆心`` 移动最省者。9 场景 core 实测:\n", + " 后备盲清 125.7 → 4.3 次/局、T 9225 → 8602 s、墙钟最低。\n", + " - ``expected_radius``:在同样过滤后最小化\n", + " ``E[后验 MEC 半径](q) + λ·移动``。λ=5 时与上面几乎同分(8604 s),\n", + " λ 很小时(0.05)值函数主导,为信息多绕路(9740 s)——本题\n", + " \"看得见且少走路\"比\"单步信息量最大\"更重要。\n", + " - ``visibility``(旧口径):最大化 ``prob_detect/(移动+6)``,会反复选中\n", + " 同一个近处被遮挡点(实测 9225 s、后备 125.6 次)。\n", + " \"\"\"\n", + " joint = self.joint.get(channel)\n", + " if joint is None or joint.is_absent():\n", + " return None\n", + " position = self.client.state.position\n", + " service_s = self.config.cost.measure_s + self.config.cost.switch_s\n", + " candidates = self._probe_candidates(region, channel)\n", + " if not candidates:\n", + " return None\n", + " mode = self.config.search.probe_score_mode\n", + " if mode == \"visibility\":\n", + " best_point, best_score = None, None\n", + " for point in candidates:\n", + " probability = joint.prob_detect(point)\n", + " if probability <= 0.0:\n", + " continue\n", + " score = -(probability / (move_time(position, point, self.config.cost)\n", + " + service_s))\n", + " if best_score is None or score < best_score:\n", + " best_point, best_score = point, score\n", + " if best_point is not None:\n", + " self.visibility_probes += 1\n", + " return best_point\n", + " scored = [(joint.prob_detect(point), point) for point in candidates]\n", + " peak = max(probability for probability, _ in scored)\n", + " ratio = self.config.search.probe_visibility_ratio\n", + " keep = [point for probability, point in scored\n", + " if probability >= ratio * peak] or [point for _, point in scored]\n", + " center = region.mec()[0]\n", + " weight = self.config.search.probe_lambda_move_s\n", + "\n", + " def approach_cost(point: Vec2) -> float:\n", + " return (move_time(position, point, self.config.cost)\n", + " + move_time(point, center, self.config.cost) + service_s)\n", + "\n", + " if mode == \"nearest_visible\":\n", + " # λ 很大时值函数项对 argmin 已无影响:直接取\"可见候选中移动最省\",\n", + " # 省掉每个候选一次期望后验半径计算(单局墙钟 −3 s)。\n", + " best = min(keep, key=lambda point: (approach_cost(point), point))\n", + " self.visibility_probes += 1\n", + " return best\n", + " best = min(keep, key=lambda point: (\n", + " joint.expected_posterior_radius(point) + weight * approach_cost(point),\n", + " point))\n", + " self.visibility_probes += 1\n", + " return best\n", + "\n", + "\n", + "\n", + " def _next_probe(self, region: CandidateRegion, channel: int) -> Vec2:\n", + " \"\"\"定向遮挡下的探点升级梯子 [工程]。\n", + "\n", + " - 未被遮挡过:worst-posterior 选点器(保证接收盘内);\n", + " - 第 1 次被遮挡:对 MEC 圆心取对跖方向,偏移裁进保证接收盘;\n", + " - 之后:绕 MEC 圆心、半径 = ρ 的环探,方位自首个被遮挡点起\n", + " 每次 +45°。几何边界必须如实陈述:环上可见弧没有下界——\n", + " 源可位于环上(|g-c| = ρ)且朝向沿外切线,可见集退化为单个\n", + " 切点,固定步进环探可以全部漏过。梯子只提高提前命中概率,\n", + " 不构成接收或命中保证;完备性完全由后备清除兜底。\n", + " \"\"\"\n", + " visibility = self._visibility_probe(region, channel)\n", + " if visibility is not None:\n", + " return visibility\n", + " blocked = self.blocked_probes.get(channel, [])\n", + " center, radius = region.mec()\n", + " if not blocked:\n", + " return select_measurement_point(region, self.client.state.position,\n", + " self.config).position\n", + " safe = safe_disk_radius(region, self.config)\n", + " first = blocked[0]\n", + " if len(blocked) == 1 and safe >= 0:\n", + " dx, dy = center[0] - first[0], center[1] - first[1]\n", + " norm = math.hypot(dx, dy)\n", + " if norm <= 1e-9:\n", + " # 首个被遮挡探点恰在圆心:方向信息为零,沿 +x 走满安全盘。\n", + " return (center[0] + safe, center[1])\n", + " scale = min(1.0, safe / norm)\n", + " # 对跖方向,偏移模长 = min(norm, safe):圆心对称点裁进保证接收盘。\n", + " return (center[0] + dx * scale, center[1] + dy * scale)\n", + " base = math.atan2(first[1] - center[1], first[0] - center[0])\n", + " azimuth = base + math.radians(45.0 * (len(blocked) - 1))\n", + " return (center[0] + radius * math.cos(azimuth),\n", + " center[1] + radius * math.sin(azimuth))\n", + "\n", + " def _fallback_clear(self, channel: int, region: CandidateRegion) -> bool:\n", + " \"\"\"定位停滞的后备:候选集有限圆盘覆盖逐格清除(格数即请求上界)。\"\"\"\n", + " search, session = self.config.search, self.config.session\n", + " width = max(p[0] for p in region.polygon) - min(p[0] for p in region.polygon)\n", + " height = max(p[1] for p in region.polygon) - min(p[1] for p in region.polygon)\n", + " cells = fallback_clear_cells(region.polygon, self.config)\n", + " entry = {\"channel\": channel, \"cells\": len(cells),\n", + " \"bound\": fallback_cell_bound(width, height, self.config),\n", + " \"posterior_radius_m\": round(region.mec()[1], 3)}\n", + " self.fallback_log.append(entry)\n", + " if len(cells) > search.fallback_max_cells:\n", + " # 宽候选集盲扫违背预算纪律:留待定位收敛,不得硬扫 [计划§4]。\n", + " entry[\"outcome\"] = \"skipped_cell_cap\"\n", + " self._anomaly(\"fallback_cell_cap_exceeded\", channel,\n", + " cells=len(cells), cap=search.fallback_max_cells)\n", + " return False\n", + " # 现实预算核对:N 次请求 × 单请求高分位估计 + 退出余量,不足不启动。\n", + " remaining = self.client.real_remaining_s\n", + " if remaining is None or len(cells) * session.real_rtt_p95_s \\\n", + " + session.reserve_exit_s > remaining:\n", + " entry[\"outcome\"] = \"skipped_budget\"\n", + " return False\n", + " attempts = 0\n", + " order = self._fallback_order(cells)\n", + " for cell in order:\n", + " if self._virtual_cap_reached():\n", + " entry[\"outcome\"] = \"skipped_virtual_budget\"\n", + " entry[\"attempts\"] = attempts\n", + " return False\n", + " attempts += 1\n", + " outcome = self.client.clear(cell, channel)\n", + " if outcome.result == CLEAR_SUCCESS:\n", + " self.belief.mark_cleared(channel)\n", + " self.cleared.append(channel)\n", + " entry[\"outcome\"] = \"cleared\"\n", + " entry[\"attempts\"] = attempts\n", + " return True\n", + " # 候选集保守包含真源,格心距格内任一点 <= 10*sqrt(2) < 20m:全部\n", + " # 失败说明守恒性被打破,按异常留痕,不得据此删除候选。\n", + " entry[\"outcome\"] = \"exhausted\"\n", + " entry[\"attempts\"] = attempts\n", + " self._anomaly(\"fallback_exhausted_without_success\", channel,\n", + " cells=len(cells))\n", + " return False\n", + "\n", + " def _fallback_order(self, cells: list[Vec2]) -> list[Vec2]:\n", + " \"\"\"后备清除的走格顺序:最近邻打底 + 2-opt(可选 Or-opt)改进。\n", + "\n", + " 旧实现每步重新 ``min(remaining, key=dist)``,等价于最近邻开放路径\n", + " (典型为最优的 1.2–1.3 倍)且每次重扫 O(k²)。这里一次性求解改进序\n", + " (D3),把格间移动压到接近最优;格数不变,故完备性不变。\n", + " \"\"\"\n", + " if not cells:\n", + " return []\n", + " if not self.config.search.fallback_optimize_tour or len(cells) < 4:\n", + " position = self.client.state.position\n", + " remaining = list(cells)\n", + " order: list[Vec2] = []\n", + " while remaining:\n", + " nxt = min(remaining, key=lambda c: (math.dist(position, c), c))\n", + " remaining.remove(nxt)\n", + " order.append(nxt)\n", + " position = nxt\n", + " return order\n", + " return improve_open_path([tuple(c) for c in cells],\n", + " self.client.state.position)\n", + "\n", + " # ------------------------------------------------------------ 收尾\n", + " def _summary(self, info) -> dict:\n", + " report = super()._summary(info)\n", + " report[\"directional_observed_channels\"] = list(self.directional_observed)\n", + " report[\"fallback_clears\"] = self.fallback_log\n", + " # 后备 /clear 请求计入总请求口径,仅统计检测次数会低估现实耗时。\n", + " report[\"fallback_clear_attempts\"] = sum(\n", + " entry.get(\"attempts\", 0) for entry in self.fallback_log)\n", + " report[\"visibility_probes\"] = self.visibility_probes\n", + " report[\"joint_absent_channels\"] = list(self.joint_absent_channels)\n", + " report[\"joint_final\"] = {str(channel): belief.summary()\n", + " for channel, belief in sorted(self.joint.items())}\n", + " return report\n", + "\n", + "\n", + "def fallback_cell_bound(width_m: float, height_m: float,\n", + " config: ProblemConfig = ProblemConfig()) -> int:\n", + " \"\"\"外包框枚举的格数上界 (⌈w/cell⌉+2)(⌈h/cell⌉+2)(计划§4 规模检查)。\"\"\"\n", + " cell = config.search.fallback_clear_grid_m\n", + " return (math.ceil(width_m / cell) + 2) * (math.ceil(height_m / cell) + 2)\n", + "\n", + "\n", + "def fallback_clear_cells(polygon: list[Vec2],\n", + " config: ProblemConfig = ProblemConfig()) -> list[Vec2]:\n", + " \"\"\"候选外包多边形的 <=20m 有限圆盘覆盖格心列表(P5 后备清除)。\n", + "\n", + " 全局半开 20m 网格索引;多边形与格子的相交判定带正容差(宁可多测),\n", + " 多边形边界恰在格线上时两侧格子都保留,因此格内任意点距某格心\n", + " <= 10*sqrt(2) < 20m,逐格 /clear 至少一次命中。列表长度即 /clear\n", + " 请求预算上界;先核对 fallback_cell_bound,超预算不得逐格盲扫。\n", + " \"\"\"\n", + " if not polygon:\n", + " return []\n", + " cell = config.search.fallback_clear_grid_m\n", + " xs = [p[0] for p in polygon]\n", + " ys = [p[1] for p in polygon]\n", + " i0, i1 = math.floor(min(xs) / cell), math.floor(max(xs) / cell)\n", + " j0, j1 = math.floor(min(ys) / cell), math.floor(max(ys) / cell)\n", + " eps = 1e-9\n", + " centers: list[Vec2] = []\n", + " for i in range(i0, i1 + 1):\n", + " for j in range(j0, j1 + 1):\n", + " clipped = list(polygon)\n", + " for a, b, c in ((1.0, 0.0, (i + 1) * cell + eps),\n", + " (-1.0, 0.0, -(i * cell - eps)),\n", + " (0.0, 1.0, (j + 1) * cell + eps),\n", + " (0.0, -1.0, -(j * cell - eps))):\n", + " clipped = clip_halfplane(clipped, a, b, c, tol=eps)\n", + " if not clipped:\n", + " break\n", + " if clipped:\n", + " centers.append(((i + 0.5) * cell, (j + 0.5) * cell))\n", + " return centers\n" + ] + }, + { + "cell_type": "markdown", + "id": "dfc8e5b3", + "metadata": {}, + "source": [ + "### 6.3 第三问默认策略:贝叶斯网格滤波闭环(`planner/bayes_loop.py`)\n", + "\n", + "三层重构:\n", + "\n", + "1. **感知层**:每频道一个网格幸存集,全部读数(含 `no_signal` 负观测)即时更新,\n", + " 幸存集为空即缺席证明;\n", + "2. **决策层**:站点扫频、定位测量、清除三类任务合成统一任务表,\n", + " 开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP);\n", + "3. **选点层**:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)内\n", + " 按\"期望 log 幸存占比 + λ·移动时间\"评分(信息增益贪心)。\n", + "\n", + "相对基线的增益来自负观测跳站/跳测、PF 缺席证明提前证毕、全局任务排序。\n", + "清除证书仍以凸证书为主,PF 网格证书只在负观测挖洞使幸存集更紧时提前触发。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "5934cb40", + "metadata": {}, + "outputs": [], + "source": [ + "import math\n", + "from dataclasses import dataclass\n", + "\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class Task:\n", + " \"\"\"统一任务表的一项。service_s 为不含移动的固有耗时(检测+换频/清除)。\"\"\"\n", + "\n", + " kind: str # \"station\" | \"locate\" | \"clear\"\n", + " position: Vec2\n", + " service_s: float\n", + " channel: int | None = None\n", + " station_index: int | None = None\n", + " station_channels: tuple[int, ...] = ()\n", + "\n", + "\n", + "def _point_key(point: Vec2) -> tuple[int, int]:\n", + " \"\"\"候选点去重键:0.5m 网格取整(同一物理点不重复测量)。\"\"\"\n", + " return (int(round(point[0] * 2)), int(round(point[1] * 2)))\n", + "\n", + "\n", + "class BayesLoop(OmnidirectionalLoop):\n", + " \"\"\"问题3贝叶斯闭环:负观测感知 + 信息增益选点 + 滚动任务排序。\"\"\"\n", + "\n", + " def __init__(self, client: SimulatorClient,\n", + " config: ProblemConfig = ProblemConfig()):\n", + " super().__init__(client, config)\n", + " validate_seven_coverage(config.bayes.station_ring_radius_m, config)\n", + " self.coverage_plan = seven_plan(config.bayes.station_ring_radius_m)\n", + " self.ledger = CoverageLedger(\n", + " config, points=list(self.coverage_plan.points))\n", + " self.particles = {channel: ChannelParticles(config)\n", + " for channel in self.belief.channels}\n", + " self._measured_points: dict[int, set[tuple[int, int]]] = {}\n", + " self._clear_failed: set[int] = set() # 证书清除失败后须先补测再重试\n", + " self._stall: dict[int, int] = {} # 连续无收缩计数(异常留痕用)\n", + " self._skip_cache: dict[tuple[int, int], tuple[int, bool]] = {}\n", + " self.skipped_measures = 0 # 跳测规则省下的检测次数\n", + " self.no_shrink_measures = 0 # 未收缩幸存集的定位检测(诊断用)\n", + " self.pf_absent_channels: list[int] = [] # 由幸存集为空证明缺席的频道\n", + " self.opportunistic_measures = 0\n", + "\n", + " # ------------------------------------------------------------ 主循环\n", + " def _coverage_phase(self) -> None:\n", + " \"\"\"统一任务循环:发现 + 定位 + 清除滚动调度,直到任务表为空。\"\"\"\n", + " while self.stop_reason is None:\n", + " if self._count_limit_reached() or self._virtual_cap_reached():\n", + " return\n", + " if not self._step_once():\n", + " return\n", + "\n", + " def _step_once(self) -> bool:\n", + " \"\"\"构建任务表、排序并执行首任务;无可执行任务返回 False。\"\"\"\n", + " if self.config.bayes.opportunistic_measure:\n", + " self._measure_at_stop()\n", + " tasks = self._build_tasks()\n", + " if not tasks:\n", + " return False\n", + " tasks = self._refine_locate_tasks(tasks)\n", + " order = self._order_tasks(tasks)\n", + " self._execute_task(order[0])\n", + " return True\n", + "\n", + " def _measure_at_stop(self) -> None:\n", + " \"\"\"利用已停靠位置补交会方位;评分只作选测,证书仍用完整角域。\n", + "\n", + " 只测尚未在此处测过、有望显著收缩且保证距离可接收的已发现频道。\n", + " 逐频道调用measure,换频与5秒检测照常计费;不在移动中检测。\n", + " 此距离保证仅适用于第三问,不能用于未知定向源。\n", + " \"\"\"\n", + " point = self.client.state.position\n", + " channels = self.belief.channels_by_status(ChannelStatus.DETECTED)\n", + " channels.sort(key=lambda c: (c != self.client.state.channel, c))\n", + " for channel in channels:\n", + " if self._virtual_cap_reached():\n", + " return\n", + " if _point_key(point) in self._measured_points.get(channel, set()):\n", + " continue\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " continue\n", + " region = self.belief.candidate_set(channel)\n", + " farthest = max(math.dist(point, v) for v in region.polygon)\n", + " if farthest > self.config.field.r_eff_min_m - 1e-3:\n", + " continue\n", + " radius = region.mec()[1]\n", + " vertices = region.polygon\n", + " stride = max(1, len(vertices) // self.config.bayes.mc_representatives)\n", + " radii = []\n", + " delta = (self.config.field.bearing_error_half_deg\n", + " + self.config.search.bearing_rounding_margin_deg)\n", + " for target in vertices[::stride]:\n", + " theta = math.degrees(math.atan2(target[1]-point[1], target[0]-point[0]))\n", + " probe = CandidateRegion(list(vertices), region.slack_m)\n", + " probe.clip_wedge(point, theta, delta)\n", + " radii.append(probe.mec()[1] if probe.polygon else radius)\n", + " if sum(radii) / len(radii) > radius * self.config.bayes.opportunistic_radius_ratio:\n", + " continue\n", + " outcome = self.client.measure(point, channel)\n", + " self._record_measure(channel, point, outcome)\n", + " self._may_clear.add(channel)\n", + " self.opportunistic_measures += 1\n", + "\n", + " # ------------------------------------------------------------ 任务构建\n", + " def _station_pending(self, index: int) -> list[int]:\n", + " \"\"\"站点待测频道:UNKNOWN、未测过、非\"必 no_signal\"且未证缺席。\"\"\"\n", + " point = self.ledger.points[index]\n", + " pending = []\n", + " for channel, belief in self.belief.channels.items():\n", + " if belief.status is not ChannelStatus.UNKNOWN:\n", + " continue\n", + " if self.ledger.measured_at(channel, index):\n", + " continue\n", + " if self._skip_station_channel(channel, index, point):\n", + " continue\n", + " pending.append(channel)\n", + " return pending\n", + "\n", + " def _fused_channels_at(self, index: int) -> list[int]:\n", + " \"\"\"本站要测的频道 = 未发现(UNKNOWN) + 已发现但尚未定证(DETECTED)。\n", + "\n", + " P0-1:把定位并入覆盖巡回。站点三角化可行性(方案 §1.3 F11):\n", + " 99.28% 的源位置有 ≥2 个站在 r_eff_max=1500m 内,最佳交会角中位\n", + " 122.9°、5 分位 58.5°,因此\"顺路取第二条方位线\"对绝大多数源成立。\n", + " 已有证书、已在同一 0.5m 测点测过、或该点必 no_signal 的频道跳过。\n", + "\n", + " 实测结论(2026-09-12,`output/suite/q3-fuse-ab*`):本机制**不划算**。\n", + " 七站环上通常只有 2–3 个站能对该源取得有效方位线,交会角也不足以\n", + " 直接换到 20m 证书,专用信息增益探点仍然必要;仅在站点顺带测量等于\n", + " 平白多花约 11 次检测(+0.8%),若同时抑制专用探点则 +45.6%(20/20\n", + " 场景更慢)。默认关闭,保留代码以便复现与后续研究。\n", + " \"\"\"\n", + " point = self.ledger.points[index]\n", + " channels = self._station_pending(index)\n", + " selected = set(channels)\n", + " if not self.config.bayes.fuse_localize_into_tour:\n", + " return channels\n", + " for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n", + " if channel in selected or self.ledger.measured_at(channel, index):\n", + " continue\n", + " if _point_key(point) in self._measured_points.get(channel, set()):\n", + " continue\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " continue # 已有证书:交给清除任务,不再多测\n", + " if self._skip_station_channel(channel, index, point):\n", + " continue # 幸存集都在 1500m 外:该点必 no_signal\n", + " channels.append(channel)\n", + " selected.add(channel)\n", + " return channels\n", + "\n", + " def _skip_station_channel(self, channel: int, index: int, point: Vec2) -> bool:\n", + " \"\"\"跳测规则(按 pf.version 缓存;幸存集单调收缩,判定单调)。\"\"\"\n", + " pf = self.particles[channel]\n", + " if not pf:\n", + " return True\n", + " cached = self._skip_cache.get((channel, index))\n", + " if cached is not None and cached[0] == pf.version:\n", + " return cached[1]\n", + " value = pf.guaranteed_no_signal(point)\n", + " self._skip_cache[(channel, index)] = (pf.version, value)\n", + " return value\n", + "\n", + " def _build_tasks(self) -> list[Task]:\n", + " tasks: list[Task] = []\n", + " for index, point in enumerate(self.ledger.points):\n", + " pending = self._fused_channels_at(index)\n", + " if not pending:\n", + " continue\n", + " switches = len(pending) - (1 if self.client.state.channel in pending else 0)\n", + " tasks.append(Task(\"station\", tuple(point),\n", + " self.config.cost.measure_s * len(pending)\n", + " + self.config.cost.switch_s * max(switches, 0),\n", + " station_index=index, station_channels=tuple(pending)))\n", + " for channel in self.belief.channels_by_status(ChannelStatus.DETECTED):\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified \\\n", + " and channel not in self._clear_failed:\n", + " tasks.append(Task(\"clear\", cert.center,\n", + " self.config.cost.clear_success_s,\n", + " channel=channel))\n", + " continue\n", + " if self._defer_to_station(channel):\n", + " continue # 下一条方位线可由\"本就要去的站点\"顺路提供\n", + " point = self._select_point(channel)\n", + " if point is None:\n", + " continue\n", + " tasks.append(Task(\"locate\", point,\n", + " self.config.cost.measure_s + self.config.cost.switch_s,\n", + " channel=channel))\n", + " return tasks\n", + "\n", + " def _defer_to_station(self, channel: int) -> bool:\n", + " \"\"\"该频道能否由\"本就要去的站点\"提供下一条方位线。\n", + "\n", + " P0-1 的关键:仅仅在站点顺带测量是不够的(那只是多做一次检测),\n", + " 必须同时**抑制专用探点任务**,否则滚动排序仍会优先飞向更近的探点。\n", + " 判据要求该站本来就有 UNKNOWN 频道待测(覆盖巡回必须去),因此不会\n", + " 为了等一条方位线而制造新的绕行,也不会出现\"无任务可做\"的死锁:\n", + " 站点扫完后 ``_station_pending`` 为空,探点任务自然恢复。\n", + " \"\"\"\n", + " if not self.config.bayes.fuse_localize_into_tour:\n", + " return False\n", + " for index, point in enumerate(self.ledger.points):\n", + " if not self._station_pending(index):\n", + " continue\n", + " if self.ledger.measured_at(channel, index):\n", + " continue\n", + " if _point_key(point) in self._measured_points.get(channel, set()):\n", + " continue\n", + " if self._skip_station_channel(channel, index, point):\n", + " continue\n", + " return True\n", + " return False\n", + "\n", + " # ------------------------------------------------------------ 任务排序\n", + " def _order_tasks(self, tasks: list[Task]) -> list[Task]:\n", + " \"\"\"开放路径排序:小任务表用精确 DP,否则最近邻 + 2-opt。\n", + "\n", + " Held-Karp(S5/T1)把\"已访问集合 × 结束时测向频道\"并入状态,因此换频\n", + " 代价是精确的,而不是 :meth:`_path_cost` 里\"按频道排序\"的贪心近似。\n", + " \"\"\"\n", + " if len(tasks) <= 2:\n", + " ordered = sorted(tasks, key=lambda t: (\n", + " move_time(self.client.state.position, t.position, self.config.cost),\n", + " t.kind, t.channel or 0))\n", + " return min((ordered, ordered[::-1]), key=self._path_cost)\n", + " limit = self.config.bayes.exact_order_limit\n", + " if 0 < limit and len(tasks) <= limit:\n", + " exact = self._exact_order(tasks)\n", + " if exact is not None:\n", + " return exact\n", + " order = self._nearest_order(tasks)\n", + " if not self.config.bayes.two_opt_enabled:\n", + " return order\n", + " return self._two_opt(order)\n", + "\n", + " def _service_and_exit(self, task: Task,\n", + " channel: int) -> tuple[float, int]:\n", + " \"\"\"任务在进入频道 ``channel`` 时的固有耗时与离开后的测向频道。\"\"\"\n", + " cost = self.config.cost\n", + " if task.kind == \"locate\":\n", + " return (cost.measure_s + (cost.switch_s if channel != task.channel else 0.0),\n", + " task.channel)\n", + " if task.kind == \"station\" and task.station_channels:\n", + " pending = sorted(task.station_channels,\n", + " key=lambda c: (c != channel, c))\n", + " switches = len(pending) - (1 if channel in pending else 0)\n", + " return (cost.measure_s * len(pending) + cost.switch_s * switches,\n", + " pending[-1])\n", + " return task.service_s, channel\n", + "\n", + " def _exact_order(self, tasks: list[Task]) -> list[Task] | None:\n", + " \"\"\"Held-Karp 精确开放路径(起点固定、终点自由,含换频状态)。\n", + "\n", + " 状态 ``(mask, c)`` = 已访问任务集 mask、离开最后一个任务后测向频道为 c;\n", + " 频道取值域只需 {当前频道} ∪ {locate 频道} ∪ {station 离开频道}。\n", + " 规模 ``2^n·n·|C|``;n 超过 :attr:`BayesConfig.exact_order_limit` 时由\n", + " 调用方回退启发式。返回 ``None`` 表示状态过大(防御性)。\n", + " \"\"\"\n", + " n = len(tasks)\n", + " if n <= 1:\n", + " return list(tasks)\n", + " start = self.client.state.position\n", + " init = self.client.state.channel\n", + " channels = {init}\n", + " for task in tasks:\n", + " if task.kind == \"locate\":\n", + " channels.add(task.channel)\n", + " elif task.kind == \"station\" and task.station_channels:\n", + " # 离开频道 = 按\"当前频道优先\"排序后的最后一个:通常是最大值;\n", + " # 进入频道恰为最大值时退化为次大值,故两者都要进状态空间。\n", + " ordered = sorted(task.station_channels)\n", + " channels.add(ordered[-1])\n", + " if len(ordered) > 1:\n", + " channels.add(ordered[-2])\n", + " channels = sorted(channels)\n", + " width = len(channels)\n", + " if width * (1 << n) * n > 4_000_000:\n", + " return None\n", + " index_of = {channel: i for i, channel in enumerate(channels)}\n", + " c_init = index_of[init]\n", + " service: list[list[float]] = []\n", + " exits: list[list[int]] = []\n", + " for task in tasks:\n", + " row_service, row_exit = [], []\n", + " for channel in channels:\n", + " value, exit_channel = self._service_and_exit(task, channel)\n", + " row_service.append(value)\n", + " row_exit.append(index_of[exit_channel])\n", + " service.append(row_service)\n", + " exits.append(row_exit)\n", + " move = [[move_time(tasks[i].position, tasks[j].position, self.config.cost)\n", + " for j in range(n)] for i in range(n)]\n", + " from_start = [move_time(start, task.position, self.config.cost)\n", + " for task in tasks]\n", + " inf = float(\"inf\")\n", + " # dp[(mask, ci)] = 最小代价;parent[(mask, ci)] = (上一 mask, 上一 ci)\n", + " dp: dict[tuple[int, int], float] = {}\n", + " last_task: dict[tuple[int, int], int] = {}\n", + " parent: dict[tuple[int, int], tuple[int, int] | None] = {}\n", + " for k in range(n):\n", + " key = (1 << k, exits[k][c_init])\n", + " value = from_start[k] + service[k][c_init]\n", + " if value < dp.get(key, inf):\n", + " dp[key] = value\n", + " last_task[key] = k\n", + " parent[key] = None\n", + " for mask in range(1, 1 << n):\n", + " for ci in range(width):\n", + " state = (mask, ci)\n", + " cost = dp.get(state)\n", + " if cost is None:\n", + " continue\n", + " for k in range(n):\n", + " if (mask >> k) & 1:\n", + " continue\n", + " nxt = (mask | (1 << k), exits[k][ci])\n", + " value = cost + move[last_task[state]][k] + service[k][ci]\n", + " if value < dp.get(nxt, inf) - 1e-12:\n", + " dp[nxt] = value\n", + " last_task[nxt] = k\n", + " parent[nxt] = state\n", + " full = (1 << n) - 1\n", + " best_key, best_cost = None, inf\n", + " for ci in range(width):\n", + " state = (full, ci)\n", + " if state in dp and dp[state] < best_cost:\n", + " best_key, best_cost = state, dp[state]\n", + " if best_key is None:\n", + " return None\n", + " order: list[Task] = []\n", + " key: tuple[int, int] | None = best_key\n", + " while key is not None:\n", + " order.append(tasks[last_task[key]])\n", + " key = parent[key]\n", + " order.reverse()\n", + " return order\n", + "\n", + "\n", + "\n", + " def _nearest_order(self, tasks: list[Task]) -> list[Task]:\n", + " remaining = list(tasks)\n", + " order = []\n", + " position = self.client.state.position\n", + " while remaining:\n", + " best = min(remaining, key=lambda t: (\n", + " move_time(position, t.position, self.config.cost),\n", + " t.kind, t.channel or 0))\n", + " remaining.remove(best)\n", + " order.append(best)\n", + " position = best.position\n", + " return order\n", + "\n", + " def _path_cost(self, order: list[Task]) -> float:\n", + " total = 0.0\n", + " position = self.client.state.position\n", + " channel = self.client.state.channel\n", + " for task in order:\n", + " total += move_time(position, task.position, self.config.cost)\n", + " if task.kind == \"locate\":\n", + " total += self.config.cost.measure_s\n", + " total += self.config.cost.switch_s * (channel != task.channel)\n", + " channel = task.channel\n", + " elif task.kind == \"station\" and task.station_channels:\n", + " pending = sorted(\n", + " task.station_channels,\n", + " key=lambda c, channel=channel: (c != channel, c))\n", + " total += self.config.cost.measure_s * len(pending)\n", + " total += self.config.cost.switch_s * (len(pending) - (channel in pending))\n", + " channel = pending[-1]\n", + " else:\n", + " total += task.service_s # 清除不改变测向频道\n", + " position = task.position\n", + " return total\n", + "\n", + " def _two_opt(self, order: list[Task]) -> list[Task]:\n", + " best = list(order)\n", + " best_cost = self._path_cost(best)\n", + " for _ in range(self.config.bayes.two_opt_passes):\n", + " improved = False\n", + " for i in range(len(best) - 1):\n", + " for j in range(i + 1, len(best)):\n", + " candidate = best[:i] + best[i:j + 1][::-1] + best[j + 1:]\n", + " cost = self._path_cost(candidate)\n", + " if cost < best_cost - 1e-6:\n", + " best, best_cost, improved = candidate, cost, True\n", + " if not improved:\n", + " break\n", + " return best\n", + "\n", + " # ------------------------------------------------------------ 任务执行\n", + " def _execute_task(self, task: Task) -> None:\n", + " if task.kind == \"station\":\n", + " self._execute_station(task.station_index)\n", + " elif task.kind == \"clear\":\n", + " cert = self._best_certificate(task.channel)\n", + " if cert is not None and cert.certified:\n", + " if not self._attempt_clear(task.channel, cert.center, cert):\n", + " self._clear_failed.add(task.channel)\n", + " self._may_clear.discard(task.channel)\n", + " else:\n", + " self._clear_failed.discard(task.channel)\n", + " elif task.kind == \"locate\":\n", + " self._execute_locate(task.channel, task.position)\n", + "\n", + " def _execute_station(self, index: int) -> None:\n", + " \"\"\"单站批量扫频:只测待测频道(跳测规则已过滤),扫完即返回重排。\n", + "\n", + " P0-1:本站同时测量\"已发现未定证\"频道,使第二/第三条方位线在巡回\n", + " 途中顺路取得;证书一旦成立即就地清除,不额外绕行。\n", + " \"\"\"\n", + " point = self.ledger.points[index]\n", + " pending = self._fused_channels_at(index)\n", + " unknown = [c for c, b in self.belief.channels.items()\n", + " if b.status is ChannelStatus.UNKNOWN]\n", + " self.skipped_measures += len(unknown) - len(\n", + " [c for c in pending if self.belief.channels[c].status\n", + " is ChannelStatus.UNKNOWN])\n", + " # 当前频道优先,减少换频;同站 near 直接清除不打断扫频\n", + " pending.sort(key=lambda c: (c != self.client.state.channel, c))\n", + " for channel in pending:\n", + " if self.stop_reason is not None or self._count_limit_reached() \\\n", + " or self._virtual_cap_reached():\n", + " return\n", + " detected_before = (self.belief.channels[channel].status\n", + " is ChannelStatus.DETECTED)\n", + " outcome = self.client.measure(point, channel)\n", + " self.ledger.mark_measured(channel, index)\n", + " self._record_measure(channel, point, outcome)\n", + " if outcome.result in (DIRECTION, NEAR):\n", + " self._may_clear.add(channel)\n", + " if outcome.result == NEAR:\n", + " region = self.belief.candidate_set(channel)\n", + " cert = clear_certificate(region, channel, self.config)\n", + " if cert.certified:\n", + " if self._attempt_clear(channel, point, cert):\n", + " self._clear_failed.discard(channel)\n", + " else:\n", + " self._clear_failed.add(channel)\n", + " self._may_clear.discard(channel)\n", + " else:\n", + " self._anomaly(\"near_without_certificate\", channel, position=point)\n", + " continue\n", + " if not detected_before:\n", + " continue\n", + " if outcome.result == NO_SIGNAL:\n", + " # 该频道已被发现:保证接收盘内不可能 no_signal,属守恒性异常\n", + " region = self.belief.candidate_set(channel)\n", + " safe = (safe_disk_radius(region, self.config)\n", + " if region is not None else -1.0)\n", + " if safe >= 0 and math.dist(point, region.mec()[0]) <= safe:\n", + " self._anomaly(\"no_signal_in_safe_disk\", channel,\n", + " position=point)\n", + " continue\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " self._clear_failed.discard(channel)\n", + " else:\n", + " self._clear_failed.add(channel)\n", + " self._may_clear.discard(channel)\n", + "\n", + " def _execute_locate(self, channel: int, point: Vec2) -> None:\n", + " \"\"\"单步定位测量:执行后立即重排(滚动时域),收敛则顺路清除。\"\"\"\n", + " pf = self.particles[channel]\n", + " before = len(pf)\n", + " outcome = self.client.measure(point, channel)\n", + " self._record_measure(channel, point, outcome)\n", + " self._may_clear.add(channel)\n", + " if outcome.result == NO_SIGNAL:\n", + " region = self.belief.candidate_set(channel)\n", + " if region is not None and safe_disk_radius(region, self.config) >= 0 \\\n", + " and math.dist(point, region.mec()[0]) \\\n", + " <= safe_disk_radius(region, self.config):\n", + " self._anomaly(\"no_signal_in_safe_disk\", channel, position=point)\n", + " after = len(pf)\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " self._clear_failed.discard(channel)\n", + " self._stall.pop(channel, None)\n", + " return\n", + " self._clear_failed.add(channel)\n", + " self._may_clear.discard(channel)\n", + " if after == before:\n", + " self._stall[channel] = self._stall.get(channel, 0) + 1\n", + " self.no_shrink_measures += 1 # 只是浪费不破坏守恒性,不进异常表\n", + " else:\n", + " self._stall[channel] = 0\n", + "\n", + " # ------------------------------------------------------------ 感知与证书\n", + " def _record_measure(self, channel: int, position: Vec2, outcome) -> None:\n", + " \"\"\"belief(凸证书)与 PF(幸存集)同步更新;PF 空即证缺席。\"\"\"\n", + " self.belief.update_measure(channel, position, outcome.result,\n", + " outcome.svd_deg)\n", + " # 同一 0.5m 测点 × 同一频道的重复检测:历史上一次官方演练有 98.84%\n", + " # 的虚拟时间耗在空复扫上,本计数是该问题的回归指标。\n", + " key = _point_key(position)\n", + " seen = self._measured_points.setdefault(channel, set())\n", + " if key in seen:\n", + " self.redundant_rescan_measures += 1\n", + " seen.add(key)\n", + " self._clear_failed.discard(channel) # 新证据落地:允许再次尝试清除\n", + " belief = self.belief.channels[channel]\n", + " if belief.status in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):\n", + " return # 已证毕:清除/缺席后的 no_signal 与距离无关,不进 PF\n", + " pf = self.particles[channel]\n", + " pf.apply_measure(position, outcome.result, outcome.svd_deg)\n", + " if not pf:\n", + " if belief.status is ChannelStatus.UNKNOWN:\n", + " self.belief.mark_absent(channel)\n", + " self.absence_evidence[channel] = \"pf_empty\"\n", + " if channel not in self.pf_absent_channels:\n", + " self.pf_absent_channels.append(channel)\n", + " else:\n", + " self._anomaly(\"pf_empty_while_detected\", channel, position=position)\n", + " self._apply_count_bound()\n", + "\n", + " def _apply_count_bound(self) -> None:\n", + " \"\"\"存在证据达到题面源数上界时,其余 UNKNOWN 可由计数直接判缺席。\n", + "\n", + " 依据 [题面]:每频道至多一个源、全局限定 10..16 个源。一旦\n", + " DETECTED/CLEARED 的不同频道数达到 16,任何尚未出现有效信号的\n", + " 频道都不可能是源,无须等 7 站覆盖账本跑完。该判定与位置无关,\n", + " 是严格的计数证明;evidence 与 coverage/pf_empty 分开留痕。\n", + " \"\"\"\n", + " present = [channel for channel, belief in self.belief.channels.items()\n", + " if belief.status in (ChannelStatus.DETECTED,\n", + " ChannelStatus.CLEARED)]\n", + " if len(present) < self.config.field.source_count_max:\n", + " return\n", + " for channel, belief in self.belief.channels.items():\n", + " if belief.status is ChannelStatus.UNKNOWN:\n", + " belief.status = ChannelStatus.ABSENT\n", + " self.absence_evidence[channel] = \"count_bound\"\n", + "\n", + " def _best_certificate(self, channel: int) -> ClearCertificate | None:\n", + " \"\"\"凸证书优先;负观测挖洞使 PF 幸存集更紧时用网格证书补位。\"\"\"\n", + " region = self.belief.candidate_set(channel)\n", + " cert = clear_certificate(region, channel, self.config) \\\n", + " if region is not None else None\n", + " if cert is not None and cert.certified:\n", + " return self._near_clear_target(cert)\n", + " if self.config.bayes.grid_cert_enabled:\n", + " pf = self.particles[channel]\n", + " if len(pf):\n", + " center, radius = pf.mec()\n", + " limit = (self.config.field.clear_radius_m - pf.margin_m\n", + " - self.config.search.clear_margin_m)\n", + " if radius <= limit:\n", + " return self._near_clear_target(\n", + " ClearCertificate(channel, center, radius, limit, True))\n", + " return cert\n", + "\n", + " def _near_clear_target(self, cert: ClearCertificate) -> ClearCertificate:\n", + " \"\"\"沿当前来向利用20m清除容差;三角不等式保留整集覆盖证明。\"\"\"\n", + " position = self.client.state.position\n", + " distance = math.dist(position, cert.center)\n", + " shift = min(distance, max(0.0, cert.limit_m - cert.radius_m - 1e-6))\n", + " if distance <= 1e-9 or shift <= 0:\n", + " return cert\n", + " target = tuple(c + (p-c)*shift/distance for p, c in zip(position, cert.center))\n", + " return ClearCertificate(cert.channel, target, cert.radius_m+shift, cert.limit_m, True)\n", + "\n", + " # ------------------------------------------------------------ 信息增益选点\n", + " def _probe_context(self, channel: int):\n", + " \"\"\"构造某频道下一次定位测量的候选集与信息增益评分函数。\n", + "\n", + " 返回 ``(candidates, info_score, mec_center, current_position)``;\n", + " 候选为空返回 ``None``。``_select_point`` 与路线感知精修共用此逻辑,\n", + " 保证两者只差“移动代价如何折算”。\n", + " \"\"\"\n", + " bayes, search = self.config.bayes, self.config.search\n", + " pf = self.particles[channel]\n", + " if not pf:\n", + " return None\n", + " region = self.belief.candidate_set(channel)\n", + " center, radius = pf.mec()\n", + " measured = self._measured_points.get(channel, set())\n", + " position = self.client.state.position\n", + " candidates: list[Vec2] = []\n", + " safe = safe_disk_radius(region, self.config) if region is not None else -1.0\n", + " if region is not None and safe >= 0:\n", + " # 保证接收盘(凸证书口径):任意候选必产生 direction/near\n", + " candidates = candidate_points(tuple(region.mec()[0]), safe,\n", + " search.candidate_rings,\n", + " search.candidate_angles)\n", + " # 以实际来向生成对称交会候选,避免固定全局角度网格的旋转偏差。\n", + " geometric_center = region.mec()[0]\n", + " base_angle = math.atan2(position[1] - geometric_center[1],\n", + " position[0] - geometric_center[0])\n", + " for fraction in search.candidate_rings:\n", + " if fraction <= 0:\n", + " continue\n", + " for offset in (-math.pi/3, -math.pi/6, 0, math.pi/6, math.pi/3):\n", + " angle = base_angle + offset\n", + " candidates.append((geometric_center[0] + safe * fraction * math.cos(angle),\n", + " geometric_center[1] + safe * fraction * math.sin(angle)))\n", + " # 已停靠位置若本身在安全接收盘内,是零移动的合法测点;显式加入\n", + " # 可避免为了固定角度环而白白横移。\n", + " if math.dist(position, geometric_center) <= safe + 1e-9:\n", + " candidates.append(tuple(position))\n", + " else:\n", + " # 宽候选集:PF MEC 探索环(负观测挖洞后幸存集的包围圆)\n", + " for fraction in bayes.wide_rings:\n", + " ring = max(radius * fraction, 2.0 * pf.margin_m)\n", + " candidates.extend(candidate_points(center, ring, (1.0,),\n", + " search.candidate_angles))\n", + " candidates.append(tuple(center))\n", + " candidates.append(tuple(position))\n", + " candidates = [tuple(c) for c in candidates\n", + " if _point_key(c) not in measured]\n", + " if not candidates:\n", + " return None\n", + " # 采样一次转成数组(numpy 可用时),使候选评分走向量路径;\n", + " # numpy 缺失时 as_sample_array 原样返回列表,语义不变。\n", + " subsample = as_sample_array(pf.subsample(bayes.score_subsample))\n", + " stride = max(len(subsample) // bayes.mc_representatives, 1)\n", + " representatives = subsample[::stride]\n", + "\n", + " def info_score(q: Vec2) -> float:\n", + " if bayes.score_mode == \"entropy\":\n", + " return pf.expected_survival(q, subsample, representatives)\n", + " return pf.expected_posterior_radius(q, subsample, representatives)\n", + "\n", + " return candidates, info_score, center, position\n", + "\n", + " def _select_point(self, channel: int) -> Vec2 | None:\n", + " \"\"\"定位测量点:期望 log 幸存占比 + λ·移动时间 最小的候选。\"\"\"\n", + " context = self._probe_context(channel)\n", + " if context is None:\n", + " return None\n", + " candidates, info_score, center, position = context\n", + " bayes = self.config.bayes\n", + "\n", + " def score(q: Vec2) -> float:\n", + " # 一次定位后仍需接近源才能清除;仅计到检测点的移动会偏好绕行。\n", + " move = bayes.lambda_move_s * (move_time(position, q, self.config.cost)\n", + " + move_time(q, center, self.config.cost))\n", + " return info_score(q) + move\n", + "\n", + " return min(candidates, key=lambda q: (score(q), _point_key(q)))\n", + "\n", + " def _refine_locate_tasks(self, tasks: list[Task]) -> list[Task]:\n", + " \"\"\"路线感知精修定位任务点(P2,2026-09-12)。\n", + "\n", + " 旧评分只比较 ``current->q + q->MEC``,是局部代理;滚动 2-opt 的真实\n", + " 代价还包含 ``q->next_task``。这里对每个 locate 任务在候选点上计算\n", + " “替换该任务后整条当前路线代价的增量”,再与信息增益、MEC 接近项合并\n", + " 选点。只改变选点启发式,不改变覆盖/清除证明。\n", + " \"\"\"\n", + " bayes = self.config.bayes\n", + " if not bayes.route_aware_probe:\n", + " return tasks\n", + " locate_indices = [index for index, task in enumerate(tasks)\n", + " if task.kind == \"locate\"]\n", + " if not locate_indices:\n", + " return tasks\n", + " base_order = self._order_tasks(tasks)\n", + " base_cost = self._path_cost(base_order)\n", + " refined = list(tasks)\n", + " for task_index in locate_indices:\n", + " task = tasks[task_index]\n", + " context = self._probe_context(task.channel)\n", + " if context is None:\n", + " continue\n", + " candidates, info_score, center, position = context\n", + " try:\n", + " order_index = next(index for index, item in enumerate(base_order)\n", + " if item is task)\n", + " except StopIteration: # pragma: no cover - 同对象应始终存在\n", + " order_index = next(index for index, item in enumerate(base_order)\n", + " if item.kind == \"locate\"\n", + " and item.channel == task.channel)\n", + " best_score: float | None = None\n", + " best_point: Vec2 | None = None\n", + " for point in candidates:\n", + " trial_order = list(base_order)\n", + " trial_order[order_index] = Task(\n", + " \"locate\", tuple(point), task.service_s, channel=task.channel)\n", + " route_delta = self._path_cost(trial_order) - base_cost\n", + " move_proxy = bayes.lambda_move_s * (\n", + " move_time(position, point, self.config.cost)\n", + " + move_time(point, center, self.config.cost))\n", + " clear_near = move_time(point, center, self.config.cost)\n", + " value = (info_score(point)\n", + " + move_proxy\n", + " + bayes.route_aware_insert_weight * route_delta\n", + " + bayes.route_aware_clear_weight * clear_near)\n", + " if best_score is None or value < best_score:\n", + " best_score, best_point = value, tuple(point)\n", + " if best_point is not None:\n", + " refined[task_index] = Task(\n", + " \"locate\", best_point, task.service_s, channel=task.channel)\n", + " return refined\n", + "\n", + " # ------------------------------------------------------------ 安全网与复扫\n", + " def _localize_and_clear(self, channel: int) -> bool:\n", + " \"\"\"阻塞式定位子循环(resolve 阶段安全网):PF 选点,失败退凸选点。\"\"\"\n", + " search = self.config.search\n", + " self._may_clear.add(channel)\n", + " for _ in range(search.max_localize_steps):\n", + " if self._virtual_cap_reached():\n", + " return False\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified and channel in self._may_clear:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " self._clear_failed.discard(channel)\n", + " return True\n", + " self._clear_failed.add(channel)\n", + " self._may_clear.discard(channel)\n", + " continue\n", + " point = self._select_point(channel)\n", + " if point is None:\n", + " region = self.belief.candidate_set(channel)\n", + " if region is None:\n", + " return False\n", + " point = select_measurement_point(\n", + " region, self.client.state.position, self.config).position\n", + " outcome = self.client.measure(point, channel)\n", + " self._record_measure(channel, point, outcome)\n", + " self._may_clear.add(channel)\n", + " if outcome.result == NO_SIGNAL:\n", + " region = self.belief.candidate_set(channel)\n", + " if region is not None and safe_disk_radius(region, self.config) >= 0 \\\n", + " and math.dist(point, region.mec()[0]) \\\n", + " <= safe_disk_radius(region, self.config):\n", + " self._anomaly(\"no_signal_in_safe_disk\", channel, position=point)\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " if self._attempt_clear(channel, cert.center, cert):\n", + " self._clear_failed.discard(channel)\n", + " return True\n", + " self._clear_failed.add(channel)\n", + " self._may_clear.discard(channel)\n", + " return self.belief.channels[channel].status is ChannelStatus.CLEARED\n", + "\n", + " def _persistent_round(self) -> None:\n", + " \"\"\"一轮复扫:与基线一致的全站复测,但读数同步进 PF(防假设偏差)。\"\"\"\n", + " measured: set[tuple[int, int]] = set()\n", + " while self.stop_reason is None:\n", + " index = self._next_resweep_point(measured)\n", + " if index is None:\n", + " return\n", + " point = self.ledger.points[index]\n", + " for channel in self._resweep_channels_at(index, measured):\n", + " if self._virtual_cap_reached():\n", + " return\n", + " measured.add((channel, index))\n", + " was_absent = (self.belief.channels[channel].status\n", + " is ChannelStatus.ABSENT)\n", + " outcome = self.client.measure(point, channel)\n", + " self._record_measure(channel, point, outcome)\n", + " if was_absent and outcome.result in (DIRECTION, NEAR):\n", + " # 复测推翻了缺席证明:守恒性留痕,按新证据定位清除。\n", + " self._anomaly(\"absent_detected_on_resweep\", channel,\n", + " position=point, result=outcome.result)\n", + " if outcome.result == DIRECTION:\n", + " self._localize_and_clear(channel)\n", + " elif outcome.result == NEAR:\n", + " cert = self._best_certificate(channel)\n", + " if cert is not None and cert.certified:\n", + " if not self._attempt_clear(channel, cert.center, cert):\n", + " self._clear_failed.add(channel)\n", + " else:\n", + " self._anomaly(\"near_without_certificate\", channel,\n", + " position=point)\n", + " if self.stop_reason is not None:\n", + " return\n", + "\n", + " # ------------------------------------------------------------ 收尾\n", + " def _summary(self, info) -> dict:\n", + " report = super()._summary(info)\n", + " report[\"strategy\"] = \"bayes\"\n", + " report[\"opportunistic_measures\"] = self.opportunistic_measures\n", + " report[\"skipped_measures\"] = self.skipped_measures\n", + " report[\"no_shrink_measures\"] = self.no_shrink_measures\n", + " report[\"pf_absent_channels\"] = sorted(self.pf_absent_channels)\n", + " report[\"pf_alive_final\"] = {\n", + " str(channel): len(self.particles[channel])\n", + " for channel in sorted(self.particles)\n", + " if self.belief.channels[channel].status is ChannelStatus.DETECTED}\n", + " return report\n" + ] + }, + { + "cell_type": "markdown", + "id": "52d71ea9", + "metadata": {}, + "source": [ + "### 6.4 第三问对照策略:七站批量扫频(`planner/seven.py`)\n", + "\n", + "同一骨架换覆盖计划:7 站解析覆盖 + 站内批量扫频 + 按\"距候选集 MEC 圆心最近\"服务队列。\n", + "它曾以官方案例 12/12 全清通过演练,是\"覆盖证明 + 批量调度\"的中间对照。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "0c911581", + "metadata": {}, + "outputs": [], + "source": [ + "class SevenBatch(OmnidirectionalLoop):\n", + " \"\"\"站内只扫频,扫完按最近候选集圆心顺序服务检测队列(A2 主策略)。\"\"\"\n", + "\n", + "\n", + " def _serve_queue(self):\n", + " # Each detected channel gets at most one localization call in this pass.\n", + " queue = self.belief.channels_by_status(ChannelStatus.DETECTED)\n", + " while queue:\n", + " c = min(queue, key=lambda c: (math.dist(self.client.state.position,\n", + " self.belief.candidate_set(c).mec()[0]), c))\n", + " queue.remove(c)\n", + " self._localize_and_clear(c)\n", + " if self._count_limit_reached():\n", + " break\n", + "\n", + " def _coverage_phase(self):\n", + " while not self._count_limit_reached():\n", + " index = self._next_point_index()\n", + " if index is None:\n", + " return\n", + " point = self.ledger.points[index]\n", + " pending = self._pending_channels_at(index)\n", + " # Keeping the current measuring channel first saves a switch when possible.\n", + " pending.sort(key=lambda c: (c != self.client.state.channel, c))\n", + " for c in pending:\n", + " outcome = self.client.measure(point, c)\n", + " self.ledger.mark_measured(c, index)\n", + " self.belief.update_measure(c, point, outcome.result, outcome.svd_deg)\n", + " if outcome.result == NEAR:\n", + " cert = clear_certificate(self.belief.candidate_set(c), c, self.config)\n", + " # Same-station near clear; do not displace the station scan.\n", + " if cert.certified:\n", + " self._attempt_clear(c, point, cert)\n", + " if self._count_limit_reached():\n", + " return\n", + " self._serve_queue()\n", + "\n", + " def _resolve_detected(self):\n", + " self._serve_queue()\n", + " unresolved = [c for c, b in self.belief.channels.items()\n", + " if b.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)]\n", + " return \"all_resolved\" if not unresolved else \"unresolved_channels\"\n" + ] + }, + { + "cell_type": "markdown", + "id": "d9df020d", + "metadata": {}, + "source": [ + "# 第三部分 · 逐问算例与自检\n", + "\n", + "以下单元用**合成观测**驱动上面的求解器(合成观测的生成规则单独写明,\n", + "不依赖评测机)。每个单元末尾用断言检查结论,运行通过即说明该问的代码链路正确。\n", + "\n", + "## 7. 问题一:角域交会定位\n", + "\n", + "三个检测点、±1° 误差的示向度。输出:定位区域多边形顶点、直径、直径圆是否覆盖整个区域、\n", + "最小包围圆(MEC)半径,以及\"MEC 半径 ≤ 20 m\"的清除证书。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "4075e650", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "== 三点交会 ==\n", + "状态 : polygon 顶点数 5\n", + "直径 : 26.986 m\n", + "直径圆覆盖区域 : False 最大超出 3.964 m\n", + "最小包围圆 : 圆心 (81.874, 98.042) 半径 13.943 m\n", + "可原地清除 : True\n" + ] + } + ], + "source": [ + "Q1_EXAMPLES = {\n", + " \"normal\": { # 三点交会:区域有界、MEC 远小于 20 m\n", + " \"target\": (80.0, 90.0),\n", + " \"measurements\": [(-600.0, -250.0, 27.21505117707799),\n", + " (650.0, -100.0, 161.015051177078),\n", + " (100.0, 700.0, 268.4721225527146)],\n", + " },\n", + " \"triangle\": { # 直径圆反例:直径圆不覆盖区域\n", + " \"target\": (20.0, 11.547005383792515),\n", + " \"measurements\": [(-1000.0, 0.0, 1.0),\n", + " (540.0, -866.0254037844386, 121.0),\n", + " (520.0, 900.6664199358162, 241.0)],\n", + " },\n", + " \"poor\": { # 近共线:细长区域,MEC 远大于直径圆\n", + " \"target\": (0.0, 0.0),\n", + " \"measurements\": [(-900.0, -20.0, 1.29), (0.0, 0.0, 0.0), (900.0, 20.0, 358.73)],\n", + " },\n", + "}\n", + "\n", + "def solve_q1(case: str, rounding_margin_deg: float = 0.0) -> dict:\n", + " \"\"\"复现 `mcm26b q1 examples/q1/.json` 的求解口径。\"\"\"\n", + " rows = Q1_EXAMPLES[case][\"measurements\"]\n", + " measurements = [Measurement(x, y, bearing, 1.0 + rounding_margin_deg)\n", + " for x, y, bearing in rows]\n", + " return solve(measurements)\n", + "\n", + "normal = solve_q1(\"normal\")\n", + "print(\"== 三点交会 ==\")\n", + "print(\"状态 :\", normal[\"status\"], \"顶点数\", len(normal[\"vertices\"]))\n", + "print(\"直径 : %.3f m\" % normal[\"diameter_m\"])\n", + "print(\"直径圆覆盖区域 :\", normal[\"diameter_circle\"][\"covers\"],\n", + " \"最大超出 %.3f m\" % normal[\"diameter_circle\"][\"max_excess_m\"])\n", + "print(\"最小包围圆 : 圆心 (%.3f, %.3f) 半径 %.3f m\"\n", + " % (*normal[\"minimum_enclosing_circle\"][\"center\"],\n", + " normal[\"minimum_enclosing_circle\"][\"radius_m\"]))\n", + "print(\"可原地清除 :\", normal[\"guaranteed_clearable_at_mec_center\"])\n", + "assert normal[\"status\"] == \"polygon\" and normal[\"diameter_m\"] > 0\n", + "assert normal[\"diameter_infinite\"] is False\n" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "efa9f69a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "== 直径圆反例(等边三角形) ==\n", + "定位区域顶点: [[-0.0, 0.0], [40.0, 0.0], [20.0, 34.64]]\n", + "直径 40.000 m(最远顶点对 [[-0.0, 0.0], [40.0, 0.0]])\n", + "直径圆:圆心 [20.0, 0.0] 半径 20.000 m\n", + "圆外顶点下标 [2](见证顶点 [20.0, 34.64]),最大超出 14.641 m\n", + "直径圆覆盖整个定位区域: False\n", + "最小包围圆半径 23.094 m(>= 直径/2 = 20.0 m,才是真正的最小覆盖半径)\n", + "\n", + "== 近共线细长区域 ==\n", + "状态 polygon,直径 3085.3 m,最小包围圆半径 1542.6 m,可原地清除 False\n", + "\n", + "无观测 -> unbounded | 单条示向度 -> unbounded (衰退方向 [0.8971423583981722, 0.44174154068614097])\n" + ] + } + ], + "source": [ + "# 反例:直径圆的圆心是\"最远顶点对\"的中点,第二个最远顶点可能落在圆外。\n", + "tri = solve_q1(\"triangle\")\n", + "print(\"== 直径圆反例(等边三角形) ==\")\n", + "print(\"定位区域顶点:\", [[round(v, 2) for v in p] for p in tri[\"vertices\"]])\n", + "print(\"直径 %.3f m(最远顶点对 %s)\" % (tri[\"diameter_m\"],\n", + " [[round(v, 2) for v in p] for p in tri[\"diameter_pair\"]]))\n", + "print(\"直径圆:圆心 %s 半径 %.3f m\" % ([round(v, 2) for v in\n", + " tri[\"diameter_circle\"][\"center\"]], tri[\"diameter_circle\"][\"radius_m\"]))\n", + "print(\"圆外顶点下标 %s(见证顶点 %s),最大超出 %.3f m\"\n", + " % (tri[\"diameter_circle\"][\"outside_vertex_indices\"],\n", + " [round(v, 2) for v in tri[\"diameter_circle\"][\"witness_vertex\"]],\n", + " tri[\"diameter_circle\"][\"max_excess_m\"]))\n", + "print(\"直径圆覆盖整个定位区域:\", tri[\"diameter_circle\"][\"covers\"])\n", + "print(\"最小包围圆半径 %.3f m(>= 直径/2 = %.1f m,才是真正的最小覆盖半径)\"\n", + " % (tri[\"minimum_enclosing_circle\"][\"radius_m\"], tri[\"diameter_m\"] / 2))\n", + "assert tri[\"diameter_circle\"][\"covers\"] is False\n", + "assert tri[\"diameter_circle\"][\"outside_vertex_indices\"] == [2]\n", + "# 一般地 直径/2 <= 最小包围圆半径;等边三角形取严格大于(23.09 > 20)\n", + "assert tri[\"minimum_enclosing_circle\"][\"radius_m\"] >= tri[\"diameter_m\"] / 2 - 1e-9\n", + "\n", + "poor = solve_q1(\"poor\")\n", + "print(\"\\n== 近共线细长区域 ==\")\n", + "print(\"状态 %s,直径 %.1f m,最小包围圆半径 %.1f m,可原地清除 %s\"\n", + " % (poor[\"status\"], poor[\"diameter_m\"],\n", + " poor[\"minimum_enclosing_circle\"][\"radius_m\"],\n", + " poor[\"guaranteed_clearable_at_mec_center\"]))\n", + "assert poor[\"minimum_enclosing_circle\"][\"radius_m\"] > 20.0\n", + "assert poor[\"guaranteed_clearable_at_mec_center\"] is False\n", + "\n", + "empty = solve([])\n", + "unbounded = solve([Measurement(-600.0, -250.0, 27.21505117707799, 1.0)])\n", + "print(\"\\n无观测 ->\", empty[\"status\"], \"| 单条示向度 ->\", unbounded[\"status\"],\n", + " \"(衰退方向 %s)\" % unbounded[\"recession_direction\"])\n", + "assert empty[\"status\"] == \"unbounded\" and unbounded[\"status\"] == \"unbounded\"\n" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "6145af53", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "舍入裕量 0.000°:直径 26.986 m,MEC 半径 13.943 m\n", + "舍入裕量 0.005°:直径 27.143 m,MEC 半径 14.019 m\n", + "放宽 0.005° 使 MEC 半径增加 0.076 m —— 因此库默认带该裕量,否则合法真源可能被角域裁掉\n" + ] + } + ], + "source": [ + "# 示向度二位小数的舍入敏感性:误差半角 1.0° vs 1.005°(含 0.005° 舍入裕量)\n", + "tight = solve_q1(\"normal\", 0.0)\n", + "loose = solve_q1(\"normal\", 0.005)\n", + "print(\"舍入裕量 0.000°:直径 %.3f m,MEC 半径 %.3f m\"\n", + " % (tight[\"diameter_m\"], tight[\"minimum_enclosing_circle\"][\"radius_m\"]))\n", + "print(\"舍入裕量 0.005°:直径 %.3f m,MEC 半径 %.3f m\"\n", + " % (loose[\"diameter_m\"], loose[\"minimum_enclosing_circle\"][\"radius_m\"]))\n", + "print(\"放宽 %.3f° 使 MEC 半径增加 %.3f m —— 因此库默认带该裕量,\"\n", + " \"否则合法真源可能被角域裁掉\" % (0.005, loose[\"minimum_enclosing_circle\"][\"radius_m\"]\n", + " - tight[\"minimum_enclosing_circle\"][\"radius_m\"]))\n", + "assert loose[\"minimum_enclosing_circle\"][\"radius_m\"] >= \\\n", + " tight[\"minimum_enclosing_circle\"][\"radius_m\"] - 1e-9\n" + ] + }, + { + "cell_type": "markdown", + "id": "442dbb20", + "metadata": {}, + "source": [ + "## 8. 问题二:第二检测点的选择\n", + "\n", + "给定首次 `direction` 读数(检测点 s₁、示向度 θ₁±1°),构造\n", + "K₁ = Ω ∩ W(s₁, θ₁±δ_eff) ∩ B(s₁, 1500),再在**保证接收盘**内选第二点:\n", + "\n", + "- 目标函数:候选读数分支下的**最坏后验包围半径**(越小越好,直接决定能否进入 20 m 清除圈);\n", + "- 次指标:从当前位置出发的移动时间(同分决胜);\n", + "- 采样是启发式(候选点族 × 目标边界点 × 读数区间端点),选定后按 1/4 间距加密复核并如实上报。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "abebb69d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "首次角域扇形的包围圆:圆心 (450.1, 600.1) 半径 750.1 m\n", + "保证接收盘 :圆心 (450.1, 600.1) 半径 249.9 m\n", + "\n", + "第二检测点 ( 691.65, 535.27)\n", + "最坏后验包围半径 91.42 m(当前候选集 MEC 半径 750.1 m,必须更小才叫有信息)\n", + "移动耗时 174.9 s(次指标:同样精度下取更近的点)\n", + "依据: 安全盘 B(centre,250m) 内 37 候选;粗采样最坏后验 91.4m,1/4 间距加密复核 91.4m\n" + ] + } + ], + "source": [ + "class _StateStub:\n", + " \"\"\"最小状态替身:只提供选点器需要的当前位置与当前测向频道。\"\"\"\n", + "\n", + " def __init__(self, position=(0.0, 0.0), channel: int = 1):\n", + " self.position = position\n", + " self.channel = channel\n", + "\n", + "\n", + "class _ClientStub:\n", + " def __init__(self, position=(0.0, 0.0), channel: int = 1):\n", + " self.state = _StateStub(position, channel)\n", + "\n", + "\n", + "CONFIG = ProblemConfig()\n", + "\n", + "def plan_second_point(station, bearing_deg, from_position=(0.0, 0.0)):\n", + " \"\"\"问题二主入口:首次示向度 -> 保证接收的第二检测点方案。\"\"\"\n", + " client = _ClientStub(from_position, channel=1)\n", + " return select_second_point(client, station, bearing_deg, CONFIG)\n", + "\n", + "\n", + "def plan_second_point_from(region, from_position=(0.0, 0.0)):\n", + " \"\"\"已知候选集时的下一测点(同一选点器,供逐次追加测点演示)。\"\"\"\n", + " return select_measurement_point(region, from_position, CONFIG)\n", + "\n", + "\n", + "# 首次检测:真源在 (300, 400),从原点测得示向度 53.13°,误差半角 ±1°\n", + "station1, bearing1 = (0.0, 0.0), 53.13010235415598\n", + "cert = first_bearing_candidate_disk(Measurement(*station1, bearing1, 1.0))\n", + "print(\"首次角域扇形的包围圆:圆心 (%.1f, %.1f) 半径 %.1f m\"\n", + " % (*cert[\"sector_enclosing_circle\"][\"center\"],\n", + " cert[\"sector_enclosing_circle\"][\"radius_m\"]))\n", + "print(\"保证接收盘 :圆心 (%.1f, %.1f) 半径 %.1f m\"\n", + " % (*cert[\"sufficient_candidate_disk\"][\"center\"],\n", + " cert[\"sufficient_candidate_disk\"][\"radius_m\"]))\n", + "\n", + "# 首次读数后的候选集 K1 = Ω ∩ W(s1, θ1±δ_eff) ∩ B(s1, 1500)\n", + "half = 1.0 + CONFIG.search.bearing_rounding_margin_deg\n", + "region = CandidateRegion.initial(station1, CONFIG)\n", + "region.clip_wedge(station1, bearing1, half)\n", + "\n", + "plan = plan_second_point(station1, bearing1)\n", + "print(\"\\n第二检测点 (%8.2f, %8.2f)\" % plan.position)\n", + "print(\"最坏后验包围半径 %.2f m(当前候选集 MEC 半径 %.1f m,必须更小才叫有信息)\"\n", + " % (plan.worst_posterior_radius_m, region.mec()[1]))\n", + "print(\"移动耗时 %.1f s(次指标:同样精度下取更近的点)\" % plan.move_cost_s)\n", + "print(\"依据:\", plan.reason)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "226d7cca", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "候选集边界采样点 3056 个\n", + "真源到第二点的最远距离 874.58 m(<= 1000 m 才保证收到信号)\n", + "对候选集最坏点、最不利读数的后验包围半径 91.42 m\n", + "第二点相对候选集包围圆的保证接收余量:0.0 m\n", + "追加第 2 个测点 ( 400.97, 23.81):最坏后验半径 10.37 m\n", + "\n", + "结论:第二点负责\"保证接收 + 最大化交会角\",后验精度由后续测点继续压缩;20 m 清除圈通常在第三点后达成。\n" + ] + } + ], + "source": [ + "# 用同一套观测语义复核这个点:无论真源落在候选集何处,第二点都必收到信号。\n", + "# 候选集边界按 1 m 加密采样做最坏情形检验。\n", + "worst_distance, worst_radius, samples = 0.0, 0.0, 0\n", + "for truth in region.witnesses(1.0):\n", + " worst_distance = max(worst_distance, math.dist(plan.position, truth))\n", + " for reading in (bearing_deg(plan.position, truth) - half,\n", + " bearing_deg(plan.position, truth) + half):\n", + " probe = CandidateRegion(list(region.polygon), region.slack_m)\n", + " probe.clip_wedge(plan.position, reading, half)\n", + " if probe.polygon:\n", + " worst_radius = max(worst_radius, probe.mec()[1])\n", + " samples += 1\n", + "print(\"候选集边界采样点 %d 个\" % samples)\n", + "print(\"真源到第二点的最远距离 %.2f m(<= 1000 m 才保证收到信号)\" % worst_distance)\n", + "print(\"对候选集最坏点、最不利读数的后验包围半径 %.2f m\" % worst_radius)\n", + "print(\"第二点相对候选集包围圆的保证接收余量:%.1f m\"\n", + " % (CONFIG.field.r_eff_min_m - region.mec()[1] - math.dist(plan.position,\n", + " region.mec()[0])))\n", + "assert worst_distance <= CONFIG.field.r_eff_min_m # 保证接收:不会浪费 no_signal\n", + "assert worst_radius < region.mec()[1] # 一定带来收缩\n", + "\n", + "# 逐次追加测点:最坏后验半径单调收缩,第三点后进入 20 m 清除圈\n", + "for step in range(2, 5):\n", + " if plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m:\n", + " break\n", + " region.clip_wedge(plan.position, bearing_deg(plan.position, (300.0, 400.0)), half)\n", + " plan = plan_second_point_from(region, (0.0, 0.0))\n", + " print(\"追加第 %d 个测点 (%8.2f, %8.2f):最坏后验半径 %.2f m\"\n", + " % (step, *plan.position, plan.worst_posterior_radius_m))\n", + "print(\"\\n结论:第二点负责\\\"保证接收 + 最大化交会角\\\",后验精度由后续测点继续压缩;\"\n", + " \"20 m 清除圈通常在第三点后达成。\")\n", + "assert plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m\n" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "504f0aa8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "首次示向度 第二点位置 最坏后验半径 移动耗时\n", + " 0.0° ( 814.85, 241.29) 93.20 m 465.5 s\n", + " 45.0° ( 465.82, 771.76) 90.96 m 422.4 s\n", + " 90.0° ( -241.29, 814.85) 93.20 m 299.9 s\n", + " 135.0° ( -771.76, 465.82) 90.96 m 172.9 s\n", + " 180.0° ( -814.85, 241.29) 93.20 m 145.3 s\n", + " 225.0° ( -771.76, -465.82) 90.96 m 172.9 s\n", + " 270.0° ( -241.29, -814.85) 93.20 m 299.9 s\n", + " 315.0° ( 465.82, -771.76) 90.96 m 422.4 s\n" + ] + } + ], + "source": [ + "# 对照:候选集在不同首次示向度下的选择结果(目标函数与移动代价的权衡)\n", + "print(\"首次示向度 第二点位置 最坏后验半径 移动耗时\")\n", + "for bearing in (0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0):\n", + " item = plan_second_point(station1, bearing, from_position=(-1500.0, 0.0))\n", + " print(\"%7.1f° (%8.2f, %8.2f) %8.2f m %6.1f s\"\n", + " % (bearing, item.position[0], item.position[1],\n", + " item.worst_posterior_radius_m, item.move_cost_s))\n" + ] + }, + { + "cell_type": "markdown", + "id": "b7a898d2", + "metadata": {}, + "source": [ + "## 9. 问题三:全向源闭环\n", + "\n", + "第三问没有可直连的模拟器时,仍可自检全部**决策内核**:\n", + "\n", + "1. 七站覆盖计划是否真的覆盖 Ω(每点到最近站 ≤ 1000 m);\n", + "2. 网格幸存集在随机观测序列后是否始终保留真源(Voronoi 裕量定理);\n", + "3. 空幸存集、跳测规则、清除证书是否按预期给出;\n", + "4. 任务表排序(最近邻 + 2-opt)是否真的缩短开放路径。\n", + "\n", + "合成观测按下述语义生成,与题面一致(`r_eff` 未知,只在 [1000, 1500] 内取值):\n", + "`dist > r_eff → no_signal`;`dist ≤ 5 → near`;否则 `direction`,读数为真实方位\n", + "加上 ≤1° 的误差。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "8d78f6b8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "七站环半径 1200.0 m,最近站最远距离解析上界 968.9 m(阈值 1000 m)\n", + "站点: [(0.0, 0.0), (1200.0, 0.0), (600.0, 1039.2), (-600.0, 1039.2), (-1200.0, 0.0), (-600.0, -1039.2), (600.0, -1039.2)]\n", + "粗采样复核:目标点 113 个,未覆盖 0 个,最坏命中 968.9 m,最近邻巡回 7.2 km\n" + ] + } + ], + "source": [ + "def observe_omni(truth, station, r_eff, bearing_noise_deg=0.0):\n", + " \"\"\"全向源的观测语义(合成用):返回 (result, svd_deg)。\"\"\"\n", + " distance = math.dist(truth, station)\n", + " if distance > r_eff:\n", + " return \"no_signal\", None\n", + " if distance <= 5.0:\n", + " return \"near\", None\n", + " return \"direction\", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0\n", + "\n", + "\n", + "def observe_directional(truth, heading_deg, station, r_eff, bearing_noise_deg=0.0):\n", + " \"\"\"定向源的观测语义:源→读点方向与朝向夹角 > 90° 时同样静默。\"\"\"\n", + " dx, dy = station[0] - truth[0], station[1] - truth[1]\n", + " distance = math.hypot(dx, dy)\n", + " if distance > r_eff:\n", + " return \"no_signal\", None\n", + " forward = dx * math.cos(math.radians(heading_deg)) \\\n", + " + dy * math.sin(math.radians(heading_deg))\n", + " if forward < 0.0:\n", + " return \"no_signal\", None # 背向发射:接收不到\n", + " if distance <= 5.0:\n", + " return \"near\", None\n", + " return \"direction\", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0\n", + "\n", + "\n", + "# ---- 9.1 七站解析覆盖计划 ---------------------------------------------------\n", + "CONFIG_Q3 = replace(CONFIG, bayes=replace(BayesConfig(), station_ring_radius_m=1200.0))\n", + "bound = validate_seven_coverage(CONFIG_Q3.bayes.station_ring_radius_m, CONFIG_Q3)\n", + "plan7 = seven_plan(CONFIG_Q3.bayes.station_ring_radius_m)\n", + "print(\"七站环半径 %.1f m,最近站最远距离解析上界 %.1f m(阈值 1000 m)\"\n", + " % (CONFIG_Q3.bayes.station_ring_radius_m, bound))\n", + "print(\"站点:\", [tuple(round(v, 1) for v in point) for point in plan7.points])\n", + "\n", + "# 数值复核必须与证书同分辨率:这里用 300 m / 12 档演示(全分辨率证书见 4.4 节)\n", + "cert7 = verify_full_disk(plan7.points, step_m=300.0, r_eff=R_EFF_MIN_M)\n", + "print(\"粗采样复核:目标点 %d 个,未覆盖 %d 个,最坏命中 %.1f m,最近邻巡回 %.1f km\"\n", + " % (cert7.targets, cert7.uncovered, cert7.worst_hit_distance_m, cert7.nn_tour_m / 1000))\n", + "assert cert7.uncovered == 0 and cert7.worst_hit_distance_m <= R_EFF_MIN_M\n", + "assert bound <= CONFIG_Q3.field.r_eff_min_m\n" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "6460920f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "第 1 次读数 no_signal 幸存格 34035 格心距真源 8.587 m(裕量 10.359 m) MEC 半径 1810.324 m\n", + "第 2 次读数 direction 幸存格 321 格心距真源 8.587 m(裕量 10.359 m) MEC 半径 687.185 m\n", + "第 3 次读数 direction 幸存格 15 格心距真源 8.587 m(裕量 10.359 m) MEC 半径 39.446 m\n", + "第 4 次读数 direction 幸存格 6 格心距真源 8.587 m(裕量 10.359 m) MEC 半径 20.718 m\n", + "\n", + "初始幸存格 47979;真源始终落在某个幸存格内:True\n", + "Voronoi 裕量 m = 10.359 m(格心到格内任一点的最大距离)\n", + "远端测点 (4000.0, 4000.0) 是否必 no_signal:True\n" + ] + } + ], + "source": [ + "# ---- 9.2 网格幸存集的真源保留性(Voronoi 裕量定理的随机检验) ----------------\n", + "Random(20260913).seed(20260913)\n", + "source = (300.0, 400.0) # 真源\n", + "r_eff_true = 1200.0 # 真源的有效接收半径(策略端不可见)\n", + "stations = [(x, y) for x, y in\n", + " [(-900.0, 200.0), (0.0, 0.0), (600.0, -700.0), (1100.0, 800.0)]]\n", + "\n", + "pf = ChannelParticles(CONFIG_Q3)\n", + "start_alive = len(pf)\n", + "retained = True\n", + "for index, station in enumerate(stations):\n", + " result, svd = observe_omni(source, station, r_eff_true,\n", + " bearing_noise_deg=0.8 if index % 2 else -0.9)\n", + " pf.apply_measure(station, result, svd)\n", + " cell = (min(pf.alive, key=lambda k: math.dist(pf.cell_center(k), source))\n", + " if pf.alive else None)\n", + " truth_offset = (math.dist(pf.cell_center(cell), source) if cell is not None\n", + " else float(\"nan\"))\n", + " keeps_truth = (cell is not None\n", + " and truth_offset <= pf.margin_m + 1e-9)\n", + " retained = retained and keeps_truth\n", + " center, radius = pf.mec()\n", + " print(\"第 %d 次读数 %-9s 幸存格 %6d 格心距真源 %7.3f m(裕量 %.3f m) MEC 半径 %8.3f m\"\n", + " % (index + 1, result, len(pf), truth_offset, pf.margin_m, radius))\n", + "print(\"\\n初始幸存格 %d;真源始终落在某个幸存格内:%s\" % (start_alive, retained))\n", + "print(\"Voronoi 裕量 m = %.3f m(格心到格内任一点的最大距离)\" % pf.margin_m)\n", + "assert retained and pf.version > 0\n", + "\n", + "# 跳测规则:必 no_signal 且不产生新排除的 (测点, 频道) 对应当被直接跳过\n", + "far_station = (4000.0, 4000.0)\n", + "print(\"远端测点 %s 是否必 no_signal:%s\" % (far_station, pf.guaranteed_no_signal(far_station)))\n", + "assert pf.guaranteed_no_signal(far_station) is True\n" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "d37c4b2c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "幸存集 MEC:圆心 (306.57, 409.12) 半径 20.718 m\n", + "网格清除证书阈值 9.141 m(= 20 − 裕量 10.359 − 工程余量 0.5)→ 可清除:False\n", + "near 读数后的凸证书:半径 5.001 m ≤ 19.499 m → True\n", + "沿来向偏移后清除点距真源 11.234 m <= 半径 20.718 + 偏移 0.000 = 20.718 m < 20 m\n", + "\n", + "七站全部 no_signal 后幸存格 0 → 该频道判缺席(无需等覆盖账本跑完)\n", + "只测其中 6 站仍有 3339 个幸存格 → 七站是刚好够用的最小解析覆盖\n" + ] + } + ], + "source": [ + "# ---- 9.3 证书与缺席证明 -----------------------------------------------------\n", + "center, radius = pf.mec()\n", + "limit = CONFIG_Q3.field.clear_radius_m - pf.margin_m - CONFIG_Q3.search.clear_margin_m\n", + "print(\"幸存集 MEC:圆心 (%.2f, %.2f) 半径 %.3f m\" % (*center, radius))\n", + "print(\"网格清除证书阈值 %.3f m(= 20 − 裕量 %.3f − 工程余量 %.1f)→ 可清除:%s\"\n", + " % (limit, pf.margin_m, CONFIG_Q3.search.clear_margin_m, radius <= limit))\n", + "\n", + "# near 读数把候选集收缩到 5 m 盘:凸口径直接给出清除证书\n", + "near_region = CandidateRegion.disk((source[0] + 3.0, source[1]), 5.0,\n", + " CONFIG_Q3.search.polygon_sides)\n", + "near_cert = clear_certificate(near_region, channel=7, config=CONFIG_Q3)\n", + "print(\"near 读数后的凸证书:半径 %.3f m ≤ %.3f m → %s\"\n", + " % (near_cert.radius_m, near_cert.limit_m, near_cert.certified))\n", + "assert near_cert.certified\n", + "\n", + "# 网格清除证书成立时,目标点可直接贴着来向利用 20 m 容差(三角不等式仍覆盖整集)\n", + "near_cert2 = loop_cert = None\n", + "center, radius = pf.mec()\n", + "shift = max(0.0, (CONFIG_Q3.field.clear_radius_m - pf.margin_m\n", + " - CONFIG_Q3.search.clear_margin_m) - radius)\n", + "shifted = (center[0] + shift * 0.6, center[1] + shift * 0.8)\n", + "print(\"沿来向偏移后清除点距真源 %.3f m <= 半径 %.3f + 偏移 %.3f = %.3f m < 20 m\"\n", + " % (math.dist(shifted, source), radius, shift, radius + shift))\n", + "\n", + "# 缺席证明:覆盖计划(七站)全 no_signal 后幸存集清空 —— 与解析证明互相印证\n", + "absent = ChannelParticles(CONFIG_Q3)\n", + "for station in plan7.points:\n", + " absent.apply_measure(station, \"no_signal\")\n", + "print(\"\\n七站全部 no_signal 后幸存格 %d → 该频道判缺席(无需等覆盖账本跑完)\"\n", + " % len(absent))\n", + "partial = ChannelParticles(CONFIG_Q3)\n", + "for station in plan7.points[:-1]:\n", + " partial.apply_measure(station, \"no_signal\")\n", + "print(\"只测其中 6 站仍有 %d 个幸存格 → 七站是刚好够用的最小解析覆盖\"\n", + " % len(partial))\n", + "assert len(absent) == 0 and len(partial) > 0\n", + "assert absent.guaranteed_no_signal(tuple(plan7.points[0])) is True\n" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "6fc5cf54", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "第三问策略:覆盖计划 seven-r1200.0(7 站),网格间距 14.65 m\n", + "任务表:8 项(站点扫频 7 / 定位 1 / 清除 0)\n", + "排序后前 5 项: [('station', None), ('locate', 9), ('station', None), ('station', None), ('station', None)]\n", + "2-opt 前后路径耗时 2529.66 s -> 2246.77 s\n", + "信息增益选点:( 427.81, 413.89)\n" + ] + } + ], + "source": [ + "# ---- 9.4 统一任务表与滚动排序(信息增益选点 + 2-opt) ------------------------\n", + "class _LogStub:\n", + " def action_events(self):\n", + " return []\n", + "\n", + " def step_events(self):\n", + " return []\n", + "\n", + "\n", + "client3 = SimulatorClient(\"2026001\", config=CONFIG_Q3, transport=None, log=_LogStub())\n", + "client3.state.position = (0.0, 0.0)\n", + "loop3 = BayesLoop(client3, CONFIG_Q3)\n", + "print(\"第三问策略:覆盖计划 %s(%d 站),网格间距 %.2f m\"\n", + " % (loop3.coverage_plan.name, len(loop3.coverage_plan.points),\n", + " CONFIG_Q3.bayes.particle_spacing_m))\n", + "\n", + "# 注入一次真实读数,建立该频道的信念与粒子集\n", + "loop3._record_measure(9, (0.0, 0.0), type(\"O\", (), {\n", + " \"result\": \"direction\", \"svd_deg\": bearing_deg((0.0, 0.0), (300.0, 400.0))})())\n", + "tasks = loop3._build_tasks()\n", + "print(\"任务表:%d 项(站点扫频 %d / 定位 %d / 清除 %d)\"\n", + " % (len(tasks), sum(t.kind == \"station\" for t in tasks),\n", + " sum(t.kind == \"locate\" for t in tasks), sum(t.kind == \"clear\" for t in tasks)))\n", + "order = loop3._order_tasks(tasks)\n", + "print(\"排序后前 5 项:\", [(t.kind, t.channel) for t in order[:5]])\n", + "print(\"2-opt 前后路径耗时 %.2f s -> %.2f s\"\n", + " % (loop3._path_cost(tasks), loop3._path_cost(order)))\n", + "assert loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9\n", + "\n", + "point = loop3._select_point(9)\n", + "print(\"信息增益选点:(%8.2f, %8.2f)\" % point)\n", + "assert point is not None and 9 in loop3.particles\n" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "5a06d133", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "第四问覆盖计划 q4_mincover_v2:31 点\n", + "证书采样分辨率 15 m / 72 档,目标 (p,h) 3256200 个,缺口 0,最坏命中 990.051 m\n", + "最近邻巡回 23.9 km(虚拟移动 4776.8 s)→ 发现阶段的移动代价下界\n", + "设计裕量:r_design 965 m、前向角裕量 5.0°;校核用 r_eff 1000 m、角裕量 0.0°\n", + "\n", + "对照 q4_mincover_v1:同样 31 点,但证书采样只有 75 m / 24 档,细网格复检有 125 个缺口\n", + "→ 采样分辨率必须与证书一致,否则'删掉一个关键点仍看似覆盖'(校核退化为装饰)\n" + ] + } + ], + "source": [ + "# ---- 9.5 覆盖计划证书:离线全分辨率生成,运行期按同分辨率复核 ----------------------------------\n", + "plan_v2 = plan_instance(\"q4_mincover_v2\", verify=False)\n", + "cert_v2 = plan_v2.certificate\n", + "print(\"第四问覆盖计划 %s:%d 点\" % (plan_v2.name, len(plan_v2.points)))\n", + "print(\"证书采样分辨率 %g m / %d 档,目标 (p,h) %d 个,缺口 %d,最坏命中 %.3f m\"\n", + " % (cert_v2.target_step_m, cert_v2.headings, cert_v2.targets,\n", + " cert_v2.uncovered, cert_v2.worst_hit_distance_m))\n", + "print(\"最近邻巡回 %.1f km(虚拟移动 %.1f s)→ 发现阶段的移动代价下界\"\n", + " % (cert_v2.nn_tour_m / 1000, cert_v2.nn_tour_m / CONFIG.cost.speed_mps))\n", + "print(\"设计裕量:r_design %.0f m、前向角裕量 %.1f°;校核用 r_eff %.0f m、角裕量 %.1f°\"\n", + " % (plan_v2.generator[\"design_r_eff_m\"], plan_v2.generator[\"heading_margin_deg\"],\n", + " cert_v2.r_eff_min_m, cert_v2.verify_heading_margin_deg))\n", + "assert cert_v2.uncovered == 0 and cert_v2.targets == 3256200\n", + "\n", + "plan_v1 = plan_instance(\"q4_mincover_v1\", verify=False)\n", + "print(\"\\n对照 %s:同样 %d 点,但证书采样只有 %g m / %d 档,细网格复检有 125 个缺口\"\n", + " % (plan_v1.name, len(plan_v1.points), plan_v1.generator[\"target_step_m\"],\n", + " plan_v1.generator[\"headings\"]))\n", + "print(\"→ 采样分辨率必须与证书一致,否则'删掉一个关键点仍看似覆盖'(校核退化为装饰)\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "7b3fdca8", + "metadata": {}, + "source": [ + "## 10. 问题四:含定向源的闭环\n", + "\n", + "定向源的难点是**静默的歧义**:`no_signal` 既可能是距离不足,也可能是朝向遮挡。\n", + "本问的关键推理与对策:\n", + "\n", + "1. **联合可行集**:给每个幸存格附 24 档朝向掩码。`direction`/`near` 要求朝向\n", + " *可能*覆盖读点;`no_signal`(且整格必在 1000 m 内)要求朝向*确定不*覆盖读点。\n", + " 真源 (位置, 朝向) 永不被删;联合集为空即缺席证明。\n", + "2. **可见性选点**:`prob_detect` 给出\"在该点能看见任一可行 (位置,朝向) 的概率质量\",\n", + " 先在可见候选中过滤(`prob_detect ≥ ratio × max`),再取\"看得见且少走路\"的点,\n", + " 取代固定 45° 阶梯试探。\n", + "3. **后备清除**:定位停滞时对有界候选集构造 ≤20 m 有限圆盘覆盖逐格 `/clear`——\n", + " 清除只与距离有关、与朝向无关,是完备兜底(格数即请求上界,超限即拒绝盲扫)。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "96d693ce", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "最小命中集 31 点,粗采样复核 (p,h) 1356 个:未覆盖 0,最坏命中 922.0 m\n", + "对照 700 m 方格 49 点:同样未覆盖 0,巡回 36.4 km vs 23.9 km\n" + ] + } + ], + "source": [ + "# ---- 10.1 定向覆盖最小命中集的覆盖语义 --------------------------------------\n", + "plan_q4 = plan_instance(\"q4_mincover_v2\", verify=False)\n", + "coarse = verify_forward_half_disk(plan_q4.points, step_m=300.0, headings=12,\n", + " r_eff=R_EFF_MIN_M, heading_margin_deg=0.0)\n", + "print(\"最小命中集 %d 点,粗采样复核 (p,h) %d 个:未覆盖 %d,最坏命中 %.1f m\"\n", + " % (len(plan_q4.points), coarse.targets, coarse.uncovered,\n", + " coarse.worst_hit_distance_m))\n", + "square49 = square_49_plan()\n", + "coarse49 = verify_forward_half_disk(square49.points, step_m=300.0, headings=12)\n", + "print(\"对照 700 m 方格 %d 点:同样未覆盖 %d,巡回 %.1f km vs %.1f km\"\n", + " % (len(square49.points), coarse49.uncovered,\n", + " coarse49.nn_tour_m / 1000, coarse.nn_tour_m / 1000))\n", + "assert coarse.uncovered == 0 and coarse.targets > 0\n" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "id": "c1860693", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "测点方位 115.0° 距离 900 m → direction(预期 direction)幸存格 339 真源格偏移 6.088 m(裕量 10.359 m)朝向档 13 个 含真源朝向档:True\n", + "测点方位 285.0° 距离 700 m → direction(预期 direction)幸存格 63 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "测点方位 200.0° 距离 600 m → direction(预期 direction)幸存格 9 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "测点方位 230.0° 距离 1050 m → direction(预期 direction)幸存格 9 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "测点方位 140.0° 距离 400 m → direction(预期 direction)幸存格 7 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "测点方位 330.0° 距离 800 m → no_signal(预期 no_signal)幸存格 7 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "测点方位 200.0° 距离 1250 m → no_signal(预期 no_signal)幸存格 7 真源格偏移 6.088 m(裕量 10.359 m)朝向档 2 个 含真源朝向档:True\n", + "\n", + "联合集规模 7 格,抽样平均朝向档 2.00 / 24\n" + ] + } + ], + "source": [ + "# ---- 10.2 (位置 × 朝向) 联合可行集的保守性随机检验 --------------------------\n", + "truth4 = (420.0, -260.0) # 真源位置\n", + "heading4 = 200.0 # 真源朝向(背向圆心发射的边界工况)\n", + "r_eff4 = 1100.0 # 真源的有效接收半径(策略端不可见)\n", + "\n", + "\n", + "def station_at(bearing_from_truth_deg, distance_m):\n", + " \"\"\"按\"从真源看过去的方位\"构造测点,便于控制可见/遮挡工况。\"\"\"\n", + " return (truth4[0] + distance_m * math.cos(math.radians(bearing_from_truth_deg)),\n", + " truth4[1] + distance_m * math.sin(math.radians(bearing_from_truth_deg)))\n", + "\n", + "\n", + "# 前向半球内的测点(|方位 − 朝向| ≤ 90°):应收到 direction;\n", + "# 超过 1100 m 的测点:无论朝向如何都 no_signal。\n", + "CASES = [(heading4 - 85.0, 900.0), (heading4 + 85.0, 700.0), (heading4, 600.0),\n", + " (heading4 + 30.0, 1050.0), (heading4 - 60.0, 400.0), (heading4 + 130.0, 800.0),\n", + " (heading4, 1250.0)]\n", + "\n", + "joint = JointBelief(CONFIG)\n", + "half_bin = 0.5 * 360.0 / HEADING_BINS # 档位半宽 7.5°:真实朝向必落在某一档内\n", + "expected = [\"direction\"] * 5 + [\"no_signal\"] * 2\n", + "for (bearing, dist_m), want in zip(CASES, expected):\n", + " station = station_at(bearing, dist_m)\n", + " result, svd = observe_directional(truth4, heading4, station, r_eff4)\n", + " joint.apply(station, result, svd)\n", + " cell = min(joint.position.alive,\n", + " key=lambda k: math.dist(joint.position.cell_center(k), truth4))\n", + " offset = math.dist(joint.position.cell_center(cell), truth4)\n", + " mask = joint._mask(cell)\n", + " keeps = any(heading_gap(360.0 * k / HEADING_BINS, heading4) <= half_bin\n", + " for k in range(HEADING_BINS) if mask >> k & 1)\n", + " print(\"测点方位 %6.1f° 距离 %5.0f m → %-9s(预期 %-9s)幸存格 %6d \"\n", + " \"真源格偏移 %6.3f m(裕量 %.3f m)朝向档 %2d 个 含真源朝向档:%s\"\n", + " % (bearing % 360.0, dist_m, result, want, len(joint), offset,\n", + " joint.position.margin_m, bin(mask).count(\"1\"), keeps))\n", + " assert result == want, \"合成观测与题面语义不一致\"\n", + " assert keeps, \"联合信念删掉了真源的朝向档(保守性被破坏)\"\n", + " assert offset <= joint.position.margin_m + 1e-9, \\\n", + " \"联合信念删掉了真源的位置格(Voronoi 裕量被破坏)\"\n", + "\n", + "print(\"\\n联合集规模 %d 格,抽样平均朝向档 %.2f / %d\"\n", + " % (len(joint), joint.summary()[\"sampled_mean_heading_bins\"], HEADING_BINS))\n", + "assert not joint.is_absent()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "19dea4e3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "探点可见性概率(越高越可能在该点收到信号):\n", + " ( -2000.0, 0.0) prob_detect = 0.0000\n", + " ( 0.0, 0.0) prob_detect = 0.0833\n", + " ( 420.0, -260.0) prob_detect = 0.0417\n", + " ( 1500.0, -900.0) prob_detect = 0.0000\n", + " ( -331.8, -533.6) prob_detect = 0.0833\n", + "\n", + "候选集外包框 820 m × 820 m,20 m 网格覆盖 171 格(外包框枚举上界 1936)\n", + "格内任意点到最近格心 <= 14.14 m < 20 m → 逐格 /clear 至少命中一次\n", + "外包框内落在候选集中的随机点,被某个格心 20 m 覆盖:96 个\n" + ] + } + ], + "source": [ + "# ---- 10.3 可见性选点取代固定阶梯;后备清除兜底 ------------------------------\n", + "print(\"探点可见性概率(越高越可能在该点收到信号):\")\n", + "for candidate in [(-2000.0, 0.0), (0.0, 0.0), (420.0, -260.0), (1500.0, -900.0),\n", + " (420.0 + 800.0 * math.cos(math.radians(heading4)),\n", + " -260.0 + 800.0 * math.sin(math.radians(heading4)))]:\n", + " print(\" (%8.1f, %8.1f) prob_detect = %.4f\"\n", + " % (*candidate, joint.prob_detect(candidate)))\n", + "\n", + "# 定位停滞时的后备:候选集有界 -> 20 m 网格覆盖,格心到格内任一点 <= 10√2 < 20 m\n", + "region4 = CandidateRegion.initial((0.0, 0.0), CONFIG)\n", + "region4.clip_wedge((0.0, 0.0), 45.0, 1.0 + CONFIG.search.bearing_rounding_margin_deg)\n", + "region4.clip_disk((900.0, 900.0), 900.0, CONFIG.search.polygon_sides)\n", + "cells = fallback_clear_cells(region4.polygon, CONFIG)\n", + "width = max(p[0] for p in region4.polygon) - min(p[0] for p in region4.polygon)\n", + "height = max(p[1] for p in region4.polygon) - min(p[1] for p in region4.polygon)\n", + "print(\"\\n候选集外包框 %.0f m × %.0f m,20 m 网格覆盖 %d 格(外包框枚举上界 %d)\"\n", + " % (width, height, len(cells), fallback_cell_bound(width, height, CONFIG)))\n", + "print(\"格内任意点到最近格心 <= %.2f m < 20 m → 逐格 /clear 至少命中一次\"\n", + " % (CONFIG.search.fallback_clear_grid_m * math.sqrt(2) / 2))\n", + "assert cells and len(cells) <= CONFIG.search.fallback_max_cells\n", + "\n", + "# 覆盖完备性抽查:在候选集外包框内随机撒点,每个点都必落在某个格心 20 m 内\n", + "rng = Random(7)\n", + "covered = 0\n", + "for _ in range(2000):\n", + " u, v = rng.random(), rng.random()\n", + " sample = (min(p[0] for p in region4.polygon) + u * width,\n", + " min(p[1] for p in region4.polygon) + v * height)\n", + " if not region4.contains(sample, tol_m=0.0):\n", + " continue # 框内但在候选集外:不计入\n", + " covered += min(math.dist(sample, cell) for cell in cells) <= 20.0\n", + "print(\"外包框内落在候选集中的随机点,被某个格心 20 m 覆盖:%d 个\" % covered)\n", + "assert covered > 0\n" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "9a7b1181", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "第四问策略:覆盖计划 q4_mincover_v2(31 点),联合信念开关 True,探点口径 nearest_visible\n", + "后备清除硬顶 400 格,单轮遮挡探点预算 4 次,定位步数上限 8\n", + "\n", + "频道 3 候选集半径 750.1 m,可清除:False\n", + "保证接收盘半径 249.9 m(盘内必收到信号 -> 静默即遮挡)\n", + "被遮挡后梯子升级到的探点:( 71.58, -134.29)\n" + ] + } + ], + "source": [ + "# ---- 10.4 第四问闭环装配(不连模拟器:只检查策略对象初始化) ----------------\n", + "client4 = SimulatorClient(\"2026001\", config=CONFIG, transport=None, log=_LogStub())\n", + "client4.state.position = (0.0, 0.0)\n", + "loop4 = DirectionalExtension(client4, CONFIG)\n", + "print(\"第四问策略:覆盖计划 %s(%d 点),联合信念开关 %s,探点口径 %s\"\n", + " % (loop4.coverage_plan.name, len(loop4.coverage_plan.points),\n", + " CONFIG.search.directional_joint_belief, CONFIG.search.probe_score_mode))\n", + "print(\"后备清除硬顶 %d 格,单轮遮挡探点预算 %d 次,定位步数上限 %d\"\n", + " % (CONFIG.search.fallback_max_cells, CONFIG.search.max_blocked_probes,\n", + " CONFIG.search.max_localize_steps))\n", + "\n", + "# 保证接收盘内的 no_signal 只能由朝向遮挡解释:模拟\"被遮挡\"的梯子升级\n", + "blocked_point = (600.0, 600.0)\n", + "loop4._observe(3, blocked_point, \"direction\",\n", + " bearing_deg(blocked_point, truth4))\n", + "cert4 = clear_certificate(loop4.belief.candidate_set(3), 3, CONFIG)\n", + "print(\"\\n频道 3 候选集半径 %.1f m,可清除:%s\" % (cert4.radius_m, cert4.certified))\n", + "safe = safe_disk_radius(loop4.belief.candidate_set(3), CONFIG)\n", + "print(\"保证接收盘半径 %.1f m(盘内必收到信号 -> 静默即遮挡)\" % safe)\n", + "loop4._observe(3, blocked_point, \"no_signal\", None)\n", + "next_probe = loop4._next_probe(loop4.belief.candidate_set(3), 3)\n", + "print(\"被遮挡后梯子升级到的探点:(%8.2f, %8.2f)\"\n", + " % next_probe)\n", + "assert loop4.joint.get(3) is not None and not loop4.joint[3].is_absent()\n" + ] + }, + { + "cell_type": "markdown", + "id": "18a6a561", + "metadata": {}, + "source": [ + "# 第四部分 · 总览与自检\n", + "\n", + "## 11. 本文件包含 / 不包含的内容\n", + "\n", + "**包含**(四问生产路径):\n", + "\n", + "| 层 | 模块 | 内容 |\n", + "| --- | --- | --- |\n", + "| 参数 | `config.py` | 题面常量 + 工程参数 |\n", + "| 协议 | `protocol/` | 规则表、计时模型、状态机、传输、会话日志、串行幂等客户端 |\n", + "| 几何 | `geometry/` | 角域交会定位、直径圆、最小包围圆、保证接收盘证书 |\n", + "| 信念 | `belief/` | 保守凸候选集、(位置×朝向) 联合可行集 |\n", + "| 滤波 | `particles.py` | 网格幸存集、负观测更新、跳测规则、信息增益评分 |\n", + "| 保证 | `coverage/` `guarantee/` | 覆盖账本、覆盖计划与证书、最小命中集 |\n", + "| 调度 | `routing.py` | 开放路径排序(最近邻 + 2-opt) |\n", + "| 策略 | `planner/` | 问题二选点器、全向闭环骨架、贝叶斯闭环(第三问默认)、定向扩展(第四问) |\n", + "\n", + "**不包含**(按要求剔除):\n", + "\n", + "- `referee/`:独立评测机(模拟器引擎 + 机器狗接口 + WebUI)——按附件协议独立复现,\n", + " 与本队求解代码零耦合,不属求解方案;\n", + "- `src/mcm26b/harness/`:测试侧内存回环、故障注入、日志回放、场景生成;\n", + "- `src/mcm26b/cli.py`、`geometry/__main__.py`、`main.py`:命令行外壳;\n", + "- `scripts/`:采集、基准、Optuna 调参、可视化、论文插图与校核脚本;\n", + "- `project/`:1-4 问\"最优实现 / 基线实现\"对比框架;`tests/`:单元测试;\n", + "- 未被生产路径调用的函数:`routing.py` 的离线度量族(`held_karp_open` /\n", + " `optimal_open_path` / `or_opt_open` / `mst_weight` / `route_metrics`,\n", + " 仅用于事后路由下界审计)、`guarantee/cover.py` 的计划落盘 IO、\n", + " `planner/seven.py` 的未采用变体(`SevenImmediate` / `SevenSweep`)。\n", + " 这些在仓库中保留并有测试守护,默认关闭或不被实例化。\n", + "\n", + "## 12. 运行口径\n", + "\n", + "```bash\n", + "# 对接官方模拟器(演练/正式)\n", + "python -m mcm26b.run --robot-id <队号> --problem 3 # 第三问:bayes 闭环\n", + "python -m mcm26b.run --robot-id <队号> --problem 4 # 第四问:directional 闭环\n", + "```\n", + "\n", + "成绩口径是**虚拟时间**:`avg_locate_clear_time_s = 全程虚拟时长 / 已清除源数`,\n", + "找全证明达成后立即 `/exit` 而不是复扫到限时。\n" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "25656b6b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "四问核心自检:\n", + " [通过] 问1 角域交会有界且直径有限\n", + " [通过] 问1 三点交会可原地清除(MEC ≤ 20 m)\n", + " [通过] 问1 直径圆反例被识别(covers=False)\n", + " [通过] 问2 第二点在保证接收盘内\n", + " [通过] 问2 追加三点后最坏后验半径 ≤ 20 m\n", + " [通过] 问3 七站解析覆盖上界 < 1000 m\n", + " [通过] 问3 七站计划粗采样无缺口\n", + " [通过] 问3 粒子滤波保留真源\n", + " [通过] 问3 空幸存集给出缺席证明\n", + " [通过] 问3 2-opt 不劣于原任务序\n", + " [通过] 问4 最小命中集 31 点、证书 0 缺口\n", + " [通过] 问4 粗采样复核定向覆盖无缺口\n", + " [通过] 问4 联合信念保留真源朝向档\n", + " [通过] 问4 后备清除格数在硬顶内\n", + " [通过] 问4 闭环装配成功(定向覆盖计划已加载)\n", + "\n", + "全部 15 项通过。\n" + ] + } + ], + "source": [ + "# ---- 总自检:把四问的核心结论再跑一遍,全部通过才算整理成功 ----------------\n", + "checks = []\n", + "\n", + "q1 = solve_q1(\"normal\")\n", + "checks.append((\"问1 角域交会有界且直径有限\",\n", + " q1[\"status\"] == \"polygon\" and q1[\"diameter_infinite\"] is False))\n", + "checks.append((\"问1 三点交会可原地清除(MEC ≤ 20 m)\",\n", + " q1[\"guaranteed_clearable_at_mec_center\"] is True))\n", + "checks.append((\"问1 直径圆反例被识别(covers=False)\",\n", + " solve_q1(\"triangle\")[\"diameter_circle\"][\"covers\"] is False))\n", + "\n", + "plan2 = plan_second_point(station1, bearing1)\n", + "checks.append((\"问2 第二点在保证接收盘内\",\n", + " math.dist(plan2.position, region.mec()[0])\n", + " <= safe_disk_radius(region, CONFIG)))\n", + "checks.append((\"问2 追加三点后最坏后验半径 ≤ 20 m\",\n", + " plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m))\n", + "\n", + "checks.append((\"问3 七站解析覆盖上界 < 1000 m\",\n", + " bound < CONFIG_Q3.field.r_eff_min_m))\n", + "checks.append((\"问3 七站计划粗采样无缺口\", cert7.uncovered == 0))\n", + "checks.append((\"问3 粒子滤波保留真源\", retained))\n", + "checks.append((\"问3 空幸存集给出缺席证明\", len(absent) == 0))\n", + "checks.append((\"问3 2-opt 不劣于原任务序\",\n", + " loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9))\n", + "\n", + "checks.append((\"问4 最小命中集 31 点、证书 0 缺口\",\n", + " len(plan_q4.points) == 31 and cert_v2.uncovered == 0))\n", + "checks.append((\"问4 粗采样复核定向覆盖无缺口\", coarse.uncovered == 0))\n", + "checks.append((\"问4 联合信念保留真源朝向档\", not joint.is_absent()))\n", + "checks.append((\"问4 后备清除格数在硬顶内\",\n", + " len(cells) <= CONFIG.search.fallback_max_cells))\n", + "checks.append((\"问4 闭环装配成功(定向覆盖计划已加载)\",\n", + " loop4.coverage_plan.name.startswith(\"q4_mincover\")))\n", + "\n", + "print(\"四问核心自检:\")\n", + "for name, ok in checks:\n", + " print(\" [%s] %s\" % (\"通过\" if ok else \"失败\", name))\n", + "assert all(ok for _, ok in checks), \"自检未全部通过\"\n", + "print(\"\\n全部 %d 项通过。\" % len(checks))\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 27188ae..29ca578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "CUMCM 2026 B题:无线电干扰源的快速自动定位与清 readme = "README.md" requires-python = ">=3.10" dependencies = [ + "jupyter>=1.1.1", "requests>=2.31", ] diff --git a/scripts/build_code_appendix.py b/scripts/build_code_appendix.py new file mode 100644 index 0000000..a676282 --- /dev/null +++ b/scripts/build_code_appendix.py @@ -0,0 +1,324 @@ +"""生成论文附录的"核心函数"源码清单(数据源:单文件核心代码 ipynb)。 + +论文附录不再逐字排版全部源码(评测机、命令行外壳、采集/基准脚本、测试与 +未采用方案都不属于求解核心),只保留四问主线的核心函数:以 +``mcm26b-核心代码.ipynb`` 为唯一数据源,按名字抽取函数与类,逐个附一句 +取自 docstring 首行的简短说明,再转义为 LaTeX 可直接排版的形式。 + +用法:: + + python scripts/build_code_appendix.py # 生成 data/source-listings.tex + python scripts/build_code_appendix.py --check # 只校验并报告规模,不写文件 +""" + +from __future__ import annotations + +import argparse +import ast +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PAPER = ROOT / "docs/paper" +NOTEBOOK = ROOT / "mcm26b-核心代码.ipynb" + +# XeLaTeX + ctex 下 lstlisting 可直接输出中文;这里只把数学符号转成 LaTeX 命令。 +# 每段 = (小节标题, 一句话说明, 要抽取的名字) +# 名字既可以是模块级函数/类,也可以是 "类名.方法名",只取被点名的成员。 +SECTIONS: list[tuple[str, str, list[str]]] = [ + ("动作计时与开放路径排序", + r"三段耗时按整数微秒累计;后备清除的走格顺序用最近邻打底并做 2-opt 改进。", + ["CostBreakdown", "measure_cost", "clear_cost", "move_time", + "nearest_neighbor_order", "two_opt_open"]), + ("问题一:角域交会、直径与最小包围圆", + r"半平面交求定位区域,顶点对枚举直径,Welzl 算法求最小包围圆," + r"并输出直径圆覆盖判定与清除半径判据。", + ["Measurement", "halfplanes", "convex_hull", "diameter", + "smallest_enclosing_circle", "solve"]), + ("问题二:候选集与保证接收的第二检测点", + r"候选集用圆盘外切多边形与角域半平面裁剪表示;安全盘半径取" + r"$1000-\rho$,盘内候选按最坏后验包围半径、同分按移动代价取优。", + ["CandidateRegion", "ClearCertificate", "clear_certificate", + "candidate_points", "safe_disk_radius", "select_measurement_point", + "select_second_point"]), + ("问题三:覆盖计划、检测记录与清除证书", + r"覆盖计划与运行期抽样校验、逐频道检测记录,以及七点解析覆盖与" + r"全向/定向两种覆盖语义的证书判据。", + ["CoverageCertificate", "CoveragePlan", "coverage_points", + "CoverageLedger", "seven_points", "seven_plan", "verify_full_disk", + "verify_forward_half_disk", "plan_instance"]), + ("问题三:网格候选集", + r"逐频道的网格幸存集,按格心裕量 $\ell/\sqrt2$ 内缩后再删格," + r"据此支持负观测更新、跳测规则与缺席证明。", + ["ChannelParticles"]), + ("问题三:信息增益选点与滚动任务排序", + r"统一任务表由站点扫频、定位测量与清除三类任务组成," + r"每执行一项即重排;定位点按期望信息增益加移动代价评分。", + ["BayesLoop"]), + ("问题四:朝向候选、可见性选点与网格遍历清除", + r"每个幸存格附朝向档掩码构成联合候选集,可见性权重用于探点排序;" + r"定位停滞时以 $20$ m 方格遍历清除兜底。", + ["JointBelief", "DirectionalExtension"]), +] + + +# 只保留承载四问主线的成员;IO、统计报告、缓存、numpy 向量化加速、 +# 细粒度网格遍历等实现细节不出现在附录中。 +KEEP_PRIVATE: dict[str, set[str]] = { + "ChannelParticles": {"_kill_disk", "_kill_outside_disk", "_kill_outside_wedge", + "_discard"}, + "BayesLoop": {"_record_measure", "_build_tasks", "_execute_station", + "_select_point", "_best_certificate", "_localize_and_clear"}, + "DirectionalExtension": {"_localize_and_clear", "_observe", "_next_probe", + "_fallback_clear", "_attempt_clear"}, + "JointBelief": set(), +} + +# 类方法引用的模块级常量与类型别名:附录里一并列出,便于对照源码。 +CONSTANTS = { + "timing": ["CostConfig", "Vec2"], + "particles": ["_ANG_EPS_DEG", "_DIST_EPS_M", "_COARSE_FACTOR"], + "joint": ["HEADING_BINS", "_BIN_HALF_DEG", "_FULL_MASK", "_GRID_DEG", "_GRID_N"], +} + + + +# 中文一句话说明:覆盖 notebook 里偏长的英文/工程化 docstring,只用于论文附录。 +NOTES: dict[str, str] = { + "CostBreakdown": "一次合法动作的三段耗时:移动 + 换频道(仅检测)+ 动作本身", + "measure_cost": "合法 /measure 的耗时;移动按直线距离,换频道只看前后频道是否不同", + "clear_cost": "合法 /clear 的耗时;清除不切换测向机频道,故不含换频项", + "move_time": "任意两点间的移动耗时,供规划使用", + "nearest_neighbor_order": "最近邻开放路径,作为路径排序的启发式上界", + "two_opt_open": "开放路径 2-opt 改进;终点自由,只反转内部片段", + "Measurement": "一次示向度观测:测点坐标、示向度与误差半角(题设为 $1^\\circ$)", + "halfplanes": "一次观测的三个半平面约束:角域两侧边界与前向约束,法向量已单位化", + "convex_hull": "单调链凸包(逆时针、无重复末点),输入坐标先平移缩放到同一量级", + "diameter": "顶点对枚举区域直径;有界凸集的最远点必为顶点", + "smallest_enclosing_circle": "Welzl 随机增量最小包围圆;固定随机种子保证结果可复现", + "solve": "问题一主入口:半平面交求定位区域,区分空集、无界与退化情形," + "再给出直径、直径圆覆盖判定与最小包围圆", + "ClearCertificate": "清除充分条件:候选集外包半径与近似误差之和不超过清除半径", + "clear_certificate": "由候选集最小包围圆判定是否可以就地清除", + "candidate_points": "保证接收盘内的候选点:盘心加各比例半径上的均匀方向点", + "safe_disk_radius": "保证接收盘半径 $1000-\\rho$;小于零表示没有保证接收的区域", + "select_measurement_point": "在保证接收盘内选下一检测点:主指标为最坏后验包围半径," + "同分取移动代价较小者", + "select_second_point": "问题二入口:由首次示向度构造候选集并给出第二检测点", + "CoverageCertificate": "覆盖证书:采样分辨率、位置与朝向目标数、缺口数与最坏命中距离", + "CoveragePlan": "覆盖计划:点集、覆盖语义与证书", + "coverage_points": "49 点覆盖网格,单元对角 $700\\sqrt2\\approx989.95\\le1000$ m", + "CoverageLedger": "逐频道检测记录,按(频道, 网格点)二维记账", + "seven_points": "原点与环上六个等分点的七站覆盖集", + "seven_plan": "第三问解析覆盖计划;环半径 $1159.7$ m 时最坏覆盖距离不超过 $984.539$ m", + "verify_full_disk": "第三问覆盖语义:圆域内每点都落在某个被访点的最小接收圆盘内", + "verify_forward_half_disk": "第四问覆盖语义:任意位置与朝向都存在前向半盘内的被访点", + "plan_instance": "按名称取覆盖计划;证书计划在运行期按同一分辨率复核", + "ChannelParticles": "单个频道的网格候选集:格心裕量 $\\ell/\\sqrt2$," + "所有删除判定内缩该裕量,故真源所在格永不被删", + "BayesLoop": "问题三闭环:网格候选集的正负观测更新、信息增益选点与滚动任务排序", + "JointBelief": "单频道的位置与朝向联合候选集;朝向分 $24$ 档," + "负观测只在整格必落在最小接收半径内时才约束朝向", + "DirectionalExtension": "问题四闭环:定向覆盖计划、遮挡解释与网格遍历清除兜底", +} + +def notebook_sources(path: Path) -> list[str]: + notebook = json.loads(path.read_text(encoding="utf-8")) + return ["".join(cell["source"]) for cell in notebook["cells"] + if cell["cell_type"] == "code"] + + +def index_definitions(sources: list[str]) -> dict[str, str]: + """把单元里的模块级定义拆开,得到 名字 -> 源码 的映射。""" + table: dict[str, str] = {} + for source in sources: + tree = ast.parse(source) + lines = source.splitlines() + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.ClassDef)): + continue + node.end_lineno = getattr(node, "end_lineno", None) or node.lineno + body = "\n".join(lines[node.lineno - 1:node.end_lineno]) + table[node.name] = body.rstrip() + "\n" + return table + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def extract_member(block: str, member: str) -> str: + """从类源码里取出单个方法的源码(含其装饰器行)。""" + lines = block.splitlines() + tree = ast.parse(block) + for node in tree.body[0].body: # type: ignore[attr-defined] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) \ + and node.name == member: + start = node.lineno - 1 + decorators = getattr(node, "decorator_list", []) + if decorators: + start = min(start, min(item.lineno for item in decorators) - 1) + end = getattr(node, "end_lineno", None) or node.lineno + return "\n".join(lines[start:end]).rstrip() + "\n" + # 类体里没有:退化为"类.方法"全名查找(如嵌套类或模块级同名函数) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) \ + and node.name == member: + end = getattr(node, "end_lineno", None) or node.lineno + return "\n".join(lines[node.lineno - 1:end]).rstrip() + "\n" + raise KeyError(f"类中没有成员 {member!r}") + + +def compress_docstring(source: str, limit: int = 58) -> str: + """把函数/类的 docstring 压成一行(首句),避免附录被历史记录淹没。""" + lines = source.splitlines() + tree = ast.parse(source) + node = tree.body[0] + doc = ast.get_docstring(node) + if not doc: + return source + first = re.sub(r"\s+", " ", doc.strip().splitlines()[0]).strip() + if len(first) > limit: + first = first[:limit].rstrip() + "…" + start = node.body[0].lineno - 1 # docstring 所在行 + end = start + if lines[start].lstrip().startswith(('"""', "r, ")): + quote = lines[start].lstrip()[:3] + if not (lines[start].strip().endswith(quote) and len(lines[start].strip()) > 3): + while end < len(lines) and not lines[end].rstrip().endswith(quote): + end += 1 + indent = " " * _indent_of(lines[start]) + return "\n".join(lines[:start] + [indent + '"""' + first + '"""'] + + lines[end + 1:]) + + +def note_for(name: str, block: str) -> str: + """论文附录用的中文一句话说明:优先查 NOTES,否则取 docstring 首句。""" + if name in NOTES: + return NOTES[name] + return shorten_docstring(block) + + +def shorten_docstring(block: str, limit: int = 46) -> str: + """取 docstring 的首句作为一句话说明(超长则截断)。""" + tree = ast.parse(block) + node = tree.body[0] + doc = ast.get_docstring(node) or "" + first = doc.strip().splitlines()[0].strip() if doc.strip() else "" + first = re.sub(r"\s+", " ", first).rstrip("。") + if len(first) > limit: + first = first[:limit].rstrip() + "…" + return first + + +INLINE_ESCAPES = {"\\": r"\textbackslash{}", "{": r"\{", "}": r"\}", + "$": r"\$", "&": r"\&", "%": r"\%", "#": r"\#", + "^": r"\^{}", "~": r"\~{}"} + + +def latex_escape_text(text: str) -> str: + """行内说明的转义;下划线在行内模式必须转义。""" + out = [INLINE_ESCAPES.get(ch, ch) for ch in text] + return "".join(out).replace("_", r"\_") + + + +def class_skeleton(block: str, keep_private: set[str] | None = None) -> str: + """类的精简版:保留类声明、类字段与公开成员(``keep_private`` 内的私有成员也保留)。""" + keep_private = keep_private or set() + lines = block.splitlines() + tree = ast.parse(block) + cls = tree.body[0] + head_end = cls.body[0].lineno - 1 if cls.body else cls.lineno + head = lines[:head_end] + doc = ast.get_docstring(cls) + if doc: + head += [' """' + doc.strip().splitlines()[0] + '"""'] + head.append("") + out = list(head) + for node in cls.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): # 数据类字段与类常量 + end = getattr(node, "end_lineno", None) or node.lineno + out += lines[node.lineno - 1:end] + continue + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + public = not node.name.startswith("_") or node.name in keep_private + if not public: + continue + start = node.lineno - 1 + decorators = getattr(node, "decorator_list", []) + if decorators: + start = min(start, min(item.lineno for item in decorators) - 1) + end = getattr(node, "end_lineno", None) or node.lineno + out += lines[start:end] + [""] + while out and not out[-1].strip(): + out.pop() + return "\n".join(out) + "\n" + + +def collect() -> list[tuple[str, str, list[tuple[str, str]]]]: + """返回 [(标题, 说明, [(函数名, 源码)])],类按公开成员精简。""" + table = index_definitions(notebook_sources(NOTEBOOK)) + sections = [] + for title, note, names in SECTIONS: + blocks: list[tuple[str, str]] = [] + for name in names: + if "." in name: + owner, member = name.split(".", 1) + blocks.append((name, extract_member(table[owner], member))) + elif name in table: + block = table[name] + skeleton = (class_skeleton(block, KEEP_PRIVATE.get(name)) + if block.startswith("class ") else block) + blocks.append((name, compress_docstring(skeleton))) + else: + raise KeyError(f"notebook 中没有定义 {name!r}") + sections.append((title, note, blocks)) + return sections + + +def render(sections) -> str: + r"""渲染成可直接 \input 的 LaTeX 片段(样式 corecode 在 main.tex 导言区定义)。""" + lines = [ + r"% Generated by scripts/build_code_appendix.py from mcm26b-核心代码.ipynb.", + r"% Do not edit by hand: rerun the generator after the notebook changes.", + r"\begingroup\linespread{1.0}\selectfont", + ] + for title, note, blocks in sections: + lines += [rf"\subsection*{{{title}}}", note, ""] + for name, source in blocks: + summary = note_for(name, source) + label = rf"\texttt{{{latex_escape_text(name)}}}" + caption = f"{label}:{summary}。" if summary else f"{label}。" + lines += [rf"\noindent\small {caption}", + r"\begin{lstlisting}[style=corecode]", + source.rstrip(), r"\end{lstlisting}", ""] + lines.append(r"\endgroup") + return "\n".join(lines).rstrip() + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="只统计规模,不写文件") + args = parser.parse_args() + sections = collect() + text = render(sections) + code_lines = sum(len(source.splitlines()) + for _, _, blocks in sections for _, source in blocks) + print(f"核心函数:{sum(len(b) for _, _, b in sections)} 个," + f"代码 {code_lines} 行,LaTeX {len(text.splitlines())} 行") + for title, _, blocks in sections: + print(f" {title}: " + "、".join(name for name, _ in blocks)) + if args.check: + return + target = PAPER / "data/source-listings.tex" + target.write_text(text, encoding="utf-8") + print(f"已写出 {target.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_core_notebook.py b/scripts/build_core_notebook.py new file mode 100644 index 0000000..d05acf3 --- /dev/null +++ b/scripts/build_core_notebook.py @@ -0,0 +1,1368 @@ +"""把 1-4 问的生产代码抽取成单文件 ipynb(无辅助/测评工具、无未采用方案)。 + +用法:: + + python scripts/build_core_notebook.py [-o mcm26b-核心代码.ipynb] + +设计: +- 代码正文**逐字复制**自 `src/mcm26b` 的生产实现,只做三类机械化改写: + 1. 去掉包内相对导入,改用单命名空间的前置导入; + 2. 去掉 `if __name__ == "__main__"` 与文件 IO 入口(覆盖计划改为内嵌字面量); + 3. 去掉文件头部的模块 docstring(其内容改写为独立的说明 markdown 单元)。 +- 明确排除:`referee/`(独立评测机)、`harness/`(测试适配/内存回环/回放)、 + `cli.py`/`geometry/__main__.py`(命令行外壳)、`scripts/`(采集/基准/可视化)、 + `project/`(最优-基线对比框架)、`tests/`,以及未被任何生产策略调用的 + `routing.py` 离线度量族与 `planner/seven.py` 的未采用变体。 +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" / "mcm26b" + + +def read_module(relative: str) -> str: + """读取生产源文件并剥离模块级 docstring(正文原样保留)。""" + text = (SRC / relative).read_text(encoding="utf-8") + return strip_module_docstring(text) + + +def strip_module_docstring(text: str) -> str: + """删除文件开头的模块 docstring(含其后的空行)。""" + match = re.match(r'\s*("""|\'\'\')(?:.|\n)*?\1\s*\n', text) + if match is None: + return text + return text[match.end():].lstrip("\n") + + +def cut(text: str, start: str, end: str | None = None) -> str: + """截取 [start, end) 片段(含 start,不含 end);找不到则报错。""" + begin = text.index(start) + if end is None: + return text[begin:] + return text[begin:text.index(end, begin)] + + +def drop(text: str, start: str, end: str | None = None) -> str: + """删除 [start, end) 片段(end 为 None 时删到文末),保留其余部分。""" + begin = text.index(start) + if end is None: + return text[:begin] + return text[:begin] + text[text.index(end, begin):] + + +# --------------------------------------------------------------------- 源文本 +CONFIG = read_module("config.py") +RULES = read_module("protocol/rules.py") +TIMING = read_module("protocol/timing.py") +STATE = read_module("protocol/state.py") +TRANSPORT = read_module("protocol/transport.py") +# 单文件版把 requests 改成惰性导入:notebook 只依赖标准库(+可选 numpy)即可运行, +# 真正调用 HTTP 时才需要 requests。逻辑与仓库实现完全等价。 +TRANSPORT = TRANSPORT.replace( + "from dataclasses import dataclass\n\nimport requests\n", + "from dataclasses import dataclass\n") +TRANSPORT = TRANSPORT.replace( + """ def post(self, url, body, headers, timeout): + try:""", + """ def post(self, url, body, headers, timeout): + import requests # 惰性导入:仅真正发起 HTTP 时才需要该依赖 + + try:""") +LOG = read_module("protocol/log.py") +CLIENT = read_module("protocol/client.py") +LOCALIZATION = read_module("geometry/localization.py") +PLANNING = read_module("geometry/planning.py") +BELIEF = read_module("belief/__init__.py") +COVERAGE = read_module("coverage/__init__.py") +COVER = read_module("guarantee/cover.py") +PARTICLES = read_module("particles.py") +JOINT = read_module("belief/joint.py") +ROUTING = read_module("routing.py") +PLANNER = read_module("planner/__init__.py") +BAYES = read_module("planner/bayes_loop.py") +SEVEN = read_module("planner/seven.py") + +# ---- routing.py:只保留生产路径用到的开放路径族 -------------------------------- +ROUTING = drop(ROUTING, "\ndef or_opt_open(", "\ndef improve_open_path(") +ROUTING = ROUTING.replace("import math\n\nfrom .config import Vec2\n", + "import math\n\nVec2 = tuple[float, float]\n\n") +ROUTING = ROUTING.replace( + 'EXACT_LIMIT = 13 # Held-Karp 状态数 2^n·n:n=13 → 106 496,纯 Python 可接受\n' + 'OR_OPT_LIMIT = 60 # 作业点数超过此值时跳过高阶 Or-opt(见 improve_open_path)\n\n\n', "") +ROUTING = drop(ROUTING, "\ndef held_karp_open(", "\ndef _dedupe(") +ROUTING = ROUTING.replace( + ''' """最近邻 + 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)''', + ''' """最近邻打底 + 2-opt 改进的开放路径(后备清除走格顺序用)。""" + order = nearest_neighbor_order(points, start) + return two_opt_open(order, start, max_passes)''') +# _dedupe 只服务已被排除的离线度量族,一并删除。 +ROUTING = drop(ROUTING, "\ndef _dedupe(", "\ndef route_metrics(") +ROUTING = drop(ROUTING, "\ndef route_metrics(") + +# ---- guarantee/cover.py:内嵌计划替代文件 IO ---------------------------------- +COVER = COVER.replace("import hashlib\nimport json\nimport math\n", + "import json\nimport math\n") +COVER = COVER.replace("from dataclasses import dataclass, field, fields\n" + "from functools import lru_cache\nfrom pathlib import Path\n", + "from dataclasses import dataclass, field, fields\n") +COVER = COVER.replace('PLAN_DIR = Path(__file__).resolve().parent / "plans"\n', "") +COVER = drop(COVER, "\ndef _load_plan_file(", "\ndef plan_instance(") +COVER = COVER.replace( + '''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")''', + '''def plan_instance(name: str, verify: bool = True) -> CoveragePlan: + """统一入口:按名称取覆盖计划(内置解析计划 / 内嵌证书计划)。""" + if name == "square49": + return square_49_plan() + if name == "seven": + return seven_plan(1200.0) + if name not in EMBEDDED_PLANS: + raise FileNotFoundError(f"未找到覆盖计划 {name!r};现有:{plan_names()}") + plan = embedded_plan(name) + 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_names() -> list[str]: + """全部可加载的计划名(内置解析计划 + 内嵌证书计划)。""" + return ["square49", "seven"] + sorted(EMBEDDED_PLANS)''') + +# ---- particles.py:单命名空间下不再需要可选 numpy 的跨模块探测 ------------------ +PARTICLES = PARTICLES.replace( + "from .config import ProblemConfig, Vec2\n" + "from .geometry.localization import smallest_enclosing_circle\n" + "from .protocol.rules import DIRECTION, NEAR, NO_SIGNAL\n", "") +PARTICLES = PARTICLES.replace( + "import math\nimport os\n\nVec2", "import math\nimport os\n\nVec2") + +# ---- joint.py:跨模块私有符号改为同命名空间引用 -------------------------------- +JOINT = JOINT.replace( + "from ..config import ProblemConfig, Vec2\n" + "from ..particles import ChannelParticles, _branch_radius_array\n" + "from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL\n", "") +JOINT = JOINT.replace( + """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""", + """# numpy 仅作可选加速后端(与 particles 同一开关:无 numpy 时走纯 Python 分支) +_np = _np""") + +# ---- planner/__init__.py:单命名空间导入 -------------------------------------- +PLANNER = PLANNER.replace( + "from ..belief import (BeliefState, CandidateRegion, ChannelStatus,\n" + " InconsistentBelief, clip_halfplane)\n" + "from ..belief.joint import JointBelief\n" + "from ..config import ProblemConfig, Vec2\n" + "from ..coverage import CoverageLedger\n" + "from ..evaluation import recompute_virtual_time, summarize\n" + "from ..guarantee import plan_instance, seven_plan, square_49_plan\n" + "from ..protocol.client import (DeadlineExceeded, ProtocolError,\n" + " SimulatorClient)\n" + "from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL\n" + "from ..protocol.timing import move_time\n" + "from ..protocol.transport import TransportError\n" + "from ..routing import improve_open_path\n", "") + +# ---- planner/bayes_loop.py --------------------------------------------------- +BAYES = BAYES.replace( + "from ..belief import CandidateRegion, ChannelStatus\n" + "from ..config import ProblemConfig, Vec2\n" + "from ..coverage import CoverageLedger\n" + "from ..particles import ChannelParticles, as_sample_array\n" + "from ..protocol.client import SimulatorClient\n" + "from ..protocol.rules import DIRECTION, NEAR, NO_SIGNAL\n" + "from ..protocol.timing import move_time\n" + "from . import (ClearCertificate, OmnidirectionalLoop, clear_certificate,\n" + " candidate_points, safe_disk_radius, select_measurement_point)\n" + "from .seven import validate_seven_coverage\n" + "from ..guarantee import seven_plan\n", "") + +# ---- planner/seven.py:只保留被 CLI 采用的 SevenBatch -------------------------- +SEVEN = SEVEN.replace( + "from ..belief import ChannelStatus\n" + "from ..config import ProblemConfig\n" + "from ..coverage import CoverageLedger\n" + "from ..guarantee import seven_plan\n" + "from ..protocol.rules import NEAR\n" + "from . import OmnidirectionalLoop, clear_certificate\n", "") +# SevenImmediate 只是"换站集"的中间类,直接把 SevenBatch 接到全向闭环骨架上; +# SevenSweep(全域扫完再统一服务)不是被采用的方案,整段删除。 +SEVEN = drop(SEVEN, "\nclass SevenImmediate(", "\nclass SevenBatch(") +SEVEN = SEVEN.replace("class SevenBatch(SevenImmediate):", "class SevenBatch(OmnidirectionalLoop):") +SEVEN = SEVEN.replace("\n defer_all = False\n", "\n") +SEVEN = SEVEN.replace(" if not self.defer_all:\n" + " self._serve_queue()\n", + " self._serve_queue()\n") +SEVEN = drop(SEVEN, "\nclass SevenSweep") +SEVEN = re.sub(r"\n{3,}", "\n\n\n", SEVEN).rstrip() + "\n" + +# ---- client.py:单命名空间导入 ------------------------------------------------- +CLIENT = CLIENT.replace( + "from ..config import ProblemConfig, Vec2\n" + "from .log import SessionLog\n" + "from .rules import (CLEAR_RESULTS, DIRECTION, EXIT_USER, MEASURE_RESULTS,\n" + " POSITION_FIELDS, STATUS_OK, STATUS_RETRYABLE)\n" + "from .state import RobotState\n" + "from .timing import CostBreakdown\n" + "from .transport import RequestsTransport, Transport, TransportError\n", "") + +LOCALIZATION = LOCALIZATION.replace("from dataclasses import dataclass\n", + "from dataclasses import dataclass\n") +PLANNING = PLANNING.replace("from math import cos, sin, radians, isfinite\n" + "from .localization import Measurement\n", + "from math import cos, sin, radians, isfinite\n") + +# ---------------------------------------------------------------- 内嵌覆盖计划 +PLAN_V2 = json.loads((SRC / "guarantee" / "plans" / "q4_mincover_v2.json") + .read_text(encoding="utf-8")) +PLAN_V1 = json.loads((SRC / "guarantee" / "plans" / "q4_mincover_v1.json") + .read_text(encoding="utf-8")) + + +def plan_to_literal(payload: dict) -> str: + """把计划 JSON 渲染成可内嵌的 Python 字面量(点集按 6 点一行压缩)。""" + slim = dict(payload) + slim.pop("sha256", None) + points = slim.pop("points") + body = json.dumps(slim, ensure_ascii=False, indent=2) + body = body[:-1].rstrip() # 去掉 JSON 末尾的 "}" + body = "\n".join(" " + line for line in body.splitlines()) + point_lines = [] + for index in range(0, len(points), 6): + chunk = ", ".join(f"({x:g}, {y:g})" for x, y in points[index:index + 6]) + point_lines.append(" " + chunk + ",") + literal = (body.rstrip() + ",\n" + ' "points": [\n' + "\n".join(point_lines) + "\n ],\n}") + # JSON 的 true/false/null 在 Python 字面量里要写成 True/False/None + for source, target in (("true", "True"), ("false", "False"), ("null", "None")): + literal = re.sub(rf"\b{source}\b", target, literal) + return re.sub(r"=(\s*)\{", "= {", literal) # Q4_MINCOVER_V2 = { + + +EMBEDDED_SOURCE = '''# 覆盖计划以字面量内嵌(原实现从 guarantee/plans/*.json 读取;数值逐位一致)。 +# v2 是现役计划:31 点、设计裕量 r_design=965 m / 前向角裕量 5°, +# 证书按 15 m / 72 档复核 3 256 200 个 (p, h) 目标,缺口 0、最坏命中 990.05 m。 +# v1(75 m/24 档采样过拟合解)保留作对照,说明"粗采样假通过"的风险。 +Q4_MINCOVER_V2 = ''' + plan_to_literal(PLAN_V2).lstrip() + ''' +Q4_MINCOVER_V1 = ''' + plan_to_literal(PLAN_V1).lstrip() + ''' + +EMBEDDED_PLANS = {"q4_mincover_v1": Q4_MINCOVER_V1, "q4_mincover_v2": Q4_MINCOVER_V2} + + +def embedded_plan(name: str) -> CoveragePlan: + """把内嵌计划字典还原成 :class:`CoveragePlan`(证书字段按数据类取值)。""" + payload = EMBEDDED_PLANS[name] + raw_cert = payload.get("certificate") or {} + known = {item.name for item in fields(CoverageCertificate)} + certificate = (CoverageCertificate(**{key: value for key, value in raw_cert.items() + if key in known}) if raw_cert else 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"])) +''' + + +def code(source: str) -> dict: + source = source.strip("\n") + "\n" + # 单命名空间下 __future__ 只能出现在文件最前:后续单元里的一律去掉 + source = source.replace("from __future__ import annotations\n\n", "", 1) + return {"cell_type": "code", "execution_count": None, "metadata": {}, + "outputs": [], "source": source.splitlines(keepends=True)} + + +def md(source: str) -> dict: + source = source.strip("\n") + "\n" + return {"cell_type": "markdown", "metadata": {}, + "source": source.splitlines(keepends=True)} + + +CELLS: list[dict] = [] +add = CELLS.append + +# =============================================================== 0 说明与前置 +add(md(r""" +# CUMCM 2026 B 题 · 无线电干扰源的快速自动定位与清除 +## 单文件核心代码(问题一 ~ 问题四) + +本 notebook 是四问**生产实现**的自包含整理版:按求解流程顺序排列的算法内核, +不含评测机、命令行外壳、采集/基准/绘图脚本、单元测试,也不含未被采用的方案。 + +### 1. 求解主线 + +| 问 | 任务 | 本文档中的实现 | +| --- | --- | --- | +| 一 | 由 ±1° 有界误差示向度交会定位,求定位区域、直径、直径圆覆盖判定、最小包围圆与 20 m 清除证书(并给出直径圆不覆盖区域的等边三角形反例) | `Measurement` / `halfplanes` / `convex_hull` / `diameter` / `smallest_enclosing_circle` / `solve` | +| 二 | 选择第二个检测点,使目标必被接收且交会角最优 | `first_bearing_candidate_disk` / `CandidateRegion` / `safe_disk_radius` / `select_second_point` | +| 三 | 全向源:发现全部干扰源、逐一定位清除,最小化虚拟总时间 | `SevenBatch`(对照)与 `BayesLoop`(默认:网格粒子滤波 + 负观测 + 信息增益选点 + 滚动 2-opt) | +| 四 | 含定向发射源(半盘覆盖):不可见的静默必须与"距离不足"区分 | `DirectionalExtension`(定向覆盖最小命中集 + (位置×朝向) 联合信念 + ≤20 m 后备清除) | + +### 2. 公共层(四问共用) + +- **参数中心**(`ProblemConfig`):题面常量与工程参数的唯一字面量来源; +- **协议层**:字段表/结果码(`rules`)、纯计时模型(`timing`)、本地状态机(`state`)、 + 传输抽象(`transport`)、会话日志(`log`)、串行幂等客户端(`SimulatorClient`); +- **信念层**:逐频道保守凸候选集(`CandidateRegion`)、网格幸存集(`ChannelParticles`)、 + (位置×朝向) 联合可行集(`JointBelief`); +- **保证层**:覆盖账本(`CoverageLedger`)、覆盖计划与证书(`CoveragePlan`); +- **调度层**:开放路径排序(`nearest_neighbor_order` / `two_opt_open` / `improve_open_path`)。 + +### 3. 三条硬约束(贯穿全部代码) + +1. **保守性**:真源(第四问含朝向)永远不被观测更新删掉——候选集用外切多边形外包圆盘、 + 半平面裁剪带正容差、网格幸存集按 Voronoi 裕量内缩后再删格; +2. **幂等串行**:一个动作未决不发起下一个;网络失败时原样重发同一 `request_id` 与请求体; + `accepted=false` 不更新任何状态,其 `virtual_time_s=0` 不是当前时刻; +3. **计时一致**:预测(客户端)—执行(模拟器)—复算(日志)三方用同一计时模型, + 按整数微秒累计。 + +### 4. 与仓库原文件的关系 + +各代码单元逐字取自 `src/mcm26b/**`,仅做机械化改写(去包内相对导入、去文件 docstring、 +去掉覆盖计划的 JSON 文件读取改为内嵌字面量、去掉未被生产路径调用的函数)。 +本文件可独立运行:**零第三方依赖**(`numpy` 仅作可选加速,缺失时自动回退纯 Python; +`requests` 仅在真正发起 HTTP 时才惰性导入)。最后的总自检单元会复算四问的核心结论, +15 项断言全部通过才算整理成功。 + +自检覆盖:问1 角域交会有界/直径圆反例、问2 保证接收与后验收缩、问3 七站解析覆盖证书与 +Voronoi 真源保留、问4 定向最小命中集证书与 (位置×朝向) 联合信念保守性。 +""")) + +add(md(""" +## 0. 环境与依赖 +""")) + +add(code(r''' +from __future__ import annotations + +import json +import math +import os +import uuid +from dataclasses import dataclass, field, fields, replace +from enum import Enum +from functools import lru_cache +from itertools import combinations +from math import cos, dist, hypot, isfinite, radians, sin +from pathlib import Path +from random import Random +from time import monotonic, sleep + +try: # numpy 仅作可选加速后端;缺失时全部回退纯 Python + import numpy as _np +except Exception: # pragma: no cover - 环境无 numpy 时走纯 Python 分支 + _np = None + +print("numpy 加速后端:", "启用" if _np is not None else "缺失(纯 Python 回退)") +''')) + +# =============================================================== 1 参数中心 +add(md(""" +# 第一部分 · 公共层 + +## 1. 参数中心(`config.py`) + +题面常量标 `[题面]`,工程参数标 `[工程]`;后者可依演练实测调整,不代表官方规定。 +任何魔法数字只在这里出现一次,其余模块通过 `ProblemConfig` 读取。 +""")) + +add(code(cut(CONFIG, "Vec2 = tuple[float, float]"))) +add(code(cut(CONFIG, "@dataclass(frozen=True)\nclass ProblemConfig:"))) + +# =============================================================== 2 协议层 +add(md(""" +## 2. 协议层(`protocol/`) + +### 2.1 规则表与字段表(`rules.py`) + +四条指令的字段约束、结果码与 HTTP 错误语义,全部落成可执行数据: +客户端用它构造并预校验请求,模拟器用同一张表校验并拒绝未知字段。 +""")) + +add(code(cut(RULES, "# ---- 结果码"))) + +add(md(""" +### 2.2 纯计时模型(`timing.py`) + +附件示例(105 / 111 / 194 / 199 秒)是唯一权威锚点: +一次合法动作 = 移动(直线距离 / 5 m/s)+ 换频道(仅检测,1 s)+ 动作本身(检测 5 s、 +清除成功 5 s / 未发现 3 s),逐段量化后**按整数微秒累计**。 +""")) + +add(code(cut(TIMING, "@dataclass(frozen=True)\nclass CostBreakdown"))) + +add(md(""" +### 2.3 本地状态机(`state.py`) + +位置 / 测向频道 / 虚拟时刻的唯一权威是最近一次 `accepted=true` 的响应; +`/clear` 不切换测向机频道,只有合法 `/measure` 才更新频道。 +""")) + +add(code(cut(STATE, "class StateError(RuntimeError):"))) + +add(md(""" +### 2.4 传输抽象与会话日志(`transport.py` / `log.py`) + +HTTP 细节与协议客户端解耦:正式运行用 `RequestsTransport`, +本地评测 / 故障注入 / 日志回放各自替换 `Transport` 实现即可,客户端代码零改动。 +会话日志逐条追加 JSONL(题面要求机器狗程序自行留档指令与响应)。 +""")) + +add(code(cut(TRANSPORT, "class TransportError(Exception):"))) +add(code(cut(LOG, "@dataclass\nclass SessionLog:"))) + +add(md(""" +### 2.5 串行幂等客户端(`client.py`) + +- 每个新动作使用新的 `request_id`;仅在网络失败后重试**完全相同的动作**时复用原 ID 与 + 原请求体(本类一次性序列化出 bytes 并原样重发); +- 必须同时检查 HTTP 状态与 `accepted`;`accepted=false` 不更新状态; +- 逐次等待响应、不得并发;本类完全同步; +- 现实截止用 `/enter` 的 `remaining_real_duration_s` 建立本地单调时钟并预留退出余量。 +""")) + +add(code(cut(CLIENT, "class ProtocolError(RuntimeError):"))) + +# =============================================================== 3 几何内核 +add(md(""" +## 3. 几何内核(`geometry/`) + +### 3.1 角域交会定位(`localization.py`) + +每条示向度 = 三个半平面:角域下边界、上边界,以及"只能在前向"约束 +(半角 < 90° 时交集是尖锥而非整条直线)。定位区域 = 全部半平面之交。 + +对偶枚举任意两个半平面的交点即得全部候选顶点,再按可行性筛选—— +有界性由"是否存在非零衰退方向"判定,因此**不需要人为加包围盒**: + +- 无交点 ⇒ 空集(数据矛盾或频道选错); +- 有非零衰退方向 ⇒ 无界,直径为 ∞; +- 否则顶点凸包即定位多边形,直径由顶点对枚举给出,最小包围圆用 Welzl 随机增量算法。 +""")) + +add(code(cut(LOCALIZATION, "Point = tuple[float, float]"))) + +add(md(""" +### 3.2 问题二的安全候选盘证书(`planning.py`) + +首次示向度只给出"目标在角域 ∩ 距离 ≤ 1500 m 的扇形内"。该扇形的**最小包围圆** +半径 a = R / (2 cos δ);再按保证接收半径(≥ 1000 m)内缩 s = 1000 − a 得到 +保证接收盘 B(圆心, s)——盘内任意点到真源的距离 ≤ s + a = 1000 m, +因此第二点必收到 `direction`(或 `near`),不会浪费一次 `no_signal`。 +""")) + +add(code(cut(PLANNING, "def first_bearing_candidate_disk("))) + +# =============================================================== 4 信念与覆盖 +add(md(""" +## 4. 信念层与保证层 + +### 4.1 逐频道保守凸候选集(`belief/__init__.py`) + +候选集 K_c 用**圆盘外切多边形 ∩ 测向角域半平面**表示。两条不变式: + +- 外切多边形严格包含圆盘、半平面裁剪带正容差 ⇒ 更新只会放大不会缩小真实候选集; +- `direction` 读数给出 K_c ⊆ Ω ∩ W(s, θ±δ_eff) ∩ B(s, 1500),δ_eff 含示向度两位小数的 + 舍入裕量 0.005°; +- `no_signal` **不删空间候选**(对定向源它只说明被遮挡,见第四问), + "该频道无源"的证明由覆盖账本或网格幸存集给出; +- `near` 读数把候选集收缩到 B(s, 5),可直接产生清除证书。 +""")) + +add(code(cut(BELIEF, "# 半平面裁剪的正容差(米)"))) + +add(md(""" +### 4.2 逐频道覆盖账本(`coverage/__init__.py`) + +"路过某点"不算"测过某频道":覆盖必须按 (频道, 点) 二维记账, +这是"全点皆 `no_signal` ⇒ 该频道不存在源"这一缺席证明的唯一依据。 +""")) + +add(code(cut(COVERAGE, "def coverage_points("))) + +add(md(""" +### 4.3 覆盖计划与证书(`guarantee/cover.py`) + +两类覆盖语义(题面约束不同,绝不可混用): + +| 语义 | 适用 | 缺席条件 | +| --- | --- | --- | +| `full_disk` | 第三问全向源 | ∪ B(c, 1000) ⊇ Ω,即 Ω 内每点都落在某个被访点的最小接收圆盘内 | +| `forward_half_disk` | 第四问定向源 | ∀(p, h) ∈ Ω × S¹,存在被访点 c 使 \\|c−p\\| ≤ 1000 且 (c−p)·u(h) ≥ 0 | + +**保证 = 离线搜索 + 运行期校验**:点表与证书一起落盘并校验采样语义、缺口数、 +最坏命中距离与生成参数哈希,运行期用同一套采样语义重新校核,未通过即拒绝启动。 +证书硬化把"采样格覆盖"提升为"连续空间覆盖":设计裕量 r_eff + step·√2/2 ≤ 1000 +且角裕量 ≥ 180/headings/2。 +""")) + +add(code(cut(COVER, "R_EFF_MIN_M = 1000.0"))) + +add(md(""" +### 4.4 第四问最小命中集(内嵌计划) + +现役计划 `q4_mincover_v2`:31 点(700 m 方格 49 点的充分条件可压到 31 点), +设计裕量 r_design = 965 m、前向角裕量 5°,按 15 m / 72 档共 3 256 200 个 (p, h) +复核缺口为 0、最坏命中 990.05 m、最近邻巡回 23.88 km。 +`q4_mincover_v1` 是同点数的粗采样(75 m / 24 档)解,细网格复检有 125 个缺口—— +保留它正是"粗采样假通过"的反例。 +""")) + +add(code(cut(COVER, "def seven_points("))) + +add(code(EMBEDDED_SOURCE)) + +add(md(""" +### 4.5 第三问解析七站计划(`planner/seven.py` 覆盖部分) + +原点 + 半径 r 的 6 等分环:域半径 R、环半径 r 时,域内任一点到最近站的距离 +≤ max(r/√3, √(R² + r² − √3Rr)),只要该上界 < 1000 m(最小有效接收半径), +"7 站全 `no_signal`"就是严格的缺席证明。r ∈ [1122.9, 1732] 可行,巡检半径越小巡回越短。 +""")) + +add(code(cut(SEVEN, "RING_RADIUS_M = 1200.0", "\nclass SevenBatch"))) + +add(md(""" +### 4.6 网格幸存集粒子滤波(`particles.py`) + +保守凸外包无法表达"角域 ∩ 距离盘 ∩ 全部 no_signal 排除盘"这类**非凸(挖洞)**可行集。 +网格幸存集可以,并额外买到三件事: + +1. **缺席证明**:幸存集为空 ⇒ 可行集为空 ⇒ 该频道无源(严格,不必等覆盖账本跑完); +2. **跳测规则**:幸存格到测点的最小距离 > r_max ⇒ 该次测量必 `no_signal` 且不产生新排除, + 直接跳过(省 5 s 检测 + 1 s 换频); +3. **负观测更新**:`no_signal` 直接删掉 B(s, 1000) 内的整格。 + +严格性由 **Voronoi 裕量**保证:格距 h 的裕量 m = √2/2·h 是格心到格内任一点的最大距离, +所有删除判定都内缩 m("整格都在可行集外才删"),因此真源所在格永不被删。 +""")) + +add(code(cut(PARTICLES, "_ANG_EPS_DEG = 1e-9"))) + +add(md(""" +### 4.7 (位置 × 朝向) 联合可行集(`belief/joint.py`) + +第四问的失败全部来自定向源:策略知道"源可能在哪个空间区域",却不知道"它朝着哪边"。 +本模块给每个幸存格附一个 24 档(15°)朝向可行性掩码,把"看不看得见"变成可推理的量。 +更新规则全部取保守侧并计入**格内角裕量** `asin(margin / max(dist − margin, 1))` +(格内任一点相对格心方位偏差的上界): + +| 观测 | 位置 | 朝向 | +| --- | --- | --- | +| `direction` @ s | 距离 ≤ 1500、角向 \\|gap\\| ≤ δ_eff + 格裕量 | 必须**可能**覆盖 s | +| `near` @ s | 距离 ≤ 5 | 必须**可能**覆盖 s(near 同样要求可见) | +| `no_signal` @ s | **不删**(可能是遮挡,也可能是距离不足) | 仅在整格必落在 1000 m 内时,删去"必然看得见"的档 | + +由此得到二维真源保留定理:真源 (位置, 朝向) 永不被删。`prob_detect` 用可见性概率选点, +取代固定的 45° 阶梯试探;联合集为空同样就是缺席证明。 +""")) + +add(code(cut(JOINT, "HEADING_BINS = 24"))) + +# =============================================================== 5 调度层 +add(md(""" +## 5. 调度层:开放路径排序(`routing.py`) + +本题的真实图问题是**开放路径**:起点固定在机器人当前位置、终点自由, +代价 = Σ 移动时间 + Σ 动作耗时。后备清除的走格顺序用最近邻打底 + 2-opt 改进 +(O(1) 增量代价评估,终点自由故只反转内部片段、不做首尾相接)。 +""")) + +add(code(ROUTING)) + +# =============================================================== 6 策略层 +add(md(""" +# 第二部分 · 策略层(`planner/`) + +## 6. 问题一/二/三共用的选点器与全向闭环 + +- **问题一**在几何内核里直接求解(第三部分给出算例); +- **问题二**用 `select_second_point`:在保证接收盘内环形采样,以"最坏后验包围圆半径" + 为主指标、移动代价为次指标; +- **问题三**用 `OmnidirectionalLoop` 骨架:覆盖 → 定位 → 清除 → 收尾, + 子类只替换覆盖计划与调度细节。 +""")) + +add(code(cut(PLANNER, "\ndef bearing_deg(a: Vec2, b: Vec2) -> float:", + "\nclass OmnidirectionalLoop:"))) + +add(md(""" +### 6.1 全向闭环骨架(`OmnidirectionalLoop`) + +发现(覆盖账本)→ 定位(候选集收敛到 20 m 内)→ 清除 → 收尾 `/exit`。 +任何异常都必须尽力 `/exit` 并产出 summary,不得静默丢会话: +断连、协议错误、守恒性破坏(候选集被剪空 / 保证接收盘内 `no_signal` / +清除证书失效)都会记入 `stop_reason` 与异常表。 +`completed` 表示任务在证明意义上完成(每频道 CLEARED 或 ABSENT), +`accounted_virtual_time_s` 记录首次"找全证明"的虚拟时刻。 +""")) + +add(code(cut(PLANNER, "\nclass OmnidirectionalLoop:", + "\nclass DirectionalExtension(OmnidirectionalLoop):"))) + +add(md(""" +### 6.2 第四问定向扩展(`DirectionalExtension`) + +与第三问的三点差异: + +1. 覆盖阶段换成定向覆盖最小命中集(`q4_mincover_v2`),发现保证对定向源同样成立; +2. 保证接收盘内的 `no_signal` 只能由**朝向遮挡**解释:该频道记为"观测到定向", + 对 MEC 圆心取对跖探点复核,并用联合信念按可见概率选点; +3. 定位停滞时对**有界**候选集构造 ≤20 m 有限圆盘覆盖,逐格 `/clear` + (清除与朝向无关,是完备后备);格数超硬顶或预算不足即跳过并留痕,绝不盲扫。 +""")) + +add(code(cut(PLANNER, "\nclass DirectionalExtension(OmnidirectionalLoop):"))) + +add(md(""" +### 6.3 第三问默认策略:贝叶斯网格滤波闭环(`planner/bayes_loop.py`) + +三层重构: + +1. **感知层**:每频道一个网格幸存集,全部读数(含 `no_signal` 负观测)即时更新, + 幸存集为空即缺席证明; +2. **决策层**:站点扫频、定位测量、清除三类任务合成统一任务表, + 开放路径最近邻 + 2-opt 排序,每执行一个任务滚动重排(动态 TSP); +3. **选点层**:定位测量点在保证接收盘(凸证书)或 PF MEC 探索环(宽候选集)内 + 按"期望 log 幸存占比 + λ·移动时间"评分(信息增益贪心)。 + +相对基线的增益来自负观测跳站/跳测、PF 缺席证明提前证毕、全局任务排序。 +清除证书仍以凸证书为主,PF 网格证书只在负观测挖洞使幸存集更紧时提前触发。 +""")) + +add(code(BAYES)) + +add(md(""" +### 6.4 第三问对照策略:七站批量扫频(`planner/seven.py`) + +同一骨架换覆盖计划:7 站解析覆盖 + 站内批量扫频 + 按"距候选集 MEC 圆心最近"服务队列。 +它曾以官方案例 12/12 全清通过演练,是"覆盖证明 + 批量调度"的中间对照。 +""")) + +add(code(cut(SEVEN, "\nclass SevenBatch"))) + +# =============================================================== 7 问题一 +add(md(""" +# 第三部分 · 逐问算例与自检 + +以下单元用**合成观测**驱动上面的求解器(合成观测的生成规则单独写明, +不依赖评测机)。每个单元末尾用断言检查结论,运行通过即说明该问的代码链路正确。 + +## 7. 问题一:角域交会定位 + +三个检测点、±1° 误差的示向度。输出:定位区域多边形顶点、直径、直径圆是否覆盖整个区域、 +最小包围圆(MEC)半径,以及"MEC 半径 ≤ 20 m"的清除证书。 +""")) + +add(code(r''' +Q1_EXAMPLES = { + "normal": { # 三点交会:区域有界、MEC 远小于 20 m + "target": (80.0, 90.0), + "measurements": [(-600.0, -250.0, 27.21505117707799), + (650.0, -100.0, 161.015051177078), + (100.0, 700.0, 268.4721225527146)], + }, + "triangle": { # 直径圆反例:直径圆不覆盖区域 + "target": (20.0, 11.547005383792515), + "measurements": [(-1000.0, 0.0, 1.0), + (540.0, -866.0254037844386, 121.0), + (520.0, 900.6664199358162, 241.0)], + }, + "poor": { # 近共线:细长区域,MEC 远大于直径圆 + "target": (0.0, 0.0), + "measurements": [(-900.0, -20.0, 1.29), (0.0, 0.0, 0.0), (900.0, 20.0, 358.73)], + }, +} + +def solve_q1(case: str, rounding_margin_deg: float = 0.0) -> dict: + """复现 `mcm26b q1 examples/q1/.json` 的求解口径。""" + rows = Q1_EXAMPLES[case]["measurements"] + measurements = [Measurement(x, y, bearing, 1.0 + rounding_margin_deg) + for x, y, bearing in rows] + return solve(measurements) + +normal = solve_q1("normal") +print("== 三点交会 ==") +print("状态 :", normal["status"], "顶点数", len(normal["vertices"])) +print("直径 : %.3f m" % normal["diameter_m"]) +print("直径圆覆盖区域 :", normal["diameter_circle"]["covers"], + "最大超出 %.3f m" % normal["diameter_circle"]["max_excess_m"]) +print("最小包围圆 : 圆心 (%.3f, %.3f) 半径 %.3f m" + % (*normal["minimum_enclosing_circle"]["center"], + normal["minimum_enclosing_circle"]["radius_m"])) +print("可原地清除 :", normal["guaranteed_clearable_at_mec_center"]) +assert normal["status"] == "polygon" and normal["diameter_m"] > 0 +assert normal["diameter_infinite"] is False +''')) + +add(code(r''' +# 反例:直径圆的圆心是"最远顶点对"的中点,第二个最远顶点可能落在圆外。 +tri = solve_q1("triangle") +print("== 直径圆反例(等边三角形) ==") +print("定位区域顶点:", [[round(v, 2) for v in p] for p in tri["vertices"]]) +print("直径 %.3f m(最远顶点对 %s)" % (tri["diameter_m"], + [[round(v, 2) for v in p] for p in tri["diameter_pair"]])) +print("直径圆:圆心 %s 半径 %.3f m" % ([round(v, 2) for v in + tri["diameter_circle"]["center"]], tri["diameter_circle"]["radius_m"])) +print("圆外顶点下标 %s(见证顶点 %s),最大超出 %.3f m" + % (tri["diameter_circle"]["outside_vertex_indices"], + [round(v, 2) for v in tri["diameter_circle"]["witness_vertex"]], + tri["diameter_circle"]["max_excess_m"])) +print("直径圆覆盖整个定位区域:", tri["diameter_circle"]["covers"]) +print("最小包围圆半径 %.3f m(>= 直径/2 = %.1f m,才是真正的最小覆盖半径)" + % (tri["minimum_enclosing_circle"]["radius_m"], tri["diameter_m"] / 2)) +assert tri["diameter_circle"]["covers"] is False +assert tri["diameter_circle"]["outside_vertex_indices"] == [2] +# 一般地 直径/2 <= 最小包围圆半径;等边三角形取严格大于(23.09 > 20) +assert tri["minimum_enclosing_circle"]["radius_m"] >= tri["diameter_m"] / 2 - 1e-9 + +poor = solve_q1("poor") +print("\n== 近共线细长区域 ==") +print("状态 %s,直径 %.1f m,最小包围圆半径 %.1f m,可原地清除 %s" + % (poor["status"], poor["diameter_m"], + poor["minimum_enclosing_circle"]["radius_m"], + poor["guaranteed_clearable_at_mec_center"])) +assert poor["minimum_enclosing_circle"]["radius_m"] > 20.0 +assert poor["guaranteed_clearable_at_mec_center"] is False + +empty = solve([]) +unbounded = solve([Measurement(-600.0, -250.0, 27.21505117707799, 1.0)]) +print("\n无观测 ->", empty["status"], "| 单条示向度 ->", unbounded["status"], + "(衰退方向 %s)" % unbounded["recession_direction"]) +assert empty["status"] == "unbounded" and unbounded["status"] == "unbounded" +''')) + +add(code(r''' +# 示向度二位小数的舍入敏感性:误差半角 1.0° vs 1.005°(含 0.005° 舍入裕量) +tight = solve_q1("normal", 0.0) +loose = solve_q1("normal", 0.005) +print("舍入裕量 0.000°:直径 %.3f m,MEC 半径 %.3f m" + % (tight["diameter_m"], tight["minimum_enclosing_circle"]["radius_m"])) +print("舍入裕量 0.005°:直径 %.3f m,MEC 半径 %.3f m" + % (loose["diameter_m"], loose["minimum_enclosing_circle"]["radius_m"])) +print("放宽 %.3f° 使 MEC 半径增加 %.3f m —— 因此库默认带该裕量," + "否则合法真源可能被角域裁掉" % (0.005, loose["minimum_enclosing_circle"]["radius_m"] + - tight["minimum_enclosing_circle"]["radius_m"])) +assert loose["minimum_enclosing_circle"]["radius_m"] >= \ + tight["minimum_enclosing_circle"]["radius_m"] - 1e-9 +''')) + +# =============================================================== 8 问题二 +add(md(""" +## 8. 问题二:第二检测点的选择 + +给定首次 `direction` 读数(检测点 s₁、示向度 θ₁±1°),构造 +K₁ = Ω ∩ W(s₁, θ₁±δ_eff) ∩ B(s₁, 1500),再在**保证接收盘**内选第二点: + +- 目标函数:候选读数分支下的**最坏后验包围半径**(越小越好,直接决定能否进入 20 m 清除圈); +- 次指标:从当前位置出发的移动时间(同分决胜); +- 采样是启发式(候选点族 × 目标边界点 × 读数区间端点),选定后按 1/4 间距加密复核并如实上报。 +""")) + +add(code(r''' +class _StateStub: + """最小状态替身:只提供选点器需要的当前位置与当前测向频道。""" + + def __init__(self, position=(0.0, 0.0), channel: int = 1): + self.position = position + self.channel = channel + + +class _ClientStub: + def __init__(self, position=(0.0, 0.0), channel: int = 1): + self.state = _StateStub(position, channel) + + +CONFIG = ProblemConfig() + +def plan_second_point(station, bearing_deg, from_position=(0.0, 0.0)): + """问题二主入口:首次示向度 -> 保证接收的第二检测点方案。""" + client = _ClientStub(from_position, channel=1) + return select_second_point(client, station, bearing_deg, CONFIG) + + +def plan_second_point_from(region, from_position=(0.0, 0.0)): + """已知候选集时的下一测点(同一选点器,供逐次追加测点演示)。""" + return select_measurement_point(region, from_position, CONFIG) + + +# 首次检测:真源在 (300, 400),从原点测得示向度 53.13°,误差半角 ±1° +station1, bearing1 = (0.0, 0.0), 53.13010235415598 +cert = first_bearing_candidate_disk(Measurement(*station1, bearing1, 1.0)) +print("首次角域扇形的包围圆:圆心 (%.1f, %.1f) 半径 %.1f m" + % (*cert["sector_enclosing_circle"]["center"], + cert["sector_enclosing_circle"]["radius_m"])) +print("保证接收盘 :圆心 (%.1f, %.1f) 半径 %.1f m" + % (*cert["sufficient_candidate_disk"]["center"], + cert["sufficient_candidate_disk"]["radius_m"])) + +# 首次读数后的候选集 K1 = Ω ∩ W(s1, θ1±δ_eff) ∩ B(s1, 1500) +half = 1.0 + CONFIG.search.bearing_rounding_margin_deg +region = CandidateRegion.initial(station1, CONFIG) +region.clip_wedge(station1, bearing1, half) + +plan = plan_second_point(station1, bearing1) +print("\n第二检测点 (%8.2f, %8.2f)" % plan.position) +print("最坏后验包围半径 %.2f m(当前候选集 MEC 半径 %.1f m,必须更小才叫有信息)" + % (plan.worst_posterior_radius_m, region.mec()[1])) +print("移动耗时 %.1f s(次指标:同样精度下取更近的点)" % plan.move_cost_s) +print("依据:", plan.reason) +''')) + +add(code(r''' +# 用同一套观测语义复核这个点:无论真源落在候选集何处,第二点都必收到信号。 +# 候选集边界按 1 m 加密采样做最坏情形检验。 +worst_distance, worst_radius, samples = 0.0, 0.0, 0 +for truth in region.witnesses(1.0): + worst_distance = max(worst_distance, math.dist(plan.position, truth)) + for reading in (bearing_deg(plan.position, truth) - half, + bearing_deg(plan.position, truth) + half): + probe = CandidateRegion(list(region.polygon), region.slack_m) + probe.clip_wedge(plan.position, reading, half) + if probe.polygon: + worst_radius = max(worst_radius, probe.mec()[1]) + samples += 1 +print("候选集边界采样点 %d 个" % samples) +print("真源到第二点的最远距离 %.2f m(<= 1000 m 才保证收到信号)" % worst_distance) +print("对候选集最坏点、最不利读数的后验包围半径 %.2f m" % worst_radius) +print("第二点相对候选集包围圆的保证接收余量:%.1f m" + % (CONFIG.field.r_eff_min_m - region.mec()[1] - math.dist(plan.position, + region.mec()[0]))) +assert worst_distance <= CONFIG.field.r_eff_min_m # 保证接收:不会浪费 no_signal +assert worst_radius < region.mec()[1] # 一定带来收缩 + +# 逐次追加测点:最坏后验半径单调收缩,第三点后进入 20 m 清除圈 +for step in range(2, 5): + if plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m: + break + region.clip_wedge(plan.position, bearing_deg(plan.position, (300.0, 400.0)), half) + plan = plan_second_point_from(region, (0.0, 0.0)) + print("追加第 %d 个测点 (%8.2f, %8.2f):最坏后验半径 %.2f m" + % (step, *plan.position, plan.worst_posterior_radius_m)) +print("\n结论:第二点负责\"保证接收 + 最大化交会角\",后验精度由后续测点继续压缩;" + "20 m 清除圈通常在第三点后达成。") +assert plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m +''')) + +add(code(r''' +# 对照:候选集在不同首次示向度下的选择结果(目标函数与移动代价的权衡) +print("首次示向度 第二点位置 最坏后验半径 移动耗时") +for bearing in (0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0): + item = plan_second_point(station1, bearing, from_position=(-1500.0, 0.0)) + print("%7.1f° (%8.2f, %8.2f) %8.2f m %6.1f s" + % (bearing, item.position[0], item.position[1], + item.worst_posterior_radius_m, item.move_cost_s)) +''')) + +# =============================================================== 9 问题三 +add(md(r""" +## 9. 问题三:全向源闭环 + +第三问没有可直连的模拟器时,仍可自检全部**决策内核**: + +1. 七站覆盖计划是否真的覆盖 Ω(每点到最近站 ≤ 1000 m); +2. 网格幸存集在随机观测序列后是否始终保留真源(Voronoi 裕量定理); +3. 空幸存集、跳测规则、清除证书是否按预期给出; +4. 任务表排序(最近邻 + 2-opt)是否真的缩短开放路径。 + +合成观测按下述语义生成,与题面一致(`r_eff` 未知,只在 [1000, 1500] 内取值): +`dist > r_eff → no_signal`;`dist ≤ 5 → near`;否则 `direction`,读数为真实方位 +加上 ≤1° 的误差。 +""")) + +add(code(r''' +def observe_omni(truth, station, r_eff, bearing_noise_deg=0.0): + """全向源的观测语义(合成用):返回 (result, svd_deg)。""" + distance = math.dist(truth, station) + if distance > r_eff: + return "no_signal", None + if distance <= 5.0: + return "near", None + return "direction", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0 + + +def observe_directional(truth, heading_deg, station, r_eff, bearing_noise_deg=0.0): + """定向源的观测语义:源→读点方向与朝向夹角 > 90° 时同样静默。""" + dx, dy = station[0] - truth[0], station[1] - truth[1] + distance = math.hypot(dx, dy) + if distance > r_eff: + return "no_signal", None + forward = dx * math.cos(math.radians(heading_deg)) \ + + dy * math.sin(math.radians(heading_deg)) + if forward < 0.0: + return "no_signal", None # 背向发射:接收不到 + if distance <= 5.0: + return "near", None + return "direction", (bearing_deg(station, truth) + bearing_noise_deg) % 360.0 + + +# ---- 9.1 七站解析覆盖计划 --------------------------------------------------- +CONFIG_Q3 = replace(CONFIG, bayes=replace(BayesConfig(), station_ring_radius_m=1200.0)) +bound = validate_seven_coverage(CONFIG_Q3.bayes.station_ring_radius_m, CONFIG_Q3) +plan7 = seven_plan(CONFIG_Q3.bayes.station_ring_radius_m) +print("七站环半径 %.1f m,最近站最远距离解析上界 %.1f m(阈值 1000 m)" + % (CONFIG_Q3.bayes.station_ring_radius_m, bound)) +print("站点:", [tuple(round(v, 1) for v in point) for point in plan7.points]) + +# 数值复核必须与证书同分辨率:这里用 300 m / 12 档演示(全分辨率证书见 4.4 节) +cert7 = verify_full_disk(plan7.points, step_m=300.0, r_eff=R_EFF_MIN_M) +print("粗采样复核:目标点 %d 个,未覆盖 %d 个,最坏命中 %.1f m,最近邻巡回 %.1f km" + % (cert7.targets, cert7.uncovered, cert7.worst_hit_distance_m, cert7.nn_tour_m / 1000)) +assert cert7.uncovered == 0 and cert7.worst_hit_distance_m <= R_EFF_MIN_M +assert bound <= CONFIG_Q3.field.r_eff_min_m +''')) + +add(code(r''' +# ---- 9.2 网格幸存集的真源保留性(Voronoi 裕量定理的随机检验) ---------------- +Random(20260913).seed(20260913) +source = (300.0, 400.0) # 真源 +r_eff_true = 1200.0 # 真源的有效接收半径(策略端不可见) +stations = [(x, y) for x, y in + [(-900.0, 200.0), (0.0, 0.0), (600.0, -700.0), (1100.0, 800.0)]] + +pf = ChannelParticles(CONFIG_Q3) +start_alive = len(pf) +retained = True +for index, station in enumerate(stations): + result, svd = observe_omni(source, station, r_eff_true, + bearing_noise_deg=0.8 if index % 2 else -0.9) + pf.apply_measure(station, result, svd) + cell = (min(pf.alive, key=lambda k: math.dist(pf.cell_center(k), source)) + if pf.alive else None) + truth_offset = (math.dist(pf.cell_center(cell), source) if cell is not None + else float("nan")) + keeps_truth = (cell is not None + and truth_offset <= pf.margin_m + 1e-9) + retained = retained and keeps_truth + center, radius = pf.mec() + print("第 %d 次读数 %-9s 幸存格 %6d 格心距真源 %7.3f m(裕量 %.3f m) MEC 半径 %8.3f m" + % (index + 1, result, len(pf), truth_offset, pf.margin_m, radius)) +print("\n初始幸存格 %d;真源始终落在某个幸存格内:%s" % (start_alive, retained)) +print("Voronoi 裕量 m = %.3f m(格心到格内任一点的最大距离)" % pf.margin_m) +assert retained and pf.version > 0 + +# 跳测规则:必 no_signal 且不产生新排除的 (测点, 频道) 对应当被直接跳过 +far_station = (4000.0, 4000.0) +print("远端测点 %s 是否必 no_signal:%s" % (far_station, pf.guaranteed_no_signal(far_station))) +assert pf.guaranteed_no_signal(far_station) is True +''')) + +add(code(r''' +# ---- 9.3 证书与缺席证明 ----------------------------------------------------- +center, radius = pf.mec() +limit = CONFIG_Q3.field.clear_radius_m - pf.margin_m - CONFIG_Q3.search.clear_margin_m +print("幸存集 MEC:圆心 (%.2f, %.2f) 半径 %.3f m" % (*center, radius)) +print("网格清除证书阈值 %.3f m(= 20 − 裕量 %.3f − 工程余量 %.1f)→ 可清除:%s" + % (limit, pf.margin_m, CONFIG_Q3.search.clear_margin_m, radius <= limit)) + +# near 读数把候选集收缩到 5 m 盘:凸口径直接给出清除证书 +near_region = CandidateRegion.disk((source[0] + 3.0, source[1]), 5.0, + CONFIG_Q3.search.polygon_sides) +near_cert = clear_certificate(near_region, channel=7, config=CONFIG_Q3) +print("near 读数后的凸证书:半径 %.3f m ≤ %.3f m → %s" + % (near_cert.radius_m, near_cert.limit_m, near_cert.certified)) +assert near_cert.certified + +# 网格清除证书成立时,目标点可直接贴着来向利用 20 m 容差(三角不等式仍覆盖整集) +near_cert2 = loop_cert = None +center, radius = pf.mec() +shift = max(0.0, (CONFIG_Q3.field.clear_radius_m - pf.margin_m + - CONFIG_Q3.search.clear_margin_m) - radius) +shifted = (center[0] + shift * 0.6, center[1] + shift * 0.8) +print("沿来向偏移后清除点距真源 %.3f m <= 半径 %.3f + 偏移 %.3f = %.3f m < 20 m" + % (math.dist(shifted, source), radius, shift, radius + shift)) + +# 缺席证明:覆盖计划(七站)全 no_signal 后幸存集清空 —— 与解析证明互相印证 +absent = ChannelParticles(CONFIG_Q3) +for station in plan7.points: + absent.apply_measure(station, "no_signal") +print("\n七站全部 no_signal 后幸存格 %d → 该频道判缺席(无需等覆盖账本跑完)" + % len(absent)) +partial = ChannelParticles(CONFIG_Q3) +for station in plan7.points[:-1]: + partial.apply_measure(station, "no_signal") +print("只测其中 6 站仍有 %d 个幸存格 → 七站是刚好够用的最小解析覆盖" + % len(partial)) +assert len(absent) == 0 and len(partial) > 0 +assert absent.guaranteed_no_signal(tuple(plan7.points[0])) is True +''')) + +add(code(r''' +# ---- 9.4 统一任务表与滚动排序(信息增益选点 + 2-opt) ------------------------ +class _LogStub: + def action_events(self): + return [] + + def step_events(self): + return [] + + +client3 = SimulatorClient("2026001", config=CONFIG_Q3, transport=None, log=_LogStub()) +client3.state.position = (0.0, 0.0) +loop3 = BayesLoop(client3, CONFIG_Q3) +print("第三问策略:覆盖计划 %s(%d 站),网格间距 %.2f m" + % (loop3.coverage_plan.name, len(loop3.coverage_plan.points), + CONFIG_Q3.bayes.particle_spacing_m)) + +# 注入一次真实读数,建立该频道的信念与粒子集 +loop3._record_measure(9, (0.0, 0.0), type("O", (), { + "result": "direction", "svd_deg": bearing_deg((0.0, 0.0), (300.0, 400.0))})()) +tasks = loop3._build_tasks() +print("任务表:%d 项(站点扫频 %d / 定位 %d / 清除 %d)" + % (len(tasks), sum(t.kind == "station" for t in tasks), + sum(t.kind == "locate" for t in tasks), sum(t.kind == "clear" for t in tasks))) +order = loop3._order_tasks(tasks) +print("排序后前 5 项:", [(t.kind, t.channel) for t in order[:5]]) +print("2-opt 前后路径耗时 %.2f s -> %.2f s" + % (loop3._path_cost(tasks), loop3._path_cost(order))) +assert loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9 + +point = loop3._select_point(9) +print("信息增益选点:(%8.2f, %8.2f)" % point) +assert point is not None and 9 in loop3.particles +''')) + +add(code(r''' +# ---- 9.5 覆盖计划证书:离线全分辨率生成,运行期按同分辨率复核 ---------------------------------- +plan_v2 = plan_instance("q4_mincover_v2", verify=False) +cert_v2 = plan_v2.certificate +print("第四问覆盖计划 %s:%d 点" % (plan_v2.name, len(plan_v2.points))) +print("证书采样分辨率 %g m / %d 档,目标 (p,h) %d 个,缺口 %d,最坏命中 %.3f m" + % (cert_v2.target_step_m, cert_v2.headings, cert_v2.targets, + cert_v2.uncovered, cert_v2.worst_hit_distance_m)) +print("最近邻巡回 %.1f km(虚拟移动 %.1f s)→ 发现阶段的移动代价下界" + % (cert_v2.nn_tour_m / 1000, cert_v2.nn_tour_m / CONFIG.cost.speed_mps)) +print("设计裕量:r_design %.0f m、前向角裕量 %.1f°;校核用 r_eff %.0f m、角裕量 %.1f°" + % (plan_v2.generator["design_r_eff_m"], plan_v2.generator["heading_margin_deg"], + cert_v2.r_eff_min_m, cert_v2.verify_heading_margin_deg)) +assert cert_v2.uncovered == 0 and cert_v2.targets == 3256200 + +plan_v1 = plan_instance("q4_mincover_v1", verify=False) +print("\n对照 %s:同样 %d 点,但证书采样只有 %g m / %d 档,细网格复检有 125 个缺口" + % (plan_v1.name, len(plan_v1.points), plan_v1.generator["target_step_m"], + plan_v1.generator["headings"])) +print("→ 采样分辨率必须与证书一致,否则'删掉一个关键点仍看似覆盖'(校核退化为装饰)") +''')) + +# =============================================================== 10 问题四 +add(md(r""" +## 10. 问题四:含定向源的闭环 + +定向源的难点是**静默的歧义**:`no_signal` 既可能是距离不足,也可能是朝向遮挡。 +本问的关键推理与对策: + +1. **联合可行集**:给每个幸存格附 24 档朝向掩码。`direction`/`near` 要求朝向 + *可能*覆盖读点;`no_signal`(且整格必在 1000 m 内)要求朝向*确定不*覆盖读点。 + 真源 (位置, 朝向) 永不被删;联合集为空即缺席证明。 +2. **可见性选点**:`prob_detect` 给出"在该点能看见任一可行 (位置,朝向) 的概率质量", + 先在可见候选中过滤(`prob_detect ≥ ratio × max`),再取"看得见且少走路"的点, + 取代固定 45° 阶梯试探。 +3. **后备清除**:定位停滞时对有界候选集构造 ≤20 m 有限圆盘覆盖逐格 `/clear`—— + 清除只与距离有关、与朝向无关,是完备兜底(格数即请求上界,超限即拒绝盲扫)。 +""")) + +add(code(r''' +# ---- 10.1 定向覆盖最小命中集的覆盖语义 -------------------------------------- +plan_q4 = plan_instance("q4_mincover_v2", verify=False) +coarse = verify_forward_half_disk(plan_q4.points, step_m=300.0, headings=12, + r_eff=R_EFF_MIN_M, heading_margin_deg=0.0) +print("最小命中集 %d 点,粗采样复核 (p,h) %d 个:未覆盖 %d,最坏命中 %.1f m" + % (len(plan_q4.points), coarse.targets, coarse.uncovered, + coarse.worst_hit_distance_m)) +square49 = square_49_plan() +coarse49 = verify_forward_half_disk(square49.points, step_m=300.0, headings=12) +print("对照 700 m 方格 %d 点:同样未覆盖 %d,巡回 %.1f km vs %.1f km" + % (len(square49.points), coarse49.uncovered, + coarse49.nn_tour_m / 1000, coarse.nn_tour_m / 1000)) +assert coarse.uncovered == 0 and coarse.targets > 0 +''')) + +add(code(r''' +# ---- 10.2 (位置 × 朝向) 联合可行集的保守性随机检验 -------------------------- +truth4 = (420.0, -260.0) # 真源位置 +heading4 = 200.0 # 真源朝向(背向圆心发射的边界工况) +r_eff4 = 1100.0 # 真源的有效接收半径(策略端不可见) + + +def station_at(bearing_from_truth_deg, distance_m): + """按"从真源看过去的方位"构造测点,便于控制可见/遮挡工况。""" + return (truth4[0] + distance_m * math.cos(math.radians(bearing_from_truth_deg)), + truth4[1] + distance_m * math.sin(math.radians(bearing_from_truth_deg))) + + +# 前向半球内的测点(|方位 − 朝向| ≤ 90°):应收到 direction; +# 超过 1100 m 的测点:无论朝向如何都 no_signal。 +CASES = [(heading4 - 85.0, 900.0), (heading4 + 85.0, 700.0), (heading4, 600.0), + (heading4 + 30.0, 1050.0), (heading4 - 60.0, 400.0), (heading4 + 130.0, 800.0), + (heading4, 1250.0)] + +joint = JointBelief(CONFIG) +half_bin = 0.5 * 360.0 / HEADING_BINS # 档位半宽 7.5°:真实朝向必落在某一档内 +expected = ["direction"] * 5 + ["no_signal"] * 2 +for (bearing, dist_m), want in zip(CASES, expected): + station = station_at(bearing, dist_m) + result, svd = observe_directional(truth4, heading4, station, r_eff4) + joint.apply(station, result, svd) + cell = min(joint.position.alive, + key=lambda k: math.dist(joint.position.cell_center(k), truth4)) + offset = math.dist(joint.position.cell_center(cell), truth4) + mask = joint._mask(cell) + keeps = any(heading_gap(360.0 * k / HEADING_BINS, heading4) <= half_bin + for k in range(HEADING_BINS) if mask >> k & 1) + print("测点方位 %6.1f° 距离 %5.0f m → %-9s(预期 %-9s)幸存格 %6d " + "真源格偏移 %6.3f m(裕量 %.3f m)朝向档 %2d 个 含真源朝向档:%s" + % (bearing % 360.0, dist_m, result, want, len(joint), offset, + joint.position.margin_m, bin(mask).count("1"), keeps)) + assert result == want, "合成观测与题面语义不一致" + assert keeps, "联合信念删掉了真源的朝向档(保守性被破坏)" + assert offset <= joint.position.margin_m + 1e-9, \ + "联合信念删掉了真源的位置格(Voronoi 裕量被破坏)" + +print("\n联合集规模 %d 格,抽样平均朝向档 %.2f / %d" + % (len(joint), joint.summary()["sampled_mean_heading_bins"], HEADING_BINS)) +assert not joint.is_absent() +''')) + +add(code(r''' +# ---- 10.3 可见性选点取代固定阶梯;后备清除兜底 ------------------------------ +print("探点可见性概率(越高越可能在该点收到信号):") +for candidate in [(-2000.0, 0.0), (0.0, 0.0), (420.0, -260.0), (1500.0, -900.0), + (420.0 + 800.0 * math.cos(math.radians(heading4)), + -260.0 + 800.0 * math.sin(math.radians(heading4)))]: + print(" (%8.1f, %8.1f) prob_detect = %.4f" + % (*candidate, joint.prob_detect(candidate))) + +# 定位停滞时的后备:候选集有界 -> 20 m 网格覆盖,格心到格内任一点 <= 10√2 < 20 m +region4 = CandidateRegion.initial((0.0, 0.0), CONFIG) +region4.clip_wedge((0.0, 0.0), 45.0, 1.0 + CONFIG.search.bearing_rounding_margin_deg) +region4.clip_disk((900.0, 900.0), 900.0, CONFIG.search.polygon_sides) +cells = fallback_clear_cells(region4.polygon, CONFIG) +width = max(p[0] for p in region4.polygon) - min(p[0] for p in region4.polygon) +height = max(p[1] for p in region4.polygon) - min(p[1] for p in region4.polygon) +print("\n候选集外包框 %.0f m × %.0f m,20 m 网格覆盖 %d 格(外包框枚举上界 %d)" + % (width, height, len(cells), fallback_cell_bound(width, height, CONFIG))) +print("格内任意点到最近格心 <= %.2f m < 20 m → 逐格 /clear 至少命中一次" + % (CONFIG.search.fallback_clear_grid_m * math.sqrt(2) / 2)) +assert cells and len(cells) <= CONFIG.search.fallback_max_cells + +# 覆盖完备性抽查:在候选集外包框内随机撒点,每个点都必落在某个格心 20 m 内 +rng = Random(7) +covered = 0 +for _ in range(2000): + u, v = rng.random(), rng.random() + sample = (min(p[0] for p in region4.polygon) + u * width, + min(p[1] for p in region4.polygon) + v * height) + if not region4.contains(sample, tol_m=0.0): + continue # 框内但在候选集外:不计入 + covered += min(math.dist(sample, cell) for cell in cells) <= 20.0 +print("外包框内落在候选集中的随机点,被某个格心 20 m 覆盖:%d 个" % covered) +assert covered > 0 +''')) + +add(code(r''' +# ---- 10.4 第四问闭环装配(不连模拟器:只检查策略对象初始化) ---------------- +client4 = SimulatorClient("2026001", config=CONFIG, transport=None, log=_LogStub()) +client4.state.position = (0.0, 0.0) +loop4 = DirectionalExtension(client4, CONFIG) +print("第四问策略:覆盖计划 %s(%d 点),联合信念开关 %s,探点口径 %s" + % (loop4.coverage_plan.name, len(loop4.coverage_plan.points), + CONFIG.search.directional_joint_belief, CONFIG.search.probe_score_mode)) +print("后备清除硬顶 %d 格,单轮遮挡探点预算 %d 次,定位步数上限 %d" + % (CONFIG.search.fallback_max_cells, CONFIG.search.max_blocked_probes, + CONFIG.search.max_localize_steps)) + +# 保证接收盘内的 no_signal 只能由朝向遮挡解释:模拟"被遮挡"的梯子升级 +blocked_point = (600.0, 600.0) +loop4._observe(3, blocked_point, "direction", + bearing_deg(blocked_point, truth4)) +cert4 = clear_certificate(loop4.belief.candidate_set(3), 3, CONFIG) +print("\n频道 3 候选集半径 %.1f m,可清除:%s" % (cert4.radius_m, cert4.certified)) +safe = safe_disk_radius(loop4.belief.candidate_set(3), CONFIG) +print("保证接收盘半径 %.1f m(盘内必收到信号 -> 静默即遮挡)" % safe) +loop4._observe(3, blocked_point, "no_signal", None) +next_probe = loop4._next_probe(loop4.belief.candidate_set(3), 3) +print("被遮挡后梯子升级到的探点:(%8.2f, %8.2f)" + % next_probe) +assert loop4.joint.get(3) is not None and not loop4.joint[3].is_absent() +''')) + +# =============================================================== 11 收尾 +add(md(""" +# 第四部分 · 总览与自检 + +## 11. 本文件包含 / 不包含的内容 + +**包含**(四问生产路径): + +| 层 | 模块 | 内容 | +| --- | --- | --- | +| 参数 | `config.py` | 题面常量 + 工程参数 | +| 协议 | `protocol/` | 规则表、计时模型、状态机、传输、会话日志、串行幂等客户端 | +| 几何 | `geometry/` | 角域交会定位、直径圆、最小包围圆、保证接收盘证书 | +| 信念 | `belief/` | 保守凸候选集、(位置×朝向) 联合可行集 | +| 滤波 | `particles.py` | 网格幸存集、负观测更新、跳测规则、信息增益评分 | +| 保证 | `coverage/` `guarantee/` | 覆盖账本、覆盖计划与证书、最小命中集 | +| 调度 | `routing.py` | 开放路径排序(最近邻 + 2-opt) | +| 策略 | `planner/` | 问题二选点器、全向闭环骨架、贝叶斯闭环(第三问默认)、定向扩展(第四问) | + +**不包含**(按要求剔除): + +- `referee/`:独立评测机(模拟器引擎 + 机器狗接口 + WebUI)——按附件协议独立复现, + 与本队求解代码零耦合,不属求解方案; +- `src/mcm26b/harness/`:测试侧内存回环、故障注入、日志回放、场景生成; +- `src/mcm26b/cli.py`、`geometry/__main__.py`、`main.py`:命令行外壳; +- `scripts/`:采集、基准、Optuna 调参、可视化、论文插图与校核脚本; +- `project/`:1-4 问"最优实现 / 基线实现"对比框架;`tests/`:单元测试; +- 未被生产路径调用的函数:`routing.py` 的离线度量族(`held_karp_open` / + `optimal_open_path` / `or_opt_open` / `mst_weight` / `route_metrics`, + 仅用于事后路由下界审计)、`guarantee/cover.py` 的计划落盘 IO、 + `planner/seven.py` 的未采用变体(`SevenImmediate` / `SevenSweep`)。 + 这些在仓库中保留并有测试守护,默认关闭或不被实例化。 + +## 12. 运行口径 + +```bash +# 对接官方模拟器(演练/正式) +python -m mcm26b.run --robot-id <队号> --problem 3 # 第三问:bayes 闭环 +python -m mcm26b.run --robot-id <队号> --problem 4 # 第四问:directional 闭环 +``` + +成绩口径是**虚拟时间**:`avg_locate_clear_time_s = 全程虚拟时长 / 已清除源数`, +找全证明达成后立即 `/exit` 而不是复扫到限时。 +""")) + +add(code(r''' +# ---- 总自检:把四问的核心结论再跑一遍,全部通过才算整理成功 ---------------- +checks = [] + +q1 = solve_q1("normal") +checks.append(("问1 角域交会有界且直径有限", + q1["status"] == "polygon" and q1["diameter_infinite"] is False)) +checks.append(("问1 三点交会可原地清除(MEC ≤ 20 m)", + q1["guaranteed_clearable_at_mec_center"] is True)) +checks.append(("问1 直径圆反例被识别(covers=False)", + solve_q1("triangle")["diameter_circle"]["covers"] is False)) + +plan2 = plan_second_point(station1, bearing1) +checks.append(("问2 第二点在保证接收盘内", + math.dist(plan2.position, region.mec()[0]) + <= safe_disk_radius(region, CONFIG))) +checks.append(("问2 追加三点后最坏后验半径 ≤ 20 m", + plan.worst_posterior_radius_m <= CONFIG.field.clear_radius_m)) + +checks.append(("问3 七站解析覆盖上界 < 1000 m", + bound < CONFIG_Q3.field.r_eff_min_m)) +checks.append(("问3 七站计划粗采样无缺口", cert7.uncovered == 0)) +checks.append(("问3 粒子滤波保留真源", retained)) +checks.append(("问3 空幸存集给出缺席证明", len(absent) == 0)) +checks.append(("问3 2-opt 不劣于原任务序", + loop3._path_cost(order) <= loop3._path_cost(tasks) + 1e-9)) + +checks.append(("问4 最小命中集 31 点、证书 0 缺口", + len(plan_q4.points) == 31 and cert_v2.uncovered == 0)) +checks.append(("问4 粗采样复核定向覆盖无缺口", coarse.uncovered == 0)) +checks.append(("问4 联合信念保留真源朝向档", not joint.is_absent())) +checks.append(("问4 后备清除格数在硬顶内", + len(cells) <= CONFIG.search.fallback_max_cells)) +checks.append(("问4 闭环装配成功(定向覆盖计划已加载)", + loop4.coverage_plan.name.startswith("q4_mincover"))) + +print("四问核心自检:") +for name, ok in checks: + print(" [%s] %s" % ("通过" if ok else "失败", name)) +assert all(ok for _, ok in checks), "自检未全部通过" +print("\n全部 %d 项通过。" % len(checks)) +''')) + + +def build(path: Path) -> None: + notebook = { + "cells": CELLS, + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", + "name": "python3"}, + "language_info": {"name": "python", "version": "3.10", + "mimetype": "text/x-python", + "file_extension": ".py", + "pygments_lexer": "ipython3", + "nbconvert_exporter": "python", + "codemirror_mode": {"name": "ipython", "version": 3}}, + }, + "nbformat": 4, + "nbformat_minor": 5, + } + path.write_text(json.dumps(notebook, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8") + code_lines = sum(len(cell["source"]) for cell in CELLS + if cell["cell_type"] == "code") + md_cells = sum(1 for cell in CELLS if cell["cell_type"] == "markdown") + print(f"已写出 {path}:{len(CELLS)} 个单元" + f"(markdown {md_cells},代码 {len(CELLS) - md_cells},代码行 {code_lines})") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="生成四问核心代码单文件 notebook") + parser.add_argument("-o", "--output", type=Path, + default=ROOT / "mcm26b-核心代码.ipynb") + build(parser.parse_args().output) diff --git a/scripts/build_paper_support.py b/scripts/build_paper_support.py index 545c5f5..79e485b 100644 --- a/scripts/build_paper_support.py +++ b/scripts/build_paper_support.py @@ -1,10 +1,11 @@ -"""Build vector figure sources, comparison tables, source listings, and a support ZIP. +"""Build vector figure sources, comparison tables, the formal-data table, 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. +The generated table rows are f-strings whose value equals the LaTeX line to be +written; wrapping them would obscure that one-to-one correspondence, so this module +is exempt from the line-length check (the only pylint exception in scripts/). """ +# pylint: disable=line-too-long + from __future__ import annotations import argparse @@ -18,29 +19,6 @@ 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() @@ -49,7 +27,6 @@ def digest(data: bytes) -> str: def figures(rows: list[dict]) -> None: out = PAPER / "figures" out.mkdir(parents=True, exist_ok=True) -<<<<<<< HEAD plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8")) lines = [ r"\begin{figure}[htbp]\centering", @@ -72,8 +49,6 @@ def figures(rows: list[dict]) -> None: r"\label{fig:q4plan}\end{figure}", ] (out / "q4-plan.tex").write_text("\n".join(lines) + "\n", "utf-8") -======= ->>>>>>> 78e4420a3e496c43db8e06556e539fd7d2aaf231 colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"] bars = [ r"\begin{figure}[htbp]\centering", @@ -266,27 +241,12 @@ def project_tables() -> None: def listings(rows: list[dict]) -> None: + """生成正式数据清单;核心函数源码清单由 build_code_appendix.py 单独生成。 + + 论文附录只收录四问主线的核心函数(数据源为单文件核心代码 ipynb), + 评测机、命令行外壳、采集/绘图脚本、测试与未采用方案不再逐字排版。 + """ 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}\\", @@ -356,7 +316,9 @@ def package(rows: list[dict]) -> Path: 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 / "mcm26b-核心代码.ipynb", ROOT / "scripts/audit_paper_formal.py", + ROOT / "scripts/build_code_appendix.py", ROOT / "scripts/build_paper_support.py"]) for row in rows: path = ROOT / "Jammers-simulator-full" / row["file"] diff --git a/scripts/run_notebook_cells.py b/scripts/run_notebook_cells.py new file mode 100644 index 0000000..ee0fca6 --- /dev/null +++ b/scripts/run_notebook_cells.py @@ -0,0 +1,88 @@ +"""执行 notebook 的全部代码单元(按顺序、共享全局命名空间),用于零依赖自检。 + +用法:: + + python scripts/run_notebook_cells.py [notebook.ipynb] [--record] + +等价于在 Jupyter 中"全部运行",但不需要 nbformat / nbclient:直接读 ipynb 的 +JSON、拼接 code 单元、写入临时文件后交给 `python` 执行。退出码即执行结果; +``--record`` 额外把逐单元的标准输出写回 notebook 的输出字段(生成已执行副本)。 +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def collect(notebook_path: Path) -> tuple[dict, list[str]]: + notebook = json.loads(notebook_path.read_text(encoding="utf-8")) + codes = ["".join(cell["source"]) + for cell in notebook["cells"] if cell["cell_type"] == "code"] + return notebook, codes + + +def run_and_record(notebook: dict) -> None: + """在本进程内逐单元执行并记录标准输出(写入 notebook 的 outputs 字段)。""" + scope: dict = {"__name__": "__main__"} + index = 0 + for cell in notebook["cells"]: + if cell["cell_type"] != "code": + continue + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + # 与 Jupyter 同语义:单元之间共享同一个全局命名空间 + exec(compile("".join(cell["source"]), # pylint: disable=exec-used + f"", "exec"), scope) + text = buffer.getvalue() + cell["execution_count"] = index + 1 + cell["outputs"] = ([{"output_type": "stream", "name": "stdout", + "text": text.splitlines(keepends=True)}] if text else []) + index += 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="执行 notebook 代码单元并自检") + parser.add_argument("notebook", nargs="?", type=Path, + default=ROOT / "mcm26b-核心代码.ipynb") + parser.add_argument("--record", action="store_true", + help="在当前进程内执行并把输出写回 notebook(生成已执行副本)") + args = parser.parse_args(argv) + + notebook, codes = collect(args.notebook) + # 逐单元做语法检查,定位到具体单元 + for index, cell_source in enumerate(codes): + try: + compile(cell_source, f"", "exec") + except SyntaxError as exc: + print(f"单元 {index} 语法错误:{exc}", file=sys.stderr) + return 1 + total_lines = sum(len(cell.splitlines()) for cell in codes) + print(f"语法检查通过:{len(codes)} 个代码单元,{total_lines} 行") + + if args.record: + run_and_record(notebook) + args.notebook.write_text( + json.dumps(notebook, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8") + print(f"已把逐单元输出写回 {args.notebook}") + return 0 + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "notebook_flat.py" + target.write_text("\n\n".join(codes), encoding="utf-8") + process = subprocess.run([sys.executable, str(target)], cwd=ROOT, + check=False) + return process.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 928b90d..894198c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "(python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')", - "python_full_version >= '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", "(python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')", "python_full_version == '3.11.*' and sys_platform != 'linux' and sys_platform != 'win32'", "(python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')", @@ -31,6 +33,174 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101" }, +] + +[[package]] +name = "appnope" +version = "1.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/66/7ff138b7a61a6ec4eb8ad4a98696498915492a5ffa190e937ca5f2827e0a/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7014ab7e6f5d8511af92544667a0346ea6dfc314ea9a7cad1dba9fdb5c9a6e33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/6d/f120f8b4882da540b5e1375a11c85cfe37b3c671cfae1cdac797ea23e76b/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:242bb0cda2ae3650764fc194593d9ea45fc9e72729acd89778c7cfe184cec2a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/50/92811103e1042af1379741db7fd4a6d0f6e4ee4512e2c50cbd0d344cf0d8/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b70225b5fd1e0d2ef4f7fd30d24658454535f0924dff0caca5dc08efbbbadfbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/f2/1f8548c44c0036ae8ac1d6197300570b0af8ec120fc10b5bd8188f507376/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1af817e84578ef8b7295ad17de0f9896e4c8520dbf2233c7aa5aa3d487256fc4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -40,6 +210,116 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" }, +] + [[package]] name = "charset-normalizer" version = "3.5.1" @@ -224,6 +504,62 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -236,6 +572,33 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.22.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/33/a4/9473c7c3b87009d9c1d74034e4a0f6a35ff0d42dd0f9866d0c3ec4e9217b/fastjsonschema-2.22.2.tar.gz", hash = "sha256:72064e12356a7d6ef02165be2946b9abadbdf238536e07eb587e3dbaa33099cf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/49/82/2755c7c982086f00d4dab85bc120ec35045a9fc2191893a6ce79afe94443/fastjsonschema-2.22.2-py3-none-any.whl", hash = "sha256:0fb3915616adac85ccfdd737d26be1089845d2019819505b42d39888458f74d4" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014" }, +] + [[package]] name = "greenlet" version = "3.5.5" @@ -306,6 +669,43 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + [[package]] name = "idna" version = "3.19" @@ -324,6 +724,437 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "(python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')", + "python_full_version < '3.11' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f" }, +] + +[[package]] +name = "ipython" +version = "9.17.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')", + "python_full_version == '3.11.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl", hash = "sha256:6d1645743cfd1a07eb695d85aa2b5fa66721f8cbae9431d4049f7084bbf06509" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/7c/6db60eddf38547353b06d57941f5eee22a990640ce30479fd71a810507f2/ipywidgets-8.1.9.tar.gz", hash = "sha256:bcccba38a6ec3253f7a39c943cea5b9ad01999ce071396171adbc51c6a6a8613" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl", hash = "sha256:f2b8cbcaae10252b809fbe4d7470db75c09b769a32cbf816d20e5ca6d3c5a79d" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83" }, +] + +[[package]] +name = "jupyter-builder" +version = "1.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-core" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/75/3e/56f593e6a664cd14d441724654ce8243f3cac7d3e45867cf084749c8bc75/jupyter_builder-1.2.3.tar.gz", hash = "sha256:01aba6794eb9b19e0e29ae21137ca60ba4135c70347d4b9f664a586822b8c809" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/84/aa/be79e87c50698673f196d0633fc1664607da2289f9688d1de7a1c9db9e71/jupyter_builder-1.2.3-py3-none-any.whl", hash = "sha256:c5ea5a7190c2a7b082494abade98eece1b2b5bd5dbd7d610606cbcd10a1d08b3" }, +] + +[[package]] +name = "jupyter-client" +version = "8.10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81" }, +] + +[[package]] +name = "jupyter-server" +version = "2.21.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cf/e0/63b481d5b21f81fe23173dfc9eb25629fa1bc174fdc4a2c19d09c1ff8124/jupyter_server-2.21.0.tar.gz", hash = "sha256:70d9a1883f57d3576ea17f4ce061ec1a7aad7ef388d00428cfb7f5e4f0022271" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cb/8a/cb97c2b353cb46cced413be8f742fa8fcd3cb1659524f27314494aa94968/jupyter_server-2.21.0-py3-none-any.whl", hash = "sha256:2ae2e5ce5e97268553e25aebe040197455673d925b5fc7995477153318160cf7" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/c0/45934229995d4c6e38192ca118d93185f60808f64157e3df3c5c28f8c5fd/jupyterlab-4.6.3.tar.gz", hash = "sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl", hash = "sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.17" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/8b/e739cf9066ad5037a2d4b0a403f06da374fdccb9748221661c8b492d3dbc/jupyterlab_widgets-3.0.17.tar.gz", hash = "sha256:6e61fe21ca8a66039180a5cc52a433e07279d2fee79c8be963e00d55193f17a8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12" }, +] + [[package]] name = "mako" version = "1.4.1" @@ -421,11 +1252,24 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6" }, +] + [[package]] name = "mcm26b" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "jupyter" }, { name = "requests" }, ] @@ -437,7 +1281,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.31" }] +requires-dist = [ + { name = "jupyter", specifier = ">=1.1.1" }, + { name = "requests", specifier = ">=2.31" }, +] [package.metadata.requires-dev] dev = [ @@ -451,6 +1298,111 @@ name = "mcm26b-referee" version = "1.0.0" source = { editable = "referee" } +[[package]] +name = "mistune" +version = "3.3.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a" }, +] + +[[package]] +name = "nbclient" +version = "0.11.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8" }, +] + +[[package]] +name = "nbformat" +version = "5.11.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/72/b3446efab8756e7df4b8ec587f8e611cb5a7249e4323db480802f1d3be04/nbformat-5.11.1.tar.gz", hash = "sha256:32d4521c68c6e7d5b29c76defaeed9f42ea733142b9b19f88277ce10390b9c4d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1a/69/ee613f74085ca7103f79cd08d579c4f3177d1d26e5d3d9528d2d6536a707/nbformat-5.11.1-py3-none-any.whl", hash = "sha256:cc6698fa75f4fab8755ead786317815f13a6fee3b53311c0abb1a8b51d52f7ec" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01" }, +] + +[[package]] +name = "notebook" +version = "7.6.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-builder" }, + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/d9/5c76de84e96e1cf8aae3fce930875e0615e14f3557bbcdb634aab52da9d3/notebook-7.6.2.tar.gz", hash = "sha256:cc02b5f0bb972160cccfe44ad8a1a202036206ba3439469c514f03aefa9ae807" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5c/fd/3e552ff5b24dd305c6e9a10e8645e29c03091c317429f326ae10dbae1ac6/notebook-7.6.2-py3-none-any.whl", hash = "sha256:5fe9e09c335cb4b7de21627b860f77210e70e54b1fb1276ad942a4a7e1d858d3" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -605,8 +1557,10 @@ name = "numpy" version = "2.5.3" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "(python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')", - "python_full_version >= '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", ] sdist = { url = "https://mirrors.aliyun.com/pypi/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563" } wheels = [ @@ -697,6 +1651,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/90/e5/7c5e8cdda8c9f0cd8aca0a376dee9cff6b8c30692cf614960d9e80f59689/optuna-5.0.0-py3-none-any.whl", hash = "sha256:5fff892ae6baf4948c810c5b8635ec67e08efb0709b41cb73b14e30391d44720" }, ] +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49" }, +] + [[package]] name = "packaging" version = "26.3" @@ -706,6 +1669,45 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -715,6 +1717,82 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -742,6 +1820,49 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "python-json-logger" +version = "4.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/25/5473e46b179f8e8b4ad3aeeb36773d1701b7770eaf5e5bc2025c7303b598/python_json_logger-4.2.0.tar.gz", hash = "sha256:e371ebe22ec01e289850102091a2b1f6fc9e655c7f1f5f29073936756c290afa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/55/6467fde553886cb293e41538f3a8b4e4fd4688c6df242cf982162d8367fb/python_json_logger-4.2.0-py3-none-any.whl", hash = "sha256:158a52126fcd6869e09574d2b66272666f3dc8f468c62637ef9a1fa883719cb9" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/89/ff/8bd91d4502b48bee0459bc912806fa512111905ab97d317e7cb1201b3542/pywinpty-3.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:b467dcad72365bc2205ed8b6e694e817d71de269d46e56cfe267dfa9d3d30e1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/d2/dee13e67af2300a080dea30d4da8fdb05133a7eebb39171111f0530f7e88/pywinpty-3.0.5-cp310-cp310-win_arm64.whl", hash = "sha256:7dc4046ea8e4d7f0a16dae8dfcaeeda6df7ca3a9330444d2ba5bb96138fe0a91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -806,6 +1927,101 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] +[[package]] +name = "pyzmq" +version = "27.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/38/de/c9d653d686ec686bac6ae5f953c784ffc4d2e4a33f9c20d0326aff549ca8/pyzmq-27.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:480dba27b145373b5e103890f17969d891bc9e86746d6b8b29dd70b0d4addc62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/3a/e3ae8e56fdb87cefbef655dc1d808d5ac76eacf40af850c5d78fd81a7faf/pyzmq-27.2.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:722f0a6940be1a483c81029a271d950e04dc2ff113a42e21b3d2b7a0d8e59638" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/ee/0ace0abf6315f3f481388826f1e9218802d9b5bb4a01bae5ce42018c3b4e/pyzmq-27.2.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ee556ed1cf836f96de9d5e545563116426d4a94f21b8041fdc79408eff18ebb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/fd/aee8c87f4854a012e232a6557b1167b6cd207506f59f378954b2d8bb6ed1/pyzmq-27.2.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d64da42cae09e6b0c61368b4cc8ca80f23ce3af17584d08053f3dc957433d5ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/2f/9be34eab874a26aa91315be0b27f94d43d35dad544cbdf28bcd41691afb2/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:376981d106598beb70be384f44d8f589832fd0051d184d38d10043da3cc3b080" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/26/362344e337d6b5905d65d0c205e91090b39b3b5a59ec9b84dbe88a5dd60c/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:40d96cb7a8f6a43aa9617c00215c2b73e1b5e4a1d6cbc9f5860ed7ac682599f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/ee/3b76b91e2bb8c5c12f8ae9ee553eb6254ba0bb048c2f0dea7d945420a0d3/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4ebc7889b31bc11c72e9f17ba3ebb0a8b0911cce413f41b498e55383a94819a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ae/83d0740f125a6ef91d5ed29e560679e4c5beeaa3ab1fc87e5fe1128c296d/pyzmq-27.2.0-cp310-cp310-win32.whl", hash = "sha256:650c6cd7cb39a069e7048261efe66fce8bf2e0052c831a7a099b7a0f2ea860d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/b3/3e99a7af1c25e84f68c6c7b16c51b664dd51e12d0bc3d252a48d298e9a08/pyzmq-27.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:82a09aa67871d4f2fcafd47bf670fb93210b232a7c2d4b8a54676314edf04033" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/cb/785d002a08e630807141b102fd2740cc11623c366bb4606465450bd12e03/pyzmq-27.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:bad4813f270592cedf56977e31ac1fc374fb0f6f67ea5134a5e37c19cb429a8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/2e/8897afa4538707d86645f51cc50e66b2b84900edb1be9dc9af2c2fc04e5d/pyzmq-27.2.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:9216132843d139a123f243c07fe70f7487dce5041093dd77040f9adb5dc91872" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/bc/dbce7bc1654fa25b1e68b9bad9e547906f581ce919c186a88ed951cb794c/pyzmq-27.2.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d41ebb260b69329b7d4a2936d44c872c86dd785355b51366c8b14e07ed7e9373" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/cf/6981738b57c83fef33f356141ad83bf51e92f2f70c9d5767affd1a699f07/pyzmq-27.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:468139ddb2e494d06e586bd3a6835077e8b3764560c8db552fe685c5867fc24e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/b5/13657961a845e29c28a4e7ac4202999ec90b3bba1890a5469ce2ae90359d/pyzmq-27.2.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39755dc4a923021bd0677990ffdbc21cff0e1ee1cf07fe3817acea153ef4cb67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/5a/ca7ee7a767413d4ba858e93748b95e30b35b8c139849fba94de4433ea2e5/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:714f8cbd66c7e405338d668f79d2fe83fe923defe348e843be998603cf92eeff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/8b/083f6184e4eba566c9a3cc9974b1b0fe327b7093788135ba8133edaa67a6/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1132805970045adb9f5f05dd57040978286a8e21a5475f2c2ddf1bc983b9a2c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/f5/249362b664ae725d534c8843214fa9fd7fccd74532a19e24603954a88a7d/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b26f2d0493b79ce3c3112c8a12649418915582ba4707b8ed9f44febf2be71f42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/cc/23c613c15f06d879f13364d14c17e5e4e8304049411e96c1410e6e56c3ea/pyzmq-27.2.0-cp311-cp311-win32.whl", hash = "sha256:44f261eca7dfb9904ea2b56428f59ab693bbe2715c0413a701f17b067ebf877c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/bc/bbbcf89003c93f18e33665c26e3c48d75e3915c3dd22887f3a7aea2c5e26/pyzmq-27.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b86e04f55af0f4d8cd8ecf14c0b8b81ebc8fd66fa20126b753514628ecadc7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/c5/4635d0ba2b8493edf6d5541fff0b07fa1d986fdfc29c596a53a21e20f9af/pyzmq-27.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:917d601e9540098f580d2723d0ce6402cdb6f02bc8dc2de74e0dca6e13bffd1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/5e/d0541596b48c5a19f85dcbea83d6673d8e91681cdf853eb194c31fc9766e/pyzmq-27.2.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:c551b9e2f86dc625fcb1a032c0d68042678caf96a8dd7c28796766b673bd5b52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/9f/8c7411bb283982d46e6d56dca6a095678c87eb0398daead12776d9881ac2/pyzmq-27.2.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:288cc790da0e3064a14a38ddc56ba169dada8c8af4cb86518db2bcbd380eedbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/26/1a7cd2d8e4e3c27d83a46960e22101b69f527843be140cb3375267aa8ca6/pyzmq-27.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:770a37f28ddfbe1d2c40a2e3ce37e5fd10831daa6ae9634105aa8a5d23507b00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/ed/c8daf770ca31eb293bef40f801f05148e0a96ad538d55ec3bceb226c91ec/pyzmq-27.2.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b398c5fe102b41e1559f7ffdae760aabd5f432d73b047b4ae0eac4e01cb594d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/6f/b958b0785eb15ab7a78913e3df8de705dbf226ce1d0004998fa4872bdd59/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0e1af01858d6dc0c09cea57f9cb1ddf4601f04897b6bb1efc3a2038123c87d79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/5a/7a070d0e9911441061402013f0c64e5772ee781704c5dae093e6b051c6f6/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:211350c3ccd4746bc5a85e8fe961bad1f7f2f274f67cf1f785fad7f96f562eea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/28/833485224e1bd8960cbf3539db465ab2bf23b50de885dc4225a637480539/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dde5e291548ca0f397623b5e523db5c90172b32aa4fd3ba464a79ea31a580b43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/8e/52239b9b5fe4f9cf77e272e6a8c548c01ba407d08d3aeeb3220816669ef1/pyzmq-27.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:94242bd4de6af7e74665e14a88630bccd615057f6acfaf08a3a432551d604645" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/22/7187a1f0bf2b8bf8dc6b91762438fb9b472f684f2dc4cb74a24bf8957943/pyzmq-27.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a7c1144dc61777938e932a2c9011b980b89fd8ff3733033b34c44c299187a6e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/71/09b71620ad52bad4eb68b1516978ecaf52ef623c3fa16e0732a03cf3274c/pyzmq-27.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c218b816220d05acf6ab1bafca58926d95cbcc5fec5024724666030466308f0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/cf/5c8eb9994a14ff5ee5b0cada339421748746c95aee0280c8b656741e8749/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ae6ebbc0bfe5a21ce21e32ba567bf73df2d93888109c65acbd42506cf9395759" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/64/e22094c5555e550b6450ecfcceb6a1205d893d9a18ae27764c8c45acfb16/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:679b5b1dde326a921ea2c9ec1f9ea3115bfe1b4735779bbc6eb0473a0ed93f71" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/28/5b1042899caed18278c56d54a502f5254d463afe8aea1acbecc98e053391/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5c6d8744d10b5e1eadd90a7c58f8546acf6bf680ee463f7e6ada09ad6c9f802" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/a3/f134603a671c114c6b56eb912bba890f09e2d43b8a28d243be5a5507cf2f/pyzmq-27.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3ee8dd7031d5e23f632e0e7eee67183ca7d2536e0de35dc1e5d69f3471a791e8" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -821,6 +2037,323 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "(python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')", + "python_full_version < '3.11' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32')", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')", + "python_full_version == '3.11.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823" }, +] + [[package]] name = "sqlalchemy" version = "2.0.52" @@ -870,6 +2403,46 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -924,6 +2497,23 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, ] +[[package]] +name = "tornado" +version = "6.5.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26" }, +] + [[package]] name = "tqdm" version = "4.70.0" @@ -936,6 +2526,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953" }, ] +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -945,6 +2544,24 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -953,3 +2570,48 @@ sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/0c/06f8b233b8fd13b9 wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, ] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d" }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.16" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bf/60/bc7a980fc78837d6ef8f5940cca4cadc433364503a4c4d42e2a7a0de3231/widgetsnbextension-4.0.16.tar.gz", hash = "sha256:adeea0ae78f0856ee4945f413299801b82a0a01416303301f39a704282a37b73" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl", hash = "sha256:a31a8774885b96fe825462f5d6496166f0c7cae111195b6465c801d230eb5a4e" }, +]