gprMax项目代码分解:理解 gprMax的项目结构、运行主线与开发模块
目录
- 1. 引言
- 2. 先看一次完整运行
- 3. 当前 gprMax 不再以一个主文件为中心
- 4. 第一层:入口系统
- 4.1 命令行入口
- 4.2 Python API
- 5. 第二层:SimulationConfig
- 6. 第三层:Context
- 6.1 Context
- 6.2 MPIContext
- 6.3 TaskfarmContext
- 7. Context 中保存了模型生命周期
- 8. 第四层:Scene
- 9. Scene 可以来自两种输入方式
- 9.1 来自输入文件
- 9.2 来自 Python API
- 10. user_objects:用户概念的代码表示
- 10.1 基本仿真对象
- 10.2 材料对象
- 10.3 几何对象
- 10.4 激励和观测对象
- 10.5 输出和辅助对象
- 11. 第五层:Model
- 12. Model.build() 在做什么
- 13. grid 模块:数值模型的数据基础
- 14. materials、sources、receivers 和 waveforms
- 14.1 materials.py
- 14.2 waveforms.py
- 14.3 sources.py
- 14.4 receivers.py
- 15. pml.py:开放空间如何在有限网格中表示
- 16. 第六层:Solver
- 17. Solver 真正执行的是什么
- 18. updates、cython 与 cuda_opencl
- 18.1 updates
- 18.2 cython
- 18.3 cuda_opencl
- 19. subgrids:局部加密为什么是一个独立系统
- 20. 输出系统不只是保存一个波形
- 20.1 接收器输出
- 20.2 场输出与快照
- 20.3 几何输出
- 21. utilities:支持性功能为何单独存在
- 22. config.py 中的两级配置
- 22.1 SimulationConfig
- 22.2 ModelConfig
- 23. 当前项目的主调用链
- 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. 总结
- Reference
1. 引言
程序并非一组并列的静态文件,而是一个动态流动的过程。输入进入系统,经过解析、构建、求解和输出等一系列环节,最终转化为可供分析的数据。让我们从一个最小可运行示例开始,沿着程序实际执行的路径逐步深入。
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_config=config.SimulationConfig(args)ifconfig.sim_config.args.taskfarm:context=TaskfarmContext()elifconfig.sim_config.args.mpiisnotNone:context=MPIContext()else:context=Context()results=context.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(inputfile="model.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 Context
Context是标准运行环境。
在这种模式下,多个模型依次运行:
模型 1 ↓ 模型 2 ↓ 模型 3每个模型内部仍然可以使用:
OpenMP CPU CUDA OpenCL Metal因此,标准上下文中的“依次运行”只表示模型之间顺序执行,不表示单个模型内部不能并行。
6.2 MPIContext
MPIContext用于把一个模型划分到多个 MPI 进程。
例如,用户可以指定三维进程拓扑:
x 方向进程数 y 方向进程数 z 方向进程数此时,不同 MPI rank 共同完成同一个模型。
这与旧版主要用于分发多个独立模型的 MPI 任务农场不同。
当前代码明确区分:
MPIContext 一个模型由多个 rank 协同求解 TaskfarmContext 多个模型被分发给不同 worker6.3 TaskfarmContext
TaskfarmContext用于模型级并行。
例如,一个 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_config=self._create_model_config(model_num)scene=self._get_scene(model_num)model=self._create_model()scene.create_internal_objects(model)model.build()solver=create_solver(model)model.solve(solver)这几行代码就是理解当前项目最重要的主线。
可以将它写成更直观的形式:
ModelConfig ↓ Scene ↓ Model ↓ build ↓ Solver ↓ solve后面的项目目录虽然很大,但大多数模块都可以放回这条主线中理解。
8. 第四层:Scene
Scene表示用户希望模拟的内容。
它包含的不是完整网格数组,而是具有物理意义的对象。
例如:
计算域 材料 几何体 波形 发射源 接收器 快照 几何输出请求 子网格这是一种高层表示。
用户思考模型时,通常会说:
在土壤中放置一个圆柱 在某处设置发射天线 在另一处设置接收器 使用某种波形 运行指定时间用户不会直接说:
把材料编号 2 写入 ID 数组的第 30 至 50 个单元Scene的价值就在于保存前一种表达。
可以将其理解为一张尚未离散化的施工图。
施工图描述:
有什么对象 对象在哪里 对象使用什么参数 对象之间是什么关系但它还不是计算机直接求解的网格。
9. Scene 可以来自两种输入方式
9.1 来自输入文件
如果用户提供.in文件,Context会创建一个空的Scene,然后调用输入解析系统:
scene=Scene()scene=parse_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
开发者也可以直接创建场景:
scene=Scene()然后向场景中加入相应对象,再调用:
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. 第五层:Model
Scene表示用户的意图,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构建完成后,系统调用:
solver=create_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 updates
updates目录保存不同场更新过程的组织逻辑。
它关注的问题是:
当前需要更新什么场 使用什么材料模型 是否存在色散 是否位于 PML 是否属于子网格18.2 cython
cython目录包含 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(inputfile="model.in")背后会启动完整流程。
26.2 策略模式
create_solver(model)根据配置选择:
CPU solver CUDA solver OpenCL solver Metal solver模型不需要知道具体使用哪一个后端。
26.3 模板方法
Context定义了标准模型生命周期:
开始仿真 创建模型配置 获取场景 创建模型 构建模型 创建求解器 求解 结束仿真MPIContext和TaskfarmContext在保留总体流程的同时,替换其中部分行为。
26.4 构建者思想
Scene不直接执行计算。
它首先保存用户对象,然后逐步将这些对象应用到Model:
scene.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这条主线既是程序的运行路径,也是后续源码学习和二次开发的路线图。
Reference
- GitHub - 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)