gprMax项目代码分解:理解 gprMax的项目结构、运行主线与开发模块
目录1. 引言2. 先看一次完整运行3. 当前 gprMax 不再以一个主文件为中心4. 第一层入口系统4.1 命令行入口4.2 Python API5. 第二层SimulationConfig6. 第三层Context6.1 Context6.2 MPIContext6.3 TaskfarmContext7. Context 中保存了模型生命周期8. 第四层Scene9. Scene 可以来自两种输入方式9.1 来自输入文件9.2 来自 Python API10. user_objects用户概念的代码表示10.1 基本仿真对象10.2 材料对象10.3 几何对象10.4 激励和观测对象10.5 输出和辅助对象11. 第五层Model12. Model.build() 在做什么13. grid 模块数值模型的数据基础14. materials、sources、receivers 和 waveforms14.1 materials.py14.2 waveforms.py14.3 sources.py14.4 receivers.py15. pml.py开放空间如何在有限网格中表示16. 第六层Solver17. Solver 真正执行的是什么18. updates、cython 与 cuda_opencl18.1 updates18.2 cython18.3 cuda_opencl19. subgrids局部加密为什么是一个独立系统20. 输出系统不只是保存一个波形20.1 接收器输出20.2 场输出与快照20.3 几何输出21. utilities支持性功能为何单独存在22. config.py 中的两级配置22.1 SimulationConfig22.2 ModelConfig23. 当前项目的主调用链23.1 命令行路径23.2 API 路径23.3 统一运行路径24. gprMax 可以划分为哪些子开发模块模块一应用入口与运行配置模块二运行上下文与并行调度模块三输入语言与场景建模模块四模型和网格构建模块五源、接收器和观测系统模块六数值求解与计算后端模块七多尺度与子网格系统模块八输出、可视化和工程基础设施25. 这些模块之间是什么关系25.1 表达层25.2 建模层25.3 执行层26. 当前架构体现了哪些设计模式26.1 外观模式26.2 策略模式26.3 模板方法26.4 构建者思想26.5 领域模型26.6 适配器思想26.7 上下文对象27. 如何阅读当前 gprMax 源码第一阶段看见程序骨架第二阶段理解 Scene 到 Model 的转换第三阶段理解一次 FDTD 更新第四阶段理解高级能力28. 后续系列文章如何安排第一篇gprMax 的入口与配置系统第二篇从输入文件到 Scene第三篇从 Scene 到 Model第四篇Yee 网格与数据结构第五篇材料和几何体如何进入网格第六篇源、波形和接收器第七篇FDTD 求解器第八篇CPU、CUDA、OpenCL 与 Metal第九篇PML 与开放边界第十篇MPI 和任务农场第十一篇子网格系统第十二篇输出、可视化和测试29. 一个用于理解全项目的简化模型30. 总结Reference1. 引言程序并非一组并列的静态文件而是一个动态流动的过程。输入进入系统经过解析、构建、求解和输出等一系列环节最终转化为可供分析的数据。让我们从一个最小可运行示例开始沿着程序实际执行的路径逐步深入。python-mgprMax user_models/cylinder_Ascan_2D.in这条命令最终会生成一个包含雷达接收信号的输出文件。此刻我们聚焦于一个核心问题从输入文件到输出结果gprMax 内部究竟经历了哪些步骤在当前gprMax的开发分支中这一过程可以被划分为如下几个模块命令行或 Python API ↓ SimulationConfig ↓ Context ↓ Scene ↓ Model ↓ Solver ↓ 输出文件这七个关键词构成了理解当前 gprMax 项目的基本框架。本文的目标并非立即深入每个类和函数的细节而是先勾勒出一幅项目地图。后续文章将分别探讨输入系统、模型构建、网格、求解器、并行计算和输出系统等具体模块。本章旨在回答以下问题当前 gprMax 项目由哪些核心部分组成一次完整的仿真如何依次经过这些部分Context、Scene、Model和Solver各自扮演什么角色gprMax 可以划分为哪些相对独立的开发模块在阅读和修改 gprMax 源码时应从哪个模块入手最为高效2. 先看一次完整运行假设我们已经准备好输入文件user_models/cylinder_Ascan_2D.in运行python-mgprMax user_models/cylinder_Ascan_2D.in从用户角度看过程十分简单读取输入文件 ↓ 执行仿真 ↓ 写出结果然而“执行仿真”实际上包含了多个性质完全不同的阶段。一个更准确的展开是解析运行参数 ↓ 建立本次仿真的全局配置 ↓ 选择运行环境 ↓ 把用户输入转换为场景对象 ↓ 把场景离散为计算模型 ↓ 选择 CPU、CUDA、OpenCL 或 Metal 求解器 ↓ 推进 FDTD 时间循环 ↓ 记录接收器和场数据 ↓ 写出结果这里有一个重要区别。输入文件描述的是用户想要模拟的物理场景而求解器需要的是经过离散化的计算模型。例如用户可能在输入中描述一个计算区域 一种土壤材料 一个金属圆柱 一个发射源 一个接收器但 FDTD 求解器不能直接计算“圆柱”或“土壤”这些概念。它需要的是网格尺寸 材料编号数组 场分量数组 更新系数 源所在的网格位置 接收器所在的网格位置 边界条件 时间步长 迭代次数因此gprMax 的核心工作不只是求解 Maxwell 方程。它还必须完成一次转换用户的物理描述 ↓ 计算机可以执行的离散模型理解这一转换是理解整个项目结构的起点。3. 当前 gprMax 不再以一个主文件为中心在较早版本中阅读 gprMax 往往从一个较大的gprMax.py文件开始。命令行参数、运行模式判断、标准运行、MPI 任务分发和基准测试等逻辑都较为集中。当前开发分支已经采用了不同的组织方式。现在的gprMax.py更像一个入口和分发器。它主要完成三件事接收参数 创建 SimulationConfig 选择 Context其核心逻辑可以概括为config.sim_configconfig.SimulationConfig(args)ifconfig.sim_config.args.taskfarm:contextTaskfarmContext()elifconfig.sim_config.args.mpiisnotNone:contextMPIContext()else:contextContext()resultscontext.run()这段代码很短却给出了当前架构最重要的线索。首先运行参数被封装为SimulationConfig然后根据运行方式创建不同的Context最后所有运行方式都通过context.run()开始执行。换句话说当前 gprMax 的入口不再直接管理模型构建和求解细节。它只负责建立正确的运行环境。这是一种更清晰的职责划分gprMax.py 负责决定“怎样运行” Context 负责组织“运行过程” Scene 负责表达“用户要模拟什么” Model 负责保存“计算机实际计算什么” Solver 负责执行“如何完成计算”4. 第一层入口系统gprMax 提供两种主要入口。4.1 命令行入口最常见的形式是python-mgprMax model.in当前项目通过__main__.py将模块执行转交给命令行入口再由gprMax.py中的cli()解析参数。命令行接口适合运行单个模型 生成 B 扫描 执行批量仿真 在服务器或集群中提交任务 编写 Shell 自动化脚本4.2 Python API当前版本也提供fromgprMax.gprMaximportrun其调用方式可以基于输入文件run(inputfilemodel.in)也可以直接提供场景对象run(scenes[scene])这一区别非常重要。旧式使用方式主要围绕文本输入文件展开。当前 API 则允许开发者跳过文本命令解析直接在 Python 中构造Scene。因此gprMax 现在拥有两条进入系统的路径文本输入文件 ↓ 解析为 Scene Python 对象 ↓ 直接提供 Scene两条路径最终汇合到相同的模型构建和求解流程。这使 gprMax 不只是一个命令行仿真程序也成为一个可以嵌入其他 Python 项目的电磁仿真引擎。5. 第二层SimulationConfig一次仿真开始之前系统需要先确定运行条件。例如运行多少个模型 从第几个模型开始 输出写到哪里 使用哪种求解器 使用哪个计算设备 是否使用子网格 是否只生成几何结构 是否复用几何结构 是否使用 MPI 日志输出到哪里这些信息不属于具体的物理场景。“地下存在一个圆柱”属于物理场景。“使用第二块 GPU 计算”则属于运行配置。当前 gprMax 使用SimulationConfig保存这一层信息。它相当于一次程序运行的总配置。可以将它理解为SimulationConfig 用户运行参数 硬件信息 设备选择 模型编号范围 输出与日志设置这层抽象解决了一个常见问题不再让各模块直接读取零散的命令行参数。如果每个模块都自行判断args.gpu args.mpi args.geometry_only args.n那么运行配置会散落在整个项目中。使用SimulationConfig后下游模块面对的是已经整理过的运行状态而不是原始命令行文本。其设计思想可以概括为先把外部参数解释成明确的内部配置再开始模型构建。6. 第三层Context有了配置之后gprMax 需要决定模型在什么环境中运行。当前开发分支提供三个主要上下文Context MPIContext TaskfarmContext它们不是三种电磁模型而是三种执行模型的方式。6.1 ContextContext是标准运行环境。在这种模式下多个模型依次运行模型 1 ↓ 模型 2 ↓ 模型 3每个模型内部仍然可以使用OpenMP CPU CUDA OpenCL Metal因此标准上下文中的“依次运行”只表示模型之间顺序执行不表示单个模型内部不能并行。6.2 MPIContextMPIContext用于把一个模型划分到多个 MPI 进程。例如用户可以指定三维进程拓扑x 方向进程数 y 方向进程数 z 方向进程数此时不同 MPI rank 共同完成同一个模型。这与旧版主要用于分发多个独立模型的 MPI 任务农场不同。当前代码明确区分MPIContext 一个模型由多个 rank 协同求解 TaskfarmContext 多个模型被分发给不同 worker6.3 TaskfarmContextTaskfarmContext用于模型级并行。例如一个 B 扫描包含 100 次天线位置不同的仿真。任务农场可以把这些模型分配给不同进程worker 1模型 1、5、9…… worker 2模型 2、6、10…… worker 3模型 3、7、11…… worker 4模型 4、8、12……每个 worker 内部又可以调用 CPU 或 GPU 求解器。所以当前项目中存在两种不同层次的 MPI 并行空间分解 多个 rank 共同计算一个模型 任务分发 多个 worker 分别计算多个模型二者不能混为一谈。7. Context 中保存了模型生命周期标准Context的运行过程可以简化为defrun(self):self._start_simulation()formodel_numinself.model_range:self._run_model(model_num)self._end_simulation()这里没有复杂的数值计算。Context的作用是组织生命周期仿真开始 ↓ 依次处理模型 ↓ 仿真结束其中单个模型的运行过程大致为model_configself._create_model_config(model_num)sceneself._get_scene(model_num)modelself._create_model()scene.create_internal_objects(model)model.build()solvercreate_solver(model)model.solve(solver)这几行代码就是理解当前项目最重要的主线。可以将它写成更直观的形式ModelConfig ↓ Scene ↓ Model ↓ build ↓ Solver ↓ solve后面的项目目录虽然很大但大多数模块都可以放回这条主线中理解。8. 第四层SceneScene表示用户希望模拟的内容。它包含的不是完整网格数组而是具有物理意义的对象。例如计算域 材料 几何体 波形 发射源 接收器 快照 几何输出请求 子网格这是一种高层表示。用户思考模型时通常会说在土壤中放置一个圆柱 在某处设置发射天线 在另一处设置接收器 使用某种波形 运行指定时间用户不会直接说把材料编号 2 写入 ID 数组的第 30 至 50 个单元Scene的价值就在于保存前一种表达。可以将其理解为一张尚未离散化的施工图。施工图描述有什么对象 对象在哪里 对象使用什么参数 对象之间是什么关系但它还不是计算机直接求解的网格。9. Scene 可以来自两种输入方式9.1 来自输入文件如果用户提供.in文件Context会创建一个空的Scene然后调用输入解析系统sceneScene()sceneparse_hash_commands(scene)传统输入命令如#domain: #dx_dy_dz: #material: #box: #cylinder: #waveform: #hertzian_dipole: #rx:会被解释为用户对象并加入Scene。这条路径可以表示为.in 文件 ↓ hash command parser ↓ user objects ↓ Scene9.2 来自 Python API开发者也可以直接创建场景sceneScene()然后向场景中加入相应对象再调用run(scenes[scene])这条路径为Python objects ↓ Scene两种方式最终都得到相同的Scene抽象。因此文本输入系统并不是求解器的一部分。它只是构造Scene的一种前端。这是当前架构中非常重要的解耦输入语法 与 模型构建 相互分离10. user_objects用户概念的代码表示user_objects目录保存可以加入Scene的高层对象。从设计角度看这些对象大致可以分为几类。10.1 基本仿真对象用于定义空间范围 空间步长 时间窗 时间步长它们回答的是仿真在哪里进行离散到什么尺度计算多长时间10.2 材料对象用于定义介电常数 电导率 磁导率 磁损耗 色散特性它们回答的是电磁波在什么介质中传播10.3 几何对象用于定义box cylinder sphere triangle 复杂几何体 分形介质它们回答的是材料如何分布在空间中10.4 激励和观测对象包括波形 电偶极子 磁偶极子 传输线源 接收器 场快照它们回答的是电磁能量从哪里进入系统在哪里被观察10.5 输出和辅助对象用于控制几何输出 场输出 模型信息 子网格 天线模型这些对象共同构成 gprMax 的领域模型。所谓领域模型是指代码中的概念与使用者熟悉的物理概念基本对应。用户想创建一个接收器代码中就存在接收器对象。用户想创建材料代码中就存在材料对象。这种组织方式比把所有输入都保存在字典或字符串中更容易扩展和验证。11. 第五层ModelScene表示用户的意图Model表示真正可以计算的模型。这是整个架构中最重要的转换。Scene 高层、连续、具有物理语义 Model 离散、数组化、适合数值计算举例来说Scene中的圆柱可能由以下信息描述圆柱轴线 半径 起点和终点 材料当执行scene.create_internal_objects(model)model.build()以后这些信息会被映射到 Yee 网格中。圆柱不再只是一个几何对象而会影响哪些网格单元属于该材料 哪些更新系数应用于这些单元 场数组需要多大 边界如何布置 源和接收器位于哪些索引因此Model是高层物理描述和低层数值求解之间的边界。12. Model.build() 在做什么model.build()并不是单一操作。从概念上看它需要完成一系列准备工作确认模型尺寸 ↓ 建立主网格 ↓ 分配场和材料数组 ↓ 创建材料 ↓ 构建几何体 ↓ 放置源和接收器 ↓ 建立 PML ↓ 准备更新系数 ↓ 初始化输出结构这些步骤的共同目标是在进入时间循环之前把所有静态信息准备好。FDTD 求解阶段需要被重复执行成千上万次所以时间循环内部应尽量只保留必要计算。可以提前完成的工作应在build()阶段完成。例如材料的介电参数不会在每个时间步突然改变那么与材料相关的更新系数就可以预先计算。这种设计遵循一个常见的高性能计算原则昂贵但只需执行一次的工作 放在构建阶段 简单但需要反复执行的工作 放在求解阶段13. grid 模块数值模型的数据基础grid目录负责网格及其相关数据结构。在 FDTD 中电场和磁场并不存储在同一个空间位置。Yee 网格把不同场分量交错放置以便离散 Maxwell 旋度方程。因此网格模块不仅要保存nx、ny、nz dx、dy、dz dt还要管理Ex、Ey、Ez Hx、Hy、Hz 材料编号 更新系数 局部与全局坐标 网格索引转换如果把Model看作一个完整的计算对象那么Grid就是它最主要的数据容器。后续分析项目时可以把网格模块单独作为一个开发主题如何把连续的电磁空间表示为计算机中的离散数组这一主题会涉及Yee 网格 空间离散 时间离散 CFL 稳定条件 数组布局 材料索引 内存占用14. materials、sources、receivers 和 waveforms这些文件可以被理解为围绕网格建立的四类物理部件。14.1 materials.py材料模块描述介质如何影响场更新。在最简单的非色散介质中主要参数包括相对介电常数 电导率 相对磁导率 磁损耗这些物理参数最终会转换为 FDTD 更新系数。因此材料模块处于两个世界之间物理材料参数 ↓ 数值更新参数14.2 waveforms.py波形模块定义源随时间如何变化。例如高斯脉冲 Ricker 波 正弦波 用户定义波形波形只描述时间函数本身不决定源位于何处。14.3 sources.py源模块把波形、方向和空间位置组合起来。可以将其理解为Source 位置 方向 类型 Waveform14.4 receivers.py接收器模块负责在指定位置记录电场分量 磁场分量 其他可观测量源向模型注入能量接收器从模型读取数据。二者分别位于求解过程的输入端和观测端。15. pml.py开放空间如何在有限网格中表示实际地下空间近似无限但计算机内存是有限的。如果简单地在模型边缘截断网格传播到边界的电磁波会发生人工反射然后返回计算区域。这些反射不是物理场景中的真实回波而是有限计算域造成的数值伪影。PML 模块的任务是让离开计算区域的波被逐渐吸收从项目结构上看PML 既属于模型构建也属于求解更新。构建阶段需要确定PML 厚度 PML 方向 参数分布 所需数组求解阶段则需要执行专门的场更新。因此PML 是一个具有独立状态和独立更新规则的边界子系统。这也是它被单独放入pml.py而不是简单写入求解器条件分支的原因。16. 第六层Solver当Model构建完成后系统调用solvercreate_solver(model)create_solver()会根据运行配置选择具体求解器。当前项目支持的计算后端包括OpenMP CPU CUDA OpenCL Metal不同后端使用不同技术但它们面对的是同一个已经构建好的模型。这体现了当前 gprMax 最清晰的设计模式之一Model 负责表示问题 Solver 负责解决问题如果模型构建和求解器紧密耦合就可能出现CPU 模型构建流程 CUDA 模型构建流程 OpenCL 模型构建流程 Metal 模型构建流程这样会造成大量重复代码。当前架构更接近一个统一 Model ↓ 多个可替换 Solver这可以视为策略模式。求解策略可以更换但模型的物理含义不需要改变。17. Solver 真正执行的是什么FDTD 求解器的核心工作是重复推进时间。简化后一次时间迭代可以理解为更新磁场 ↓ 更新磁场边界 ↓ 加入磁源 ↓ 更新电场 ↓ 更新电场边界 ↓ 加入电源 ↓ 记录接收器 ↓ 保存需要的场快照这个过程重复执行Iterations次。因此求解器最主要的性能压力来自大规模数组访问 重复场更新 内存带宽 并行线程调度 设备间数据传输这也解释了为什么 gprMax 使用混合技术栈Python 组织项目和模型生命周期 Cython 实现性能敏感的 CPU 代码 OpenMP 进行共享内存并行 CUDA、OpenCL、Metal 利用不同 GPU 或计算设备 MPI 完成空间分解或模型任务调度Python 并不负责逐网格、逐时间步更新全部电磁场。它主要负责把模型组织好并把计算交给更适合高性能数值运算的后端。官方仓库也说明gprMax 主要使用 Python 编写而性能关键部分使用 Cython并提供 OpenMP CPU 与 GPU 求解器。18. updates、cython 与 cuda_opencl从开发角度看求解系统还可以进一步拆分。18.1 updatesupdates目录保存不同场更新过程的组织逻辑。它关注的问题是当前需要更新什么场 使用什么材料模型 是否存在色散 是否位于 PML 是否属于子网格18.2 cythoncython目录包含 CPU 性能关键代码。这些代码通常操作连续数值数组并通过编译减少 Python 解释器开销。它适合处理三重网格循环 场分量更新 材料系数访问 PML 更新 源注入18.3 cuda_opencl该目录承担 GPU 或通用计算设备相关实现。这里需要处理的不只是把 CPU 循环改写为 GPU kernel还包括设备选择 内存分配 主机与设备数据传输 kernel 编译 线程块配置 设备能力检测因此后端开发可以作为独立于物理模型开发的一个方向。开发者可能很熟悉 CUDA却不需要立即理解输入文件语法。另一位开发者可能专注于新增几何对象也不必修改 CUDA kernel。这种分工正是模块化架构的实际价值。19. subgrids局部加密为什么是一个独立系统标准 FDTD 网格通常采用统一空间步长。如果模型中只有一个小区域需要高分辨率统一缩小整个模型的网格间距会迅速增加网格单元数量 内存占用 时间迭代次数 计算时间子网格的思想是主区域使用较粗网格 局部区域使用较细网格但这并不是简单建立两个数组。子网格系统还需要处理主网格与子网格的坐标关系 时间步长关系 边界场交换 源和接收器映射 几何对象坐标转换 不同网格之间的插值因此subgrids不是普通几何功能而是对数值离散体系的扩展。它可以单独构成一个开发模块。20. 输出系统不只是保存一个波形gprMax 的输出至少包含三类信息。20.1 接收器输出这是最常用的数据包括随时间变化的场分量。A 扫描和 B 扫描通常主要使用这一类结果。20.2 场输出与快照snapshots.py和fields_outputs.py用于记录指定时间或区域内的电磁场。这类数据可用于观察波前传播 反射和散射 边界吸收效果 天线近场 不同材料中的传播差异20.3 几何输出geometry_outputs和vtkhdf_filehandlers用于输出模型几何及可视化数据。几何输出用于确认物体位置是否正确 材料分布是否正确 网格分辨率是否合适 源和接收器是否位于预期位置这说明输出层不仅面向最终实验数据也承担模型验证和调试职责。21. utilities支持性功能为何单独存在utilities目录通常不会包含 FDTD 核心算法却对整个系统至关重要。它可能处理日志 主机信息 GPU 信息 时间统计 路径 数据格式 终端输出 进度条 数值辅助函数这些功能具有两个共同特点第一它们会被多个模块使用。第二它们不应该被任何一个业务模块私有占有。例如设备检测既可能被SimulationConfig使用也可能被Context和日志系统使用。将这些功能放入公共工具模块可以避免重复实现。不过工具模块也需要保持边界。如果一个函数只服务于材料计算它通常更适合留在材料模块而不是一律放入utilities。22. config.py 中的两级配置当前项目中有两个需要区分的配置概念SimulationConfig ModelConfig22.1 SimulationConfig表示整次程序运行的配置。例如总共运行多少个模型 使用何种计算后端 输出目录 MPI 设置 日志设置22.2 ModelConfig表示某一次具体模型运行的配置。例如当进行 B 扫描时当前是第几条 A 扫描 当前输出文件名 当前使用哪个设备 当前模型是否复用几何这两级配置对应两个不同生命周期SimulationConfig 在整个程序运行期间存在 ModelConfig 每个模型运行时重新创建这种划分比把全部状态放在一个巨大配置对象中更清晰。它也帮助我们理解一次 simulation 可以包含多个 model run23. 当前项目的主调用链现在可以把前面的内容压缩成一条实际主线。23.1 命令行路径python -m gprMax model.in ↓ __main__.py ↓ cli() ↓ run_main(args)23.2 API 路径run(inputfile...) 或 run(scenes[...]) ↓ run_main(args)23.3 统一运行路径run_main(args) ↓ SimulationConfig(args) ↓ 选择 Context ↓ context.run() ↓ 为每次运行创建 ModelConfig ↓ 获取或解析 Scene ↓ 创建 Model ↓ scene.create_internal_objects(model) ↓ model.build() ↓ create_solver(model) ↓ model.solve(solver) ↓ 写出结果这一条路径应该成为后续阅读源码时始终保留的坐标系。遇到任何文件时可以问这个文件处在主调用链的哪个位置如果无法回答说明我们还没有理解它与系统的关系。24. gprMax 可以划分为哪些子开发模块从开发和教学角度看当前 gprMax 可以划分为八个子模块。这里的“模块”不是严格对应某一个文件夹而是根据职责划分的开发领域。模块一应用入口与运行配置主要文件__main__.py gprMax.py config.py主要问题CLI 和 API 如何统一 参数如何验证 设备如何选择 一次 simulation 如何配置 每次 model run 如何配置适合学习Python 包入口 参数解析 配置对象 依赖管理 应用生命周期模块二运行上下文与并行调度主要文件contexts.py mpi_model.py taskfarm.py主要问题标准运行如何组织 一个模型如何进行 MPI 空间分解 多个模型如何进行任务农场调度 不同 rank 如何分工适合学习模板方法 继承与多态 MPI 任务调度 并行生命周期模块三输入语言与场景建模主要文件和目录scene.py user_inputs.py hash_cmds_*.py user_objects/主要问题输入文件如何解析 Python 对象如何加入场景 用户对象如何验证 Scene 如何保存模型意图适合学习领域专用语言 解析器 对象模型 命令模式 声明式建模模块四模型和网格构建主要文件和目录model.py grid/ materials.py fractals/主要问题Scene 如何变成离散 Model 网格如何创建 材料如何写入网格 几何对象如何体素化 更新系数如何准备适合学习Yee 网格 离散化 几何栅格化 数组数据结构 构建者模式模块五源、接收器和观测系统主要文件sources.py receivers.py waveforms.py snapshots.py fields_outputs.py主要问题源如何注入 波形如何定义 接收器如何采样 场快照如何记录 输出变量如何组织适合学习激励建模 采样 时间序列 观察者式数据采集模块六数值求解与计算后端主要文件和目录solvers.py updates/ cython/ cuda_opencl/ pml.py主要问题FDTD 时间步如何推进 CPU 与 GPU 后端如何统一 PML 如何更新 材料模型如何进入更新方程 性能瓶颈在哪里适合学习FDTD 高性能计算 策略模式 OpenMP CUDA OpenCL Metal模块七多尺度与子网格系统主要目录subgrids/主要问题主网格和细网格如何耦合 不同时间步如何同步 坐标如何转换 边界场如何交换适合学习局部网格加密 多尺度数值方法 插值 耦合计算模块八输出、可视化和工程基础设施主要目录geometry_outputs/ vtkhdf_filehandlers/ utilities/ tests/ tools/ docs/主要问题结果如何写入 几何如何可视化 日志如何记录 模型如何测试 工具脚本如何组织 文档如何维护适合学习HDF5 VTK 可重复实验 自动化测试 日志和诊断 科学软件工程25. 这些模块之间是什么关系八个开发模块不是平行堆放的。它们大致形成三个层次。25.1 表达层入口与配置 输入语言 Scene user_objects这一层负责表达用户意图。25.2 建模层Model Grid Materials Sources Receivers PML Subgrids这一层负责把物理问题转换为离散计算问题。25.3 执行层Context Solver Cython CUDA/OpenCL/Metal MPI Outputs这一层负责实际执行计算并保存结果。可以表示为用户意图 ↓ 表达层 ↓ 离散模型 ↓ 建模层 ↓ 可执行数值问题 ↓ 执行层 ↓ 仿真结果这种分层比简单记忆文件名更有价值。文件名可能在版本迭代中发生变化但这三类职责通常不会消失。26. 当前架构体现了哪些设计模式不必把每段代码都强行归入某种经典设计模式但当前结构确实体现了几个明确的设计思想。26.1 外观模式run()为 Python 用户提供一个相对简单的入口。用户不需要自行创建配置、上下文、模型和求解器。run(inputfilemodel.in)背后会启动完整流程。26.2 策略模式create_solver(model)根据配置选择CPU solver CUDA solver OpenCL solver Metal solver模型不需要知道具体使用哪一个后端。26.3 模板方法Context定义了标准模型生命周期开始仿真 创建模型配置 获取场景 创建模型 构建模型 创建求解器 求解 结束仿真MPIContext和TaskfarmContext在保留总体流程的同时替换其中部分行为。26.4 构建者思想Scene不直接执行计算。它首先保存用户对象然后逐步将这些对象应用到Modelscene.create_internal_objects(model)model.build()模型由多个阶段逐步形成。26.5 领域模型Material、Source、Receiver、Waveform和几何对象都直接对应电磁仿真概念。代码结构尽量使用领域语言而不是全部使用低层数组和索引表达。26.6 适配器思想文本输入命令和 Python API 是两种不同输入形式但最终都被转换为Scene。不同计算后端也通过统一求解接口操作Model。26.7 上下文对象运行相关的状态被放入SimulationConfig ModelConfig Context而不是作为大量独立参数在函数之间反复传递。27. 如何阅读当前 gprMax 源码推荐按照主调用链阅读而不是按照目录字母顺序阅读。第一阶段看见程序骨架先阅读__main__.py gprMax.py config.py contexts.py目标不是理解每个参数而是能够回答程序从哪里启动 如何选择 Context Context 如何启动一次模型第二阶段理解 Scene 到 Model 的转换继续阅读scene.py user_objects/ model.py grid/目标是回答用户对象如何进入 Scene Scene 如何创建内部对象 Model.build() 如何建立离散模型第三阶段理解一次 FDTD 更新阅读solvers.py updates/ cython/ pml.py目标是回答求解器如何选择 每个时间步更新什么 CPU 后端如何执行 PML 如何参与更新第四阶段理解高级能力最后阅读mpi_model.py taskfarm.py subgrids/ cuda_opencl/ geometry_outputs/这些模块建立在前面三层之上。如果一开始就进入 MPI、CUDA 或子网格代码很多变量和对象的来源会显得不明所以。28. 后续系列文章如何安排本文只是总论。后续可以按照以下顺序展开。第一篇gprMax 的入口与配置系统重点解释__main__.py cli() run() run_main() SimulationConfig ModelConfig第二篇从输入文件到 Scene重点解释hash commands parse_hash_commands() user_inputs user_objects Scene第三篇从 Scene 到 Model重点解释create_internal_objects() Model.build() 对象验证 内部对象创建 构建顺序第四篇Yee 网格与数据结构重点解释Grid 场数组 材料数组 空间步长 时间步长 索引系统第五篇材料和几何体如何进入网格重点解释materials.py 基本几何对象 复杂几何对象 分形介质 体素化第六篇源、波形和接收器重点解释waveforms.py sources.py receivers.py 场采样第七篇FDTD 求解器重点解释create_solver() solver 生命周期 电场更新 磁场更新 源注入 输出采样第八篇CPU、CUDA、OpenCL 与 Metal重点解释统一模型 不同后端 内存管理 并行计算 性能差异第九篇PML 与开放边界重点解释为什么需要吸收边界 PML 如何构建 PML 如何更新第十篇MPI 和任务农场重点解释MPIContext MPIModel TaskfarmContext 空间分解 模型级并行第十一篇子网格系统重点解释局部加密 主网格与子网格 坐标映射 时间同步第十二篇输出、可视化和测试重点解释HDF5 VTK geometry outputs snapshots tests tools这一顺序遵循一个简单原则每篇文章只引入一层新的复杂性并建立在前一篇已经形成的心智模型之上。29. 一个用于理解全项目的简化模型最后我们可以暂时忽略绝大多数实现细节只保留下面六个对象SimulationConfig Context Scene Model Solver Output它们分别回答六个问题对象回答的问题SimulationConfig这次仿真怎样运行Context模型按照什么执行方式运行Scene用户想模拟什么Model计算机实际要计算什么Solver计算具体如何进行Output计算结果如何保存和观察一次完整仿真就是这六个问题依次得到回答的过程。怎样运行 ↓ 在哪种上下文中运行 ↓ 模拟什么场景 ↓ 场景如何离散 ↓ 使用什么方法求解 ↓ 结果保存在哪里只要这条主线清楚后续看到再复杂的代码也能找到它所在的位置。30. 总结当前 gprMax 已经不是一个以单个入口文件为中心的脚本式项目。它更接近一个分层的科学计算框架入口和配置层 ↓ 运行上下文层 ↓ 场景表达层 ↓ 模型构建层 ↓ 数值求解层 ↓ 输出与工程支持层其中最核心的转换是Scene → ModelScene保存用户对物理世界的描述。Model保存计算机可以直接求解的离散数据。最核心的执行分离是Model → SolverModel表示问题。Solver选择具体计算后端并完成求解。最核心的运行抽象是Context它把标准运行、MPI 空间分解和 MPI 任务农场纳入相同的模型生命周期。因此理解当前 gprMax 项目时不应再从旧版run_std_sim()、run_mpi_sim()等函数出发而应沿着新的主线run_main() ↓ SimulationConfig ↓ Context ↓ Scene ↓ Model ↓ Solver这条主线既是程序的运行路径也是后续源码学习和二次开发的路线图。ReferenceGitHub - gprMax/gprMax: gprMax is open source software that simulates electromagnetic wave propagation using the Finite-Difference Time-Domain (FDTD) method for numerical modelling of Ground Penetrating Radar (GPR)