"""生成论文附录的"核心函数"源码清单(数据源:单文件核心代码 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()