commit 377b6450081de7a48da3dcbf451b395be9c26b5a
Author: vvvvonly <519780052@qq.com>
Date: Tue May 26 18:45:19 2026 +0800
first commit
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f6076a3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,396 @@
+# AFEM — 自适应网格细化的 GNN + PPO 强化学习
+
+## 项目架构
+
+```
+afem/
+├── src/ # 应用层
+│ ├── config.yaml # 配置文件
+│ ├── main.py # 入口:解析命令行 → train / test / viz
+│ ├── network.py # GNN + Actor-Critic 完整网络定义
+│ ├── ppo.py # RolloutBuffer + PPOTrainer
+│ ├── utils.py # 读配置、保存/加载 checkpoint
+│ └── visualize.py # viz 模式:加载模型 → 推理 → 存 PNG
+│
+├── environment/ # 仿真环境层
+│ ├── mesh_refinement.py # ★ 核心:网格细化 RL 环境
+│ │ # - GNN 图观测构建(节点 + 边特征)
+│ │ # - continuous_sizing_field (score-based + budget) 细化策略
+│ │ # - spatial 奖励
+│ ├── helmholtz.py # Helmholtz FEM 求解器 + 残差误差估计
+│ ├── fem_problem.py # FEM 问题封装 + PDE 循环缓冲区
+│ ├── fem_util.py # 三角形面积、中点、随机采样、尺寸场函数
+│ ├── domain.py # 计算域:meshpy 三角剖分
+│ ├── utils.py # 数组拼接、随机索引采样
+│ └── visualization.py # plotly 网格渲染(RL 环境用)
+│
+├── checkpoints/ # 模型保存
+├── result/ # 可视化输出
+└── README.md
+```
+
+---
+
+## 项目简介
+
+### 物理场景
+
+二维 Helmholtz 电磁散射:
+
+```
+∇²u_scat + k²·ε_r·u_scat = k²·(1-ε_r)·u_inc
+```
+
+- **入射波**: 沿 -x 方向的平面波 `u_inc = exp(i·k·x)`
+- **散射体**: 圆形介质柱(ε_r 随机采样),位置和半径可配
+- **边界条件**: SBC (Sommerfeld) `∂u/∂n = i·k·u`
+- **域**: 可配矩形域,初始网格密度自适应 + domain area 线性缩放:`N_init = N_base × (k/k_ref)^k_exponent × domain_area`。k_ref 和 k_exponent 均可通过 helmholtz config 配置(默认 k_exponent=1.5, k_ref=6.0),保证不同域尺寸下每单位面积单元数一致
+- 可配 exponent:^2 = P1 Helmholtz 理论最优 (污染误差 ∝ k²),^1.5 = 工程折中。建议 N_base 配合 exponent 调整,使 N_init 约为 COMSOL 目标 (λ/10√ε_r) 的 30-50%,为 RL agent 留出充分细化空间
+- **介质区前渐近区边缘约束**: 介质内 λ_d = 2π/(k√ε_r) 更短,强制迭代细化至 h ≤ λ_d/N(默认 N=1.5,helmholtz.pre_asymptotic_N 可配)。约 1.5 点/波长,刚好跨过渐近区门槛,赋予初始网格基本相位解析能力但不过度消耗物理预算,为 RL agent 留出充分的选择性细化空间
+- **后验误差**: 残差型 indicator(Ainsworth & Oden 风格),含单元内部残差 + 梯度跳变 + SBC 边界残差
+
+### 强化学习建模
+
+| 概念 | 对应实体 |
+|------|---------|
+| **智能体** | 每个三角形网格单元 |
+| **状态** | GNN 节点特征(几何 + PDE 残差 + 复数场分解 + 物理参数,节点 12 维 + 边 1 维) |
+| **动作** | 1 维连续标量 x_i → score = -x_i 排序,在物理预算内 top-k 选细化单元(x 越小优先级越高) |
+| **奖励** | 局部子单元 η 的 log-ratio 改善(spatial: sum 聚合 / spatial_max: max 聚合)+ α 衰减全局 η log-ratio shaping |
+| **终止** | 达到最大步数或超过最大单元数 |
+
+---
+
+## 网络架构
+
+双 GNN 架构(policy / value 各自独立基座):
+
+```
+图观测 → MessagePassingBase → MLP → 动作分布 / value 标量
+ ├─ nn.Linear(嵌入)
+ ├─ MessagePassingStack(2 层消息传递,inner 残差 + LayerNorm)
+ │ └─ MessagePassingStep × N
+ │ ├─ EdgeModule: MLP([src | dst | edge_attr])
+ │ └─ NodeModule: MLP([node | scatter(入边)])
+ └─ 输出: 节点隐向量
+```
+
+| 超参数 | 值 |
+|--------|-----|
+| latent_dim | 64 |
+| 消息传递层数 | 2 |
+| 残差连接 | inner |
+| 归一化 | inner LayerNorm |
+| 边 dropout | 0.1 |
+| Actor MLP | 2 层 tanh |
+| Critic MLP | 2 层 tanh |
+| Optimizer | Adam, lr=3e-4, lr_decay=0.995 |
+| **动作分布** | `DiagGaussianDistribution`(连续 Box 动作空间),`log_std` 可学习,clamp 在 [-4.0, -1.0] |
+| **log_std 策略** | 初始化 -2.0(std≈0.135),每步 optimizer.step() 后 clamp 到 [-4.0, -1.0](std ∈ [0.018, 0.368]),熵系数 0.001 |
+
+### 动作分布策略说明
+
+环境定义的是 `_action_space`(下划线前缀),网络初始化时必须用 `environment._action_space` 而非 `environment.action_space`(后者默认为 None,会错误回退到 `CategoricalDistribution(1)`,导致 policy gradient 恒为零)。
+
+`continuous_sizing_field`(score-based)的动作有效范围约 [-3, 3]:
+- score = -x_i,x 越小 ⇒ 优先级越高(纯排序,不设正负门槛)
+- `initial_log_std=-2.0`(std≈0.135),clamp 在 [-4.0, -1.0](std ∈ [0.018, 0.368])
+- 加 `entropy_coefficient=0.001` 提供微弱探索压力,避免 log_std 过早收敛到下限
+
+---
+
+## 输入特征
+
+### 节点特征(12 维)
+
+| 维度 | 来源 | 名称 | 说明 |
+|------|------|------|------|
+| 1 | cfg | `volume` | 无量纲单元面积:volume / λ² |
+| 3 | cfg | `internal_residual` / `gradient_jump` / `sbc_residual` | PDE 残差三分量(无量纲化,经 log₁₀ 压缩):
`(h_K/k_local)·√V·|r|` / `√(½Σ h_e·\|jump\|²/k_local)` / `(h_bnd/k_local)·\|SBC\|` |
+| 1 | cfg | `element_penalty` | 单元惩罚系数 λ |
+| 1 | cfg | `timestep` | 当前 rollout 步数 |
+| 1 | cfg | `wave_number` | Helmholtz 波数 k |
+| 1 | cfg | `k_local_sqrt_vol` | k × √体积(局域波数 × 特征长度) |
+| 1 | cfg | `is_sbc_boundary` | 是否与 SBC 吸收边界相邻 (0/1) |
+| 1 | cfg | `dist_to_interface` | 到介质圆柱边界的带符号距离,无量纲化后经 sign·ln(1+|d|) 压缩:`sign(d)·ln(1+|(dist-radius)/λ|)` — 近场近似线性保留分辨力,远场对数压缩避免 OOD,与残差 log₁₀ 风格一致 |
+| 1 | fix | `epsilon_r` | 单元中点相对介电常数(圆柱内 = εᵣ,外 = 1.0) |
+| 1 | fix | `total_solution_magnitude` | 散射场复数解的振幅 |
+
+> - **cfg**: 由 `element_features` 配置控制
+> - **fix**: 始终启用(Helmholtz 复数场分解,硬编码)
+>
+> GNN 输入用 `_compute_residual_components`(k_local 无量纲化,log₁₀ 压缩)。Reward 用逐单元 η_K(`_eta_indicator`),与 GNN 特征公式一致但不经 log 压缩。
+
+### 边特征(1 维)
+
+| 维度 | 名称 | 说明 |
+|------|------|------|
+| 1 | `euclidean_distance` | 相邻单元中点欧几里得距离 / λ(无量纲边特征) |
+
+
+---
+
+## 调用逻辑
+
+```
+main.py --mode train/test/viz
+ │
+ ├─→ utils.load_config() # 读 YAML
+ ├─→ environment.MeshRefinement # 创建 RL 环境
+ │ └─→ FEMProblemCircularQueue # 管理 N 个随机 PDE 实例
+ │ └─→ HelmholtzProblem # FEM 求解 + 残差误差
+ ├─→ network.create_model() # 创建 ActorCritic
+ │
+ └─ [train] → ppo.PPOTrainer.fit_iteration() 循环
+ ├─ collect_rollouts() # 256 步 rollout
+ │ └─ buffer.compute_returns_and_advantage()
+ │ └─ 单路 GAE # 逐 agent 时序差分(scatter_add 处理网格细化),奖励含势函数塑形项
+ │ └─ Return / value 归一化
+ └─ train_step() # 多 epoch PPO 更新
+ ├─ policy_loss() # Clipped PPO
+ ├─ value_loss() # Clipped value loss
+ └─ entropy_loss() # 熵正则
+```
+
+### 环境内部调用
+
+```
+MeshRefinement.reset()
+ └─→ FEMProblemWrapper.reset()
+ └─→ initial_mesh (meshpy → 介质内 前渐近区边缘迭代细化)
+
+MeshRefinement.step(action)
+ ├─→ score = -x 排序 + 物理预算约束 → top-k 细化单元
+ ├─→ FEMProblemWrapper.refine_mesh() # scikit-fem refine
+ ├─→ calculate_solution_and_get_error()
+ │ ├─→ HelmholtzProblem.calculate_solution() # FEM 求解
+ │ └─→ _compute_residual_indicator() # 残差误差
+ ├─→ _get_reward_by_type() # spatial 奖励
+ └─→ last_observation # 构建 Data(x, edge_index, edge_attr)
+```
+
+
+### 训练
+
+```bash
+CUDA_VISIBLE_DEVICES=7 python src/main.py --mode train --config src/config.yaml
+```
+
+首次迭代需收集 256 步 rollout(含 FEM 求解),后续打印:
+
+```
+it | loss ev agents reward x<0 elig sel time
+```
+
+| 字段 | 含义 | 健康范围 |
+|------|------|---------|
+| `x<0` | `mean(x_i < 0)`,负值动作比例(纯诊断) | 越负的单元优先级越高 |
+| `elig` | 通过双过滤器的候选占比 | 排除数值退化 + 低误差的单元 |
+| `mask` | 被 Dörfler-P95 掩码 (η<0.05·η_P95) 滤掉的占比 | 因场景而异,非固定比例 |
+| `sel` | 实际选中的细化单元数 | 每步最多 N_current // 4 |
+| `n_budget` | 全局物理预算(每 episode 固定) | k=30 → ~1800 |
+
+### 测试
+
+```bash
+python src/main.py --mode test --checkpoint checkpoints/model_final.pt --k-test 6.0
+python src/main.py --mode test --checkpoint checkpoints/model_final.pt \
+ --k-test 6.0 --center 0.3,0.6 --radius 0.15
+```
+
+输出:
+```
+Step 0: reward=--- error=1.0000 elements=174 budget=1885
+Step 1: reward=+12.345 error=0.7160 elements=618 x<0=0.45 sel=87
+...
+```
+
+每步打印 `reward error elements x<0 sel`,第 0 步额外显示 `N_budget`。
+
+### 可视化
+
+```bash
+python src/main.py --mode viz --checkpoint checkpoints/model_final.pt --k-test 30.0
+```
+
+输出: `result/visualization.png`(总览)+ `result/visualization_steps/step*.png`(逐步对比)。
+
+---
+
+## 后验误差估计
+
+### 残差 indicator 公式(无量纲化)
+
+引入局部波数 $k_{local} = k\sqrt{\max(\varepsilon_r, 1.0)}$,消除纯几何尺度 $h$ 带来的特征偏差,
+使误差指示子反映"相位分辨率残差"而非"网格粗疏程度"。
+
+对 P1 三角单元 K,三项残差分量为:
+
+$$r_{\text{int}} = \frac{h_K}{k_{local}} \sqrt{V_K} \cdot \left| k^2\varepsilon_r u + k^2(\varepsilon_r-1)u_{inc} \right|_K \tag{1}$$
+
+$$r_{\text{jump}} = \sqrt{\frac{1}{2}\sum_{e\in\partial K} \frac{h_e}{k_{local}} \cdot \left| [[\nabla u \cdot n]] \right|^2_e} \tag{2}$$
+
+$$r_{\text{sbc}} = \frac{h_{bnd}}{k_{local}} \cdot \left| \frac{\partial u}{\partial n} - ik_{local}u \right| \tag{3}$$
+
+**逐单元误差指示子**:
+
+$$\eta_K = \sqrt{r_{\text{int}}^2 + r_{\text{jump}}^2 + r_{\text{sbc}}^2}$$
+
+量纲分析($k_{local} \sim [L]^{-1}$,$h_e \sim [L]$,$|\text{jump}|^2 \sim [L]^{-2}$):
+三项均严格无量纲:$h_e/k_{local} \cdot |\text{jump}|^2 \sim [L]^2 \cdot [L]^{-2} = 1$。
+细化后 $h_e$ 缩小直接降低跳变项,为 RL agent 提供可感知的正向 reward 信号。
+
+`η_K` 的计算(`_compute_residual_indicator`)与 GNN 输入特征(`_compute_residual_components`)公式完全一致,特征仅多一层 log₁₀ 压缩。关键验证点:
+- 内部残差:P1 元 ∇²u_h ≡ 0,仅含反应项 `k²ε_r·u + k²(ε_r-1)·u_inc`,除以 `k_local` 后跨介质公平可比
+- 梯度跳变:`(h_e/k_local)·|jump|²`,½ 分配给相邻左右单元;$h_e$ 保留边积分路径,细化后自然衰减
+- SBC 项在 η_K² 中为 `(h_bnd²/k_local²)·|B|²`,分量 `r_sbc = (h_bnd/k_local)·|B|`
+
+### 连续尺寸场策略(score-based + 物理预算约束 + 动作掩码)
+
+Actor 输出标量 x_i → score = -x_i 直接排序,在预算和上限内选 top-k:
+
+```
+A_budget_i = ½(λ_local_i / 6)² // 每局部波长方向 ~6 尺度点(仅用于 N_budget 计算)
+λ_local_i = 2π / (k · √ε_r_i)
+
+N_budget = max(N_phys, ⌈5·N_init⌉) // rho_min=5.0,至少 5× 初始单元数,保证 RL 多步细化空间
+N_phys = ⌈ Σ |K_i| / A_budget_i ⌉ // 全局物理预算(k=30 真空 ~1800)
+
+remaining = N_budget − N_current
+V_min_safeguard = 1e-10 × domain_area // 纯数值底线(防止 FEM 求解器退化)
+eligible: area > V_min_safeguard AND η_K ≥ 0.05·η_P95 // 数值底线 + Dörfler-P95
+num = min(|eligible|, N_current//4, remaining//3)
+selected = top-k by score = -x_i → 1-to-4 切分
+```
+
+- score = -x_i:x 越小 ⇒ 优先级越高(纯排序,不设正负门槛)
+- 不再使用 `0.25·A_budget` 启发式面积地板:RL 应自主学会"细化到多细",而非被人类经验 (12 点/波长) 限制。仅保留数值底线 V_min_safeguard = 1e-10 × domain_area 防止浮点精度问题。
+- per-step cap 从固定 200 改为自适应 `N_current // 4`,随网格规模缩放但增速更缓,避免大网格时单步消耗过多预算。rho_min 从 3.0 提升到 5.0,赋予更多预算余量。
+- **sel=0 提前终止**:当 agent 选中 0 个单元细化(预算耗尽或 Dörfler 屏蔽所有候选)时 episode 自动结束,不再浪费 FEM 求解
+- **k_exponent 可配**:初始网格缩放指数可通过 `helmholtz.k_exponent` 配置(默认 1.5),² 为 P1 Helmholtz 理论最优
+- **动作掩码 (Dörfler-P95)**:η_K < 0.05·η_P95 的单元移出候选池。P95 锚定物理误差尺度,免疫远场噪声稀释(与 median/mean 不同),确保只有误差达标的区域消耗细化预算
+
+### 奖励计算
+
+---
+
+#### 变量
+
+| 符号 | 含义 |
+|------|------|
+| `η_K = √(r_int² + r_jump² + r_sbc²)` | 逐单元误差指示子,`r_*` 定义见式 (1)–(3) |
+| `C(i)` | 父单元 i 经 1-to-4 切分产生的子单元集合 |
+| `M_new[j]` | 子单元 j 对应的父单元索引 |
+| `n_i = |C(i)|` | 父单元 i 的子单元数(1 表示未切分) |
+| `E_global = √(Σ η_K²) / \|\|u_h\|\|_{L₂(Ω)}` | 全局无量纲误差 |
+
+---
+
+#### 算法
+
+**Step 0 — 保存旧状态** (`_set_previous_step`)
+
+```
+η_old ← 旧逐单元 η_K
+||u_h_old|| ← 旧解 L₂ 范数 (≈ √(Σ |ū_K|² · area_K))
+```
+
+**Step 1 — 网格细化** (`_refine_mesh`)
+
+```
+x = action.flatten()
+score = -x // x 越小 ⇒ 优先级越高
+
+remaining = N_budget − N_old
+max_by_budget = max(0, remaining // 3)
+// 数值底线 + Dörfler-P95 掩码
+V_min_safeguard = 1e-10 × domain_area // 纯数值安全底线,防止 FEM 退化
+η_p95 = percentile(η_old, 95)
+eligible = {i | V_old[i] > V_min_safeguard AND η_old_i ≥ 0.05·η_p95}
+num = min(|eligible|, N_old//3, max_by_budget)
+elements_to_refine = top-k of eligible by score
+
+M_new[j] ∈ {0,…,N_old-1} // 子→父映射
+```
+
+**Step 2 — FEM 求解 + 误差估计**
+
+```
+η_new ← 新逐单元 η_K
+||u_h_new|| ← 新解 L₂ 范数
+```
+
+**Step 3 — 局部奖励**(动态截断 ε_dynamic)
+
+ε_dynamic = max(0.01 × η_P95, 1e-6) // P95 锚定,免疫远场噪声稀释
+ε_dynamic = max(0.05 × mean(η_new), 1e-6) // 自适应钳制,切断远场低 η 区 reward hacking
+spatial: r_local_i = log(η_old_i + ε_dynamic) − log( √(Σ_{j: M_new[j]=i} η_new_j²) + ε_dynamic )
+spatial_max: r_local_i = log(η_old_i + ε_dynamic) − log( max_{j: M_new[j]=i} η_new_j + ε_dynamic )
+```
+
+> **L₂ 聚合保证 r_local ≥ 0**: 对 1-to-4 切分:
+> ```
+> Σ η_child² = int²/4 + jump² + sbc² ≤ η_parent² = int² + jump² + sbc²
+> → r_local = ½[log(η_parent²) − log(Σ η_child²)] ≥ 0
+> ```
+> - 纯 int 主导: r_local = log(2) ≈ 0.69(强正奖励)
+> - 纯 jump/sbc 主导: r_local = 0(中性,不惩罚不奖励)
+> - **永远不会惩罚细化**——与 L₁ sum 不同,L₂ 天然避免了对 jump/sbc 主导区的结构性负偏置。
+
+**Step 4 — 动作惩罚**
+
+```
+penalty_i = λ · (n_i − 1) // λ = 0.06
+ + (λ_limit / N_old) · 𝟙[达到最大单元数上限] // λ_limit = 10000
+
+r_local_i ← r_local_i − penalty_i
+```
+
+**Step 5 — 全局势函数塑形**(仅发给被细化的父单元)
+
+```
+E_global = √(Σ_K η_K²) / ||u_h||_{L₂(Ω)}
+global_bonus = α · [ log(E_global_old) − log(E_global_new) ] // α = 0.2
+
+r_i = r_local_i − penalty_i + global_bonus · 𝟙[i 被细化] // 未细化的单元 reward ≈ 0
+```
+
+> 全局改进信号只分配给实际参与细化的单元,避免被未细化单元稀释。
+
+---
+
+#### 奖励标度校准(旧尺寸场下测量,score-based 后需重新标定)
+
+在随机策略下实测各分量量级(1321 个 refined-parent 样本):
+
+| 分量 | 均值 | 占 r_local 比例 |
+|------|------|:---:|
+| `r_local` (仅 refined parents) | +0.364 | — |
+| `penalty` λ·(n−1), λ=0.02 | +0.045 | 1/8 |
+| `α·ΔlogE` α=0.2 | +0.069 | 1/5 |
+| **net** | **+0.387** | |
+
+满足 `r_local ≫ penalty` 且 `α·ΔlogE ≈ r_local / 5`,局部 credit assignment 不被全局信号淹没。
+
+---
+
+#### 设计要点
+
+| 组件 | 聚合 | 作用 |
+|------|------|------|
+| 局部项 `log(η_old / √(Σ η_child²))` | scatter_add(子→父求平方和再开方) | L₂ 聚合保证 r_local ≥ 0:不惩罚任何细化,int 主导区获强正奖励 (≈+0.69),纯 jump/sbc 区中性 |
+| 动作惩罚 `λ(n_i−1)` λ=0.02 | per-parent | 轻微抑制网格膨胀(1-to-4 切分扣 0.06,仅占 r_local 的 ~16%) |
+| 元素上限惩罚 | 达到 20000 上限时触发 | 极端情况兜底,λ_limit / N_old ≈ 0.05~0.5 per agent |
+| 全局项 `α·ΔlogE` α=0.2 | 仅细化父单元 | L₂ 无量纲全局误差下降趋势,只发给实际参与细化的单元,避免被未细化单元稀释 |
+
+---
+
+## PPO 关键细节
+
+- **单路 GAE**: 势函数塑形后的奖励已包含全局改进信号,用 `scatter_add` 将细化后的子单元值聚合回父单元,单路 GAE 即可
+- **奖励归一化**: rollout 内 reward 做 z-score 归一化(std < 1e-8 则跳过)
+- **Value clipping**: 默认 clip_range=0.2
+- **梯度裁剪**: max_grad_norm=0.5
+- **log_std clamp**: 每步 `optimizer.step()` 后将 `log_std` clamp 到 `[-4.0, -1.0]`,std ∈ [0.018, 0.368]
+ 初始化 `-2.0` (std≈0.135),避免 `continuous_sizing_field` 有效范围 [-3, 3] 内噪声过大
+- **熵正则**: `entropy_coefficient=0.001`,防止 log_std 过早收敛
diff --git a/analysis_reward.md b/analysis_reward.md
new file mode 100644
index 0000000..ace1379
--- /dev/null
+++ b/analysis_reward.md
@@ -0,0 +1,154 @@
+ ---
+ ASMR++ 奖励计算完整分析
+
+ 默认配置使用 reward_type: spatial_max + error_metric: maximum。整个奖励信号链分以下步骤:
+
+ Step 1: 误差估计 — 精细网格参考解
+
+ 参考网格 (初始网格细化6次)
+ ↓ FEM求解
+ 参考解 u_ref (视为"真值")
+ ↓
+ 粗网格解 u_coarse 在每个积分点(参考网格元素中点)与 u_ref 比较
+ ↓
+ 绝对误差 |u_ref - u_coarse| per 积分点
+ ↓ scatter_max per 粗元素
+ error_per_element: 每个粗网格元素内的最大误差 (num_elements, solution_dim)
+
+ 用精细网格做数值积分 (error_integrator.py:86-169),支持三种积分方式:mean(积分平均值)、squared(积分平方误差)、maximum(元素内最大误差)。默认Poisson 是标量 PDE,solution_dim=1。
+
+ Step 2: spatial_max 奖励计算
+
+ 核心代码在 mesh_refinement.py:657-714,以下是逐步推导:
+
+ 奖励基准 (reward_per_agent_and_dim)
+ = previous_error_per_element ← 细分前该元素的误差
+
+ ┌─────┬─────┐
+ │ │ │
+ ├─────┼─────┤
+ │ │ │
+ └─────┴─────┘
+
+ 父元素 i (error=0.8) 子元素: j1(0.3), j2(0.5), j3(0.6), j4(0.1)
+ ↓ scatter_max per agent_mapping
+ max_mapped_error[i] = max(0.3, 0.5, 0.6, 0.1) = 0.6
+ ↓
+ reward_raw[i] = 0.8 - 0.6 = +0.2 ✅ 误差最大的子元素也比父元素好
+
+ 关键:spatial_max 只奖励"所有子元素误差都下降"的情况。如果有任一子元素误差仍等于原父元素误差,reward=0。
+
+ 父元素 j (error=0.5) 子元素: k1(0.5), k2(0.1), k3(0.2), k4(0.05)
+ ↓
+ max_mapped_error[j] = max(0.5, 0.1, 0.2, 0.05) = 0.5
+ ↓
+ reward_raw[j] = 0.5 - 0.5 = 0 ❌ 有一个子元素仍未改善
+
+ 对比 spatial (非 max) 模式:
+
+ reward_raw[i] = previous_error[i] - Σ_j φ_ij * error[j]
+ = 标量加法 (np.add.at) 把所有子元素误差从父元素误差中减去
+ 这种模式下即使部分子元素没有改善,整体仍有正奖励。
+
+ Step 3: 归一化 + 降维到标量
+
+ # 除以初始网格的误差 → 把误差改善量归一化到 [0, ~1] 区间
+ reward_per_agent_and_dim = reward_per_agent_and_dim / initial_approximation_error
+
+ # 多维 PDE 降维: dot product with solution_dimension_weights
+ # Poisson 是标量PDE, weights=[1.0], 即恒等变换
+ reward_per_agent = project_to_scalar(reward_per_agent_and_dim)
+ # = np.dot(reward_per_agent_and_dim, [1.0]) = reward_per_agent_and_dim
+
+ Step 4: 元素惩罚 (Element Penalty)
+
+ # 统计每个父元素产生了多少子元素
+ element_counts = unique(agent_mapping, return_counts=True)[1] # 每个父元素→子元素的数量
+ element_counts = element_counts - 1 # 减1因为是"新增的"子元素数
+
+ # 默认 λ ~ 0.01 (loguniform 采样于 [1e-3, 1e-1])
+ element_penalty = λ * element_counts
+
+ ┌──────────────────────────┬────────────────┬──────────────────┐
+ │ 场景 │ element_counts │ penalty (λ=0.01) │
+ ├──────────────────────────┼────────────────┼──────────────────┤
+ │ 未细分元素 │ 0 │ 0 │
+ ├──────────────────────────┼────────────────┼──────────────────┤
+ │ 分裂为 4 个子三角 │ 3 │ 0.03 │
+ ├──────────────────────────┼────────────────┼──────────────────┤
+ │ 被波及细分 (Rivara 平滑) │ 1-3 │ 0.01-0.03 │
+ └──────────────────────────┴────────────────┴──────────────────┘
+
+ 作用: 惩罚是正则化项,防止策略无节制细分所有元素。只在"误差改善 > 细分代价"时细分才有利。
+
+ Step 5: 元素上限惩罚 (Element Limit Penalty)
+
+ if num_elements > maximum_elements (20000):
+ element_limit_penalty = 1000 / previous_num_elements # ≈ 0.05~0.5 per agent
+ else:
+ element_limit_penalty = 0
+
+ Step 6: 最终每 Agent 奖励
+
+ r_i = error_improvement_i / initial_error
+ - λ * new_elements_created_by_i
+ - limit_penalty
+
+ 形状为 (num_agents_t,) — 每个 agent(父元素)一个标量奖励。
+
+ Step 7: 奖励到 TD 误差 — 与论文公式 (3) 的对应
+
+ Buffer 存储:
+ r_i(s_t, a_t) ← 父元素 i 的奖励 (num_agents_t,)
+ V_i(s_t) ← 父元素 i 的价值 (num_agents_t,)
+ φ_ij = agent_mapping ← 子元素j → 父元素i 的映射
+ V_j(s_{t+1}) ← 子元素的价值 (num_agents_{t+1},)
+
+ GAE Delta 计算:
+ projected_V = scatter_sum(V_j(s_{t+1}), index=φ_ij) ← Σ_j φ_ij·V_j(s_{t+1})
+ δ_i = r_i + γ * projected_V_i - V_i(s_t)
+
+ 对应论文 (3): δ_i^t = r(s^t, a^t)_i + γ·Σ_j φ_ij^t·V_j(s^{t+1}) - V_i(s^t)
+
+ Step 8: 混合奖励 (Mixed Return, global_weight=0.5)
+
+ 在 MixedOnPolicyBuffer 中额外计算:
+
+ # 全局奖励 (均值)
+ r_global = mean(r_i) # 所有agent的平均奖励
+
+ # 全局价值 (均值)
+ V_global = mean(V_i) # 所有agent的平均价值
+
+ # 全局 GAE
+ δ_global = r_global + γ·V_global' - V_global
+
+ # 局部 GAE
+ δ_local_i = 上述 per-agent GAE
+
+ # 混合 Advantage
+ A_i = (1 - 0.5) * A_local_i + 0.5 * A_global
+
+ 完整奖励流总结
+
+ FEM求解 → 逐元素误差估计 (±积分 vs 参考网格)
+ ↓
+ spatial_max: error_before - max_error_of_children
+ ↓
+ 归一化 (/ initial_error)
+ ↓
+ - λ * new_elements + limit_penalty
+ ↓
+ r_i (per agent) ────────────→ 局部 GAE → A_local_i
+ │ ↓
+ └→ r_global = mean(r_i) → 全局 GAE → A_global
+ ↓
+ A_i = 0.5·A_local_i + 0.5·A_global
+ ↓
+ 送入 PPO policy_loss
+
+ 设计精巧之处:
+ 1. 空间奖励 + agent_mapping:每个元素独立计算误差改善,通过 agent_mapping φ_ij 追踪父→子关系
+ 2. spatial_max 语义:reward 表示"最差子元素的误差下降量"——驱动策略优先细分误差最大的区域
+ 3. 元素惩罚:防止盲目细分,精确到每个 agent 独立计算代价
+ 4. 混合奖励:局部信号指导细粒度决策 + 全局信号稳定整体训练
\ No newline at end of file
diff --git a/asmr++_architecture.md b/asmr++_architecture.md
new file mode 100644
index 0000000..bc93ce2
--- /dev/null
+++ b/asmr++_architecture.md
@@ -0,0 +1,255 @@
+# ASMR++ 网络架构与数据流 (默认配置)
+
+> 基于 `configs/asmr_pp/asmr_default.yaml` — `value_function_aggr: spatial`, `projection_type: sum`
+
+## 架构总览
+
+```mermaid
+flowchart TD
+ subgraph ENV["♻️ 环境: MeshRefinement"]
+ A1["FEMProblemCircularQueue
随机采样 PDE 问题"]
+ A2["生成初始粗网格
(meshpy, 2D 三角剖分)"]
+ A3["FEM 求解器
计算 PDE 解和逐单元误差"]
+ A4["构建观测图
(节点=单元, 边=邻接关系)"]
+ A1 --> A2 --> A3 --> A4
+ end
+
+ subgraph GRAPH["📊 观测图 (torch_geometric Data)"]
+ B1["节点特征 (x)
━━━━━━━━━━━━━━━━
solution_mean / solution_std
volume / timestep
element_penalty
source_term (PDE 特征)
共 ~10-15 维"]
+ B2["边特征 (edge_attr)
━━━━━━━━━━━━━━━━
euclidean_distance
共 1 维"]
+ B3["边索引 (edge_index)
━━━━━━━━━━━━━━━━
双向邻接 + 自环"]
+ end
+
+ subgraph NORM["📏 观测归一化器"]
+ C1["node.x: running mean/std"]
+ C2["edge_attr: running mean/std"]
+ end
+
+ subgraph HMPN["🧠 HMPN 基础网络 (HomogeneousMessagePassingBase)"]
+ subgraph EMBED["输入嵌入"]
+ D1["节点嵌入: Linear(in→64)"]
+ D2["边嵌入: Linear(in→64)"]
+ end
+ subgraph STACK["消息传递堆栈 (num_steps=2, residual=inner, layernorm=inner)"]
+ subgraph STEP1["Step 1/2"]
+ E1["边更新 HomogeneousEdgeModule
concat[src(64), dst(64), edge(64)]
→ LatentMLP(192→64, 2层, LeakyReLU)
→ LayerNorm → +inner residual"]
+ E2["节点更新 HomogeneousMessagePassingNodeModule
scatter_mean(edge→dest) → concat[node(64), agg(64)]
→ LatentMLP(128→64, 2层, LeakyReLU)
→ LayerNorm → +inner residual"]
+ E1 --> E2
+ end
+ subgraph STEP2["Step 2/2"]
+ F1["边更新 (同上)"]
+ F2["节点更新 (同上)"]
+ F1 --> F2
+ end
+ STEP1 --> STEP2
+ end
+ D1 --> STEP1
+ D2 --> STEP1
+ STEP2 --> G["输出: 节点潜在特征 (num_nodes, 64)"]
+ end
+
+ subgraph HEADS["🎯 策略与价值头 (share_base=False, 各自独立 GNN)"]
+ subgraph ACTOR["Actor 头"]
+ H1["Policy MLP
2层, Tanh
64→64→64"]
+ H2["Linear(64→action_dim)"]
+ H3["log_std (可学习)"]
+ H4["DiagGaussian(μ, σ)
每节点输出独立动作"]
+ H1 --> H2 --> H4
+ H3 --> H4
+ end
+ subgraph CRITIC["Critic 头 — 逐节点价值,不做 scatter 聚合"]
+ I1["Value MLP
2层, Tanh
64→64→1"]
+ I2["输出形状: (num_agents, 1)
每个 agent 独立 V_i(s)
value_function_aggr=spatial
→ 不聚合,保持逐节点"]
+ I1 --> I2
+ end
+ G --> H1
+ G --> I1
+ end
+
+ subgraph BUFFER["🗃️ MixedOnPolicyBuffer (global_weight=0.5)"]
+ J1["局部 GAE (逐节点)
δ_i = r_i + γ·Σ_j φ_ij·V_j(s') - V_i(s)
projection_type='sum': Σ 通过 agent_mapping 反投影"]
+ J2["全局 GAE (图级别)
δ_global = r_global + γ·V_mean(s') - V_mean(s)"]
+ J3["混合 Advantage
A_i = (1-0.5)·A_i_local + 0.5·A_global"]
+ J1 --> J3
+ J2 --> J3
+ end
+
+ subgraph PPO["🔄 PPO 训练"]
+ K1["256 步 Rollout"]
+ K2["5 Epochs, batch_size=32"]
+ K3["policy_loss + 0.5·value_loss
clip_range=0.2"]
+ K4["梯度裁剪 0.5, Adam lr=3e-4"]
+ K1 --> K2 --> K3 --> K4
+ end
+
+ ENV --> GRAPH --> NORM --> HMPN
+ HMPN --> HEADS
+ ACTOR -->|动作| ENV
+ CRITIC -->|"V_i(s) 逐节点"| BUFFER
+ ENV -->|"r_i, agent_mapping φ"| BUFFER
+ BUFFER --> PPO
+ PPO -->|更新参数| HMPN
+ PPO -->|更新参数| HEADS
+```
+
+## 核心纠正: projection_type 的真实作用
+
+**之前的错误理解**:
+- ~~Critic 输出 scatter_sum → 图级别价值~~ ❌
+
+**正确理解**:
+- `value_function_aggr: "spatial"` → Critic **不做任何聚合**,输出 `(num_agents, 1)` 逐节点价值 ✅
+- `projection_type: "sum"` → 在 **Buffer** 中通过 `agent_mapping` 反投影下一步价值时使用 ✅
+
+两个参数作用于完全不同的位置:
+
+| 参数 | 作用位置 | 作用 |
+|------|----------|------|
+| `value_function_aggr: "spatial"` | `SwarmPPOActorCritic._get_values_and_distribution()` | 控制 Critic 输出是否聚合: `"spatial"` → 保持逐节点 |
+| `projection_type: "sum"` | `SpatialOnPolicyBuffer._project_to_previous_step()` | 控制 agent_mapping 反投影方式: sum→子元素价值求和回父元素 |
+
+## 详细数据流 (序列图)
+
+```mermaid
+sequenceDiagram
+ actor Trainer
+ participant Env as MeshRefinement
+ participant Norm as Normalizer
+ participant GNN as HMPN Base
+ participant Actor as Policy Head
+ participant Critic as Value Head
+ participant Buffer as MixedOnPolicyBuffer
+
+ Note over Trainer,Buffer: === Rollout (256 步) ===
+
+ Trainer->>Env: reset()
+ Env->>Env: 随机 Poisson PDE + 随机域 + GMM 负载
+ Env->>Env: 初始粗网格 → FEM 求解 → 构建观测图
+
+ loop 256 步
+ Env-->>Norm: 观测图 (原始 node.x, edge_attr)
+ Norm-->>GNN: 归一化后图
+ GNN->>GNN: Edge Dropout (0.1, 仅训练)
+ GNN->>GNN: 嵌入 → MP Step1 → MP Step2
+ GNN-->>Actor: node_features (num_nodes, 64)
+ GNN-->>Critic: node_features (num_nodes, 64)
+
+ Actor->>Actor: MLP → μ, σ → 采样动作
+ Critic->>Critic: MLP(64→1) → V_i(s): (num_agents, 1) 逐节点
+
+ Actor-->>Env: actions (num_agents, 1)
+ Env->>Env: 元素选择 → 网格细分
+ Env->>Env: FEM 求解 → 计算空间奖励 r_i
+ Env-->>Buffer: (obs, a, r_i, V_i, log_prob, agent_mapping φ)
+ end
+
+ Note over Buffer: === GAE 计算 (逐节点 + 混合奖励) ===
+
+ Buffer->>Buffer: 局部 δ_i(t) = r_i + γ·Σ_j φ_ij·V_j(t+1) - V_i(t)
+ Buffer->>Buffer: projection_type='sum': Σ_j 通过 agent_mapping 反投影
+ Buffer->>Buffer: 局部 GAE → A_local_i (逐节点)
+ Buffer->>Buffer: 全局 GAE → A_global (图级, 用 mean(V_i) 算)
+ Buffer->>Buffer: A_i = 0.5·A_local_i + 0.5·A_global
+ Buffer->>Buffer: R_i = A_i + V_i(s)
+
+ Note over Trainer,Buffer: === 训练 (5 Epochs × batch 32) ===
+
+ loop 5 Epochs
+ Buffer-->>Trainer: (obs, a, old_log_prob, old_V_i, A_i, R_i)
+ Trainer->>GNN: 重新前向传播
+ GNN-->>Actor: node_features
+ GNN-->>Critic: node_features
+ Actor->>Actor: 新 log_prob
+ Critic->>Critic: 新 V_i (逐节点)
+ Trainer->>Trainer: ratio = exp(log_prob_new - log_prob_old)
+ Trainer->>Trainer: policy_loss = -min(ratio·A_i, clip(ratio,0.8,1.2)·A_i)
+ Trainer->>Trainer: value_loss = 0.5·clip(V_new, V_old±0.2) vs R_i
+ Trainer->>Trainer: backward() + grad_clip(0.5) + Adam.step()
+ end
+```
+
+## 论文公式 (3) 与代码对应
+
+论文中的 TD 误差公式:
+
+$$\delta^t_i = r(s^t, a^t)_i + \gamma \sum_j \phi_{ij}^t V_j(s^{t+1}) - V_i(s^t)$$
+
+在代码中的实现路径 (`spatial_on_policy_buffer.py:174-178`):
+
+```python
+# _get_agent_wise_advantages_and_returns()
+for step in range(self.buffer_size):
+ if self.dones[step]:
+ delta = self.rewards[step] - self.values[step] # r_i - V_i(s)
+ else:
+ delta = self.rewards[step] \
+ + self.discount_factor * projected_next_values[step] \ # + γ·Σ_j φ_ij·V_j(s')
+ - self.values[step] # - V_i(s)
+```
+
+其中 `projected_next_values[step]` 由 `_project_to_previous_step()` 产生:
+
+```python
+# projection_type='sum'
+projected_value = scatter_sum(values[step], index=agent_mappings[step], dim=0)
+# ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
+# V_j(s_{t+1}) φ_ij: 新agent j → 旧agent i
+```
+
+## 关键默认参数
+
+| 参数 | 值 | 代码位置 |
+|------|-----|----------|
+| **算法** | PPO | `config["algorithm"]["name"]` |
+| **网络骨架** | Homogeneous MPN | `config["network"]["type_of_base"]` |
+| **GNN 架构** | mpn (message passing) | `config["network"]["base"]["architecture"]` |
+| **潜在维度** | 64 | `config["network"]["latent_dimension"]` |
+| **MP 步数** | 2 | `config["network"]["base"]["stack"]["num_steps"]` |
+| **残差** | inner | `config["network"]["base"]["stack"]["residual_connections"]` |
+| **层归一化** | inner | `config["network"]["base"]["stack"]["layer_norm"]` |
+| **边→节点聚合** | mean | `config["network"]["base"]["scatter_reduce"]` |
+| **Base MLP** | 2层, LeakyReLU | `config["network"]["base"]["stack"]["mlp"]` |
+| **Actor MLP** | 2层, Tanh | `config["network"]["actor"]["mlp"]` |
+| **Critic MLP** | 2层, Tanh | `config["network"]["critic"]["mlp"]` |
+| **价值函数范围** | **spatial** (逐节点, 不聚合) | `config["algorithm"]["ppo"]["value_function_aggr"]` |
+| **价值投影方式** | **sum** (agent_mapping 反投影用) | `config["algorithm"]["ppo"]["projection_type"]` |
+| **混合奖励权重** | 0.5 | `config["algorithm"]["mixed_return"]["global_weight"]` |
+| **共享 Base** | False (Actor/Critic 各自独立 GNN) | `config["network"]["share_base"]` |
+| **动作分布** | DiagGaussian (连续) | 动作空间为 `gym.spaces.Box` |
+| **Rollout 步数** | 256 | `config["algorithm"]["ppo"]["num_rollout_steps"]` |
+| **训练轮次** | 5 | `config["algorithm"]["ppo"]["epochs_per_iteration"]` |
+| **Batch 大小** | 32 | `config["algorithm"]["batch_size"]` |
+| **GAE λ** | 0.95 | `config["algorithm"]["ppo"]["gae_lambda"]` |
+| **折现 γ** | 1.0 | `config["algorithm"]["discount_factor"]` |
+| **PPO clip** | 0.2 | `config["algorithm"]["ppo"]["clip_range"]` |
+| **梯度裁剪** | 0.5 | `config["algorithm"]["ppo"]["max_grad_norm"]` |
+| **学习率** | 3e-4 | `config["network"]["training"]["learning_rate"]` |
+| **边 Dropout** | 0.1 | `config["network"]["base"]["edge_dropout"]` |
+| **Episode 步数** | 6 | `config["environment"]["mesh_refinement"]["num_timesteps"]` |
+| **PDE** | Poisson (GMM 负载, zero Dirichlet) | `config["environment"]["mesh_refinement"]["fem"]["pde_type"]` |
+
+## projection_type 的两种职责
+
+`projection_type` 在 Buffer 中有**两处**使用,都是通过 `agent_mapping` 做跨时间步的 agent 反投影:
+
+### 1. 价值反投影 — 公式 (3) 的 Σ 项
+```python
+# _project_to_previous_step() — spatial_on_policy_buffer.py:33
+projected_value = scatter_sum(values[step], index=agent_mappings[step], dim=0)
+# 下一步的 V_j(s_{t+1}) 按 agent_mapping φ_ij 求和回当前步的 agent i
+```
+
+### 2. GAE 时间差分反投影 — 动态规划递推
+```python
+# _get_agent_wise_advantages_and_returns() — spatial_on_policy_buffer.py:169
+projected_last_gae = scatter_sum(last_gae, index=self._agent_mappings[step], dim=0)
+# 上一步累积的 GAE 按 agent_mapping 反投影
+```
+
+## 核心创新点
+
+1. **Swarm 视角 + 变长 Agent**: 每个网格元素是一个 agent,元素分裂后 agent 数量动态增长
+2. **空间奖励 + agent_mapping**: 通过 `agent_mapping φ_ij` 追踪父→子关系,支持逐节点的 TD 误差计算(公式 3)
+3. **混合奖励学习**: 局部逐节点 Advantage + 全局图级 Advantage 加权混合 (0.5:0.5)
+4. **MPN 通信**: 边更新 + 节点更新的消息传递,元素通过共享三角形边交换 PDE 解信息
+5. **自适应细化**: 连续动作 → 概率性元素选择 → 非均匀网格,资源集中在误差大的区域
diff --git a/checkpoints/model_final.pt b/checkpoints/model_final.pt
new file mode 100644
index 0000000..ad28a58
Binary files /dev/null and b/checkpoints/model_final.pt differ
diff --git a/checkpoints/model_iter0050.pt b/checkpoints/model_iter0050.pt
new file mode 100644
index 0000000..b1a73cc
Binary files /dev/null and b/checkpoints/model_iter0050.pt differ
diff --git a/checkpoints/model_iter0100.pt b/checkpoints/model_iter0100.pt
new file mode 100644
index 0000000..3195245
Binary files /dev/null and b/checkpoints/model_iter0100.pt differ
diff --git a/checkpoints/model_iter0150.pt b/checkpoints/model_iter0150.pt
new file mode 100644
index 0000000..b87bc5d
Binary files /dev/null and b/checkpoints/model_iter0150.pt differ
diff --git a/checkpoints/model_iter0200.pt b/checkpoints/model_iter0200.pt
new file mode 100644
index 0000000..a461fa7
Binary files /dev/null and b/checkpoints/model_iter0200.pt differ
diff --git a/checkpoints/model_iter0250.pt b/checkpoints/model_iter0250.pt
new file mode 100644
index 0000000..e4d1695
Binary files /dev/null and b/checkpoints/model_iter0250.pt differ
diff --git a/checkpoints/model_iter0300.pt b/checkpoints/model_iter0300.pt
new file mode 100644
index 0000000..5b7aeae
Binary files /dev/null and b/checkpoints/model_iter0300.pt differ
diff --git a/checkpoints/model_iter0350.pt b/checkpoints/model_iter0350.pt
new file mode 100644
index 0000000..2814b5c
Binary files /dev/null and b/checkpoints/model_iter0350.pt differ
diff --git a/checkpoints/model_iter0400.pt b/checkpoints/model_iter0400.pt
new file mode 100644
index 0000000..27924d6
Binary files /dev/null and b/checkpoints/model_iter0400.pt differ
diff --git a/checkpoints/model_iter0401.pt b/checkpoints/model_iter0401.pt
new file mode 100644
index 0000000..dfe6856
Binary files /dev/null and b/checkpoints/model_iter0401.pt differ
diff --git a/checkpoints_before/last.pt b/checkpoints_before/last.pt
new file mode 100644
index 0000000..fe918a5
Binary files /dev/null and b/checkpoints_before/last.pt differ
diff --git a/checkpoints_before/model_final.pt b/checkpoints_before/model_final.pt
new file mode 100644
index 0000000..e23c830
Binary files /dev/null and b/checkpoints_before/model_final.pt differ
diff --git a/checkpoints_before/model_iter0001.pt b/checkpoints_before/model_iter0001.pt
new file mode 100644
index 0000000..268f592
Binary files /dev/null and b/checkpoints_before/model_iter0001.pt differ
diff --git a/checkpoints_before/model_iter0002.pt b/checkpoints_before/model_iter0002.pt
new file mode 100644
index 0000000..b1191ae
Binary files /dev/null and b/checkpoints_before/model_iter0002.pt differ
diff --git a/checkpoints_before/model_iter0050.pt b/checkpoints_before/model_iter0050.pt
new file mode 100644
index 0000000..98b803c
Binary files /dev/null and b/checkpoints_before/model_iter0050.pt differ
diff --git a/checkpoints_before/model_iter0100.pt b/checkpoints_before/model_iter0100.pt
new file mode 100644
index 0000000..9e63670
Binary files /dev/null and b/checkpoints_before/model_iter0100.pt differ
diff --git a/checkpoints_before/model_iter0150.pt b/checkpoints_before/model_iter0150.pt
new file mode 100644
index 0000000..cefaa45
Binary files /dev/null and b/checkpoints_before/model_iter0150.pt differ
diff --git a/checkpoints_before/model_iter0200.pt b/checkpoints_before/model_iter0200.pt
new file mode 100644
index 0000000..839f237
Binary files /dev/null and b/checkpoints_before/model_iter0200.pt differ
diff --git a/checkpoints_before/model_iter0250.pt b/checkpoints_before/model_iter0250.pt
new file mode 100644
index 0000000..a2fd790
Binary files /dev/null and b/checkpoints_before/model_iter0250.pt differ
diff --git a/checkpoints_before/model_iter0300.pt b/checkpoints_before/model_iter0300.pt
new file mode 100644
index 0000000..5135250
Binary files /dev/null and b/checkpoints_before/model_iter0300.pt differ
diff --git a/checkpoints_before/model_iter0350.pt b/checkpoints_before/model_iter0350.pt
new file mode 100644
index 0000000..9e3987f
Binary files /dev/null and b/checkpoints_before/model_iter0350.pt differ
diff --git a/cmp_adv.py b/cmp_adv.py
new file mode 100644
index 0000000..cf15b7d
--- /dev/null
+++ b/cmp_adv.py
@@ -0,0 +1,112 @@
+"""Compare iter100 vs iter150 checkpoints: action_mean diff and refine_mask equality."""
+import numpy as np
+import torch
+from torch_geometric.data import Batch
+
+from src.network import create_model
+from src.utils import load_checkpoint, setup_helmholtz_config
+
+
+def load_config():
+ from src.utils import load_config as _lc
+ from pathlib import Path
+ cfg_path = Path(__file__).resolve().parent / "src" / "config.yaml"
+ return _lc(str(cfg_path))
+
+
+def compare_checkpoints(ckpt_a, ckpt_b, label_a="iter100", label_b="iter150"):
+ config = load_config()
+ setup_helmholtz_config(config)
+ algo = config.get("algorithm", {})
+
+ from environment.mesh_refinement import MeshRefinement
+
+ env = MeshRefinement(
+ environment_config=config.get("environment", {}).get("mesh_refinement", {}),
+ seed=99,
+ )
+
+ # ── Load both models ──
+ model_a = create_model(env, config.get("network", {}), algo.get("ppo", {}))
+ load_checkpoint(model_a, ckpt_a)
+ model_a.eval()
+
+ model_b = create_model(env, config.get("network", {}), algo.get("ppo", {}))
+ load_checkpoint(model_b, ckpt_b)
+ model_b.eval()
+
+ # ── Get same initial observation ──
+ env.reset()
+ obs = env.reset() # second reset ensures same state
+
+ with torch.no_grad():
+ batch = Batch.from_data_list([obs])
+
+ # Model A
+ shared_a, batch_a = model_a._encode(batch)
+ latent_pi_a = model_a.policy_mlp(shared_a)
+ action_mean_a = model_a.action_out(latent_pi_a).cpu().numpy().flatten()
+ dist_a = model_a._make_distribution(latent_pi_a)
+ actions_a = dist_a.get_actions(deterministic=True).cpu().numpy().flatten()
+
+ # Model B
+ shared_b, batch_b = model_b._encode(batch)
+ latent_pi_b = model_b.policy_mlp(shared_b)
+ action_mean_b = model_b.action_out(latent_pi_b).cpu().numpy().flatten()
+ dist_b = model_b._make_distribution(latent_pi_b)
+ actions_b = dist_b.get_actions(deterministic=True).cpu().numpy().flatten()
+
+ # ── Compare action_mean ──
+ diff = action_mean_a - action_mean_b
+ print(f"\n{'='*60}")
+ print(f" 1. action_mean comparison")
+ print(f"{'='*60}")
+ print(f" {label_a} action_mean: min={action_mean_a.min():.6f} max={action_mean_a.max():.6f} mean={action_mean_a.mean():.6f} std={action_mean_a.std():.6f}")
+ print(f" {label_b} action_mean: min={action_mean_b.min():.6f} max={action_mean_b.max():.6f} mean={action_mean_b.mean():.6f} std={action_mean_b.std():.6f}")
+ print(f" ---")
+ print(f" |diff|: min={np.abs(diff).min():.8f} max={np.abs(diff).max():.8f} mean={np.abs(diff).mean():.8f}")
+ print(f" diff = 0 exactly: {int(np.sum(diff == 0))} / {len(diff)} ({100 * np.sum(diff == 0) / len(diff):.2f}%)")
+ print(f" |diff| < 1e-6: {int(np.sum(np.abs(diff) < 1e-6))} / {len(diff)}")
+ print(f" |diff| < 1e-4: {int(np.sum(np.abs(diff) < 1e-4))} / {len(diff)}")
+ print(f" cos similarity: {np.dot(action_mean_a, action_mean_b) / (np.linalg.norm(action_mean_a) * np.linalg.norm(action_mean_b) + 1e-12):.8f}")
+
+ # ── Compare refine_mask (action > 0) ──
+ mask_a = actions_a > 0.0
+ mask_b = actions_b > 0.0
+ mask_equal = np.array_equal(mask_a, mask_b)
+
+ print(f"\n{'='*60}")
+ print(f" 2. refine_mask comparison")
+ print(f"{'='*60}")
+ print(f" {label_a} refine_mask: sum={mask_a.sum()} / {len(mask_a)} ({100 * mask_a.sum() / len(mask_a):.1f}%)")
+ print(f" {label_b} refine_mask: sum={mask_b.sum()} / {len(mask_b)} ({100 * mask_b.sum() / len(mask_b):.1f}%)")
+ print(f" refine_mask exactly equal: {mask_equal}")
+ print(f" mask XOR sum: {(mask_a ^ mask_b).sum()} / {len(mask_a)}")
+
+ if not mask_equal:
+ diff_idx = np.where(mask_a != mask_b)[0]
+ print(f" First 20 differing indices: {diff_idx[:20].tolist()}")
+ print(f" At those indices, {label_a} action_mean: {action_mean_a[diff_idx[:10]]}")
+ print(f" At those indices, {label_b} action_mean: {action_mean_b[diff_idx[:10]]}")
+
+ # ── 3. Parameter-level diff ──
+ print(f"\n{'='*60}")
+ print(f" 3. Model parameter weight diff (L2 norm)")
+ print(f"{'='*60}")
+ sd_a = torch.load(ckpt_a, map_location="cpu")["model_state_dict"]
+ sd_b = torch.load(ckpt_b, map_location="cpu")["model_state_dict"]
+ for k in sorted(sd_a.keys()):
+ w_a = sd_a[k].float()
+ w_b = sd_b[k].float()
+ l2 = torch.norm(w_a - w_b).item()
+ rel = l2 / (torch.norm(w_a).item() + 1e-12)
+ print(f" {k:55s} |Δ|₂={l2:.6e} rel={rel:.6e}")
+
+
+if __name__ == "__main__":
+ import sys
+ d1 = sys.argv[1] if len(sys.argv) > 1 else "checkpoints/model_iter0100.pt"
+ d2 = sys.argv[2] if len(sys.argv) > 2 else "checkpoints/model_iter0150.pt"
+ l1 = sys.argv[3] if len(sys.argv) > 3 else "iter100"
+ l2 = sys.argv[4] if len(sys.argv) > 4 else "iter150"
+ compare_checkpoints(d1, d2, l1, l2)
diff --git a/environment/__pycache__/abstract_env.cpython-310.pyc b/environment/__pycache__/abstract_env.cpython-310.pyc
new file mode 100644
index 0000000..57898ea
Binary files /dev/null and b/environment/__pycache__/abstract_env.cpython-310.pyc differ
diff --git a/environment/__pycache__/abstract_env.cpython-313.pyc b/environment/__pycache__/abstract_env.cpython-313.pyc
new file mode 100644
index 0000000..7425d5d
Binary files /dev/null and b/environment/__pycache__/abstract_env.cpython-313.pyc differ
diff --git a/environment/__pycache__/domain.cpython-310.pyc b/environment/__pycache__/domain.cpython-310.pyc
new file mode 100644
index 0000000..82ff068
Binary files /dev/null and b/environment/__pycache__/domain.cpython-310.pyc differ
diff --git a/environment/__pycache__/domain.cpython-313.pyc b/environment/__pycache__/domain.cpython-313.pyc
new file mode 100644
index 0000000..2d83e81
Binary files /dev/null and b/environment/__pycache__/domain.cpython-313.pyc differ
diff --git a/environment/__pycache__/fem_problem.cpython-310.pyc b/environment/__pycache__/fem_problem.cpython-310.pyc
new file mode 100644
index 0000000..56765dd
Binary files /dev/null and b/environment/__pycache__/fem_problem.cpython-310.pyc differ
diff --git a/environment/__pycache__/fem_problem.cpython-313.pyc b/environment/__pycache__/fem_problem.cpython-313.pyc
new file mode 100644
index 0000000..88cf28c
Binary files /dev/null and b/environment/__pycache__/fem_problem.cpython-313.pyc differ
diff --git a/environment/__pycache__/fem_util.cpython-310.pyc b/environment/__pycache__/fem_util.cpython-310.pyc
new file mode 100644
index 0000000..aee08f5
Binary files /dev/null and b/environment/__pycache__/fem_util.cpython-310.pyc differ
diff --git a/environment/__pycache__/fem_util.cpython-313.pyc b/environment/__pycache__/fem_util.cpython-313.pyc
new file mode 100644
index 0000000..fad99d0
Binary files /dev/null and b/environment/__pycache__/fem_util.cpython-313.pyc differ
diff --git a/environment/__pycache__/helmholtz.cpython-310.pyc b/environment/__pycache__/helmholtz.cpython-310.pyc
new file mode 100644
index 0000000..0b307bb
Binary files /dev/null and b/environment/__pycache__/helmholtz.cpython-310.pyc differ
diff --git a/environment/__pycache__/helmholtz.cpython-313.pyc b/environment/__pycache__/helmholtz.cpython-313.pyc
new file mode 100644
index 0000000..138a0f3
Binary files /dev/null and b/environment/__pycache__/helmholtz.cpython-313.pyc differ
diff --git a/environment/__pycache__/keys.cpython-310.pyc b/environment/__pycache__/keys.cpython-310.pyc
new file mode 100644
index 0000000..9847c4f
Binary files /dev/null and b/environment/__pycache__/keys.cpython-310.pyc differ
diff --git a/environment/__pycache__/keys.cpython-313.pyc b/environment/__pycache__/keys.cpython-313.pyc
new file mode 100644
index 0000000..fd4f3bb
Binary files /dev/null and b/environment/__pycache__/keys.cpython-313.pyc differ
diff --git a/environment/__pycache__/mesh_refinement.cpython-310.pyc b/environment/__pycache__/mesh_refinement.cpython-310.pyc
new file mode 100644
index 0000000..ddb97f1
Binary files /dev/null and b/environment/__pycache__/mesh_refinement.cpython-310.pyc differ
diff --git a/environment/__pycache__/mesh_refinement.cpython-313.pyc b/environment/__pycache__/mesh_refinement.cpython-313.pyc
new file mode 100644
index 0000000..b48cc1e
Binary files /dev/null and b/environment/__pycache__/mesh_refinement.cpython-313.pyc differ
diff --git a/environment/__pycache__/mie_solution.cpython-310.pyc b/environment/__pycache__/mie_solution.cpython-310.pyc
new file mode 100644
index 0000000..828e0f0
Binary files /dev/null and b/environment/__pycache__/mie_solution.cpython-310.pyc differ
diff --git a/environment/__pycache__/utils.cpython-310.pyc b/environment/__pycache__/utils.cpython-310.pyc
new file mode 100644
index 0000000..58316b8
Binary files /dev/null and b/environment/__pycache__/utils.cpython-310.pyc differ
diff --git a/environment/__pycache__/utils.cpython-313.pyc b/environment/__pycache__/utils.cpython-313.pyc
new file mode 100644
index 0000000..44461c2
Binary files /dev/null and b/environment/__pycache__/utils.cpython-313.pyc differ
diff --git a/environment/__pycache__/visualization.cpython-310.pyc b/environment/__pycache__/visualization.cpython-310.pyc
new file mode 100644
index 0000000..92c7cb1
Binary files /dev/null and b/environment/__pycache__/visualization.cpython-310.pyc differ
diff --git a/environment/__pycache__/visualization.cpython-313.pyc b/environment/__pycache__/visualization.cpython-313.pyc
new file mode 100644
index 0000000..9219ea8
Binary files /dev/null and b/environment/__pycache__/visualization.cpython-313.pyc differ
diff --git a/environment/domain.py b/environment/domain.py
new file mode 100644
index 0000000..b39e8ba
--- /dev/null
+++ b/environment/domain.py
@@ -0,0 +1,70 @@
+import copy
+from typing import Any, Dict, Union
+
+import numpy as np
+from skfem import MeshTri1
+
+
+class Domain:
+ """Square domain [0,1]x[0,1] with initial coarse mesh and fine integration mesh."""
+
+ def __init__(
+ self,
+ *,
+ domain_config: Dict[Union[str, int], Any],
+ random_state: np.random.RandomState,
+ ):
+ xmin, ymin, xmax, ymax = domain_config.get("boundary", [0.0, 0.0, 1.0, 1.0])
+ self._boundary = np.array([xmin, ymin, xmax, ymax])
+ self._random_state = random_state
+
+ num_elements = domain_config.get("initial_num_elements", None)
+ if num_elements is not None:
+ domain_area = (xmax - xmin) * (ymax - ymin)
+ self._max_volume = 2.0 * domain_area / float(num_elements)
+ else:
+ self._max_volume = domain_config.get("max_initial_element_volume", 0.05)
+
+ self._initial_mesh = self._create_initial_mesh()
+
+ @property
+ def initial_mesh(self) -> MeshTri1:
+ return copy.deepcopy(self._initial_mesh)
+
+ def replace_initial_mesh(self, mesh: MeshTri1) -> None:
+ """Replace the stored initial mesh (e.g. after Nyquist enforcement)."""
+ self._initial_mesh = mesh
+
+ def get_integration_mesh(self) -> MeshTri1:
+ return self._initial_mesh.refined(4)
+
+ @property
+ def boundary_line_segments(self) -> np.ndarray:
+ boundary_edges = self._initial_mesh.boundary_facets()
+ boundary_node_indices = self._initial_mesh.facets[:, boundary_edges]
+ return self._initial_mesh.p[:, boundary_node_indices].T.reshape(-1, 4)
+
+ def _create_initial_mesh(self) -> MeshTri1:
+ return self._meshpy_square()
+
+ def _meshpy_square(self) -> MeshTri1:
+ import meshpy.triangle as triangle
+
+ xmin, ymin, xmax, ymax = self._boundary
+ points = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]
+ facets = [(0, 1), (1, 2), (2, 3), (3, 0)]
+
+ info = triangle.MeshInfo()
+ info.set_points(points)
+ info.set_facets(facets)
+
+ mesh = triangle.build(info, max_volume=self._max_volume)
+ vertices = np.array(mesh.points).T
+ triangles = np.array(mesh.elements).T
+ return MeshTri1(vertices, triangles)
+
+
+def create_domain(
+ *, domain_config: Dict[Union[str, int], Any], random_state: np.random.RandomState
+) -> Domain:
+ return Domain(domain_config=domain_config, random_state=random_state)
diff --git a/environment/fem_problem.py b/environment/fem_problem.py
new file mode 100644
index 0000000..a3f781e
--- /dev/null
+++ b/environment/fem_problem.py
@@ -0,0 +1,197 @@
+import copy
+import os
+from typing import Any, Dict, List, Optional, Union
+
+import numpy as np
+from skfem import Basis, Mesh
+
+from .fem_util import get_element_midpoints
+from .helmholtz import HelmholtzProblem, create_helmholtz_problem
+from .utils import IndexSampler
+
+
+class FEMProblemWrapper:
+ """Wraps a HelmholtzProblem, managing mesh, solution cache, and refinement history."""
+
+ def __init__(
+ self,
+ *,
+ fem_config: Dict[Union[str, int], Any],
+ fem_problem: HelmholtzProblem,
+ pde_features: Dict[str, List[str]],
+ ):
+ self._fem_config = fem_config
+ self.fem_problem = fem_problem
+ self._pde_element_feature_names = pde_features["element_features"]
+ self._mesh: Optional[Mesh] = None
+ self._previous_mesh: Optional[Mesh] = None
+ self._solution: Optional[np.ndarray] = None
+ self._nodal_solution: Optional[np.ndarray] = None
+ self._refinements_per_element: Optional[np.ndarray] = None
+ self._plot_boundary = np.array(fem_config.get("domain", {}).get("boundary", [0, 0, 1, 1]))
+
+ def reset(self):
+ self._mesh = self.fem_problem.initial_mesh
+ self._previous_mesh = copy.deepcopy(self._mesh)
+ self._refinements_per_element = np.zeros(self.num_elements, dtype=np.int32)
+
+ def calculate_solution_and_get_error(self) -> Dict[str, np.ndarray]:
+ self.calculate_solution()
+ return self.get_error_estimate_per_element()
+
+ def calculate_solution(self) -> None:
+ self._solution = self.fem_problem.calculate_solution(basis=self._basis, cache=True)
+ self._nodal_solution = self._solution
+
+ def get_error_estimate_per_element(self) -> Dict[str, np.ndarray]:
+ return self.fem_problem.get_error_estimate_per_element(
+ basis=self._basis, solution=self._solution
+ )
+
+ def refine_mesh(self, elements_to_refine: np.ndarray) -> np.ndarray:
+ if len(elements_to_refine) > 0:
+ refined_mesh = self._mesh.refined(elements_to_refine)
+ new_midpoints = refined_mesh.p[:, refined_mesh.t].mean(axis=1)
+ element_finder = self._mesh.element_finder()
+ corresponding_elements = element_finder(*new_midpoints)
+ element_indices, inverse_indices, counts = np.unique(
+ corresponding_elements, return_counts=True, return_inverse=True
+ )
+ self._refinements_per_element[element_indices] += counts - 1
+ self._refinements_per_element = self._refinements_per_element[inverse_indices]
+ else:
+ refined_mesh = self._mesh
+ inverse_indices = np.arange(self._mesh.t.shape[1]).astype(np.int64)
+
+ self.mesh = refined_mesh
+ return inverse_indices
+
+ # ---- PDE 相关的单元特征(source_term 等)----
+ def element_features(self) -> np.ndarray:
+ return self.fem_problem.element_features(
+ mesh=self._mesh, element_feature_names=self._pde_element_feature_names
+ )
+
+ # ---- 将多分量值归约为标量(Helmholtz 取实部)----
+ def project_to_scalar(self, values: np.ndarray) -> np.ndarray:
+ return self.fem_problem.project_to_scalar(values=values)
+
+ # ---- 当前 FEM 网格 ----
+ @property
+ def mesh(self) -> Optional[Mesh]:
+ return self._mesh
+
+ @mesh.setter
+ def mesh(self, mesh: Mesh) -> None:
+ self._previous_mesh = copy.deepcopy(self._mesh)
+ self._mesh = mesh
+
+ # ---- P1 线性基函数 ----
+ @property
+ def _basis(self) -> Basis:
+ return self.fem_problem.mesh_to_basis(self._mesh)
+
+ # ---- 细化前的网格(奖励计算中回溯用)----
+ @property
+ def previous_mesh(self) -> Mesh:
+ return self._previous_mesh
+
+ # ---- 当前网格单元总数 ----
+ @property
+ def num_elements(self) -> int:
+ return self._mesh.t.shape[1]
+
+ # ---- 每个单元被细化的次数 ----
+ @property
+ def refinements_per_element(self) -> np.ndarray:
+ return self._refinements_per_element
+
+ # ---- 顶点上的 FEM 解 ----
+ @property
+ def nodal_solution(self) -> np.ndarray:
+ assert self._nodal_solution is not None, "Solution not computed yet"
+ return self._nodal_solution
+
+ # ---- 单元中点坐标 (num_elements, 2) ----
+ @property
+ def element_midpoints(self) -> np.ndarray:
+ return get_element_midpoints(self._mesh)
+
+ # ---- 单元顶点索引 (num_elements, 3) ----
+ @property
+ def element_indices(self) -> np.ndarray:
+ return self._mesh.t.T
+
+ # ---- 顶点坐标 (num_vertices, 2) ----
+ @property
+ def vertex_positions(self) -> np.ndarray:
+ return self._mesh.p.T
+
+ # ---- 网格边(相邻顶点对索引)----
+ @property
+ def mesh_edges(self) -> np.ndarray:
+ return self._mesh.facets
+
+ # ---- 每个单元的相邻单元(排除边界)----
+ @property
+ def element_neighbors(self) -> np.ndarray:
+ return self._mesh.f2t[:, self._mesh.f2t[1] != -1]
+
+ # ---- 可视化用的计算域边界框 ----
+ @property
+ def plot_boundary(self):
+ return self._plot_boundary
+
+ # ---- 额外的 plotly 渲染图层 ----
+ def additional_plots(self) -> Dict:
+ return self.fem_problem.additional_plots_from_mesh(self._mesh)
+
+
+class FEMProblemCircularQueue:
+ """Circular buffer of Helmholtz instances for training generalization."""
+
+ def __init__(
+ self,
+ *,
+ fem_config: Dict[Union[str, int], Any],
+ random_state: np.random.RandomState = np.random.RandomState(),
+ ):
+ self._fem_config = fem_config
+ self._random_state = random_state
+
+ num_pdes = fem_config.get("num_pdes", 100)
+ self._use_buffer = num_pdes is not None and num_pdes > 0
+ num_pdes = num_pdes if self._use_buffer else 1
+
+ self._index_sampler = IndexSampler(num_pdes, random_state=self._random_state)
+ self._fem_problems: List[Optional[FEMProblemWrapper]] = [None for _ in range(num_pdes)]
+
+ pde_config = fem_config.get(fem_config.get("pde_type", "helmholtz"), {})
+ self._pde_features = {
+ "element_features": [
+ name for name, include in pde_config.get("element_features", {}).items() if include
+ ],
+ }
+
+ def next(self) -> FEMProblemWrapper:
+ return self._next_from_idx(pde_idx=self._index_sampler.next())
+
+ def _next_from_idx(self, pde_idx: int) -> FEMProblemWrapper:
+ if (not self._use_buffer) or self._fem_problems[pde_idx] is None:
+ new_seed = self._random_state.randint(0, 2**31)
+ new_problem = create_helmholtz_problem(
+ fem_config=self._fem_config,
+ random_state=np.random.RandomState(seed=new_seed),
+ )
+ self._fem_problems[pde_idx] = FEMProblemWrapper(
+ fem_config=self._fem_config,
+ fem_problem=new_problem,
+ pde_features=self._pde_features,
+ )
+ self._fem_problems[pde_idx].reset()
+ return self._fem_problems[pde_idx]
+
+ # PDE 提供的单元特征个数
+ @property
+ def num_pde_element_features(self) -> int:
+ return len(self._pde_features["element_features"])
diff --git a/environment/fem_util.py b/environment/fem_util.py
new file mode 100644
index 0000000..0576b54
--- /dev/null
+++ b/environment/fem_util.py
@@ -0,0 +1,54 @@
+import numpy as np
+from skfem import Mesh
+
+
+def get_element_midpoints(mesh: Mesh, transpose: bool = True) -> np.ndarray:
+ midpoints = np.mean(mesh.p[:, mesh.t], axis=1)
+ return midpoints.T if transpose else midpoints
+
+# 算三个顶点的mean/std/...
+def get_aggregation_per_element(
+ solution: np.ndarray,
+ element_indices: np.ndarray,
+ aggregation_function_str: str = "mean",
+) -> np.ndarray:
+ vals = solution[element_indices]
+ if aggregation_function_str == "mean":
+ return vals.mean(axis=1)
+ elif aggregation_function_str == "std":
+ return vals.std(axis=1)
+ elif aggregation_function_str == "min":
+ return vals.min(axis=1)
+ elif aggregation_function_str == "max":
+ return vals.max(axis=1)
+ elif aggregation_function_str == "median":
+ return np.median(vals, axis=1)
+ raise ValueError(f"Unknown aggregation function: {aggregation_function_str}")
+
+
+# 计算三角形面积
+def get_triangle_areas_from_indices(
+ positions: np.ndarray, triangle_indices: np.ndarray
+) -> np.ndarray:
+ i0, i1, i2 = triangle_indices[:, 0], triangle_indices[:, 1], triangle_indices[:, 2]
+ return np.abs(0.5 * (
+ (positions[i1, 0] - positions[i0, 0]) * (positions[i2, 1] - positions[i0, 1])
+ - (positions[i2, 0] - positions[i0, 0]) * (positions[i1, 1] - positions[i0, 1])
+ ))
+
+
+# penalty:\alpha的采样方式
+def sample_in_range(max_value: float, min_value: float, sampling_type: str) -> float:
+ if sampling_type == "uniform":
+ return np.random.uniform(min_value, max_value)
+ elif sampling_type == "loguniform":
+ return np.exp(np.random.uniform(np.log(min_value), np.log(max_value)))
+ raise ValueError(f"Unknown sampling type: {sampling_type}")
+
+
+def construct_sizing_field_1d(x: np.ndarray, eps: float = 1e-4) -> np.ndarray:
+ """Softplus 激活 → 目标网格面积 (numpy 版)。"""
+ def _softplus(x):
+ return np.log1p(np.exp(np.clip(x, -50, 50)))
+ x = np.atleast_1d(np.asarray(x, dtype=np.float64))
+ return _softplus(x) + eps
diff --git a/environment/helmholtz.py b/environment/helmholtz.py
new file mode 100644
index 0000000..1a2eeb5
--- /dev/null
+++ b/environment/helmholtz.py
@@ -0,0 +1,619 @@
+import copy
+from typing import Any, Dict, List, Optional, Union
+
+import numpy as np
+from skfem import Basis, ElementTriP1, Mesh, asm, solve
+from skfem.assembly import BilinearForm, FacetBasis, LinearForm
+from skfem.helpers import dot, grad
+
+from .domain import create_domain
+from .fem_util import get_aggregation_per_element, get_element_midpoints
+
+
+class HelmholtzProblem:
+ """2D Helmholtz scattering FEM solver with Sommerfeld BC."""
+
+ def __init__(
+ self,
+ *,
+ fem_config: Dict[Union[str, int], Any],
+ random_state: np.random.RandomState = np.random.RandomState(),
+ ):
+ helmholtz_config = fem_config.get("helmholtz", {})
+
+ # ── 1. 波数 k ──
+ wave_number_mode = helmholtz_config.get("wave_number_mode", "fixed")
+ if wave_number_mode == "random_uniform":
+ k_min = helmholtz_config.get("wave_number_min", 2.0)
+ k_max = helmholtz_config.get("wave_number_max", 8.0)
+ self._k = float(random_state.uniform(k_min, k_max))
+ else:
+ self._k = float(helmholtz_config.get("wave_number", 10.0))
+
+ # ── 2. 介质散射体参数 ──
+ sc = helmholtz_config.get("scatterer", {})
+ scatterer_mode = sc.get("mode", "fixed")
+
+ if scatterer_mode == "random_uniform":
+ self._cx = float(
+ random_state.uniform(sc.get("cx_min", 0.3), sc.get("cx_max", 0.7))
+ )
+ self._cy = float(
+ random_state.uniform(sc.get("cy_min", 0.3), sc.get("cy_max", 0.7))
+ )
+ self._radius = float(
+ random_state.uniform(
+ sc.get("radius_min", 0.1), sc.get("radius_max", 0.25)
+ )
+ )
+ self._eps_r = float(
+ random_state.uniform(
+ sc.get("eps_r_min", 2.0), sc.get("eps_r_max", 7.0)
+ )
+ )
+ else:
+ self._cx = float(sc.get("cx", 0.5))
+ self._cy = float(sc.get("cy", 0.5))
+ self._radius = float(sc.get("radius", 0.2))
+ self._eps_r = float(sc.get("eps_r", 2.0))
+
+ # ── 3. 组装 FEM 双线性和线性形式 ──
+ self._bilin_form = self._make_bilinear_form()
+ self._lin_form_real = self._make_linear_form_real()
+ self._lin_form_imag = self._make_linear_form_imag()
+
+ # ── 4. 初始化域(k^exponent 自适应网格密度 × domain area 线性缩放)──
+ # exponent 和 k_ref 均可通过 helmholtz config 配置
+ # exponent=2: P1 Helmholtz 理论最优 (污染误差 ∝ (kh)^2, N ∝ k^2)
+ # exponent=1.5: 工程折中,避免高 k 初始过密
+ # domain area 缩放: 保证不同域尺寸下每单位面积单元数一致 → h 不变
+ domain_cfg = copy.deepcopy(fem_config.get("domain"))
+ boundary = domain_cfg.get("boundary", [0, 0, 1, 1])
+ domain_area = (boundary[2] - boundary[0]) * (boundary[3] - boundary[1])
+ k_ref = helmholtz_config.get("k_ref", 6.0)
+ k_exponent = helmholtz_config.get("k_exponent", 1.5)
+ base_elements = domain_cfg.get("initial_num_elements", 400)
+ scaled_elements = int(base_elements * (self._k / k_ref) ** k_exponent * domain_area)
+ domain_cfg["initial_num_elements"] = max(scaled_elements, int(base_elements * domain_area))
+ self._domain = create_domain(
+ domain_config=domain_cfg,
+ random_state=copy.deepcopy(random_state),
+ )
+
+ # ── 4.5. 介质区前渐近区边缘约束 ──
+ # 放宽 Nyquist (N=4) → 前渐近区边缘 (N=1~1.5),赋予介质内初始网格基本相位解析能力
+ # 约束: h_init ≤ λ_local / N,λ_local = 2π/(k√ε_r)
+ # N=1.5 对应约 1.5 点/波长,刚好跨过渐近区门槛,不撑爆物理预算
+ pre_asymptotic_N = helmholtz_config.get("pre_asymptotic_N", 1.5)
+ pre_asymptotic_mesh = self._enforce_nyquist_in_dielectric(
+ self._domain.initial_mesh, N=pre_asymptotic_N
+ )
+ self._domain.replace_initial_mesh(pre_asymptotic_mesh)
+
+ # ── 5. PDE 特征名称 ──
+ pde_config = fem_config.get(fem_config.get("pde_type", "helmholtz"), {})
+ self._element_feature_names = [
+ name
+ for name, include in pde_config.get("element_features", {}).items()
+ if include
+ ]
+
+ # ── Public interface ─────────────────────────────────────
+
+ def mesh_to_basis(self, mesh: Mesh) -> Basis:
+ return Basis(mesh, ElementTriP1())
+
+ def calculate_solution(self, basis: Basis, cache: bool = False) -> np.ndarray:
+ K = asm(self._bilin_form, basis)
+ f = asm(self._lin_form_real, basis) + 1j * asm(self._lin_form_imag, basis)
+
+ boundary_facets = basis.mesh.boundary_facets()
+ facet_basis = FacetBasis(basis.mesh, basis.elem, facets=boundary_facets)
+
+ @BilinearForm
+ def boundary_mass(u, v, w):
+ return u * v
+
+ M_boundary = asm(boundary_mass, facet_basis)
+ K_total = K.astype(np.complex128) - 1j * self._k * M_boundary
+ u_scat = solve(K_total, f)
+
+ return u_scat
+
+ def get_error_estimate_per_element(
+ self, basis: Basis, solution: np.ndarray
+ ) -> Dict[str, np.ndarray]:
+ eps_r_arr = _compute_eps_r_at_midpoints(basis.mesh, self._cx, self._cy, self._radius, self._eps_r)
+ return {"indicator": _compute_residual_indicator(basis.mesh, solution, k=self._k, eps_r=eps_r_arr)}
+
+ def element_features(self, mesh: Mesh, element_feature_names: List[str]) -> Optional[np.ndarray]:
+ features_list = []
+ if "epsilon_r" in element_feature_names:
+ features_list.append(
+ _compute_eps_r_at_midpoints(mesh, self._cx, self._cy, self._radius, self._eps_r)[:, None]
+ )
+ return np.concatenate(features_list, axis=1) if features_list else None
+
+ def _enforce_nyquist_in_dielectric(self, mesh: Mesh, N: float = 1.5, max_iter: int = 10) -> Mesh:
+ """Iteratively refine elements inside the dielectric until h_K ≤ λ_d/N.
+
+ λ_d = 2π/(k√ε_r) is the wavelength inside the dielectric.
+ N=1.5 corresponds to the edge of the pre-asymptotic regime (~1.5 points
+ per wavelength) — just enough for the wave field to exhibit basic phase
+ resolution without exhausting the physical element budget. This relaxes
+ the old Nyquist N=4 constraint, leaving headroom for the RL agent to
+ selectively refine where residual indicators demand it.
+ """
+ lambda_d = 2.0 * np.pi / (self._k * np.sqrt(self._eps_r))
+ h_max = lambda_d / N
+
+ for _ in range(max_iter):
+ i0, i1, i2 = mesh.t[0], mesh.t[1], mesh.t[2]
+ x0, y0 = mesh.p[0, i0], mesh.p[1, i0]
+ x1, y1 = mesh.p[0, i1], mesh.p[1, i1]
+ x2, y2 = mesh.p[0, i2], mesh.p[1, i2]
+
+ e01 = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
+ e12 = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
+ e20 = np.sqrt((x0 - x2) ** 2 + (y0 - y2) ** 2)
+ h_K = np.maximum(np.maximum(e01, e12), e20)
+
+ midpoints = np.mean(mesh.p[:, mesh.t], axis=1).T
+ in_dielectric = (
+ (midpoints[:, 0] - self._cx) ** 2
+ + (midpoints[:, 1] - self._cy) ** 2
+ <= self._radius**2
+ )
+
+ to_refine = np.where(in_dielectric & (h_K > h_max))[0]
+ if len(to_refine) == 0:
+ break
+ mesh = mesh.refined(to_refine)
+
+ return mesh
+
+ # ── Properties ───────────────────────────────────────────
+
+ @property
+ def initial_mesh(self) -> Mesh:
+ return self._domain.initial_mesh
+
+ @property
+ def boundary_line_segments(self) -> np.ndarray:
+ return self._domain.boundary_line_segments
+
+
+ @staticmethod
+ def project_to_scalar(values: np.ndarray) -> np.ndarray:
+ return values
+
+ def additional_plots_from_mesh(self, mesh: Mesh) -> Dict:
+ return {}
+
+ # ── FEM form assembly ────────────────────────────────────
+
+ def _eps_r_at_quad_points(self, x, y):
+ in_cyl = (x - self._cx) ** 2 + (y - self._cy) ** 2 <= self._radius**2
+ return np.where(in_cyl, self._eps_r, 1.0)
+
+ def _make_bilinear_form(self):
+ k = self._k
+
+ @BilinearForm
+ def bilin(u, v, w):
+ eps_r = self._eps_r_at_quad_points(w.x[0], w.x[1])
+ return dot(grad(u), grad(v)) - k**2 * eps_r * u * v
+
+ return bilin
+
+ def _make_linear_form_real(self):
+ k = self._k
+
+ @LinearForm
+ def lin(v, w):
+ eps_r = self._eps_r_at_quad_points(w.x[0], w.x[1])
+ return k**2 * (eps_r - 1.0) * np.cos(k * w.x[0]) * v
+
+ return lin
+
+ def _make_linear_form_imag(self):
+ k = self._k
+
+ @LinearForm
+ def lin(v, w):
+ eps_r = self._eps_r_at_quad_points(w.x[0], w.x[1])
+ return k**2 * (eps_r - 1.0) * np.sin(k * w.x[0]) * v
+
+ return lin
+
+
+# ── 辅助函数 ──────────────────────────────────────────────────
+
+
+def _compute_eps_r_at_midpoints(
+ mesh: Mesh,
+ cx: float = 0.5,
+ cy: float = 0.5,
+ radius: float = 0.2,
+ eps_r_in: float = 2.0,
+) -> np.ndarray:
+ """
+ 计算每个单元中点处的相对介电常数 ε_r。
+
+ 判断单元中点是否落在介质圆柱内:
+ - 在圆柱内 → ε_r = eps_r_in (如 2.0)
+ - 在圆柱外 → ε_r = 1.0 (真空)
+
+ Returns:
+ eps_r: shape (num_elements,)
+ """
+ midpoints = get_element_midpoints(mesh)
+ x_mid, y_mid = midpoints[:, 0], midpoints[:, 1]
+ in_cylinder = (x_mid - cx) ** 2 + (y_mid - cy) ** 2 <= radius**2
+ return np.where(in_cylinder, eps_r_in, 1.0)
+
+
+def _compute_residual_indicator(
+ mesh: Mesh,
+ u_h: np.ndarray,
+ k: float = 10.0,
+ eps_r: Union[float, np.ndarray] = 1.0,
+) -> np.ndarray:
+ """
+ 基于残差的逐单元后验误差估计 — 无量纲化版本。
+
+ 引入局部波数 k_local = k√ε_r 消除纯几何尺度 h 带来的特征偏差,
+ 使误差指示子反映"相位分辨率残差"而非"网格粗疏程度"。
+
+ P1 单元三项:
+ 1. r_int = (h_K/k_local)·√V_K · |k²ε_r·u_h + k²(ε_r-1)·u_inc|
+ 2. r_jump = √(½ Σ_{e∈∂K} (h_e/k_local)·|[[∇u_h·n]]|²)
+ 3. r_sbc = (h_bnd/k_local)·|∂u/∂n - i·k_local·u|
+
+ Returns:
+ eta_elements: shape (num_elements,) 的逐单元误差指标
+ """
+ n_elements = mesh.t.shape[1]
+ eps_r = np.asarray(eps_r)
+ k_local = k * np.sqrt(np.maximum(eps_r, 1.0))
+
+ # ── 1. 单元几何量 ──
+ i0, i1, i2 = mesh.t[0], mesh.t[1], mesh.t[2]
+ x0, y0 = mesh.p[0, i0], mesh.p[1, i0]
+ x1, y1 = mesh.p[0, i1], mesh.p[1, i1]
+ x2, y2 = mesh.p[0, i2], mesh.p[1, i2]
+
+ det_J = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
+ element_areas = np.abs(det_J) / 2.0
+
+ edge_len_01 = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
+ edge_len_12 = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
+ edge_len_20 = np.sqrt((x0 - x2) ** 2 + (y0 - y2) ** 2)
+ h_K = np.maximum(np.maximum(edge_len_01, edge_len_12), edge_len_20)
+
+ # ── 2. 梯度(常数,因为是 P1 单元)──
+ u0, u1, u2 = u_h[i0], u_h[i1], u_h[i2]
+ inv_det = np.divide(1.0, det_J, where=det_J != 0, out=np.zeros_like(det_J))
+ du10, du20 = u1 - u0, u2 - u0
+
+ grad_x = ((y2 - y0) * du10 - (y1 - y0) * du20) * inv_det
+ grad_y = (-(x2 - x0) * du10 + (x1 - x0) * du20) * inv_det
+ grad_per_element = np.column_stack([grad_x, grad_y])
+
+ # ── 3. 单元内部残差 ──
+ u_mid = (u0 + u1 + u2) / 3.0
+ x_mid = (x0 + x1 + x2) / 3.0
+ u_inc_mid = np.exp(1j * k * x_mid)
+ f_mid = (k**2) * (eps_r - 1.0) * u_inc_mid
+ r_mid = f_mid + (k**2) * eps_r * u_mid
+
+ cell_residual_sq = (h_K**2) * element_areas * np.abs(r_mid) ** 2 / (k_local ** 2)
+ cell_residual_sq[element_areas < 1e-15] = 0.0
+
+ # ── 4. 内部边梯度跳变 ──
+ interior_facets_idx = np.where(mesh.f2t[1] != -1)[0]
+ elem_left = mesh.f2t[0, interior_facets_idx]
+ elem_right = mesh.f2t[1, interior_facets_idx]
+
+ edges_p1 = mesh.p[:, mesh.facets[0, interior_facets_idx]].T
+ edges_p2 = mesh.p[:, mesh.facets[1, interior_facets_idx]].T
+ edge_vectors = edges_p2 - edges_p1
+ h_e = np.linalg.norm(edge_vectors, axis=1)
+ n_e = np.c_[edge_vectors[:, 1], -edge_vectors[:, 0]] / (h_e[:, None] + 1e-15)
+
+ grad_left = grad_per_element[elem_left]
+ grad_right = grad_per_element[elem_right]
+ jump_val = np.abs(np.sum((grad_left - grad_right) * n_e, axis=1))
+ jump_val_sq = jump_val ** 2
+
+ jump_residual_sq = np.zeros(n_elements)
+ np.add.at(jump_residual_sq, elem_left, 0.5 * h_e * jump_val_sq / k_local[elem_left])
+ np.add.at(jump_residual_sq, elem_right, 0.5 * h_e * jump_val_sq / k_local[elem_right])
+
+ # ── 5. 合并 ──
+ eta_sq = cell_residual_sq + jump_residual_sq
+
+ # ── 6. SBC 边界残差 ──
+ boundary_facets_idx = np.where(mesh.f2t[1] == -1)[0]
+ if len(boundary_facets_idx) > 0:
+ bnd_elem = mesh.f2t[0, boundary_facets_idx]
+ bnd_p1 = mesh.p[:, mesh.facets[0, boundary_facets_idx]].T
+ bnd_p2 = mesh.p[:, mesh.facets[1, boundary_facets_idx]].T
+ bnd_vectors = bnd_p2 - bnd_p1
+ h_bnd = np.linalg.norm(bnd_vectors, axis=1)
+ n_bnd = np.c_[bnd_vectors[:, 1], -bnd_vectors[:, 0]] / (h_bnd[:, None] + 1e-15)
+
+ grad_bnd = grad_per_element[bnd_elem]
+ du_dn = np.sum(grad_bnd * n_bnd, axis=1)
+
+ if eps_r.ndim == 1:
+ k_local = k * np.sqrt(np.maximum(eps_r[bnd_elem], 1.0))
+ else:
+ k_local = k
+
+ u_edge_mean = (
+ u_h[mesh.facets[0, boundary_facets_idx]]
+ + u_h[mesh.facets[1, boundary_facets_idx]]
+ ) / 2.0
+ sbc_residual = du_dn - 1j * k_local * u_edge_mean
+ sbc_residual_sq = (h_bnd ** 2) * np.abs(sbc_residual) ** 2 / (k_local ** 2)
+ np.add.at(eta_sq, bnd_elem, sbc_residual_sq)
+
+ eta_sq = np.maximum(eta_sq, 0.0)
+ return np.sqrt(eta_sq)
+
+
+def _compute_residual_components(
+ mesh: Mesh,
+ u_h: np.ndarray,
+ k: float = 10.0,
+ eps_r: Union[float, np.ndarray] = 1.0,
+ apply_log: bool = True,
+) -> Dict[str, np.ndarray]:
+ """
+ 计算逐单元的三项 PDE 物理残差(分离版,无量纲化)。
+
+ 引入 k_local = k√ε_r 消除几何尺度偏差,使 GNN 跨介质公平感知"相位分辨率残差"。
+ 保留源项信息(k²(ε_r-1)·u_inc),确保极粗网格下介质内部巨大物理激励仍可被网络捕捉。
+
+ P1 单元返回:
+ internal_residual: (h_K/k_local)·√V_i · |k²ε_r·u + k²(ε_r-1)·u_inc|
+ gradient_jump: √(½ Σ_{e∈∂K_i} (h_e/k_local)·|[[∇u·n]]|²)
+ sbc_residual: (h_bnd/k_local)·|∂u/∂n - i·k_local·u|
+ element_areas: 单元面积
+ is_sbc_boundary: 该单元是否与 SBC 边界相邻 (0/1)
+
+ Args:
+ apply_log: True → log10 压缩(喂 GNN);False → 原始值(喂 reward)
+ """
+ n_elements = mesh.t.shape[1]
+ eps_r = np.asarray(eps_r)
+ k_local = k * np.sqrt(np.maximum(eps_r, 1.0))
+
+ # ── 1. 单元几何量 ──
+ i0, i1, i2 = mesh.t[0], mesh.t[1], mesh.t[2]
+ x0, y0 = mesh.p[0, i0], mesh.p[1, i0]
+ x1, y1 = mesh.p[0, i1], mesh.p[1, i1]
+ x2, y2 = mesh.p[0, i2], mesh.p[1, i2]
+
+ det_J = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
+ element_areas = np.abs(det_J) / 2.0
+
+ edge_len_01 = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
+ edge_len_12 = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
+ edge_len_20 = np.sqrt((x0 - x2) ** 2 + (y0 - y2) ** 2)
+ h_K = np.maximum(np.maximum(edge_len_01, edge_len_12), edge_len_20)
+
+ # ── 2. 梯度(常数,因为是 P1 单元)──
+ u0, u1, u2 = u_h[i0], u_h[i1], u_h[i2]
+ inv_det = np.divide(1.0, det_J, where=det_J != 0, out=np.zeros_like(det_J))
+ du10, du20 = u1 - u0, u2 - u0
+
+ grad_x = ((y2 - y0) * du10 - (y1 - y0) * du20) * inv_det
+ grad_y = (-(x2 - x0) * du10 + (x1 - x0) * du20) * inv_det
+ grad_per_element = np.column_stack([grad_x, grad_y])
+
+ # P1 单元内部残差: ∇²u_h = 0(线性元二阶导为零),故仅含反应项
+ # 完整强形式: |∇²u + k²·ε_r·u + k²·(ε_r-1)·u_inc|
+ # 对 P1: ∇²u_h ≡ 0 → 残差 = |k²·ε_r·u + k²·(ε_r-1)·u_inc|
+ u_mid = (u0 + u1 + u2) / 3.0
+ x_mid = (x0 + x1 + x2) / 3.0
+ u_inc_mid = np.exp(1j * k * x_mid)
+ f_mid = (k**2) * (eps_r - 1.0) * u_inc_mid
+ r_mid = f_mid + (k**2) * eps_r * u_mid
+ internal_residual = (h_K / k_local) * np.sqrt(element_areas) * np.abs(r_mid)
+ internal_residual[element_areas < 1e-15] = 0.0
+
+ # ── 4. 内部边梯度跳变 (逐单元) ──
+ interior_facets_idx = np.where(mesh.f2t[1] != -1)[0]
+ elem_left = mesh.f2t[0, interior_facets_idx]
+ elem_right = mesh.f2t[1, interior_facets_idx]
+
+ edges_p1 = mesh.p[:, mesh.facets[0, interior_facets_idx]].T
+ edges_p2 = mesh.p[:, mesh.facets[1, interior_facets_idx]].T
+ edge_vectors = edges_p2 - edges_p1
+ h_e = np.linalg.norm(edge_vectors, axis=1)
+ n_e = np.c_[edge_vectors[:, 1], -edge_vectors[:, 0]] / (h_e[:, None] + 1e-15)
+
+ grad_left = grad_per_element[elem_left]
+ grad_right = grad_per_element[elem_right]
+ jump_val = np.abs(np.sum((grad_left - grad_right) * n_e, axis=1))
+
+ gradient_jump = np.zeros(n_elements, dtype=np.float64)
+ jump_sq_per_edge = jump_val ** 2
+ np.add.at(gradient_jump, elem_left, 0.5 * h_e * jump_sq_per_edge / k_local[elem_left])
+ np.add.at(gradient_jump, elem_right, 0.5 * h_e * jump_sq_per_edge / k_local[elem_right])
+ gradient_jump = np.sqrt(gradient_jump)
+
+ # ── 5. SBC 边界残差 + 边界标记 ──
+ sbc_residual = np.zeros(n_elements, dtype=np.float64)
+ is_sbc_boundary = np.zeros(n_elements, dtype=np.float32)
+ boundary_facets_idx = np.where(mesh.f2t[1] == -1)[0]
+ if len(boundary_facets_idx) > 0:
+ bnd_elem = mesh.f2t[0, boundary_facets_idx]
+ bnd_p1 = mesh.p[:, mesh.facets[0, boundary_facets_idx]].T
+ bnd_p2 = mesh.p[:, mesh.facets[1, boundary_facets_idx]].T
+ bnd_vectors = bnd_p2 - bnd_p1
+ h_bnd = np.linalg.norm(bnd_vectors, axis=1)
+ n_bnd = np.c_[bnd_vectors[:, 1], -bnd_vectors[:, 0]] / (h_bnd[:, None] + 1e-15)
+
+ grad_bnd = grad_per_element[bnd_elem]
+ du_dn = np.sum(grad_bnd * n_bnd, axis=1)
+
+ if eps_r.ndim == 1:
+ k_local = k * np.sqrt(np.maximum(eps_r[bnd_elem], 1.0))
+ else:
+ k_local = k
+
+ u_edge_mean = (
+ u_h[mesh.facets[0, boundary_facets_idx]]
+ + u_h[mesh.facets[1, boundary_facets_idx]]
+ ) / 2.0
+ sbc_val = np.abs(du_dn - 1j * k_local * u_edge_mean)
+ np.add.at(sbc_residual, bnd_elem, (h_bnd / k_local) * sbc_val)
+ is_sbc_boundary[bnd_elem] = 1.0
+
+ # ── 对数预处理:压缩跨数量级动态范围(仅 GNN 特征需要)──
+ if apply_log:
+ _log_eps = 1e-8
+ internal_residual = np.log10(np.maximum(internal_residual, _log_eps))
+ gradient_jump = np.log10(np.maximum(gradient_jump, _log_eps))
+ sbc_residual = np.log10(np.maximum(sbc_residual, _log_eps))
+
+ return {
+ "internal_residual": internal_residual.astype(np.float32),
+ "gradient_jump": gradient_jump.astype(np.float32),
+ "sbc_residual": sbc_residual.astype(np.float32),
+ "element_areas": element_areas.astype(np.float32),
+ "is_sbc_boundary": is_sbc_boundary,
+ }
+
+
+def _compute_residual_density(
+ mesh: Mesh,
+ u_h: np.ndarray,
+ k: float = 10.0,
+ eps_r: Union[float, np.ndarray] = 1.0,
+) -> Dict[str, np.ndarray]:
+ """
+ Compute intensive (h-free) residual density components for reward.
+
+ Unlike _compute_residual_components which includes h-scaling
+ (h_K·√V, h_e·|jump|, h_bnd·|sbc|), this returns the raw PDE residuals
+ that are independent of element size — true "error densities".
+
+ Returns:
+ rho_int: |k²·ε_r·u + k²·(ε_r-1)·u_inc| per element
+ rho_jump: √(mean_{e∈∂K_int} |[[∇u·n]]|²) per element
+ rho_sbc: √(mean_{e∈∂K∩Γ_sbc} |∂u/∂n - i·k·u|²) per element
+ """
+ n_elements = mesh.t.shape[1]
+ eps_r = np.asarray(eps_r)
+
+ # ── 1. element geometry ──
+ i0, i1, i2 = mesh.t[0], mesh.t[1], mesh.t[2]
+ x0, y0 = mesh.p[0, i0], mesh.p[1, i0]
+ x1, y1 = mesh.p[0, i1], mesh.p[1, i1]
+ x2, y2 = mesh.p[0, i2], mesh.p[1, i2]
+
+ det_J = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
+
+ # ── 2. gradient (constant per P1 element) ──
+ u0, u1, u2 = u_h[i0], u_h[i1], u_h[i2]
+ inv_det = np.divide(1.0, det_J, where=det_J != 0, out=np.zeros_like(det_J))
+ du10, du20 = u1 - u0, u2 - u0
+
+ grad_x = ((y2 - y0) * du10 - (y1 - y0) * du20) * inv_det
+ grad_y = (-(x2 - x0) * du10 + (x1 - x0) * du20) * inv_det
+ grad_per_element = np.column_stack([grad_x, grad_y])
+
+ # ── 3. interior residual density: |k²·ε_r·u_mid + k²·(ε_r-1)·u_inc_mid| ──
+ u_mid = (u0 + u1 + u2) / 3.0
+ x_mid = (x0 + x1 + x2) / 3.0
+ u_inc_mid = np.exp(1j * k * x_mid)
+ r_mid = (k**2) * eps_r * u_mid + (k**2) * (eps_r - 1.0) * u_inc_mid
+ rho_int = np.abs(r_mid)
+
+ # ── 4. gradient jump density: √(mean |[[∇u·n]]|²) per element ──
+ interior_facets_idx = np.where(mesh.f2t[1] != -1)[0]
+ elem_left = mesh.f2t[0, interior_facets_idx]
+ elem_right = mesh.f2t[1, interior_facets_idx]
+
+ edges_p1 = mesh.p[:, mesh.facets[0, interior_facets_idx]].T
+ edges_p2 = mesh.p[:, mesh.facets[1, interior_facets_idx]].T
+ edge_vectors = edges_p2 - edges_p1
+ h_e = np.linalg.norm(edge_vectors, axis=1)
+ n_e = np.c_[edge_vectors[:, 1], -edge_vectors[:, 0]] / (h_e[:, None] + 1e-15)
+
+ grad_left = grad_per_element[elem_left]
+ grad_right = grad_per_element[elem_right]
+ jump_val_sq = np.abs(np.sum((grad_left - grad_right) * n_e, axis=1)) ** 2
+
+ jump_sq_sum = np.zeros(n_elements, dtype=np.float64)
+ jump_count = np.zeros(n_elements, dtype=np.float64)
+ np.add.at(jump_sq_sum, elem_left, jump_val_sq)
+ np.add.at(jump_sq_sum, elem_right, jump_val_sq)
+ np.add.at(jump_count, elem_left, 1)
+ np.add.at(jump_count, elem_right, 1)
+
+ rho_jump = np.zeros(n_elements, dtype=np.float64)
+ mask_jump = jump_count > 0
+ rho_jump[mask_jump] = np.sqrt(jump_sq_sum[mask_jump] / jump_count[mask_jump])
+
+ # ── 5. SBC boundary density: √(mean |∂u/∂n - i·k·u|²) per element ──
+ rho_sbc = np.zeros(n_elements, dtype=np.float64)
+ boundary_facets_idx = np.where(mesh.f2t[1] == -1)[0]
+ if len(boundary_facets_idx) > 0:
+ bnd_elem = mesh.f2t[0, boundary_facets_idx]
+ bnd_p1 = mesh.p[:, mesh.facets[0, boundary_facets_idx]].T
+ bnd_p2 = mesh.p[:, mesh.facets[1, boundary_facets_idx]].T
+ bnd_vectors = bnd_p2 - bnd_p1
+ h_bnd = np.linalg.norm(bnd_vectors, axis=1)
+ n_bnd = np.c_[bnd_vectors[:, 1], -bnd_vectors[:, 0]] / (h_bnd[:, None] + 1e-15)
+
+ grad_bnd = grad_per_element[bnd_elem]
+ du_dn = np.sum(grad_bnd * n_bnd, axis=1)
+
+ if eps_r.ndim == 1:
+ k_local = k * np.sqrt(np.maximum(eps_r[bnd_elem], 1.0))
+ else:
+ k_local = k
+
+ u_edge_mean = (
+ u_h[mesh.facets[0, boundary_facets_idx]]
+ + u_h[mesh.facets[1, boundary_facets_idx]]
+ ) / 2.0
+ sbc_val_sq = np.abs(du_dn - 1j * k_local * u_edge_mean) ** 2
+
+ sbc_sq_sum = np.zeros(n_elements, dtype=np.float64)
+ sbc_count = np.zeros(n_elements, dtype=np.float64)
+ np.add.at(sbc_sq_sum, bnd_elem, sbc_val_sq)
+ np.add.at(sbc_count, bnd_elem, 1)
+
+ mask_sbc = sbc_count > 0
+ rho_sbc[mask_sbc] = np.sqrt(sbc_sq_sum[mask_sbc] / sbc_count[mask_sbc])
+
+ return {
+ "rho_int": rho_int.astype(np.float64),
+ "rho_jump": rho_jump.astype(np.float64),
+ "rho_sbc": rho_sbc.astype(np.float64),
+ }
+
+
+# ── 工厂函数 ──────────────────────────────────────────────────
+
+
+def create_helmholtz_problem(
+ *, fem_config: Dict[Union[str, int], Any], random_state: np.random.RandomState
+) -> HelmholtzProblem:
+ """
+ 创建 Helmholtz 问题实例。
+
+ Args:
+ fem_config: FEM 配置字典
+ random_state: 随机状态
+
+ Returns:
+ HelmholtzProblem 实例
+ """
+ return HelmholtzProblem(fem_config=fem_config, random_state=random_state)
diff --git a/environment/mesh_refinement.py b/environment/mesh_refinement.py
new file mode 100644
index 0000000..2947b33
--- /dev/null
+++ b/environment/mesh_refinement.py
@@ -0,0 +1,1514 @@
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
+import gym
+import numpy as np
+import plotly.graph_objects as go
+import torch
+from plotly.basedatatypes import BaseTraceType
+from skfem import Mesh
+from torch_geometric.data import Data
+
+from .fem_problem import FEMProblemCircularQueue, FEMProblemWrapper
+from .fem_util import (
+ construct_sizing_field_1d,
+ get_aggregation_per_element,
+ get_triangle_areas_from_indices,
+ sample_in_range,
+)
+from .utils import save_concatenate
+from .visualization import get_plotly_mesh_traces_and_layout
+
+class MeshRefinement(gym.Env):
+ """Graph-based 2D mesh refinement RL environment using scikit-FEM backend."""
+
+ def __init__(
+ self, environment_config: Dict[Union[str, int], Any], seed: Optional[int] = None
+ ):
+ """
+ Args:
+ environment_config: Config for the environment.
+ Details can be found in the configs/references/mesh_refinement_reference.yaml example file
+ seed: Optional seed for the random number generator.
+ """
+ self._environment_config = environment_config
+ self._random_state: np.random.RandomState = np.random.RandomState(seed=seed)
+ self._num_node_features: int = 0
+ self._num_edge_features: int = 0
+
+ self.fem_problem_queue = FEMProblemCircularQueue(
+ fem_config=environment_config.get("fem"),
+ random_state=np.random.RandomState(seed=seed),
+ )
+ self.fem_problem: Optional[FEMProblemWrapper] = None
+
+ ################################################
+ # general environment parameters #
+ ################################################
+ self._refinement_strategy: str = environment_config.get("refinement_strategy")
+ self._max_timesteps = environment_config.get("num_timesteps")
+ self._element_limit_penalty = environment_config.get("element_limit_penalty")
+ self._maximum_elements = environment_config.get("maximum_elements")
+ self._element_penalty_config = self._environment_config.get("element_penalty")
+ self._sample_penalty = self._element_penalty_config.get("sample_penalty")
+
+ ################################################
+ # graph connectivity, feature and action space #
+ ################################################
+ self._reward_type = environment_config.get("reward_type")
+ self._global_reward_alpha = float(environment_config.get("global_reward_alpha", 0.2))
+ _rho_w = environment_config.get("rho_weights", {})
+ self._w_rho_int = float(_rho_w.get("w_int", 1.0))
+ self._w_rho_jump = float(_rho_w.get("w_jump", 1.0))
+ self._w_rho_sbc = float(_rho_w.get("w_sbc", 1.0))
+ self._include_vertices = environment_config.get("include_vertices")
+ self._set_graph_sizes()
+
+ ################################################
+ # internal state and cache #
+ ################################################
+ self._timestep: int = 0
+ self._element_penalty_lambda = None # 0 # set default value
+ self._initial_approximation_errors: Optional[Dict[str, float]] = None
+ self._reward = None
+ self._cumulative_return: np.array = 0 # return of the environment
+ # dictionary containing the error estimation for the current solution for different error evaluation metrics
+ self._error_estimation_dict: Optional[Dict[str, np.array]] = None
+ self._initial_error_norm = None
+ self.current_error = None # scalar total error, updated each step
+ self.initial_error = (
+ None # scalar initial total error, fixed as normalization baseline
+ )
+
+ # last-step history for delta-based rewards and plotting
+ self._previous_error_per_element: Optional[np.array] = None
+ self._previous_num_elements: Optional[int] = None
+ self._previous_agent_mapping = None
+ self._previous_element_volumes = None
+ self._previous_std_per_element = None
+ self._previous_eta_components: Optional[Dict[str, np.ndarray]] = None
+ self._previous_rho_components: Optional[Dict[str, np.ndarray]] = None
+
+ # fields/internal variables for spatial mesh refinement, especially a spatial reward
+ self._agent_mapping = None # mapping List[old_element_indices] of size new_element_indices that maps
+ self._reward_per_agent: Optional[np.array] = (
+ 0 # cumulative return of the environment per agent
+ )
+ self._cumulative_reward_per_agent: Optional[np.array] = (
+ 0 # cumulative reward of the environment per agent
+ )
+
+ # additional policy information that is not passed through the graph
+ self._include_additional_policy_information = environment_config.get(
+ "include_additional_policy_information"
+ )
+
+ self._manual_normalization = environment_config.get(
+ "manual_normalization", None
+ ) # manually normalize the error
+ ################################################
+ # recording and plotting #
+ ################################################
+ self._initial_num_elements = None
+
+ def _set_graph_sizes(self):
+ """
+ Internally sets the
+ * action dimension
+ * number of node types and node features for each type
+ * number of edge types and edge features for each type
+ depending on the configuration. Uses the same edge features for all edge types.
+ Returns:
+
+ """
+ edge_feature_config = self._environment_config.get("edge_features")
+ self._edge_features = [
+ feature_name
+ for feature_name, include_feature in edge_feature_config.items()
+ if include_feature
+ ]
+ # set number of edge features
+ num_edge_features = 0
+ if "euclidean_distance" in self._edge_features:
+ num_edge_features += 1
+
+ self._element_feature_functions = self._register_element_features()
+
+ self._num_node_features = len(self._element_feature_functions)
+ self._num_node_features += self.fem_problem_queue.num_pde_element_features
+ self._num_edge_features = num_edge_features
+
+ def _register_element_features(self) -> Dict[str, Callable[[], np.array]]:
+ cfg = self._environment_config.get("element_features")
+ names = [n for n, inc in cfg.items() if inc]
+ feats = {}
+
+ if "x_position" in names:
+ feats["x_position"] = lambda: self._element_midpoints[:, 0]
+ if "y_position" in names:
+ feats["y_position"] = lambda: self._element_midpoints[:, 1]
+ if "volume" in names:
+ feats["volume"] = lambda: self._volume_normalized
+ if "solution_std" in names:
+ feats["internal_residual"] = lambda: self._residual_components["internal_residual"]
+ feats["gradient_jump"] = lambda: self._residual_components["gradient_jump"]
+ feats["sbc_residual"] = lambda: self._residual_components["sbc_residual"]
+ if "element_penalty" in names:
+ feats["element_penalty"] = lambda: np.repeat(self._element_penalty_lambda, self._num_elements)
+ if "timestep" in names:
+ feats["timestep"] = lambda: np.repeat(self._timestep, self._num_elements)
+ if "wave_number" in names:
+ feats["wave_number"] = lambda: np.repeat(self._wave_number, self._num_elements)
+ if "k_local_sqrt_vol" in names:
+ feats["k_local_sqrt_vol"] = lambda: self._k_local_sqrt_vol
+ if "is_sbc_boundary" in names:
+ feats["is_sbc_boundary"] = lambda: self._residual_components["is_sbc_boundary"]
+ if "dist_to_interface" in names:
+ feats["dist_to_interface"] = lambda: self._dist_to_interface
+
+ # Complex field decomposition (always present for Helmholtz)
+ feats["epsilon_r"] = lambda: self._epsilon_r_elements
+ feats["total_solution_magnitude"] = lambda: np.abs(self._complex_solution_mean)
+ return feats
+
+ def reset(self) -> Data:
+ """
+ Resets the environment and returns an (initial) observation of the next rollout
+ according to the reset environment state
+
+ Returns:
+ The observation of the initial state.
+ """
+ # get the next fem problem. This samples a new domain and new load function, resets the mesh and the solution.
+ self.fem_problem = self.fem_problem_queue.next()
+
+ # calculate the solution of the finite element problem for the initial mesh and retrieve an error per element
+ self._error_estimation_dict = (
+ self.fem_problem.calculate_solution_and_get_error()
+ )
+
+ # reset the internal state of the environment. This includes the current timestep, the current element penalty
+ # and some values for calculating the reward and plotting the env
+ self._reset_internal_state()
+
+ observation = self.last_observation
+ return observation
+
+ def _reset_internal_state(self):
+ """
+ Resets the internal state of the environment
+ Returns:
+
+ """
+ self._agent_mapping = np.arange(self._num_elements).astype(
+ np.int64
+ ) # map to identity at first step
+ self._previous_agent_mapping = np.arange(self._num_elements).astype(
+ np.int64
+ ) # map to identity at first step
+ self._previous_element_volumes = self.element_volumes
+ self._previous_eta_indicator = self._eta_indicator
+ self._previous_eta_components = self._eta_components_raw
+ self._previous_rho_components = self._rho_components
+ self._previous_solution_l2_norm = self._compute_solution_l2_norm()
+ self._reward_per_agent = np.zeros(self.num_agents)
+ self._cumulative_reward_per_agent = np.zeros(self._num_elements)
+
+ # reset timestep and rewards
+ self._timestep = 0
+ self._reward = 0
+ self._cumulative_return = 0
+ self._diag_selected_count = -1 # 防止跨 episode 残留触发 is_terminal
+
+ # reset internal state that tracks statistics over the episode
+ self._previous_error_per_element = self.error_per_element
+
+ # collect a dictionary of initial errors to normalize them when calculating metrics during evaluation
+ self._initial_approximation_errors = (
+ self._calculate_initial_approximation_errors()
+ )
+
+ self._previous_num_elements = self._num_elements
+ self._initial_num_elements = self._num_elements
+
+ self._initial_median_area = float(np.median(self.element_volumes))
+
+ k = self._wave_number
+ eps_r_elem = self._epsilon_r_elements
+ lambda_local = 2.0 * np.pi / (k * np.sqrt(np.maximum(eps_r_elem, 1.0)))
+ A_budget = 0.5 * (lambda_local / 6.0) ** 2
+ self._element_budget_area = A_budget
+ N_phys = int(np.ceil(np.sum(self.element_volumes / A_budget)))
+ rho_min = 5.0
+ self._n_budget = max(N_phys, int(np.ceil(rho_min * self._num_elements)))
+
+ if self.error_per_element is not None:
+ self._initial_error_norm = np.linalg.norm(self.error_per_element, axis=0)
+ # Record initial total error as normalization baseline for reward calculation
+ self.current_error = self._compute_total_error()
+ self.initial_error = self.current_error
+ # Protection against near-zero initial error (prevents division by zero)
+ if self.initial_error < 1e-8:
+ self.initial_error = 1.0
+
+ # reset the element penalty, necessary if it is sampled
+ if self._sample_penalty:
+ sampling_type = self._element_penalty_config.get(
+ "sampling_type", "loguniform"
+ )
+ min_value = self._element_penalty_config.get("min")
+ max_value = self._element_penalty_config.get("max")
+ element_penalty_lambda = sample_in_range(
+ max_value, min_value, sampling_type
+ )
+ self._element_penalty_lambda = element_penalty_lambda
+ else: # element penalty is a scalar value
+ self._element_penalty_lambda = self._element_penalty_config.get("value")
+
+ def _calculate_initial_approximation_errors(self):
+ if self._manual_normalization:
+ return {
+ error_name: self._manual_normalization
+ for error_name in self.error_estimation_dict
+ }
+ else:
+ result = {}
+ for error_name, errors in self.error_estimation_dict.items():
+ errors = np.atleast_1d(np.asarray(errors, dtype=np.float64))
+ val = np.sqrt(np.sum(errors ** 2))
+ result[error_name] = float(val) + 1e-12
+ return result
+
+ def step(self, action: np.ndarray) -> Tuple[Data, np.array, bool, Dict[str, Any]]:
+ """
+ Performs a step of the Mesh Refinement task.
+
+ Wrapped in try-except to prevent program crashes from ill-conditioned
+ FEM solves caused by degenerate meshes (especially in early training
+ when the continuous sizing field produces extreme element shapes).
+
+ On FEM failure: returns done=True with an extreme penalty reward (-10000)
+ to implicitly teach the agent to avoid generating invalid meshes.
+
+ Args:
+ action: the action the agents will take in this step. Has shape (num_agents, action_dimension)
+ Given as an array of shape (num_agents, action_dimension)
+
+ Returns: A 4-tuple (observations, reward, done, info), where
+ * observations is a graph of the agents and their positions, in this case of the refined mesh
+ * reward is a single scalar shared between all agents, i.e., per **graph**
+ * done is a boolean flag that says whether the current rollout is finished or not
+ * info is a dictionary containing additional information
+ """
+ assert not self.is_terminal, (
+ f"Tried to perform a step on a terminated environment. Currently on step "
+ f"{self._timestep:} of {self._max_timesteps:} "
+ f"with {self._num_elements}/{self._maximum_elements} elements."
+ )
+
+ self._timestep += 1
+
+ # ================================================================
+ # 核心逻辑: try-except 物理防崩盘机制
+ # 捕获 FEM 求解器因畸形网格抛出的任何异常
+ # ================================================================
+ try:
+ self._set_previous_step()
+
+ # refine mesh and store which element has become which set of new elements
+ self._agent_mapping = self._refine_mesh(action=action)
+
+ # solve equation and calculate error per element/element
+ self._previous_error_per_element = self.error_per_element
+
+ self._error_estimation_dict = (
+ self.fem_problem.calculate_solution_and_get_error()
+ )
+
+ # query returns
+ observation = self.last_observation
+
+ reward_dict = self._get_reward_dict()
+ metric_dict = self._get_metric_dict()
+ action_dict = self._get_action_dict(action=action)
+
+ # done after a given number of steps or if the mesh becomes too large
+ done = self.is_terminal
+ info = (
+ reward_dict
+ | metric_dict
+ | action_dict
+ | {
+ "is_truncated": self.is_truncated,
+ "return": self._cumulative_return,
+ "neg_action_ratio": getattr(self, "_diag_neg_ratio", 0.0),
+ "eligible_ratio": getattr(self, "_diag_eligible_ratio", 0.0),
+ "masked_ratio": getattr(self, "_diag_masked_ratio", 0.0),
+ "selected_count": getattr(self, "_diag_selected_count", 0),
+ "n_budget": self._n_budget,
+ }
+ )
+ return observation, self._reward, done, info
+
+ # except (np.linalg.LinAlgError, ValueError, RuntimeError, Exception) as e:
+ except (np.linalg.LinAlgError, ValueError, RuntimeError) as e:
+ # ============================================================
+ # FEM 物理崩溃捕获
+ # 可能原因:
+ # 1. 畸形网格导致刚度矩阵奇异 (LinAlgError)
+ # 2. 连续动作产生了退化元素 (ValueError)
+ # 3. scikit-fem 内部网格操作异常 (RuntimeError)
+ #
+ # 策略: 立即终止本回合,给予极端惩罚,迫使智能体学习
+ # 避免产生会导致 FEM 崩溃的网格。
+ # ============================================================
+ import sys
+
+ if not hasattr(self, "_crash_print_count"):
+ self._crash_print_count = 0
+ if self._crash_print_count < 5:
+ print(
+ f"[FEM Crash] step={self._timestep}, "
+ f"elements_before={self._previous_num_elements if self._previous_num_elements is not None else '?'}, "
+ f"type={type(e).__name__}: {str(e)[:300]}",
+ file=sys.stderr,
+ flush=True,
+ )
+ self._crash_print_count += 1
+ elif self._crash_print_count == 5:
+ print(
+ f"[FEM Crash] ... suppressing further crash prints ...",
+ file=sys.stderr,
+ flush=True,
+ )
+ self._crash_print_count += 1
+ crash_penalty = -10000.0
+ # 使用细化前的元素数,确保 reward 尺寸与 policy 输出的 values 一致
+ # self._previous_num_elements 已在 _set_previous_step() 中保存
+ num_agents = (
+ self._previous_num_elements
+ if self._previous_num_elements is not None
+ else (self.num_agents if self.num_agents > 0 else 1)
+ )
+ self._reward = np.full(num_agents, crash_penalty, dtype=np.float32)
+ self._cumulative_return = self._cumulative_return + np.sum(self._reward)
+ # 确保 agent_mapping 与 reward/values 维度一致
+ self._agent_mapping = np.arange(num_agents, dtype=np.int64)
+ # _num_elements is a property, cannot be set directly
+
+ # 返回当前观测 (如果可用) 或空图
+ try:
+ observation = self.last_observation
+ except Exception:
+ # 创建一个最小空图作为 fallback
+ observation = Data(
+ x=torch.zeros(
+ (num_agents, self.num_node_features), dtype=torch.float32
+ ),
+ edge_index=torch.zeros((2, 0), dtype=torch.long),
+ edge_attr=torch.zeros(
+ (0, self.num_edge_features), dtype=torch.float32
+ ),
+ )
+
+ info = {
+ "is_truncated": False,
+ "return": float(np.sum(self._reward)),
+ "weighted_remaining_error": float("inf"),
+ "num_elements": self._num_elements
+ if self.fem_problem is not None
+ else 0,
+ "num_agents": num_agents,
+ "fem_crash": True,
+ "crash_reason": str(e)[:200], # 截断以防日志过长
+ }
+ self._timestep = self._max_timesteps # 强制终止
+ return observation, self._reward, True, info
+
+ def inference_step(
+ self, action: np.ndarray
+ ) -> Tuple[Data, float, bool, Dict[str, Any]]:
+ """
+ Performs a step of the Mesh Refinement task *without* calculating the reward or difference to the fine-grained
+ reference. This is used for inference
+
+ Args:
+ action: the action the agents will take in this step. Has shape (num_agents, action_dimension)
+ Given as an array of shape (num_agents, action_dimension)
+
+ Returns: A 4-tuple (observations, reward, done, info), where
+ * observations is a graph of the agents and their positions, in this case of the refined mesh
+ * reward is a single scalar shared between all agents, i.e., per **graph**
+ * done is a boolean flag that says whether the current rollout is finished or not
+ * info is a dictionary containing additional information
+ """
+ assert not self.is_terminal, (
+ f"Tried to perform a step on a terminated environment. Currently on step "
+ f"{self._timestep:} of {self._max_timesteps:} "
+ f"with {self._num_elements}/{self._maximum_elements} elements."
+ )
+
+ self._timestep += 1
+ self._agent_mapping = self._refine_mesh(action=action)
+ # solve equation
+ self.fem_problem.calculate_solution()
+ observation = self.last_observation
+ done = self.is_terminal
+ info = {}
+ return observation, self._reward, done, info
+
+ def _set_previous_step(self):
+ """
+ Sets variables for the previous timestep. These are used for the reward function, as well as for different
+ kinds of plots and metrics
+ """
+ self._previous_num_elements = self._num_elements
+ self._previous_agent_mapping = self._agent_mapping
+ self._previous_element_volumes = self.element_volumes
+ self._previous_eta_indicator = self._eta_indicator
+ self._previous_eta_components = self._eta_components_raw
+ self._previous_rho_components = self._rho_components
+ self._previous_solution_l2_norm = self._compute_solution_l2_norm()
+
+ def _compute_solution_l2_norm(self) -> float:
+ """Approximate ||u_h||_{L2(Ω)} via element centroids: sqrt(Σ_K |ū_K|² · area_K)."""
+ u_scat = self.fem_problem.nodal_solution # complex (n_vertices,)
+ elem_idx = self._element_indices # (n_elements, 3)
+ vols = self.element_volumes # (n_elements,)
+ u_elem = u_scat[elem_idx] # (n_elements, 3)
+ u_elem_mean = np.mean(u_elem, axis=1) # (n_elements,) complex mean
+ u_mag = np.abs(u_elem_mean)
+ return float(np.sqrt(np.sum(u_mag ** 2 * vols)))
+
+ def _refine_mesh(self, action: np.array) -> np.array:
+ """
+ Refines fem_problem.mesh by splitting all faces/elements for which the average of agent activation surpasses a
+ threshold.
+ If this refinement exceeds the maximum number of nodes allowed in the environment, we return a boolean flag
+ that indicates so and stops the environment
+
+ Optionally smoothens the newly created mesh as a post-processing step
+
+ Args:
+ action: An action/activation per element.
+ - continuous_sizing_field: shape (num_agents, 1) or (num_agents,) → 目标网格面积
+ - absolute/absolute_discrete: shape (num_agents,) or (num_agents, 1) → scalar threshold
+ Returns: An array of mapped element indices
+
+ """
+ # 标量动作统一 flatten 到 1D
+ action = action.flatten()
+
+ elements_to_refine = self._get_elements_to_refine(action)
+
+ # updates self.fem_problem.mesh
+ element_mapping = self.fem_problem.refine_mesh(elements_to_refine)
+ return element_mapping
+
+ def _get_elements_to_refine(self, action: np.array) -> np.array:
+ """
+ Calculate which elements to refine based on the action, refinement strategy and the
+ maximum number of elements allowed in the environment
+ Args:
+ action: An action/activation per agent, i.e., per element. 1D array of shape (num_agents,).
+ - continuous_sizing_field: 每个 agent 输出 1 个标量 → Softplus → 期望最大单元面积
+ - absolute/absolute_discrete: scalar threshold
+
+ Returns: An array of ids corresponding to elements_to_refine
+
+ """
+ # select elements to refine based on the average actions of its surrounding agents/nodes
+
+ if self._refinement_strategy == "continuous_sizing_field":
+ # ================================================================
+ # Score-based 细化选择(由 actor 直接排序,物理预算约束)
+ #
+ # Actor 输出标量 x_i: x_i < 0 → 希望细化; x_i > 0 → 不希望细化
+ # 排序依据 score = -x_i,在预算和上限内选 top-k
+ #
+ # 物理预算 N_budget: Σ area_K / A_budget,其中
+ # A_budget = ½(λ_local/6)²,对应每局部波长方向 ~6 个尺度点
+ #
+ # 动作掩码 (Dörfler-P95): η_K < 0.05·η_P95 的单元移出候选池,
+ # P95 锚定物理误差尺度,免疫远场噪声稀释,强制预算投入误差主导区
+ # ================================================================
+ x = action.flatten()
+
+ # ── 训练监控指标(在所有 early return 之前计算)──
+ self._diag_neg_ratio = float(np.mean(x < 0.0))
+
+ remaining = self._n_budget - self._num_elements
+ max_parents_by_budget = max(0, remaining // 3)
+
+ if max_parents_by_budget <= 0:
+ self._diag_eligible_ratio = 0.0
+ self._diag_selected_count = 0
+ return np.array([], dtype=np.int64)
+
+ # 动态计算每单元预算面积(仅用于 N_budget 全局资源上限)
+ eps_r_elem = self._epsilon_r_elements
+ k = self._wave_number
+ lambda_local = 2.0 * np.pi / (k * np.sqrt(np.maximum(eps_r_elem, 1.0)))
+ A_budget = 0.5 * (lambda_local / 6.0) ** 2
+
+ # 纯数值安全底线:仅防止 scikit-fem 因浮点精度导致的退化/奇异。
+ # 不再用 0.25*A_budget —— RL 应自主学会"多细才够",
+ # 而非被人为启发式 (12 点/波长) 限制。
+ domain_area = float(np.prod(self.fem_problem.plot_boundary[2:] - self.fem_problem.plot_boundary[:2]))
+ V_min_safeguard = 1e-10 * domain_area
+
+ # Filter 1: numerical safeguard only — no physics heuristic
+ area_eligible = np.where(self.element_volumes > V_min_safeguard)[0]
+
+ # Filter 2: Dörfler-style action mask — exclude elements below 5% of η_P95
+ # P95 anchors the threshold to physically meaningful error scale,
+ # immune to far-field noise dilution (unlike median or mean).
+ # η_K < 0.05·η_P95 → not worth the refinement budget.
+ eta_current = self._eta_indicator
+ eta_p95 = np.percentile(eta_current, 95)
+ error_eligible = np.where(eta_current >= 0.05 * eta_p95)[0]
+
+ eligible = np.intersect1d(area_eligible, error_eligible)
+
+ self._diag_eligible_ratio = float(len(eligible)) / max(self._num_elements, 1)
+ self._diag_masked_ratio = (
+ 1.0 - float(len(eligible)) / max(len(area_eligible), 1)
+ if len(area_eligible) > 0 else 0.0
+ )
+
+ num = min(
+ len(eligible),
+ max(1, self._num_elements // 4),
+ max_parents_by_budget,
+ )
+
+ if num <= 0:
+ self._diag_selected_count = 0
+ return np.array([], dtype=np.int64)
+
+ # x 越小 ⇒ 优先级越高(纯排序,不设正负门槛)
+ score = -x
+ selected = eligible[np.argsort(score[eligible])[-num:]]
+ self._diag_selected_count = len(selected)
+ elements_to_refine = selected
+
+ elif self._refinement_strategy in ["absolute", "absolute_discrete"]:
+ elements_to_refine = np.argwhere(action > 0.0).flatten()
+ else:
+ raise ValueError(
+ f"Unknown refinement strategy '{self._refinement_strategy}"
+ )
+ return elements_to_refine
+
+ def render(self, mode: str = "human", render_intermediate_steps: bool = False, *args, **kwargs):
+ if not (render_intermediate_steps or self.is_terminal):
+ return [], {}
+ remaining_error = self._get_remaining_error(return_dimensions=False)
+ title = (
+ f"Solution. Element Penalty: {self._element_penalty_lambda:.1e} "
+ f"Reward: {np.sum(self._reward):.3f} Return: {np.sum(self._cumulative_return):.3f} "
+ f"Agents: {self.num_agents} Remaining Error: {remaining_error:.3f}"
+ )
+ traces, layout = get_plotly_mesh_traces_and_layout(
+ mesh=self.mesh, scalars=np.real(self.scalar_solution),
+ mesh_dimension=2, title=title, boundary=self.fem_problem.plot_boundary,
+ )
+ _fp = self.fem_problem.fem_problem
+ cx = getattr(_fp, "_cx", 0.5)
+ cy = getattr(_fp, "_cy", 0.5)
+ r = getattr(_fp, "_radius", 0.2)
+ traces.append(go.Scatter3d(
+ x=cx + r * np.cos(np.linspace(0, 2 * np.pi, 128)),
+ y=cy + r * np.sin(np.linspace(0, 2 * np.pi, 128)),
+ z=np.zeros(128), mode="lines",
+ line=dict(color="cyan", width=2, dash="dash"),
+ name="Dielectric", showlegend=True,
+ ))
+ return traces, {"layout": layout}
+
+ def _get_remaining_error(
+ self, return_dimensions: bool = False
+ ) -> Union[np.array, Tuple]:
+ """
+ Get the remaining error by aggregating over all elements and taking the convex sum of all solution dimensions
+ """
+ remaining_error_per_dimension = np.sqrt(
+ np.sum(self.error_per_element**2, axis=0)
+ )
+
+ # Collapse per-element/per-dim initial error to scalar if needed
+ norm = np.atleast_1d(np.asarray(self.initial_approximation_error, dtype=np.float64))
+ if remaining_error_per_dimension.ndim < norm.ndim or (
+ remaining_error_per_dimension.ndim == norm.ndim
+ and remaining_error_per_dimension.shape != norm.shape
+ ):
+ norm = np.sqrt(np.sum(norm**2))
+
+ remaining_error_per_dimension = (
+ remaining_error_per_dimension / norm
+ ) # normalize
+ remaining_error = self.project_to_scalar(remaining_error_per_dimension)
+ # Ensure scalar output (defensive against (1,) or (Ne,) arrays from 1D PDEs)
+ remaining_error = float(np.asarray(remaining_error).ravel()[0])
+
+ if return_dimensions:
+ return remaining_error, remaining_error_per_dimension
+ else:
+ return remaining_error
+
+ def _compute_total_error(self, error_per_element: np.ndarray = None) -> float:
+ """
+ Compute a scalar total error from a per-element error array.
+ Uses the same aggregation (sum or max) as _get_remaining_error,
+ but without normalization by the initial approximation error.
+
+ Args:
+ error_per_element: Per-element error array of shape (num_elements, solution_dimension).
+ If None, uses the current error_per_element.
+
+ Returns: A scalar total error.
+ """
+ if error_per_element is None:
+ error_per_element = self.error_per_element
+ error_per_dim = np.sqrt(np.sum(error_per_element**2, axis=0))
+ return float(self.project_to_scalar(error_per_dim))
+
+ @property
+ def last_observation(self) -> Data:
+ """
+ Retrieve an observation graph for the current state of the environment.
+
+ We use an additional self.last_observation wrapper to make sure that classes that inherit from this
+ one have access to node and edge features outside the Data() structure
+ Returns: A Data() object of the graph that describes the current state of this environment
+
+ """
+ graph_dict = {}
+ graph_dict = graph_dict | self._get_graph_nodes()
+ graph_dict = graph_dict | self._get_graph_edges()
+
+ observation_graph = Data(**graph_dict)
+
+ return observation_graph
+
+ def _get_graph_nodes(self) -> Dict[str, Dict[str, torch.Tensor]]:
+ """
+ Returns a dictionary of node features that are used to describe the current state of this environment.
+
+ Returns: A dictionary of node features. This dictionary has the format
+ {"x": element_features}
+ where element and node features depend on the context, but include things like the evaluation of the target
+ function, the degree of the node, etc.
+ """
+ # Builds feature matrix of shape (#elements, #features)
+ # by iterating over the functions in self._element_feature_functions.
+ element_features = np.array(
+ [fn() for key, fn in self._element_feature_functions.items()]
+ ).T
+ element_features = save_concatenate(
+ [element_features, self.fem_problem.element_features()], axis=1
+ )
+ element_features = torch.tensor(element_features, dtype=torch.float32)
+ node_dict = {"x": element_features}
+ return node_dict
+
+ def _get_graph_edges(
+ self,
+ ) -> Dict[Union[str, Tuple[str, str, str]], Dict[str, torch.Tensor]]:
+ """
+ Returns a dictionary of edge features that are used to describe the current state of this environment.
+ Note that we always use symmetric graphs and self edges.
+
+ Returns: A dictionary of edge features. This dictionary has the format
+ {
+ "edge_index": indices,
+ "edge_attr": features
+ }
+ """
+ edge_index, edge_attr = self._element2element_features(self._num_edge_features)
+ edge_dict = {"edge_index": edge_index, "edge_attr": edge_attr}
+ return edge_dict
+
+ def _element2element_features(self, num_edge_features: int):
+ # concatenate incoming, outgoing and self edges of each node to get an undirected graph
+ src_nodes = np.concatenate(
+ (
+ self._element_neighbors[0],
+ self._element_neighbors[1],
+ np.arange(self._num_elements),
+ ),
+ axis=0,
+ )
+ dest_nodes = np.concatenate(
+ (
+ self._element_neighbors[1],
+ self._element_neighbors[0],
+ np.arange(self._num_elements),
+ ),
+ axis=0,
+ )
+ num_edges = self._element_neighbors.shape[1] * 2 + self._num_elements
+ edge_features = np.empty(shape=(num_edges, num_edge_features))
+ edge_feature_position = 0
+ if "euclidean_distance" in self._edge_features:
+ euclidean_distances = np.linalg.norm(
+ self._element_midpoints[dest_nodes]
+ - self._element_midpoints[src_nodes],
+ axis=1,
+ )
+ lam = 2.0 * np.pi / self._wave_number
+ edge_features[:, edge_feature_position] = euclidean_distances / lam
+ edge_feature_position += 1
+ edge_index = torch.tensor(np.vstack((src_nodes, dest_nodes))).long()
+ edge_attr = torch.tensor(edge_features, dtype=torch.float32)
+ return edge_index, edge_attr
+
+ def _get_reward_dict(self) -> Dict[str, np.float32]:
+ """
+ Calculate the reward for the current timestep depending on the environment states and the action
+ the agents took.
+ Args:
+
+ Returns:
+ Dictionary that must contain "reward" as well as partial reward data
+
+ """
+ reward, reward_dict = self._get_reward_by_type()
+
+ self._reward = reward
+ self._cumulative_return = self._cumulative_return + np.sum(self._reward)
+
+ return reward_dict
+
+ def _get_metric_dict(self) -> Dict[str, Any]:
+ remaining_error, remaining_error_per_dimension = self._get_remaining_error(
+ return_dimensions=True
+ )
+
+ metric_dict = {
+ "weighted_remaining_error": remaining_error,
+ "error_times_agents": remaining_error * self.num_agents,
+ "delta_elements": self._num_elements - self._previous_num_elements,
+ "avg_total_refinements": np.log(
+ self._num_elements / self._initial_num_elements
+ )
+ / np.log(4),
+ "avg_step_refinements": np.log(
+ self._num_elements / self._previous_num_elements
+ )
+ / np.log(4),
+ "num_elements": self._num_elements,
+ "num_agents": self.num_agents,
+ "reached_element_limits": self.reached_element_limits,
+ "refinement_std": self._refinements_per_element.std(),
+ }
+
+ for error_metric, element_errors in self.error_estimation_dict.items():
+ if element_errors.ndim >= 1 and element_errors.shape[0] == self._num_elements:
+ error_per_dimension = np.max(element_errors, axis=0)
+ else:
+ error_per_dimension = element_errors
+ error_per_dimension = (
+ error_per_dimension / self._initial_approximation_errors[error_metric]
+ )
+ remaining_error = self.project_to_scalar(error_per_dimension)
+ metric_dict[f"{error_metric}_error"] = remaining_error
+ return metric_dict
+
+ def _get_action_dict(self, action: np.ndarray) -> Dict[str, Any]:
+ """
+ Returns a dictionary of information about the action that was taken in the current timestep
+ Args:
+ action: The action that was taken in the current timestep
+
+ Returns: A dictionary of information about the action that was taken in the current timestep
+
+ """
+ action_dict = {}
+ if self._refinement_strategy in ["absolute", "absolute_discrete"]:
+ action_dict["action_mean"] = np.mean(action)
+ action_dict["action_std"] = np.std(action)
+ return action_dict
+
+ def _get_reward_by_type(self) -> Tuple[np.array, Dict]:
+ """
+ Potential-based reward shaping on η indicator.
+
+ spatial_max — Per-agent reward (parent i → children C(i)):
+ r_local_i = log(η_old_i + ε_dynamic) − log(max_{j∈C(i)} η_new_j + ε_dynamic)
+ − λ·(|C(i)| − 1)
+
+ spatial — Per-agent reward (parent i → children C(i)):
+ r_local_i = log(η_old_i + ε_dynamic) − log(√(Σ_{j∈C(i)} η_new_j²) + ε_dynamic)
+ − λ·(|C(i)| − 1)
+
+ ε_dynamic = max(0.01 × η_P95, 1e-6) — anchored to P95 of residual,
+ immune to far-field dilution; prevents reward hacking on near-zero-η elements.
+
+ Potential function: Φ(s) = −log(E_global)
+ E_global = √(Σ_K η_K²) / ||u_h||_{L2(Ω)} (dimensionless)
+ Shaped reward: r_i = r_local_i + α · (log E_old − log E_new)
+ """
+
+ reward_dict = {}
+ # Dynamic epsilon anchored to P95 of η — immune to far-field dilution
+ # that plagues mean-based approaches. P95 is driven by physically
+ # meaningful error in the dielectric, not background noise.
+ # ε_dynamic = max(0.01 × η_P95, 1e-6)
+ eta_current_raw = self._eta_indicator
+ eta_p95 = float(np.percentile(eta_current_raw, 95))
+ eps = max(0.01 * eta_p95, 1e-6)
+
+ old_eta = self._previous_eta_indicator + eps
+ new_eta = eta_current_raw + eps
+
+ if self._reward_type == "spatial_max":
+ from torch_scatter import scatter_max
+
+ agent_mapping = torch.tensor(self.agent_mapping)
+ child_eta = torch.tensor(new_eta)
+ max_child_eta, _ = scatter_max(
+ src=child_eta,
+ index=agent_mapping,
+ dim=0,
+ dim_size=old_eta.shape[0],
+ )
+ max_child_eta = max_child_eta.numpy()
+ reward_per_agent_and_dim = np.log(old_eta) - np.log(max_child_eta)
+
+ elif self._reward_type == "spatial":
+ from torch_scatter import scatter_add
+
+ agent_mapping = torch.tensor(self.agent_mapping)
+ # L₂ aggregation: √(Σ η_child²) — never punishes refinement
+ child_eta = torch.tensor(new_eta)
+ sum_sq_child_eta = scatter_add(
+ src=child_eta * child_eta,
+ index=agent_mapping,
+ dim=0,
+ dim_size=old_eta.shape[0],
+ )
+ l2_child_eta = np.sqrt(sum_sq_child_eta.numpy()) + eps
+ reward_per_agent_and_dim = np.log(old_eta) - np.log(l2_child_eta)
+
+ else:
+ raise ValueError(f"Unknown reward type {self._reward_type}")
+
+ reward_per_agent = self.project_to_scalar(reward_per_agent_and_dim)
+
+ # apply action/element penalty
+ unique_old, counts = np.unique(self.agent_mapping, return_counts=True)
+ element_penalty = np.zeros(len(reward_per_agent), dtype=reward_per_agent.dtype)
+ element_penalty[unique_old] = self._element_penalty_lambda * (counts - 1)
+ element_limit_penalty = (
+ (self._element_limit_penalty / self._previous_num_elements)
+ if self.reached_element_limits
+ else 0
+ )
+ reward_per_agent = (
+ reward_per_agent - element_penalty - element_limit_penalty
+ )
+
+ # ── Potential-based shaping: only refined parents get the global bonus ──
+ l2_old = self._previous_solution_l2_norm
+ l2_new = self._compute_solution_l2_norm()
+ eta_l2_old = float(np.sqrt(np.sum(old_eta ** 2)))
+ eta_l2_new = float(np.sqrt(np.sum(new_eta ** 2)))
+ eps_l2 = 1e-12
+ E_old = eta_l2_old / max(l2_old, eps_l2)
+ E_new = eta_l2_new / max(l2_new, eps_l2)
+ global_bonus = self._global_reward_alpha * float(np.log(E_old + eps_l2) - np.log(E_new + eps_l2))
+ refined_parents = unique_old[counts > 1]
+ reward_per_agent[refined_parents] += global_bonus
+
+ self._reward_per_agent = reward_per_agent
+ self._cumulative_reward_per_agent = (
+ self._cumulative_reward_per_agent[self._previous_agent_mapping]
+ + reward_per_agent
+ )
+ reward = reward_per_agent
+
+ reward_dict["reward"] = reward
+ reward_dict["potential_bonus"] = global_bonus
+ reward_dict["penalty"] = -reward
+ reward_dict["element_limit_penalty"] = element_limit_penalty
+ reward_dict["element_penalty"] = element_penalty
+ reward_dict["element_penalty_lambda"] = self._element_penalty_lambda
+ return reward, reward_dict
+
+ @property
+ def mesh(self) -> Mesh:
+ """
+ Returns the current mesh.
+ """
+ return self.fem_problem.mesh
+
+ @property
+ def agent_node_type(self) -> str:
+ return "element"
+
+ @property
+ def _vertex_positions(self) -> np.array:
+ """
+ Returns the positions of all vertices/nodes of the mesh.
+ Returns: np.array of shape (num_vertices, 2)
+ """
+ return self.fem_problem.vertex_positions
+
+ @property
+ def _element_indices(self) -> np.array:
+ return self.fem_problem.element_indices
+
+ @property
+ def _element_midpoints(self) -> np.array:
+ """
+ Returns the midpoints of all elements/faces.
+ Returns: np.array of shape (num_elements, 2)
+
+ """
+ return self.fem_problem.element_midpoints
+
+ @property
+ def _mesh_edges(self) -> np.array:
+ """
+ Returns: the edges of all vertices/nodes of the mesh. Shape (2, num_edges)
+ """
+ return self.fem_problem.mesh_edges
+
+ @property
+ def _element_neighbors(self) -> np.array:
+ """
+ Find neighbors of each element. Shape (2, num_neighbors)
+ Returns:
+
+ """
+ # f2t are element/face neighborhoods, which are set to -1 for boundaries
+ return self.fem_problem.element_neighbors
+
+ @property
+ def _num_elements(self) -> int:
+ return len(self._element_indices)
+
+ @property
+ def _num_vertices(self) -> int:
+ return len(self._vertex_positions)
+
+ @property
+ def element_volumes(self) -> np.array:
+ return get_triangle_areas_from_indices(
+ positions=self._vertex_positions, triangle_indices=self._element_indices
+ )
+
+ @property
+ def num_node_features(self) -> int:
+ return self._num_node_features
+
+ @property
+ def num_edge_features(self) -> int:
+ return self._num_edge_features
+
+ @property
+ def action_dimension(self) -> int:
+ """
+ Returns: The dimensionality of the action space.
+
+ - continuous_sizing_field: 1D continuous output → 目标网格面积 (Softplus 激活)
+ - absolute_discrete: 2 discrete actions (refine / don't refine)
+ - others: single continuous scalar
+ """
+ if self._refinement_strategy == "continuous_sizing_field":
+ return 1 # 1D 连续标量 → Softplus → 目标面积 S_i
+ elif self._refinement_strategy == "absolute_discrete":
+ return 2
+ else: # single continuous value
+ return 1
+
+ @property
+ def num_agents(self) -> int:
+ if self.fem_problem is not None and self.fem_problem.mesh is not None:
+ return self._num_elements
+ else:
+ return 1 # placeholder
+
+ @property
+ def _action_space(self) -> gym.Space:
+ """
+
+ Returns: The **current** action space of the environment. Bound to change, since the number of agents
+ changes
+
+ """
+ if self._refinement_strategy in ["absolute_discrete", "argmax", "single_agent"]:
+ return gym.spaces.MultiDiscrete([self.action_dimension] * self.num_agents)
+ elif self._refinement_strategy == "continuous_sizing_field":
+ # 连续 1D 输出: 每个 agent 输出 1 个标量 → Softplus → 目标网格面积
+ # 无界连续空间,PPO Gaussian policy 负责探索
+ return gym.spaces.Box(
+ low=-1e5,
+ high=1e5,
+ shape=(self.num_agents, self.action_dimension),
+ dtype=np.float32,
+ )
+ else:
+ return gym.spaces.Box(
+ low=-1e5,
+ high=1e5,
+ shape=(
+ self.num_agents,
+ self.action_dimension,
+ ),
+ dtype=np.float32,
+ )
+
+ @property
+ def agent_mapping(self) -> np.array:
+ assert self._agent_mapping is not None, "Element mapping not initialized"
+ return self._agent_mapping
+
+ @property
+ def previous_agent_mapping(self) -> np.array:
+ assert self._previous_agent_mapping is not None, (
+ "Previous element mapping not initialized"
+ )
+ return self._previous_agent_mapping
+
+ @property
+ def reached_element_limits(self) -> bool:
+ """
+ True if the number of elements/faces in the mesh is above the maximum allowed value.
+ Returns:
+
+ """
+ return self._num_elements > self._maximum_elements
+
+ @property
+ def is_truncated(self) -> bool:
+ return self._timestep >= self._max_timesteps
+
+ @property
+ def is_terminal(self) -> bool:
+ # Agent selected nothing to refine — budget exhausted or
+ # Doerfler mask filtered everything. Episode converged naturally.
+ # -1 = not yet evaluated (reset state), 0 = nothing selected this step.
+ sc = getattr(self, "_diag_selected_count", -1)
+ if sc == 0:
+ return True
+ return self.reached_element_limits or self.is_truncated
+
+ @property
+ def solution(self) -> np.array:
+ """
+ Returns: solution vector per *vertex* of the mesh.
+ An array (num_vertices, solution_dimension),
+ where every entry corresponds to the solution of the parameterized fem_problem
+ equation at the position of the respective node/vertex.
+
+ """
+ return self.fem_problem.nodal_solution
+
+ def project_to_scalar(self, values: np.array) -> np.array:
+ """
+ Projects a value per node or graph and solution dimension to a scalar value per node.
+ Args:
+ values: A vector of shape ([num_vertices/nodes,] solution_dimension)
+
+ Returns: A scalar value per vertex
+ """
+ return self.fem_problem.project_to_scalar(values)
+
+ @property
+ def scalar_solution(self):
+ return self.project_to_scalar(self.solution)
+
+ @property
+ def error_per_element(self) -> np.array:
+ """
+ Returns: error per element of the mesh. np.array of shape (num_elements, solution_dimension)
+
+ """
+ return self._error_estimation_dict.get("indicator")
+
+ @property
+ def initial_approximation_error(self) -> np.array:
+ """
+ Returns: error per element of the mesh. np.array of shape (num_elements, solution_dimension)
+
+ """
+ return self._initial_approximation_errors.get("indicator")
+
+ @property
+ def error_estimation_dict(self) -> Dict[str, np.array]:
+ """
+ Returns a dictionary of all error estimation methods and their respective errors.
+ These errors may be per element/face, or per integration point, depending on the metric.
+ Returns:
+
+ """
+ return self._error_estimation_dict
+
+ @property
+ def _refinements_per_element(self) -> np.array:
+ return self.fem_problem.refinements_per_element
+
+ @property
+ def _solution_std_per_element(self) -> np.array:
+ """
+ Computes the standard deviation of the solution per element.
+ Returns: np.array of shape (num_elements, solution_dimension)
+
+ Note: 此属性仅用于 backward compatibility;
+ 新代码使用 _residual_components 替代。
+ """
+ return get_aggregation_per_element(
+ self.solution, self._element_indices, aggregation_function_str="std"
+ )
+
+ # =========================================================================
+ # PDE 物理残差特征 (替代 solution_std)
+ # =========================================================================
+
+ @property
+ def _residual_components(self) -> Dict[str, np.ndarray]:
+ """逐单元的三项 PDE 残差 + 边界标记。"""
+ from .helmholtz import _compute_residual_components
+
+ fp = self.fem_problem.fem_problem
+ k = getattr(fp, "_k", 10.0)
+ u_scat = self.fem_problem.nodal_solution
+ eps_r = self._epsilon_r_elements
+ return _compute_residual_components(
+ self.fem_problem.mesh, u_scat, k=k, eps_r=eps_r
+ )
+
+ @property
+ def _k_local_sqrt_vol(self) -> np.ndarray:
+ """每个单元的 k_local × sqrt(volume)。"""
+ k = self._wave_number
+ eps_r = self._epsilon_r_elements
+ k_local = k * np.sqrt(np.maximum(eps_r, 1.0))
+ return (k_local * np.sqrt(self.element_volumes)).astype(np.float32)
+
+ @property
+ def _volume_normalized(self) -> np.ndarray:
+ """无量纲单元面积: volume / lambda^2。"""
+ lam = 2.0 * np.pi / self._wave_number
+ return (self.element_volumes / (lam * lam)).astype(np.float32)
+
+ @property
+ def _eta_indicator(self) -> np.ndarray:
+ """
+ 标准 FEM 残差误差指示器,用于 reward 计算。
+
+ η_i = √(R_int_i² + J_grad_i² + R_sbc_i²)
+
+ 其中:
+ R_int_i = h_K · √V_i · |k²ε_r u + k²(ε_r-1)u_inc|
+ J_grad_i = √(½ Σ_{e∈∂K_i} h_e² · |[[∇u·n]]|²)
+ R_sbc_i = √h_bnd · |∂u/∂n - i·k_local·u|
+
+ 与 _compute_residual_indicator 的公式完全一致。
+
+ Returns: shape (num_elements,) float64
+ """
+ from .helmholtz import _compute_residual_components
+
+ fp = self.fem_problem.fem_problem
+ k = getattr(fp, "_k", 10.0)
+ u_scat = self.fem_problem.nodal_solution
+ comps = _compute_residual_components(
+ self.fem_problem.mesh, u_scat, k=k,
+ eps_r=self._epsilon_r_elements, apply_log=False,
+ )
+ self._cached_eta_components_raw = comps
+ return np.sqrt(
+ comps["internal_residual"] ** 2
+ + comps["gradient_jump"] ** 2
+ + comps["sbc_residual"] ** 2
+ )
+
+ @property
+ def _eta_components_raw(self) -> Dict[str, np.ndarray]:
+ """返回逐单元的三项原始残差分量(apply_log=False),由 _eta_indicator 缓存。"""
+ if not hasattr(self, "_cached_eta_components_raw") or self._cached_eta_components_raw is None:
+ _ = self._eta_indicator # triggers caching
+ return self._cached_eta_components_raw
+
+ @property
+ def _rho_components(self) -> Dict[str, np.ndarray]:
+ """返回逐单元的残差密度三分量(不含 h-缩放),用于 reward 计算。
+
+ Returns:
+ rho_int: |k²·ε_r·u + k²·(ε_r-1)·u_inc|
+ rho_jump: √(mean |[[∇u·n]]|²) per element
+ rho_sbc: √(mean |∂u/∂n - i·k·u|²) per element
+ """
+ from .helmholtz import _compute_residual_density
+
+ fp = self.fem_problem.fem_problem
+ k = getattr(fp, "_k", 10.0)
+ u_scat = self.fem_problem.nodal_solution
+ return _compute_residual_density(
+ self.fem_problem.mesh, u_scat, k=k,
+ eps_r=self._epsilon_r_elements,
+ )
+
+ # =========================================================================
+ # SBC 状态空间辅助属性:介电常数 + 复数场均值
+ # =========================================================================
+
+ @property
+ def _wave_number(self) -> float:
+ """Helmholtz 波数 k,从当前 FEM 问题实例读取(支持随机采样)。"""
+ fp = self.fem_problem.fem_problem
+ return getattr(fp, '_k', 10.0)
+
+ @property
+ def _epsilon_r_elements(self) -> np.ndarray:
+ """
+ 每个单元的相对介电常数 εr。
+
+ 从 FEM 问题实例读取介质几何参数,按单元中点判断是否在介质内。
+
+ Returns: shape (num_elements,) float64 array
+ """
+ fp = self.fem_problem.fem_problem
+ cx = getattr(fp, "_cx", 0.5)
+ cy = getattr(fp, "_cy", 0.5)
+ radius = getattr(fp, "_radius", 0.2)
+ eps_r = getattr(fp, "_eps_r", 2.0)
+ midpoints = self._element_midpoints
+ x_mid, y_mid = midpoints[:, 0], midpoints[:, 1]
+ in_cylinder = (x_mid - cx) ** 2 + (y_mid - cy) ** 2 <= radius**2
+ return np.where(in_cylinder, eps_r, 1.0)
+
+ @property
+ def _dist_to_interface(self) -> np.ndarray:
+ """每个单元中点到介质圆柱边界的带符号距离(内部为负,外部为正)。
+
+ 用真空波长 lambda = 2*pi/k 做无量纲归一化,再经 sign(d)·ln(1+|d|) 压缩。
+ ln 压缩保留近场分辨力(小 |d| 时近似线性),远场自然对数压缩,
+ 与残差特征的 log₁₀ 压缩风格一致。无硬截断,处处可导。
+ """
+ fp = self.fem_problem.fem_problem
+ cx = getattr(fp, "_cx", 0.5)
+ cy = getattr(fp, "_cy", 0.5)
+ radius = getattr(fp, "_radius", 0.2)
+ midpoints = self._element_midpoints
+ dist = np.sqrt((midpoints[:, 0] - cx) ** 2 + (midpoints[:, 1] - cy) ** 2)
+ lam = 2.0 * np.pi / self._wave_number
+ d = (dist - radius) / lam
+ return (np.sign(d) * np.log1p(np.abs(d))).astype(np.float32)
+
+ @property
+ def _eps_r_global(self) -> float:
+ """散射体材料的相对介电常数(全局标量)。"""
+ fp = self.fem_problem.fem_problem
+ return getattr(fp, "_eps_r", 2.0)
+
+ @property
+ def _complex_solution_mean(self) -> np.ndarray:
+ """
+ 每个单元内复数 FEM 解的均值 (complex128)。
+
+ SBC 边界条件下解为复数值;内部残差和边界残差均基于复数场。
+ 使用 P1 节点值的三点平均作为单元代表值。
+
+ Returns: shape (num_elements,) complex128 array
+ """
+ return get_aggregation_per_element(
+ self.solution,
+ self._element_indices,
+ aggregation_function_str="mean",
+ )
+
+ @property
+ def sample_penalty(self) -> bool:
+ return self._sample_penalty
+
+ @property
+ def refinement_strategy(self) -> str:
+ return self._refinement_strategy
+
+ @property
+ def has_homogeneous_graph(self) -> bool:
+ return not self._include_vertices
+
+ @property
+ def mesh_dimension(self) -> int:
+ return 2
+
+ def set_element_penalty_lambda(
+ self, position_or_value: float, from_position: bool = True
+ ):
+ """
+ Sets the element penalty lambda from the provided position.
+ Args:
+ position_or_value: A float between 0 and 1 that determines the element penalty lambda if from_position.
+ Otherwise, the value of the element penalty lambda.
+ from_position: If True, the element penalty lambda is taken log-uniformly from the provided position,
+ regardless of how the value is usually sampled.
+
+
+ Returns: None
+
+ Note: Sets self._element_penalty_lambda
+
+ """
+ element_penalty_config = self._environment_config.get("element_penalty")
+
+ if element_penalty_config.get("sample_penalty"):
+ if from_position:
+ # sample element penalty loguniformly for comparison between methods
+ log_min = np.log(element_penalty_config.get("min"))
+ log_max = np.log(element_penalty_config.get("max"))
+ self._element_penalty_lambda = np.exp(
+ position_or_value * log_min + (1 - position_or_value) * log_max
+ )
+ else: # fixed element penalty
+ self._element_penalty_lambda = position_or_value
+ else:
+ # element penalty is fixed
+ self._element_penalty_lambda = element_penalty_config.get("value")
+
+ ####################
+ # additional plots #
+ ####################
+
+ def _plot_value_per_element(
+ self,
+ value_per_element: np.array,
+ title: str,
+ normalize_by_element_volume: bool = False,
+ mesh: Optional[Mesh] = None,
+ ) -> go.Figure:
+ """
+ only return traces if asked or at the last step to avoid overlay of multiple steps
+ Args:
+ value_per_element: A numpy array of shape (num_elements,).
+ title: The title of the plot.
+ normalize_by_element_volume: If True, the values are normalized by the element volume as value /= element_volume.
+ mesh: The mesh to plot the values on. If None, the mesh of the current state is used.
+ Returns: A plotly figure with an outline of the mesh and value per element in the element midpoints.
+
+ """
+ if mesh is None:
+ assert len(value_per_element) == self.num_agents, (
+ f"Need to provide a value per agent, given "
+ f"'{value_per_element.shape}' and '{self.num_agents}'"
+ )
+ mesh = self.fem_problem.mesh
+ mesh_dimension = 2
+ else:
+ mesh_dimension = mesh.dim()
+
+ if normalize_by_element_volume:
+ value_per_element = value_per_element / self.element_volumes
+
+ boundary = self.fem_problem.plot_boundary
+ traces, layout = get_plotly_mesh_traces_and_layout(
+ mesh=mesh,
+ scalars=value_per_element,
+ mesh_dimension=mesh_dimension,
+ title=title,
+ boundary=boundary,
+ )
+
+ value_per_element_plot = go.Figure(data=traces, layout=layout)
+ return value_per_element_plot
+
+ def _plot_error_per_element(
+ self, normalize_by_element_volume: bool = True
+ ) -> go.Figure:
+ weighted_remaining_error = self._get_remaining_error(return_dimensions=False)
+ return self._plot_value_per_element(
+ value_per_element=self.project_to_scalar(self.error_per_element),
+ normalize_by_element_volume=normalize_by_element_volume,
+ title=f"Element Errors. Remaining total error: {weighted_remaining_error:.4f}",
+ )
+
+ def additional_plots(
+ self, iteration: int, policy_step_function: Optional[callable] = None
+ ) -> Dict[str, go.Figure]:
+ """
+ Function that takes an algorithm iteration as input and returns a number of additional plots about the
+ current environment as output. Some plots may be always selected, some only on e.g., iteration 0.
+ Args:
+ iteration: The current iteration of the algorithm.
+ policy_step_function: (Optional)
+ A function that takes a graph as input and returns the action(s) and (q)-value(s)
+ for each agent.
+
+ """
+ _, remaining_error_per_solution_dimension = self._get_remaining_error(
+ return_dimensions=True
+ )
+ additional_plots = {
+ "refinements_per_element": self._plot_value_per_element(
+ value_per_element=self._refinements_per_element,
+ title="Refinements per element",
+ ),
+ "scalar_solution_std_per_element": self._plot_value_per_element(
+ value_per_element=self.project_to_scalar(
+ self._solution_std_per_element
+ ),
+ title=f"Element Std of Solution Norm",
+ ),
+ "scalar_solution_error_per_element": self._plot_error_per_element(
+ normalize_by_element_volume=False
+ ),
+ }
+
+ if policy_step_function is not None:
+ from .utils import detach
+
+ actions, values = policy_step_function(observation=self.last_observation)
+ if len(actions) == self._num_elements:
+ additional_plots["final_actor_evaluation"] = (
+ self._plot_value_per_element(
+ detach(actions),
+ title=f"Action per Agent at step {self._timestep}",
+ )
+ )
+ if len(values) == self._num_elements:
+ additional_plots["final_critic_evaluation"] = (
+ self._plot_value_per_element(
+ detach(values),
+ title=f"Critic Evaluation at step {self._timestep}",
+ )
+ )
+ if self._reward_type in ["spatial", "spatial_max", "spatial_volume"]:
+ additional_plots["cumulative_reward"] = self._plot_value_per_element(
+ value_per_element=self._cumulative_reward_per_agent,
+ title="Cumulative Reward",
+ mesh=self.fem_problem.previous_mesh,
+ )
+ additional_plots["reward_per_agent"] = self._plot_value_per_element(
+ value_per_element=self._reward_per_agent,
+ title="Final Reward",
+ mesh=self.fem_problem.previous_mesh,
+ )
+ additional_plots |= self.fem_problem.additional_plots()
+ return additional_plots
+
+ def __deepcopy__(self, memo):
+ """
+ Overwrite deepcopy to reinitialize stateless (lambda-) functions
+ it is sufficient to call the register functions,
+ as only new objects for the stateless lambda functions have to be created
+ Args:
+ memo:
+
+ Returns:
+
+ """
+ from copy import deepcopy
+
+ cls = self.__class__
+ result = cls.__new__(cls)
+ memo[id(self)] = result
+ for k, v in self.__dict__.items():
+ setattr(result, k, deepcopy(v, memo))
+
+ setattr(
+ result, "_element_feature_functions", result._register_element_features()
+ )
+ return result
diff --git a/environment/mie_solution.py b/environment/mie_solution.py
new file mode 100644
index 0000000..41dd8fd
--- /dev/null
+++ b/environment/mie_solution.py
@@ -0,0 +1,202 @@
+"""2D Mie scattering analytical solution for a dielectric cylinder (TM polarization).
+
+Computes the exact scattered and total fields for a circular dielectric cylinder
+under plane-wave illumination u_inc = exp(i·k0·x).
+
+Line-by-line translation of the validated MATLAB reference (result/mie.py).
+"""
+
+import numpy as np
+from scipy.special import jv, hankel1
+from typing import Optional, Tuple
+
+
+def mie_scattered_field(
+ points: np.ndarray,
+ k0: float,
+ eps_r: float,
+ radius: float,
+ cx: float = 0.5,
+ cy: float = 0.5,
+) -> np.ndarray:
+ """Compute the scattered E_z field at arbitrary query points.
+
+ The scattered field is u_scat = u_total − u_inc, valid both inside and
+ outside the cylinder. This matches the FEM scattered-field formulation.
+
+ Parameters
+ ----------
+ points : (N, 2) np.ndarray — (x, y) coordinates
+ k0 : float — vacuum wavenumber
+ eps_r : float — relative permittivity
+ radius : float — cylinder radius
+ cx, cy : float — cylinder centre
+
+ Returns
+ -------
+ E_scat : (N,) np.complex128
+ """
+ m = np.sqrt(eps_r)
+ k1 = k0 * m # wavenumber inside cylinder
+ x_size = k0 * radius # size parameter
+
+ # ── polar coordinates relative to cylinder centre ──
+ dx = points[:, 0] - cx
+ dy = points[:, 1] - cy
+ R = np.sqrt(dx * dx + dy * dy)
+ Phi = np.arctan2(dy, dx) # [-π, π], matches MATLAB cart2pol
+
+ # ── Wiscombe truncation (matches MATLAB round(…)) ──
+ N_trunc = int(np.round(x_size + 4.05 * x_size ** (1.0 / 3.0) + 2))
+ N_trunc = max(N_trunc, 3)
+
+ E_scat = np.zeros(len(points), dtype=np.complex128)
+ E_int = np.zeros(len(points), dtype=np.complex128)
+
+ for n in range(-N_trunc, N_trunc + 1):
+ # boundary values — matches MATLAB besselj / besselh(…, 1, …)
+ J_nx = jv(n, x_size)
+ J_nmx = jv(n, k1 * radius)
+ H_nx = hankel1(n, x_size)
+
+ # derivatives via recurrence Z'_n = ½ (Z_{n-1} − Z_{n+1})
+ J_nx_p = 0.5 * (jv(n - 1, x_size) - jv(n + 1, x_size))
+ J_nmx_p = 0.5 * (jv(n - 1, k1 * radius) - jv(n + 1, k1 * radius))
+ H_nx_p = 0.5 * (hankel1(n - 1, x_size) - hankel1(n + 1, x_size))
+
+ # TM scattering coefficient a_n
+ num_a = m * J_nx * J_nmx_p - J_nx_p * J_nmx
+ den_a = J_nmx * H_nx_p - m * J_nmx_p * H_nx
+ a_n = num_a / den_a
+
+ # internal coefficient c_n
+ num_c = J_nx * H_nx_p - J_nx_p * H_nx # Wronskian (2i/(π x) from theory)
+ c_n = num_c / den_a
+
+ # phase factor iⁿ · exp(i·n·φ)
+ phase = (1j) ** n * np.exp(1j * n * Phi)
+
+ # scattered field (valid outside the cylinder)
+ out = R >= radius
+ if out.any():
+ E_scat[out] += a_n * hankel1(n, k0 * R[out]) * phase[out]
+
+ # internal total field (valid inside the cylinder)
+ inside = R < radius
+ if inside.any():
+ E_int[inside] += c_n * jv(n, k1 * R[inside]) * phase[inside]
+
+ # phase reference at cylinder centre (matches MATLAB phase_shift)
+ phase_shift = np.exp(1j * k0 * cx)
+ E_scat *= phase_shift
+ E_int *= phase_shift
+
+ # ── scattered field inside cylinder = internal total − incident ──
+ E_inc = np.exp(1j * k0 * points[:, 0])
+ inside = R < radius
+ if inside.any():
+ E_scat[inside] = E_int[inside] - E_inc[inside]
+
+ return E_scat
+
+
+def mie_total_field(
+ points: np.ndarray,
+ k0: float,
+ eps_r: float,
+ radius: float,
+ cx: float = 0.5,
+ cy: float = 0.5,
+) -> np.ndarray:
+ """Compute the total E_z field.
+
+ Outside: u_inc + u_scat
+ Inside: internal field (refracted wave)
+ """
+ m = np.sqrt(eps_r)
+ k1 = k0 * m
+ x_size = k0 * radius
+
+ dx = points[:, 0] - cx
+ dy = points[:, 1] - cy
+ R = np.sqrt(dx * dx + dy * dy)
+ Phi = np.arctan2(dy, dx)
+
+ N_trunc = int(np.round(x_size + 4.05 * x_size ** (1.0 / 3.0) + 2))
+ N_trunc = max(N_trunc, 3)
+
+ E_scat = np.zeros(len(points), dtype=np.complex128)
+ E_int = np.zeros(len(points), dtype=np.complex128)
+
+ for n in range(-N_trunc, N_trunc + 1):
+ J_nx = jv(n, x_size)
+ J_nmx = jv(n, k1 * radius)
+ H_nx = hankel1(n, x_size)
+
+ J_nx_p = 0.5 * (jv(n - 1, x_size) - jv(n + 1, x_size))
+ J_nmx_p = 0.5 * (jv(n - 1, k1 * radius) - jv(n + 1, k1 * radius))
+ H_nx_p = 0.5 * (hankel1(n - 1, x_size) - hankel1(n + 1, x_size))
+
+ num_a = m * J_nx * J_nmx_p - J_nx_p * J_nmx
+ den_a = J_nmx * H_nx_p - m * J_nmx_p * H_nx
+ a_n = num_a / den_a
+
+ num_c = J_nx * H_nx_p - J_nx_p * H_nx
+ c_n = num_c / den_a
+
+ phase = (1j) ** n * np.exp(1j * n * Phi)
+
+ out = R >= radius
+ if out.any():
+ E_scat[out] += a_n * hankel1(n, k0 * R[out]) * phase[out]
+
+ inside = R < radius
+ if inside.any():
+ E_int[inside] += c_n * jv(n, k1 * R[inside]) * phase[inside]
+
+ phase_shift = np.exp(1j * k0 * cx)
+ E_scat *= phase_shift
+ E_int *= phase_shift
+
+ E_inc = np.exp(1j * k0 * points[:, 0])
+
+ E_total = np.zeros(len(points), dtype=np.complex128)
+ E_total[R >= radius] = E_inc[R >= radius] + E_scat[R >= radius]
+ E_total[R < radius] = E_int[R < radius]
+
+ return E_total
+
+
+def mie_grid_solution(
+ k0: float,
+ eps_r: float,
+ radius: float,
+ cx: float = 0.5,
+ cy: float = 0.5,
+ x_range: Tuple[float, float] = (0.0, 1.0),
+ y_range: Tuple[float, float] = (0.0, 1.0),
+ Nx: int = 400,
+ Ny: int = 400,
+) -> dict:
+ """Compute Mie solution on a regular grid (for plotting / visual checks).
+
+ Returns a dict with keys: X, Y, R, Phi, E_inc, E_scat, E_total.
+ """
+ x_vec = np.linspace(x_range[0], x_range[1], Nx)
+ y_vec = np.linspace(y_range[0], y_range[1], Ny)
+ X, Y = np.meshgrid(x_vec, y_vec)
+
+ points = np.column_stack([X.ravel(), Y.ravel()])
+ dx = points[:, 0] - cx
+ dy = points[:, 1] - cy
+ R = np.sqrt(dx * dx + dy * dy).reshape(Ny, Nx)
+ Phi = np.arctan2(dy, dx).reshape(Ny, Nx)
+
+ E_inc = np.exp(1j * k0 * X)
+ E_scat = mie_scattered_field(points, k0, eps_r, radius, cx, cy).reshape(Ny, Nx)
+ E_total = mie_total_field(points, k0, eps_r, radius, cx, cy).reshape(Ny, Nx)
+
+ return {
+ "X": X, "Y": Y, "R": R, "Phi": Phi,
+ "E_inc": E_inc, "E_scat": E_scat, "E_total": E_total,
+ }
diff --git a/environment/utils.py b/environment/utils.py
new file mode 100644
index 0000000..cb64a52
--- /dev/null
+++ b/environment/utils.py
@@ -0,0 +1,92 @@
+"""
+环境层通用工具
+=============
+提供数组拼接、索引采样、tensor→numpy 转换等辅助功能。
+"""
+
+from typing import Dict, Iterable, List, Optional, Union
+
+import numpy as np
+from numpy import ndarray
+from torch import Tensor
+from torch_geometric.data.data import BaseData
+
+
+def save_concatenate(
+ arrays: Iterable[np.ndarray], *args, **kwargs
+) -> Optional[np.ndarray]:
+ """
+ 安全拼接多个数组。自动过滤 None 值,空列表返回 None。
+
+ Args:
+ arrays: 要拼接的数组列表(可能包含 None)
+
+ Returns:
+ 拼接后的数组;若全为 None 则返回 None
+
+ Example:
+ >>> result = save_concatenate([arr1, None, arr2], axis=1)
+ """
+ arrays = [array for array in arrays if array is not None]
+ if len(arrays) == 0:
+ return None
+ return np.concatenate(arrays, *args, **kwargs)
+
+
+class IndexSampler:
+ """
+ 随机索引采样器 — 用于循环缓冲区中随机抽取 PDE 实例。
+
+ 内部维护一个随机排列的索引数组,每次调用 next() 返回一个索引。
+ 遍历完所有索引后自动重新洗牌。
+
+ Example:
+ >>> sampler = IndexSampler(100, np.random.RandomState(42))
+ >>> idx = sampler.next() # 随机抽取一个索引
+ """
+
+ def __init__(self, size: int, random_state: np.random.RandomState):
+ self._size = size
+ self._indices = np.arange(size)
+ self._random_state = random_state
+ self._reset()
+
+ def next(self) -> int:
+ """返回下一个随机索引,到底后自动洗牌重排。"""
+ if self._position == self._size:
+ self._reset()
+ index = self._indices[self._position]
+ self._position += 1
+ return index
+
+ def _reset(self):
+ self._position = 0
+ self._random_state.shuffle(self._indices)
+
+ def __len__(self):
+ return self._size
+
+
+def detach(
+ tensor: Union[Tensor, Dict[str, Tensor], List[Tensor]],
+) -> Union[ndarray, Dict[str, ndarray], List[ndarray], BaseData]:
+ """
+ 将 PyTorch tensor 安全转换为 numpy 数组(自动处理 GPU→CPU)。
+
+ Args:
+ tensor: PyTorch tensor、tensor 字典或 tensor 列表
+
+ Returns:
+ 对应的 numpy 数组
+
+ Example:
+ >>> action_np = detach(actions_tensor) # → np.ndarray
+ """
+ if isinstance(tensor, dict):
+ return {key: detach(value) for key, value in tensor.items()}
+ elif isinstance(tensor, list):
+ return [detach(value) for value in tensor]
+ if tensor.is_cuda:
+ return tensor.cpu().detach().numpy()
+ else:
+ return tensor.detach().numpy()
diff --git a/environment/visualization.py b/environment/visualization.py
new file mode 100644
index 0000000..fbdaf0a
--- /dev/null
+++ b/environment/visualization.py
@@ -0,0 +1,69 @@
+from typing import Any, Dict, List, Optional, Tuple
+
+import numpy as np
+import plotly.graph_objects as go
+from plotly.basedatatypes import BaseTraceType
+from skfem import Mesh
+
+
+# 将网格与标量场转为 plotly 三角形 traces + 布局,供 RL 环境实时渲染
+def get_plotly_mesh_traces_and_layout(
+ mesh: Mesh,
+ scalars: np.ndarray,
+ title: str = "Mesh",
+ mesh_dimension: int = 2,
+ boundary: Optional[np.ndarray] = None,
+) -> Tuple[List[BaseTraceType], Dict[str, Any]]:
+ vertices = mesh.p
+ triangles = mesh.t
+ n_elements = triangles.shape[1]
+ s = np.asarray(scalars, dtype=np.float64).flatten()
+
+ x_tri = vertices[0, triangles].T
+ y_tri = vertices[1, triangles].T
+ intensity_tri = s[triangles].T
+
+ vmin, vmax = s.min(), s.max()
+
+ traces = []
+ for elem_idx in range(n_elements):
+ x_e, y_e, s_e = x_tri[elem_idx], y_tri[elem_idx], intensity_tri[elem_idx]
+ traces.append(go.Scatter(
+ x=x_e.tolist() + [x_e[0]],
+ y=y_e.tolist() + [y_e[0]],
+ mode="lines",
+ fill="toself",
+ fillcolor=_get_color(float(np.mean(s_e)), vmin, vmax),
+ line=dict(color="black", width=0.5),
+ showlegend=False,
+ hoverinfo="skip",
+ ))
+
+ if traces:
+ traces[0].marker = dict(
+ color=s.min(), colorscale="RdBu_r", showscale=True,
+ colorbar=dict(title="Solution"),
+ )
+
+ layout = {
+ "title": title,
+ "xaxis": {"title": "x", "scaleanchor": "y"},
+ "yaxis": {"title": "y"},
+ "showlegend": False,
+ }
+ if boundary is not None:
+ layout["xaxis"]["range"] = [boundary[0], boundary[2]]
+ layout["yaxis"]["range"] = [boundary[1], boundary[3]]
+
+ return traces, layout
+
+
+# 标量值 → matplotlib RdBu_r 色表映射的 RGBA 字符串
+def _get_color(value: float, vmin: float, vmax: float) -> str:
+ import matplotlib.cm as cm
+ import matplotlib.colors as mcolors
+
+ norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
+ rgba = cm.RdBu_r(norm(value))
+ r, g, b, a = rgba
+ return f"rgba({int(r * 255)},{int(g * 255)},{int(b * 255)},{a:.2f})"
diff --git a/mie.m b/mie.m
new file mode 100644
index 0000000..5e9d35b
--- /dev/null
+++ b/mie.m
@@ -0,0 +1,94 @@
+clc; clear; close all;
+
+% ================= 1. 物理参数定义 =================
+r = 0.1; % 圆柱半径
+eps_r = 5.0; % 相对介电常数
+m = sqrt(eps_r); % 相对折射率 m = ~1.414
+k0 = 6; % 背景真空中波数 (k=6)
+k1 = k0 * m; % 圆柱内部波数
+x_size = k0 * r; % 尺寸参数 x = k0*a
+
+% ================= 2. 计算域网格设置 =================
+x_range = 1;
+y_range = 1;
+Nx = 500;
+Ny = 500;
+x_vec = linspace(0, x_range, Nx);
+y_vec = linspace(0, y_range, Ny);
+[X, Y] = meshgrid(x_vec, y_vec);
+
+xc = 0.5; yc = 0.5;
+[Phi, R] = cart2pol(X - xc, Y - yc); % 转换为极坐标
+
+% ================= 3. 场初始化 =================
+E_scat = zeros(size(X)); % 散射场
+E_int = zeros(size(X)); % 内部场
+
+% Wiscombe 截断准则(决定级数展开需要算到第几阶)
+N_trunc = round(x_size + 4.05 * x_size^(1/3) + 2);
+
+% ================= 4. 2D Mie 级数展开计算 =================
+% 2D 圆柱级数从 -N 到 +N
+for n = -N_trunc : N_trunc
+
+ % 边界处的贝塞尔函数值
+ J_nx = besselj(n, x_size);
+ J_nmx = besselj(n, k1 * r);
+ H_nx = besselh(n, 1, x_size);
+
+ % 边界处的导数值 (利用递推公式 Z_n' = 0.5 * (Z_{n-1} - Z_{n+1}))
+ J_nx_p = 0.5 * (besselj(n-1, x_size) - besselj(n+1, x_size));
+ J_nmx_p = 0.5 * (besselj(n-1, k1*r) - besselj(n+1, k1*r));
+ H_nx_p = 0.5 * (besselh(n-1, 1, x_size) - besselh(n+1, 1, x_size));
+
+ % 计算 TM 偏振下的散射系数 a_n (对应 E_z)
+ num_a = m .* J_nx .* J_nmx_p - J_nx_p .* J_nmx;
+ den_a = J_nmx .* H_nx_p - m .* J_nmx_p .* H_nx;
+ a_n = num_a ./ den_a;
+
+ % 计算内部透射系数 c_n
+ num_c = J_nx .* H_nx_p - J_nx_p .* H_nx; % 这其实是 Wronskian
+ c_n = num_c ./ den_a;
+
+ % 空间相位因子: i^n * exp(i*n*phi)
+ phase = (1i)^n * exp(1i * n * Phi);
+
+ % 累加外部散射场 (仅在 R >= r 区域有效)
+ out_idx = R >= r;
+ E_scat(out_idx) = E_scat(out_idx) + a_n .* besselh(n, 1, k0 * R(out_idx)) .* phase(out_idx);
+
+ % 累加内部总场 (仅在 R < r 区域有效)
+ in_idx = R < r;
+ E_int(in_idx) = E_int(in_idx) + c_n .* besselj(n, k1 * R(in_idx)) .* phase(in_idx);
+end
+
+% ================= 5. 组装全场并绘图 =================
+% 入射平面波: u_inc = exp(i*k0*x)
+phase_shift = exp(1i * k0 * xc);
+E_scat = E_scat .* phase_shift;
+E_int = E_int .* phase_shift;
+
+E_inc = exp(1i * k0 * X);
+
+% 总场 = 外部(入射 + 散射) + 内部场
+% 组装总场
+E_total = zeros(size(X));
+E_total(R >= r) = E_inc(R >= r) + E_scat(R >= r);
+E_total(R < r) = E_int(R < r);
+
+
+% 绘图
+figure('Color','w');
+pcolor(X, Y, real(E_total-E_inc));
+max_E_real = max(max(real(E_total-E_inc)));
+shading interp;
+axis equal tight;
+colorbar;
+colormap jet;
+title(sprintf('2D Cylinder Mie Scattering |E_{scatter}| (Max = %.4f)', max_E_real));
+
+% 绘制圆柱边界
+hold on;
+theta_circle = linspace(0, 2*pi, 100);
+plot(xc + r * cos(theta_circle), yc + r * sin(theta_circle), 'k--', 'LineWidth', 1.5);
+hold off;
diff --git a/outlook.md b/outlook.md
new file mode 100644
index 0000000..72cb5e7
--- /dev/null
+++ b/outlook.md
@@ -0,0 +1,14 @@
+一、 引入因果律:对偶加权残差法(Dual-Weighted Residual, DWR)与其让 GNN 在空间中盲目摸索残差的传播规律,不如直接利用偏微分方程的伴随算子(Adjoint Operator)显式求解误差的传播路径。在 DWR 理论中,我们定义一个关心的目标泛函 $J(e)$(例如远场总场的误差)。为了找到局部残差 $R(u_h)$ 是如何影响 $J(e)$ 的,我们需要求解原方程的对偶(伴随)问题:$$\mathcal{L}^* z = J'(\cdot)$$由于亥姆霍兹方程是自伴随或复对称的,对偶解 $z$ 本质上就是一个以目标区域为源的反向传播波(Green's function 的叠加)。严格的误差表示定理(Error Representation Theorem)给出:$$J(e) = \sum_{K \in \Omega_h} \left( \langle r_{\text{int}}, z - z_h \rangle_K + \langle r_{\text{jump}}, z - z_h \rangle_{\partial K} \right)$$第一性原理 AI 方案:物理先验特征:在 FEM 求解器中,顺手在极粗网格上解一次对偶问题得到 $z_h$(计算代价极小)。将权重项 $\omega_K = |z - z_h|_K$(或者启发式地使用 $|z_h|_K$ 的梯度)作为 GNN 的节点输入特征。自然适配:网络会立刻“看”到,虽然介质外部的 $r_{\text{jump}}$ 很大,但那里的对偶权重 $\omega_K$ 极小;而介质内部的对偶权重巨大。网络在不加任何人为截断的情况下,自然顺着物理因果律将算力投向介质内部。
+
+COMSOL 的自适应往往隐式或显式地结合了对偶加权残差(DWR),能够识别“远场误差是由哪里传播过来的”。
+
+二、 相空间与动量解耦:Wigner 分布与相空间光学残差在含有横向动量(如余弦载波项)和复杂色散介质的全场计算中,空间域的标量残差 $\eta_K$ 掩盖了误差的物理本质。污染效应的核心在于波矢(动量 $\mathbf{k}$)方向的失配。从相空间光学的角度来看,可以用维格纳分布函数(Wigner Distribution Function, WDF) 将标量场映射到位置-动量相空间 $W(\mathbf{x}, \mathbf{p})$。在渐近区,波场满足相空间的射线输运方程。数值解 $u_h$ 与真实解的差异,在空间域表现为弥散的干涉条纹,但在相空间中,却能清晰地表现为动量谱的分叉与频移。第一性原理 AI 方案:抛弃纯空间域的 $L_2$ 残差聚合。在误差提取步骤,对全场残差提取局部波矢谱(类似于短时傅里叶变换或 WDF 近似提取)。将动量偏差(Momentum Mismatch)作为核心 Reward。当且仅当一个细化动作能够将数值波阵面的 $\mathbf{k}$ 矢量方向拉回到正确的理论物理色散面上时,才给予正向激励。这样,网络优化的不再是单纯的数值差异,而是逼近真实的物理色散关系。
+
+三、 算子层面的修正:变分稳定化(GLS / Trefftz 方法)目前的强化学习框架试图用网格细化($h$-refinement)去填补 P1 单元固有的色散缺陷。从底层物理看,这是在用极高的计算成本为糟糕的基函数买单。如果从变分形式(Weak Form)出发,标准的 Galerkin 方法在亥姆霍兹算子下会失去最佳逼近性(Céa 引理中的稳定性常数随波数爆炸)。我们需要在算子层面进行修正。第一性原理 AI 方案:Galerkin Least-Squares (GLS) 稳定化:在标准的变分方程中,加入与残差相关的稳定项:$$B_{GLS}(u_h, v_h) = B_{Gal}(u_h, v_h) + \sum_K \tau_K \langle \mathcal{L}u_h - f, \mathcal{L}v_h \rangle_K$$通过精心设计稳定化参数 $\tau_K$,可以直接在 FEM 矩阵组装层面抵消 P1 单元的色散误差。此时,外部的虚假污染误差会在物理求解阶段被直接压制,GNN 面对的将是一个干净、局域化的残差场。物理信息的基函数(Trefftz / Plane Wave Basis):放弃多项式基函数。对于散射总场问题,介质内部和外部的物理场本质上是局部平面波或柱面波的叠加。如果在单元内部使用满足 $\nabla^2 \phi + k^2\varepsilon_r \phi = 0$ 的平面波作为基函数(即 Trefftz 方法或平面波非连续 Galerkin 方法 PWDG),网格内部残差 $r_{\text{int}}$ 将恒等于零。此时,所有的物理误差将以第一性原理的方式,极其干净地全部集中在介质与空气交界面的梯度/通量跳变 $r_{\text{jump}}$ 上。网络只需要专注于处理界面处的阻抗匹配即可,彻底根除了污染效应。
+
+
+1. 优先推进:对偶加权残差法 (DWR) 的 AI 赋能这是目前投资回报率(ROI)最高、最能快速落地的方案。可行性 (极高): 你现有的 ASMR++ 框架已经极其完善(GNN 观测 + 连续尺寸场 + PPO)。引入 DWR 不需要重构底层的 FEM 求解核心。你只需要在粗网格计算时,额外配置一个右端项(目标泛函的导数)求解一次伴随方程,将其作为额外的 GNN 节点特征。代码改动量最小,且能够迅速验证效果。创新性 (中高): DWR 本身是传统自适应有限元(AFEM)的经典理论,但在传统计算中,求解伴随方程的开销往往被认为过大。通过 RL 与 GNN,让智能体“学习” DWR 提供的因果律,从而在极少步骤内预测出最优的网格尺寸场,这是一个极其 solid 的 AI4S 创新点。物理信息嵌入 (高): 完美解决了“污染效应”中的非局部性问题。智能体的图神经网络不再是盲目地卷积局部几何残差,而是顺着伴随场(反向传播的波)的指引,直接“看”到了误差的因果律。发文章角度: 非常适合投往计算力学或物理机器学习的顶级期刊(如 JCP, CMAME)。故事主线明确:“通过强化学习结合 DWR,打破高频 Helmholtz 方程自适应网格细化中的污染效应陷阱”。
+
+2. 旗舰目标:相空间动量解耦 (Wigner 分布)这是上限最高、最颠覆性的方案,也是构建科研护城河的终极武器。可行性 (较高挑战): 计算二维波场的 Wigner 分布函数 (WDF) 会带来维度爆炸(2D 空间 $\rightarrow$ 4D 相空间),将其放入 RL 的每个 Reward 计算 loop 中会导致严重的效率瓶颈。你需要设计一种轻量级的局部波矢提取算法。创新性 (极高): 目前 AI4S 领域的 PDE 求解和网格优化几乎全部停留在空间域($L_2$ 或 $H^1$ 范数)。将相空间光学的概念引入有限元误差估计,是从根本上切换了视角。物理信息嵌入 (极高): 若要真正挑战跨越不同介质的零样本泛化 (Zero-shot generalization),单纯的空间域残差是极其脆弱的。因为不同 $\varepsilon_r$ 对应的空间波长和残差量级完全不同。但在 WDF 描述的相空间中,不同介质的波传播都遵循统一的射线哈密顿力学。以动量失配(Momentum Mismatch)作为 Reward,智能体优化的不再是表象的干涉条纹,而是底层的色散流形。发文章角度: 冲击综合性或交叉学科顶刊(如 Nature Computational Science, Light: Science & Applications, 或 PRL)。结合在相位恢复和 WDF 重构上已有的技术积累,这可以包装成一个完全超越传统 FEM 思维的“相空间 AI 自适应物理引擎”。
+
+3. 基础支撑:算子层面的变分稳定化 (GLS / Trefftz)这是一个偏传统计算力学但极其硬核的方案。可行性 (中等): 需要深入修改你的 helmholtz.py,改变弱形式(Weak Form)的矩阵组装过程。特别是 Trefftz 方法或平面波不连续伽辽金 (PWDG),其积分规则和界面通量定义与标准 P1 连续元完全不同。创新性 (高): Trefftz 方法本身就自带极强的物理先验(基函数严格满足局部齐次方程)。用 RL 智能体去动态配置界面处的阻抗匹配和通量惩罚,是一个极具技术深度的方向。物理信息嵌入 (最高): 它是唯一从算子理论层面彻底消灭 P1 单元色散误差(Dispersion Error)的方案。网格内部毫无误差,所有优化预算全部分配在界面跳变上。发文章角度: 属于极其硬核的数值分析与 AI 结合工作,更受传统数学和力学审稿人的青睐。
diff --git a/output/build_pptx.py b/output/build_pptx.py
new file mode 100644
index 0000000..17e3c83
--- /dev/null
+++ b/output/build_pptx.py
@@ -0,0 +1,1042 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Build the AFEM group meeting PPTX deck -- Chinese version."""
+
+from pptx import Presentation
+from pptx.util import Inches, Pt, Emu, Cm
+from pptx.dml.color import RGBColor
+from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
+from pptx.enum.shapes import MSO_SHAPE
+from pptx.oxml.ns import qn
+
+# Color palette (Nature-style restrained)
+WHITE = RGBColor(0xFF, 0xFF, 0xFF)
+BLACK = RGBColor(0x1A, 0x1A, 0x1A)
+DARK_GRAY = RGBColor(0x33, 0x33, 0x33)
+BODY_GRAY = RGBColor(0x44, 0x44, 0x44)
+CAPTION = RGBColor(0x88, 0x88, 0x88)
+LIGHT_LINE = RGBColor(0xDD, 0xDD, 0xDD)
+LIGHTER_LINE = RGBColor(0xEE, 0xEE, 0xEE)
+ACCENT_BLUE = RGBColor(0x2C, 0x5F, 0x8A)
+ACCENT_TEAL = RGBColor(0x3A, 0x7B, 0x7B)
+ACCENT_WARM = RGBColor(0x8B, 0x45, 0x2C)
+ACCENT_GREEN = RGBColor(0x3A, 0x7B, 0x4F)
+HIGHLIGHT_BG = RGBColor(0xE8, 0xF0, 0xF8)
+WARN_BG = RGBColor(0xFE, 0xF3, 0xE8)
+TABLE_HDR = RGBColor(0xE8, 0xF0, 0xF8)
+TABLE_ALT = RGBColor(0xF5, 0xF7, 0xFA)
+
+SLIDE_W = Inches(13.333)
+SLIDE_H = Inches(7.5)
+
+TITLE_SIZE = Pt(28)
+SUBHEAD_SIZE = Pt(18)
+BODY_SIZE = Pt(14)
+SMALL_SIZE = Pt(12)
+CAPTION_SIZE = Pt(8)
+TAKEAWAY_SIZE = Pt(11)
+
+prs = Presentation()
+prs.slide_width = SLIDE_W
+prs.slide_height = SLIDE_H
+blank_layout = prs.slide_layouts[6]
+
+
+def add_blank_slide():
+ return prs.slides.add_slide(blank_layout)
+
+def set_slide_bg(slide, color=WHITE):
+ bg = slide.background
+ fill = bg.fill
+ fill.solid()
+ fill.fore_color.rgb = color
+
+def add_rect(slide, left, top, width, height, fill_color=None, line_color=None, line_width=None):
+ shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
+ shape.line.fill.background()
+ if fill_color:
+ shape.fill.solid()
+ shape.fill.fore_color.rgb = fill_color
+ else:
+ shape.fill.background()
+ if line_color:
+ shape.line.color.rgb = line_color
+ shape.line.fill.solid()
+ if line_width:
+ shape.line.width = line_width
+ return shape
+
+def add_textbox(slide, left, top, width, height, text="", font_size=BODY_SIZE,
+ font_color=BODY_GRAY, bold=False, alignment=PP_ALIGN.LEFT,
+ font_name='Microsoft YaHei', anchor=MSO_ANCHOR.TOP, line_spacing=1.3):
+ txbox = slide.shapes.add_textbox(left, top, width, height)
+ txbox.text_frame.word_wrap = True
+ tf = txbox.text_frame
+ tf.paragraphs[0].alignment = alignment
+ tf.paragraphs[0].space_before = Pt(0)
+ tf.paragraphs[0].space_after = Pt(0)
+ tf.paragraphs[0].line_spacing = line_spacing
+ run = tf.paragraphs[0].add_run()
+ run.text = text
+ run.font.size = font_size
+ run.font.color.rgb = font_color
+ run.font.bold = bold
+ run.font.name = font_name
+ rPr = run._r.get_or_add_rPr()
+ rPr.set(qn('a:eaTypeface'), font_name)
+ return txbox
+
+def add_multiline_textbox(slide, left, top, width, height, lines, font_size=BODY_SIZE,
+ font_color=BODY_GRAY, font_name='Microsoft YaHei',
+ line_spacing=1.5, alignment=PP_ALIGN.LEFT):
+ txbox = slide.shapes.add_textbox(left, top, width, height)
+ txbox.text_frame.word_wrap = True
+ tf = txbox.text_frame
+ for i, line_data in enumerate(lines):
+ if isinstance(line_data, str):
+ text, is_bold, fs, clr = line_data, False, font_size, font_color
+ elif len(line_data) == 2:
+ text, is_bold = line_data
+ fs, clr = font_size, font_color
+ elif len(line_data) == 3:
+ text, is_bold, fs = line_data
+ clr = font_color
+ else:
+ text, is_bold, fs, clr = line_data
+ if i == 0:
+ p = tf.paragraphs[0]
+ else:
+ p = tf.add_paragraph()
+ p.alignment = alignment
+ p.space_before = Pt(2)
+ p.space_after = Pt(2)
+ p.line_spacing = line_spacing
+ run = p.add_run()
+ run.text = text
+ run.font.size = fs
+ run.font.color.rgb = clr
+ run.font.bold = is_bold
+ run.font.name = font_name
+ rPr = run._r.get_or_add_rPr()
+ rPr.set(qn('a:eaTypeface'), font_name)
+ return txbox
+
+def add_bullet_textbox(slide, left, top, width, height, bullets, font_size=BODY_SIZE,
+ font_color=BODY_GRAY, font_name='Microsoft YaHei',
+ bullet_char="-", line_spacing=1.5):
+ txbox = slide.shapes.add_textbox(left, top, width, height)
+ txbox.text_frame.word_wrap = True
+ tf = txbox.text_frame
+ for i, bullet_text in enumerate(bullets):
+ if i == 0:
+ p = tf.paragraphs[0]
+ else:
+ p = tf.add_paragraph()
+ p.alignment = PP_ALIGN.LEFT
+ p.space_before = Pt(3)
+ p.space_after = Pt(3)
+ p.line_spacing = line_spacing
+ run_marker = p.add_run()
+ run_marker.text = f"{bullet_char} "
+ run_marker.font.size = font_size
+ run_marker.font.color.rgb = ACCENT_BLUE
+ run_marker.font.name = font_name
+ rPr = run_marker._r.get_or_add_rPr()
+ rPr.set(qn('a:eaTypeface'), font_name)
+ run_text = p.add_run()
+ run_text.text = bullet_text
+ run_text.font.size = font_size
+ run_text.font.color.rgb = font_color
+ run_text.font.name = font_name
+ rPr2 = run_text._r.get_or_add_rPr()
+ rPr2.set(qn('a:eaTypeface'), font_name)
+ return txbox
+
+def add_top_bar(slide):
+ add_rect(slide, Inches(0), Inches(0), SLIDE_W, Pt(3), fill_color=ACCENT_BLUE)
+
+def add_slide_number(slide, num):
+ add_textbox(slide, Inches(11.8), Inches(7.05), Inches(1.2), Inches(0.35),
+ text=str(num), font_size=Pt(9), font_color=CAPTION,
+ alignment=PP_ALIGN.RIGHT)
+
+def add_source_label(slide, text, left=None, top=None):
+ if left is None:
+ left = Inches(0.6)
+ if top is None:
+ top = Inches(6.95)
+ add_textbox(slide, left, top, Inches(6), Inches(0.35),
+ text=text, font_size=CAPTION_SIZE, font_color=CAPTION)
+
+def add_takeaway_bar(slide, text):
+ add_rect(slide, Inches(0.6), Inches(6.55), Inches(12.1), Inches(0.38),
+ fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, Inches(0.75), Inches(6.55), Inches(11.85), Inches(0.38),
+ text=f">> {text}", font_size=TAKEAWAY_SIZE, font_color=ACCENT_BLUE,
+ bold=False, anchor=MSO_ANCHOR.MIDDLE)
+
+def add_slide_title(slide, title_text):
+ add_top_bar(slide)
+ add_textbox(slide, Inches(0.6), Inches(0.35), Inches(12.1), Inches(0.7),
+ text=title_text, font_size=TITLE_SIZE, font_color=BLACK, bold=True)
+ add_rect(slide, Inches(0.6), Inches(1.05), Inches(1.5), Pt(2), fill_color=ACCENT_BLUE)
+
+def add_kpi_box(slide, left, top, width, height, value, label, color=ACCENT_BLUE):
+ add_rect(slide, left, top, width, height, fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, left + Inches(0.1), top + Inches(0.08), width - Inches(0.2), Inches(0.4),
+ text=value, font_size=Pt(22), font_color=color, bold=True,
+ alignment=PP_ALIGN.CENTER)
+ add_textbox(slide, left + Inches(0.1), top + Inches(0.5), width - Inches(0.2), Inches(0.35),
+ text=label, font_size=Pt(10), font_color=CAPTION,
+ alignment=PP_ALIGN.CENTER)
+
+
+# ======================================================================
+# SLIDE 1: TITLE
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_rect(slide, Inches(0), Inches(0), SLIDE_W, Inches(0.08), fill_color=ACCENT_BLUE)
+add_rect(slide, Inches(0), Inches(0), Inches(0.08), SLIDE_H, fill_color=ACCENT_BLUE)
+
+add_textbox(slide, Inches(1.2), Inches(1.6), Inches(10.5), Inches(1.4),
+ text="AFEM:基于 GNN + PPO 强化学习\n的自适应网格细化方法",
+ font_size=Pt(38), font_color=BLACK, bold=True, line_spacing=1.3)
+
+add_textbox(slide, Inches(1.2), Inches(3.2), Inches(10.5), Inches(0.9),
+ text="二维 Helmholtz 电磁散射问题的智能网格优化 -- 算法流程与创新汇总",
+ font_size=Pt(18), font_color=BODY_GRAY)
+
+add_rect(slide, Inches(1.2), Inches(4.2), Inches(3.0), Pt(2), fill_color=ACCENT_BLUE)
+
+meta_lines = [
+ ("组会汇报 | 2025 年 5 月", False, Pt(14), CAPTION),
+ ("", False, Pt(8), CAPTION),
+ ("物理场景:二维 Helmholtz 方程 / 圆形介质散射体 / SBC 吸收边界", False, Pt(12), CAPTION),
+ ("方法栈:GNN (Message Passing) / PPO / 连续尺寸场 / 残差型误差估计", False, Pt(12), CAPTION),
+]
+add_multiline_textbox(slide, Inches(1.2), Inches(4.5), Inches(10.5), Inches(1.6),
+ meta_lines, line_spacing=1.5)
+add_slide_number(slide, 1)
+
+
+# ======================================================================
+# SLIDE 2: BACKGROUND
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "研究背景:为什么自适应网格细化很重要")
+
+left_bullets = [
+ "Helmholtz 方程描述电磁波在介质中的散射与传播,是电磁兼容、隐身设计、天线仿真等领域的基础方程",
+ "有限元 (FEM) 求解精度高度依赖网格质量:网格过粗导致数值色散/污染效应;网格过密浪费计算资源",
+ "高频 (k >> 1) 下污染效应严重:kh > 0.5 时 FEM 解定性错误,后续误差指示子完全不可靠",
+ "核心挑战:如何用最少的网格单元达到目标精度?在误差大的区域加密,误差小的区域保持稀疏",
+]
+add_bullet_textbox(slide, Inches(0.6), Inches(1.35), Inches(6.0), Inches(3.2),
+ left_bullets, font_size=SMALL_SIZE)
+
+add_rect(slide, Inches(7.2), Inches(1.35), Inches(5.5), Inches(3.2), fill_color=HIGHLIGHT_BG)
+physics_lines = [
+ ("物理方程", True, Pt(14), ACCENT_BLUE),
+ ("", False, Pt(4), BODY_GRAY),
+ ("nabla^2 u_scat + k^2 * eps_r * u_scat = k^2 * (1-eps_r) * u_inc", True, Pt(13), ACCENT_TEAL),
+ ("", False, Pt(4), BODY_GRAY),
+ ("入射波:沿 -x 方向的平面波 u_inc = exp(i*k*x)", False, Pt(11), BODY_GRAY),
+ ("散射体:圆形介质柱(eps_r 随机采样)", False, Pt(11), BODY_GRAY),
+ ("边界条件:SBC 吸收边界 du/dn = i*k*u", False, Pt(11), BODY_GRAY),
+ ("计算域:可配矩形域 [Lx, Ly]", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(7.4), Inches(1.5), Inches(5.1), Inches(2.8),
+ physics_lines, line_spacing=1.3)
+
+kpis = [
+ ("kh > 1.4", "高频下典型 kh 值\n(远超 0.5 安全线)", ACCENT_WARM),
+ ("400 -> 20,000", "网格单元数变化范围\n(初始 -> 最大上限)", ACCENT_BLUE),
+ ("[2, 20]", "训练波数 k 覆盖范围\n(涵盖中频到高频)", ACCENT_TEAL),
+]
+for i, (val, label, clr) in enumerate(kpis):
+ add_kpi_box(slide, Inches(0.6 + i * 4.2), Inches(4.95), Inches(3.8), Inches(1.1),
+ val, label, color=clr)
+
+add_takeaway_bar(slide, "Helmholtz 高频求解的核心矛盾:精度 vs 效率。需要智能网格细化策略来平衡二者。")
+add_source_label(slide, "参考文献:Ainsworth & Oden, A Posteriori Error Estimation in Finite Element Analysis, 2000")
+add_slide_number(slide, 2)
+
+
+# ======================================================================
+# SLIDE 3: KNOWLEDGE GAP
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "知识缺口与技术瓶颈")
+
+add_rect(slide, Inches(0.6), Inches(1.35), Inches(5.7), Inches(2.5), fill_color=None,
+ line_color=LIGHT_LINE, line_width=Pt(1))
+add_textbox(slide, Inches(0.8), Inches(1.4), Inches(5.3), Inches(0.4),
+ text="传统自适应方法的局限", font_size=SUBHEAD_SIZE, font_color=ACCENT_WARM, bold=True)
+trad_bullets = [
+ "基于误差指示子的 h-adaptivity 细化规则完全由人工设计",
+ "细化判据固定(如设定误差阈值),无法适应不同 PDE 的物理特征",
+ "SOLVE-ESTIMATE-MARK-REFINE 循环不考虑长期回报(每一步仅看当前误差)",
+ "无法学习特定问题的网格模式,无法迁移到新 PDE 配置",
+]
+add_bullet_textbox(slide, Inches(0.8), Inches(1.85), Inches(5.3), Inches(1.8),
+ trad_bullets, font_size=Pt(11))
+
+add_rect(slide, Inches(7.0), Inches(1.35), Inches(5.7), Inches(2.5), fill_color=None,
+ line_color=ACCENT_BLUE, line_width=Pt(1.5))
+add_textbox(slide, Inches(7.2), Inches(1.4), Inches(5.3), Inches(0.4),
+ text="本工作的目标", font_size=SUBHEAD_SIZE, font_color=ACCENT_BLUE, bold=True)
+goal_bullets = [
+ "用强化学习 (RL) 替代人工规则,自动发现最优细化策略",
+ "GNN 处理变长拓扑:每个三角形单元是一个独立的 RL agent",
+ "连续尺寸场输出 -> 概率性元素选择 -> 非均匀自适应网格",
+ "物理预算约束 + 误差驱动奖励 -> 计算资源集中在物理关键区域",
+]
+add_bullet_textbox(slide, Inches(7.2), Inches(1.85), Inches(5.3), Inches(1.8),
+ goal_bullets, font_size=Pt(11), bullet_char=">")
+
+add_textbox(slide, Inches(0.6), Inches(4.2), Inches(12.1), Inches(0.4),
+ text="本次汇报的核心创新(相较前序工作)", font_size=SUBHEAD_SIZE,
+ font_color=BLACK, bold=True)
+
+innovations = [
+ ("[1] 无量纲化残差误差估计", "k_local 归一化三项残差分量,消除纯几何尺度偏差,跨介质公平可比", ACCENT_BLUE),
+ ("[2] Score-based 连续尺寸场", "score = -x_i 纯排序 + 物理预算约束 + Doerfler-P95 动作掩码", ACCENT_TEAL),
+ ("[3] L2 聚合奖励设计", "sqrt(sum eta_child^2) <= eta_parent 保证 r_local >= 0,永不惩罚细化", ACCENT_GREEN),
+ ("[4] 尺度不变性架构", "N_init x domain_area + lambda 无量纲化特征 + ln 压缩 + 前渐近区约束", ACCENT_WARM),
+]
+for i, (title, desc, clr) in enumerate(innovations):
+ y = Inches(4.7 + i * 0.6)
+ add_rect(slide, Inches(0.6), y, Pt(3), Inches(0.45), fill_color=clr)
+ add_textbox(slide, Inches(0.85), y - Inches(0.02), Inches(3.0), Inches(0.45),
+ text=title, font_size=Pt(13), font_color=clr, bold=True)
+ add_textbox(slide, Inches(3.8), y - Inches(0.02), Inches(8.7), Inches(0.45),
+ text=desc, font_size=Pt(11), font_color=BODY_GRAY)
+
+add_takeaway_bar(slide, "核心思路:让网格细化的每一步都具有明确的物理语义,而非纯数据驱动的黑箱映射")
+add_slide_number(slide, 3)
+
+
+# ======================================================================
+# SLIDE 4: SYSTEM OVERVIEW
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "系统架构:RL 自适应网格细化闭环管线")
+
+stages = [
+ ("物理问题\n采样", ACCENT_BLUE),
+ ("初始网格\n生成", ACCENT_BLUE),
+ ("GNN\n观测", ACCENT_TEAL),
+ ("Actor\n动作", ACCENT_TEAL),
+ ("尺寸场\n排序", ACCENT_WARM),
+ ("预算\n选择", ACCENT_WARM),
+ ("网格\n细化", ACCENT_GREEN),
+ ("FEM\n求解", ACCENT_GREEN),
+ ("误差\n估计", ACCENT_GREEN),
+ ("Reward\n计算", ACCENT_GREEN),
+]
+
+y_center = Inches(2.6)
+box_w = Inches(1.1)
+box_h = Inches(0.85)
+gap = (Inches(12.1) - box_w * 10) / 9
+
+for i, (label, clr) in enumerate(stages):
+ x = Inches(0.6) + i * (box_w + gap)
+ add_rect(slide, x, y_center, box_w, box_h, fill_color=HIGHLIGHT_BG,
+ line_color=clr, line_width=Pt(1.5))
+ add_textbox(slide, x, y_center + Inches(0.05), box_w, box_h - Inches(0.1),
+ text=label, font_size=Pt(11), font_color=clr, bold=True,
+ alignment=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
+ if i < len(stages) - 1:
+ arrow_x = x + box_w
+ add_textbox(slide, arrow_x, y_center + Inches(0.22), gap, Inches(0.35),
+ text=">", font_size=Pt(16), font_color=LIGHT_LINE, bold=True,
+ alignment=PP_ALIGN.CENTER)
+
+add_textbox(slide, Inches(6.0), Inches(3.55), Inches(1.5), Inches(0.35),
+ text="<-- 下一轮迭代(多步 rollout)", font_size=Pt(10), font_color=ACCENT_TEAL,
+ alignment=PP_ALIGN.CENTER)
+
+# RL modeling
+add_textbox(slide, Inches(0.6), Inches(4.1), Inches(6.0), Inches(0.35),
+ text="RL 问题建模", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+rl_lines = [
+ ("Agent = 每个三角形单元(数量动态变化,约 400 -> 20,000)", False, Pt(11), BODY_GRAY),
+ ("State = GNN 节点 12 维特征(几何 + PDE 残差 + 场量 + 物理参数)", False, Pt(11), BODY_GRAY),
+ ("Action = 1 维连续标量 x_i -> score = -x_i 排序 -> top-k 选择细化单元", False, Pt(11), BODY_GRAY),
+ ("Reward = L2 聚合局部改善 + 全局势函数塑形 - 动作惩罚", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(0.6), Inches(4.5), Inches(6.0), Inches(2.0),
+ rl_lines, line_spacing=1.6)
+
+# PPO training
+add_textbox(slide, Inches(7.2), Inches(4.1), Inches(5.5), Inches(0.35),
+ text="PPO 训练配置", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+train_lines = [
+ ("双 GNN 架构:Policy / Value 各自独立 MessagePassingBase", False, Pt(11), BODY_GRAY),
+ ("2 层消息传递,inner 残差 + LayerNorm,latent_dim=64", False, Pt(11), BODY_GRAY),
+ ("DiagGaussian 连续动作分布,log_std 可学习,clamp [-4, -1]", False, Pt(11), BODY_GRAY),
+ ("256 步 Rollout,5 Epochs,GAE lambda=0.95,lr=3e-4,梯度裁剪 0.5", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(7.2), Inches(4.5), Inches(5.5), Inches(2.0),
+ train_lines, line_spacing=1.6)
+
+add_takeaway_bar(slide, "闭环 RL 管线:物理求解 -> GNN 感知 -> 策略决策 -> 网格操作 -> 误差反馈 -> 策略更新")
+add_slide_number(slide, 4)
+
+
+# ======================================================================
+# SLIDE 5: INNOVATION 1 - Non-dimensionalized residual error
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新 [1]:无量纲化残差误差估计 -- 消除几何尺度偏差")
+
+add_textbox(slide, Inches(0.6), Inches(1.25), Inches(5.8), Inches(0.35),
+ text="前序问题:原始残差包含 h_K、h_e 等几何尺度,不同区域不可直接比较", font_size=Pt(13), font_color=ACCENT_WARM)
+add_textbox(slide, Inches(0.6), Inches(1.55), Inches(5.8), Inches(0.35),
+ text="解决方案:引入局部波数 k_local 做无量纲归一化,反映相位分辨率残差", font_size=Pt(13), font_color=ACCENT_BLUE)
+
+formulas = [
+ ("内部残差 r_int",
+ "(h_K/k_local) * sqrt(V) * |k^2*eps_r*u + k^2*(eps_r-1)*u_inc|_K",
+ "单元内部 PDE 残差;除以 k_local 使大 eps_r 介质区与真空区可比"),
+ ("梯度跳变 r_jump",
+ "sqrt(1/2 * sum_{e in dK} (h_e/k_local) * |[[grad u * n]]|^2_e)",
+ "相邻单元梯度跳变;h_e/k_local 使细化后跳变自然衰减"),
+ ("SBC 边界 r_sbc",
+ "(h_bnd/k_local) * |du/dn - i*k_local*u|",
+ "Sommerfeld 吸收边界残差,仅在边界单元非零"),
+]
+
+for i, (name, formula, desc) in enumerate(formulas):
+ x = Inches(0.6 + i * 4.1)
+ add_rect(slide, x, Inches(2.0), Inches(3.85), Inches(1.65), fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, x + Inches(0.15), Inches(2.05), Inches(3.55), Inches(0.3),
+ text=name, font_size=Pt(13), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, x + Inches(0.15), Inches(2.35), Inches(3.55), Inches(0.65),
+ text=formula, font_size=Pt(11), font_color=BLACK)
+ add_textbox(slide, x + Inches(0.15), Inches(3.05), Inches(3.55), Inches(0.5),
+ text=desc, font_size=Pt(10), font_color=CAPTION)
+
+add_rect(slide, Inches(0.6), Inches(3.95), Inches(12.1), Inches(0.7), fill_color=None,
+ line_color=ACCENT_BLUE, line_width=Pt(1.5))
+add_textbox(slide, Inches(0.8), Inches(4.0), Inches(3.5), Inches(0.55),
+ text="逐单元误差指示子", font_size=Pt(15), font_color=BLACK, bold=True,
+ anchor=MSO_ANCHOR.MIDDLE)
+add_textbox(slide, Inches(4.0), Inches(4.0), Inches(3.5), Inches(0.55),
+ text="eta_K = sqrt(r_int^2 + r_jump^2 + r_sbc^2)", font_size=Pt(15),
+ font_color=ACCENT_BLUE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
+add_textbox(slide, Inches(7.5), Inches(4.0), Inches(5.0), Inches(0.55),
+ text="三项均严格无量纲\n跨介质、跨频率公平可比", font_size=Pt(13),
+ font_color=ACCENT_GREEN, anchor=MSO_ANCHOR.MIDDLE)
+
+add_textbox(slide, Inches(0.6), Inches(4.85), Inches(12.1), Inches(0.3),
+ text="量纲分析验证", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+da_lines = [
+ ("k_local ~ [L]^-1, h_e ~ [L], |jump|^2 ~ [L]^-2 => h_e/k_local * |jump|^2 ~ [L]^2 * [L]^-2 = 1 严格无量纲", False, Pt(11), BODY_GRAY),
+ ("GNN 输入用 log10 压缩的特征;Reward 用原始 eta_K(不经 log 压缩),两者公式一致,物理语义对齐", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(0.6), Inches(5.15), Inches(12.1), Inches(0.8),
+ da_lines, line_spacing=1.5)
+
+add_takeaway_bar(slide, "k_local 归一化使误差指示子反映相位分辨率残差而非网格粗疏程度,为 RL agent 提供物理一致的误差信号")
+add_slide_number(slide, 5)
+
+
+# ======================================================================
+# SLIDE 6: INNOVATION 2 - 12D enhanced input features
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新 [2]:12 维增强输入特征 -- 赋予 GNN 几何与物理感知")
+
+add_textbox(slide, Inches(0.6), Inches(1.25), Inches(12.1), Inches(0.35),
+ text="前序 11 维 -> 现 12 维,新增 dist_to_interface。全部尺度相关特征均以真空波长 lambda=2*pi/k 无量纲化", font_size=Pt(13), font_color=ACCENT_BLUE)
+
+# Feature table — compact layout to avoid overflow
+row_h = Inches(0.30)
+table_top = Inches(1.65)
+cols = [Inches(0.6), Inches(2.0), Inches(5.5), Inches(9.8)]
+col_w = [Inches(1.4), Inches(3.5), Inches(4.3), Inches(3.1)]
+headers = ["维度", "特征名称", "物理含义", "归一化"]
+
+for j, (cx, hdr, w) in enumerate(zip(cols, headers, col_w)):
+ add_rect(slide, cx, table_top, w, row_h, fill_color=TABLE_HDR)
+ add_textbox(slide, cx + Inches(0.06), table_top, w - Inches(0.12), row_h,
+ text=hdr, font_size=Pt(9), font_color=BLACK, bold=True,
+ anchor=MSO_ANCHOR.MIDDLE)
+
+features = [
+ ("volume", "无量纲单元面积", "volume / lambda^2"),
+ ("internal_residual", "内部残差(k_local 无量纲化 + log10)", "--"),
+ ("gradient_jump", "梯度跳变残差(k_local 无量纲化 + log10)", "--"),
+ ("sbc_residual", "SBC 边界残差(k_local 无量纲化 + log10)", "--"),
+ ("element_penalty", "单元惩罚系数 lambda", "--"),
+ ("timestep", "当前 rollout 步数", "--"),
+ ("wave_number", "Helmholtz 波数 k", "--"),
+ ("k_local_sqrt_vol", "k_local x sqrt(volume) 已无量纲", "--"),
+ ("is_sbc_boundary", "是否与 SBC 边界相邻 (0/1)", "--"),
+ ("dist_to_interface", "到介质边界的带符号距离 [新增]", "sign(d)*ln(1+|d|/lambda)"),
+ ("epsilon_r", "单元中点介电常数(内=eps_r, 外=1.0)", "--"),
+ ("total_solution_magnitude", "散射场复数解的振幅", "--"),
+]
+
+for i, (name, meaning, norm) in enumerate(features):
+ y = table_top + row_h + i * row_h
+ bg = TABLE_ALT if i % 2 == 1 else WHITE
+ is_new = "[新增]" in meaning
+ cells = [name, meaning, norm]
+ for j, (cx, cell_text, w) in enumerate(zip(cols, cells, col_w)):
+ add_rect(slide, cx, y, w, row_h, fill_color=bg, line_color=LIGHTER_LINE, line_width=Pt(0.5))
+ clr = ACCENT_TEAL if is_new and j == 1 else BODY_GRAY
+ bld = is_new and j == 1
+ add_textbox(slide, cx + Inches(0.06), y, w - Inches(0.12), row_h,
+ text=cell_text, font_size=Pt(8), font_color=clr, bold=bld,
+ anchor=MSO_ANCHOR.MIDDLE)
+
+# Edge feature note — positioned after table (table bottom = 1.65 + 0.30 + 12*0.30 = 5.55")
+add_textbox(slide, Inches(0.6), Inches(5.65), Inches(12.1), Inches(0.25),
+ text="边特征 (1 维):euclidean_distance / lambda -- 相邻单元中点无量纲距离 | 合计:12 (节点) + 1 (边) = 13 维图特征",
+ font_size=Pt(9), font_color=BODY_GRAY)
+
+add_takeaway_bar(slide, "全部与尺度相关的特征均以 lambda 做无量纲归一化;dist_to_interface 用 sign·ln(1+|d|) 对数压缩,近场线性、远场自然压缩,与残差 log10 风格统一")
+add_slide_number(slide, 6)
+
+
+# ======================================================================
+# SLIDE 7: INNOVATION 3 - Score-based sizing field
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新 [3]:Score-based 连续尺寸场 + 物理预算约束 + 动作掩码")
+
+add_textbox(slide, Inches(0.6), Inches(1.25), Inches(5.7), Inches(0.35),
+ text="前序方案:S_i = N_base x Softplus(x_i) / Softplus(0) x median_area", font_size=Pt(12), font_color=ACCENT_WARM)
+add_textbox(slide, Inches(0.6), Inches(1.55), Inches(5.7), Inches(0.35),
+ text="--> 依赖 median_area 基准,域缩放后语义漂移 (1x1 -> 2x2 基准 x4)", font_size=Pt(10), font_color=CAPTION)
+add_textbox(slide, Inches(6.9), Inches(1.25), Inches(5.6), Inches(0.35),
+ text="当前方案:score = -x_i 纯排序 + 物理预算约束", font_size=Pt(12), font_color=ACCENT_BLUE)
+add_textbox(slide, Inches(6.9), Inches(1.55), Inches(5.6), Inches(0.35),
+ text="--> score 排序丢失面积语义,但获得尺度不变性", font_size=Pt(10), font_color=CAPTION)
+
+add_rect(slide, Inches(0.6), Inches(2.1), Inches(12.1), Inches(3.1), fill_color=HIGHLIGHT_BG)
+add_textbox(slide, Inches(0.8), Inches(2.15), Inches(5.0), Inches(0.3),
+ text="细化选择算法", font_size=Pt(14), font_color=BLACK, bold=True)
+
+algo_steps = [
+ ("Step 1: 物理预算",
+ "A_budget_i = 1/2 x (lambda_local_i / 6)^2 仅用于 N_budget 计算\nN_budget = max(N_phys, ceil(5 x N_init)) rho_min=5.0,至少 5 倍初始单元数"),
+ ("Step 2: Score 排序",
+ "score = -x_i (Actor 输出标量)\nx 越小 -> 优先级越高,纯排序,不设正负门槛"),
+ ("Step 3: 双过滤器",
+ "eligible = {i | area_i > 0.25 x A_budget_i AND eta_i >= 0.05 x eta_P95}\narea_floor: 排除已足够细的单元\nDoerfler-P95: 排除低误差单元 (P95 锚定物理误差尺度)"),
+ ("Step 4: Top-k 选择",
+ "num = min(|eligible|, N_current//4, remaining//3) (自适应 cap, 增速 N//4)\nselected = top-k by score -> 1-to-4 切分细化"),
+]
+
+for i, (title, content) in enumerate(algo_steps):
+ y = Inches(2.55 + i * 0.63)
+ add_textbox(slide, Inches(0.9), y, Inches(2.0), Inches(0.55),
+ text=title, font_size=Pt(11), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, Inches(2.9), y, Inches(9.5), Inches(0.55),
+ text=content, font_size=Pt(10), font_color=BODY_GRAY)
+
+add_rect(slide, Inches(0.6), Inches(5.45), Inches(12.1), Inches(0.95), fill_color=None,
+ line_color=ACCENT_BLUE, line_width=Pt(0.5))
+add_textbox(slide, Inches(0.8), Inches(5.5), Inches(11.7), Inches(0.85),
+ text="为什么用 Doerfler-P95 而非 median/mean?P95 锚定物理误差尺度,免疫远场噪声稀释。远场低 eta 区即使占 90% 的单元,也不会拉低锚点。确保只有误差真正达标的区域才消耗细化预算。",
+ font_size=Pt(11), font_color=BODY_GRAY)
+
+add_takeaway_bar(slide, "Score-based 排序 + 物理预算 + Doerfler-P95 掩码:三层保障确保细化资源只投入到物理上需要的地方")
+add_slide_number(slide, 7)
+
+
+# ======================================================================
+# SLIDE 8: INNOVATION 4 - L2 aggregation reward
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新 [4]:L2 聚合奖励设计 -- 保证非负,永不惩罚细化")
+
+add_rect(slide, Inches(0.6), Inches(1.25), Inches(12.1), Inches(0.85), fill_color=HIGHLIGHT_BG)
+add_textbox(slide, Inches(0.8), Inches(1.3), Inches(11.7), Inches(0.75),
+ text="核心洞察:对 1-to-4 切分,用 L2 聚合 sqrt(sum eta_child^2) <= eta_parent 天然成立 -- 因为平方后 int 项 1->1/4 而 jump/sbc 项 1->1。\n如果用 L1 sum,sum eta_child > eta_parent(因 jump/sbc 项不变),会导致「细化=惩罚」。L2 聚合从根本上避免了这一结构性负偏置。",
+ font_size=Pt(12), font_color=BLACK)
+
+add_rect(slide, Inches(0.6), Inches(2.35), Inches(7.5), Inches(1.85), fill_color=None,
+ line_color=ACCENT_BLUE, line_width=Pt(1.5))
+add_textbox(slide, Inches(0.8), Inches(2.4), Inches(7.1), Inches(0.3),
+ text="逐步奖励计算", font_size=Pt(14), font_color=BLACK, bold=True)
+reward_lines = [
+ ("r_local_i = log(eta_old_i + eps) - log( sqrt(sum_{j:M[j]=i} eta_new_j^2) + eps )", True, Pt(13), ACCENT_BLUE),
+ ("", False, Pt(4), BODY_GRAY),
+ ("- 纯 int 主导区: eta_parent^2 = int^2, sum eta_child^2 = int^2/4 -> r_local = log(2) = +0.69 (强正奖励)", False, Pt(11), BODY_GRAY),
+ ("- 纯 jump/sbc 主导区: eta_parent^2 = jump^2, sum eta_child^2 = jump^2 -> r_local = 0 (中性)", False, Pt(11), BODY_GRAY),
+ ("- 永不惩罚细化 -- 与 L1 sum 方案根本不同", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(0.8), Inches(2.7), Inches(7.1), Inches(1.4),
+ reward_lines, line_spacing=1.35)
+
+add_rect(slide, Inches(8.5), Inches(2.35), Inches(4.2), Inches(1.85), fill_color=WARN_BG)
+add_textbox(slide, Inches(8.7), Inches(2.4), Inches(3.8), Inches(0.3),
+ text="epsilon_dynamic 动态截断", font_size=Pt(14), font_color=BLACK, bold=True)
+ed_lines = [
+ ("eps = max(0.05 x mean(eta_new), 1e-6)", True, Pt(11), ACCENT_WARM),
+ ("", False, Pt(4), BODY_GRAY),
+ ("自适应钳制,切断远场", False, Pt(11), BODY_GRAY),
+ ("低 eta 区的 reward hacking", False, Pt(11), BODY_GRAY),
+ ("", False, Pt(4), BODY_GRAY),
+ ("防止 log(0) 数值爆炸,", False, Pt(11), BODY_GRAY),
+ ("锚定当前误差分布而非", False, Pt(11), BODY_GRAY),
+ ("固定阈值", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(8.7), Inches(2.7), Inches(3.8), Inches(1.4),
+ ed_lines, line_spacing=1.2)
+
+add_textbox(slide, Inches(0.6), Inches(4.45), Inches(6.0), Inches(0.3),
+ text="动作惩罚与元素上限", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+pen_lines = [
+ ("penalty_i = lambda x (n_i-1) + (lambda_limit/N_old) x 1[达到上限], lambda=0.06, lambda_limit=10000", False, Pt(12), BODY_GRAY),
+ ("lambda 仅为 r_local 均值的约 1/6,轻微抑制网格膨胀,不影响主要学习信号", False, Pt(11), CAPTION),
+]
+add_multiline_textbox(slide, Inches(0.6), Inches(4.8), Inches(6.0), Inches(0.7),
+ pen_lines, line_spacing=1.5)
+
+add_textbox(slide, Inches(7.2), Inches(4.45), Inches(5.5), Inches(0.3),
+ text="全局势函数塑形", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+glob_lines = [
+ ("E_global = sqrt(sum eta_K^2) / ||u_h||_{L2(Omega)} (无量纲全局误差)", False, Pt(12), BODY_GRAY),
+ ("global_bonus = alpha x [log(E_old) - log(E_new)], alpha = 0.2", False, Pt(12), BODY_GRAY),
+ ("仅发给被细化的父单元 -- 避免被未细化单元稀释信号", False, Pt(11), CAPTION),
+]
+add_multiline_textbox(slide, Inches(7.2), Inches(4.8), Inches(5.5), Inches(0.7),
+ glob_lines, line_spacing=1.5)
+
+add_takeaway_bar(slide, "奖励公式 = L2 聚合局部改善 (>=0) + 全局势函数塑形 (仅细化单元) - 轻微动作惩罚 -> 每个被细化父单元净奖励约 +0.387")
+add_slide_number(slide, 8)
+
+
+# ======================================================================
+# SLIDE 9: REWARD CALIBRATION
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "奖励标度校准:随机策略下各分量量级实测")
+
+add_textbox(slide, Inches(0.6), Inches(1.25), Inches(12.1), Inches(0.35),
+ text="随机策略下 1,321 个 refined-parent 样本实测(score-based 尺寸场)", font_size=Pt(12), font_color=CAPTION)
+
+kpi_data = [
+ ("+0.364", "r_local (L2 聚合)", "局部误差改善,主体信号"),
+ ("+0.045", "penalty (lambda=0.02)", "仅占 r_local 的约 1/8"),
+ ("+0.069", "alpha x Delta_logE (alpha=0.2)", "全局改善信号,约 r_local/5"),
+ ("+0.387", "净奖励 net reward", "r_local >> penalty [check]"),
+]
+
+for i, (val, label, desc) in enumerate(kpi_data):
+ x = Inches(0.6 + i * 3.1)
+ add_rect(slide, x, Inches(1.7), Inches(2.85), Inches(1.2), fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, x + Inches(0.1), Inches(1.75), Inches(2.65), Inches(0.4),
+ text=val, font_size=Pt(24), font_color=ACCENT_BLUE, bold=True,
+ alignment=PP_ALIGN.CENTER)
+ add_textbox(slide, x + Inches(0.1), Inches(2.15), Inches(2.65), Inches(0.3),
+ text=label, font_size=Pt(11), font_color=BLACK, bold=True,
+ alignment=PP_ALIGN.CENTER)
+ add_textbox(slide, x + Inches(0.1), Inches(2.45), Inches(2.65), Inches(0.35),
+ text=desc, font_size=Pt(9), font_color=CAPTION,
+ alignment=PP_ALIGN.CENTER)
+
+add_textbox(slide, Inches(0.6), Inches(3.2), Inches(12.1), Inches(0.3),
+ text="设计验证", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+design_checks = [
+ ("[OK] r_local >> penalty", "局部 credit assignment 不被惩罚信号淹没,agent 能清晰感知细化 -> 误差下降的因果关系"),
+ ("[OK] alpha x Delta_logE = r_local / 5", "全局信号提供趋势引导但不主导局部决策,避免 loss of local credit assignment"),
+ ("[OK] r_local >= 0 保证", "L2 聚合天然保证非负,网络永远不会因细化而受到惩罚"),
+]
+for i, (check, detail) in enumerate(design_checks):
+ add_textbox(slide, Inches(0.8), Inches(3.5 + i * 0.45), Inches(2.8), Inches(0.35),
+ text=check, font_size=Pt(12), font_color=ACCENT_GREEN, bold=True)
+ add_textbox(slide, Inches(3.6), Inches(3.5 + i * 0.45), Inches(9.1), Inches(0.35),
+ text=detail, font_size=Pt(11), font_color=BODY_GRAY)
+
+add_textbox(slide, Inches(0.6), Inches(5.1), Inches(12.1), Inches(0.3),
+ text="奖励信号链", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+flow_steps = [
+ ("FEM 求解", "eta_K per element", ACCENT_BLUE),
+ ("L2 聚合", "log(eta_old / sqrt(sum_chi^2))", ACCENT_TEAL),
+ ("+ eps_dynamic", "截断保护", ACCENT_WARM),
+ ("- penalty", "lambda x (n-1) 防膨胀", ACCENT_WARM),
+ ("+ global", "alpha x Delta_logE 仅细化单元", ACCENT_GREEN),
+ ("-> r_i", "送入 PPO GAE", ACCENT_BLUE),
+]
+
+for i, (step_name, step_desc, clr) in enumerate(flow_steps):
+ x = Inches(0.6 + i * 2.05)
+ add_rect(slide, x, Inches(5.45), Inches(1.8), Inches(0.7), fill_color=HIGHLIGHT_BG,
+ line_color=clr, line_width=Pt(1))
+ add_textbox(slide, x + Inches(0.1), Inches(5.48), Inches(1.6), Inches(0.3),
+ text=step_name, font_size=Pt(11), font_color=clr, bold=True,
+ alignment=PP_ALIGN.CENTER)
+ add_textbox(slide, x + Inches(0.1), Inches(5.78), Inches(1.6), Inches(0.3),
+ text=step_desc, font_size=Pt(9), font_color=CAPTION,
+ alignment=PP_ALIGN.CENTER)
+ if i < len(flow_steps) - 1:
+ add_textbox(slide, x + Inches(1.8), Inches(5.6), Inches(0.25), Inches(0.3),
+ text=">", font_size=Pt(14), font_color=LIGHT_LINE, bold=True,
+ alignment=PP_ALIGN.CENTER)
+
+add_takeaway_bar(slide, "奖励各分量量级经过标定,满足 r_local >> penalty 且 alpha x Delta_logE 适度,agent 能学到细化 = 有益的信息")
+add_slide_number(slide, 9)
+
+
+# ======================================================================
+# SLIDE 10: SCALE INVARIANCE
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新 [5]:尺度不变性架构 -- 从 1x1 到 2x2 的泛化")
+
+add_textbox(slide, Inches(0.6), Inches(1.25), Inches(12.1), Inches(0.4),
+ text="问题:1x1 域训练 -> 2x2 域测试时,中心介质处网格未加密,远场误差显著增大", font_size=Pt(14), font_color=ACCENT_WARM)
+
+add_textbox(slide, Inches(0.6), Inches(1.75), Inches(12.1), Inches(0.3),
+ text="根因分析(双重漂移)", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+roots = [
+ ("N_init 不随 domain area 缩放", "4x 面积用同数量单元 -> h 2x, area 4x", "N_init *= domain_area"),
+ ("特征绝对值漂移", "volume/edge/dist 值随 domain 线性或平方放大", "全部用 lambda 无量纲化"),
+ ("dist 远场 OOD", "2x2 域远角 dist/lambda 可达训练域 3x", "sign·ln(1+|d|/lambda) 对数压缩"),
+]
+
+for i, (problem, cause, fix) in enumerate(roots):
+ x = Inches(0.6 + i * 4.1)
+ add_rect(slide, x, Inches(2.1), Inches(3.85), Inches(1.3), fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, x + Inches(0.1), Inches(2.13), Inches(3.65), Inches(0.3),
+ text=problem, font_size=Pt(13), font_color=ACCENT_WARM, bold=True)
+ add_textbox(slide, x + Inches(0.1), Inches(2.45), Inches(3.65), Inches(0.4),
+ text=f"原因: {cause}", font_size=Pt(10), font_color=BODY_GRAY)
+ add_textbox(slide, x + Inches(0.1), Inches(2.85), Inches(3.65), Inches(0.4),
+ text=f"--> {fix}", font_size=Pt(11), font_color=ACCENT_GREEN)
+
+add_rect(slide, Inches(0.6), Inches(3.65), Inches(7.5), Inches(2.05), fill_color=None,
+ line_color=ACCENT_BLUE, line_width=Pt(1))
+add_textbox(slide, Inches(0.8), Inches(3.7), Inches(7.1), Inches(0.3),
+ text="四项联动改进 = 完整的尺度不变性", font_size=Pt(14), font_color=BLACK, bold=True)
+k_mesh_lines = [
+ ("1. N_init = N_base x (k/k_ref)^k_exponent x domain_area (exponent/k_ref 可配,保证每单位面积密度一致)", False, Pt(12), BODY_GRAY),
+ ("2. volume -> volume / lambda^2, euclidean_distance -> euclidean_distance / lambda", False, Pt(12), BODY_GRAY),
+ ("3. dist_to_interface -> sign(d)*ln(1+|d|/lambda) (近场线性、远场对数压缩,与 log10 残差风格一致)", False, Pt(12), BODY_GRAY),
+ ("4. 介质区前渐近区边缘约束: 强制迭代细化至 h <= lambda_d/N (N=1.5)", False, Pt(12), BODY_GRAY),
+ ("--> 四项联动:N_init 修 h 漂移 + lambda 归一化修特征绝对值 + tanh 修远场 OOD", False, Pt(11), CAPTION),
+]
+add_multiline_textbox(slide, Inches(0.8), Inches(4.0), Inches(7.1), Inches(1.5),
+ k_mesh_lines, line_spacing=1.3)
+
+add_textbox(slide, Inches(8.5), Inches(3.7), Inches(4.2), Inches(0.3),
+ text="N_init 缩放效果示例", font_size=Pt(13), font_color=BLACK, bold=True)
+k_table_lines = [
+ ("exponent 可配: ^2 = 理论最优, ^1.5 = 工程折中", False, Pt(10), BODY_GRAY),
+ ("N_init 始终 = COMSOL 目标的 30-50%", False, Pt(10), BODY_GRAY),
+ ("", False, Pt(4), BODY_GRAY),
+ ("改前: 无 domain_area 缩放", True, Pt(10), ACCENT_WARM),
+ ("-> 换 domain size 后 N_init 不变", False, Pt(10), CAPTION),
+ ("-> h 随 domain 缩放,特征 OOD", False, Pt(10), CAPTION),
+]
+add_multiline_textbox(slide, Inches(8.5), Inches(4.0), Inches(4.2), Inches(1.7),
+ k_table_lines, line_spacing=1.3)
+
+add_takeaway_bar(slide, "N_init x domain_area + lambda 无量纲化 + ln 对数压缩:三项联动使模型可物理一致地泛化到任意尺寸测试域")
+add_slide_number(slide, 10)
+
+
+# ======================================================================
+# SLIDE 11: DUAL GNN ARCHITECTURE
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "双 GNN 架构与 PPO 训练细节")
+
+add_textbox(slide, Inches(0.6), Inches(1.3), Inches(12.1), Inches(0.35),
+ text="图观测 -> MessagePassingBase (Policy/Value 各自独立) -> Actor/Critic 头", font_size=Pt(13), font_color=BLACK, bold=True)
+
+add_rect(slide, Inches(0.6), Inches(1.8), Inches(5.8), Inches(3.0), fill_color=HIGHLIGHT_BG)
+add_textbox(slide, Inches(0.8), Inches(1.85), Inches(5.4), Inches(0.3),
+ text="MessagePassingBase (x2, Policy / Value 各自独立基座)", font_size=Pt(13), font_color=ACCENT_BLUE, bold=True)
+
+gnn_items = [
+ ("节点嵌入", "Linear(12 -> 64)"),
+ ("边嵌入", "Linear(1 -> 64)"),
+ ("MP Step 1", "EdgeModule: MLP([src|dst|edge_attr]) -> 64d"),
+ ("", "NodeModule: MLP([node|scatter_mean(入边)]) -> 64d"),
+ ("", "+ inner 残差 + LayerNorm"),
+ ("MP Step 2", "同 Step 1,堆叠 2 层"),
+ ("输出", "节点隐向量 (num_nodes, 64)"),
+]
+
+for i, (label, detail) in enumerate(gnn_items):
+ y = Inches(2.25 + i * 0.32)
+ if label:
+ add_textbox(slide, Inches(0.9), y, Inches(1.6), Inches(0.28),
+ text=label, font_size=Pt(10), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, Inches(2.5), y, Inches(3.7), Inches(0.28),
+ text=detail, font_size=Pt(10), font_color=BODY_GRAY)
+
+add_rect(slide, Inches(7.0), Inches(1.8), Inches(5.7), Inches(1.4), fill_color=None,
+ line_color=ACCENT_TEAL, line_width=Pt(1))
+add_textbox(slide, Inches(7.2), Inches(1.85), Inches(5.3), Inches(0.3),
+ text="Actor 头(策略网络)", font_size=Pt(13), font_color=ACCENT_TEAL, bold=True)
+actor_items = [
+ ("MLP: 2 层 Tanh (64 -> 64 -> 64)", False, Pt(11), BODY_GRAY),
+ ("Linear(64 -> 1): 输出 x_i (连续标量)", False, Pt(11), BODY_GRAY),
+ ("log_std: 可学习参数,初始化 -2.0 (std = 0.135)", False, Pt(11), BODY_GRAY),
+ ("DiagGaussian(mu, sigma): 每节点独立动作分布", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(7.2), Inches(2.2), Inches(5.3), Inches(0.9),
+ actor_items, line_spacing=1.3)
+
+add_rect(slide, Inches(7.0), Inches(3.4), Inches(5.7), Inches(1.4), fill_color=None,
+ line_color=ACCENT_GREEN, line_width=Pt(1))
+add_textbox(slide, Inches(7.2), Inches(3.45), Inches(5.3), Inches(0.3),
+ text="Critic 头(价值网络)", font_size=Pt(13), font_color=ACCENT_GREEN, bold=True)
+critic_items = [
+ ("MLP: 2 层 Tanh (64 -> 64 -> 1)", False, Pt(11), BODY_GRAY),
+ ("输出: V_i(s) 逐节点价值 (num_agents, 1)", False, Pt(11), BODY_GRAY),
+ ("spatial value function: 不做聚合,保持逐节点", False, Pt(11), BODY_GRAY),
+ ("GAE 中用 scatter_add 做子->父投影,处理变长拓扑", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(7.2), Inches(3.75), Inches(5.3), Inches(0.9),
+ critic_items, line_spacing=1.3)
+
+add_textbox(slide, Inches(0.6), Inches(5.1), Inches(12.1), Inches(0.3),
+ text="PPO 关键设计", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+ppo_details = [
+ ("单路 GAE", "scatter_add 将子单元值聚合回父单元,无需多路 GAE", ACCENT_BLUE),
+ ("log_std clamp", "每步 optimizer.step() 后 clamp 到 [-4.0, -1.0],std in [0.018, 0.368]", ACCENT_TEAL),
+ ("熵正则", "entropy_coefficient=0.001,防止 log_std 过早收敛到下限", ACCENT_GREEN),
+ ("梯度裁剪", "max_grad_norm=0.5,稳定训练过程", ACCENT_WARM),
+]
+for i, (tag, desc, clr) in enumerate(ppo_details):
+ x = Inches(0.6 + i * 3.1)
+ add_rect(slide, x, Inches(5.45), Inches(2.85), Inches(0.85), fill_color=HIGHLIGHT_BG)
+ add_textbox(slide, x + Inches(0.1), Inches(5.5), Inches(2.65), Inches(0.3),
+ text=tag, font_size=Pt(13), font_color=clr, bold=True, alignment=PP_ALIGN.CENTER)
+ add_textbox(slide, x + Inches(0.1), Inches(5.8), Inches(2.65), Inches(0.4),
+ text=desc, font_size=Pt(10), font_color=BODY_GRAY, alignment=PP_ALIGN.CENTER)
+
+add_takeaway_bar(slide, "双 GNN 各自独立建模 + DiagGaussian 连续动作 + scatter_add 单路 GAE -> 适合变长 agent 拓扑的 RL 训练框架")
+add_slide_number(slide, 11)
+
+
+# ======================================================================
+# SLIDE 12: TRAINING OBSERVATIONS
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "训练观察与诊断:奖励稀疏性与大波数泛化")
+
+add_rect(slide, Inches(0.6), Inches(1.3), Inches(5.8), Inches(2.4), fill_color=WARN_BG)
+add_textbox(slide, Inches(0.8), Inches(1.35), Inches(5.4), Inches(0.35),
+ text="观察 1: 75% rollout 步骤零 reward", font_size=Pt(14), font_color=ACCENT_WARM, bold=True)
+obs1_lines = [
+ ("4 步 rollout 中,第 0 步细化后介质区已达标 (h/lambda = 13 > N=15 参考线)", False, Pt(11), BODY_GRAY),
+ ("步 1-3 全为零 reward,75% 的 FEM 求解白白浪费", False, Pt(11), BODY_GRAY),
+ ("原因: 1-to-4 切分太粗,一步即达标,不存在差一点的中间状态", False, Pt(11), BODY_GRAY),
+ ("偶尔的 spike (reward ~60) 来自随机探索中极负的 x_i 触发第二步细化", False, Pt(11), BODY_GRAY),
+ ("--> 步 0 的 reward 信号足够训练「在哪里细化」的判断,但多步策略无法学习", False, Pt(11), CAPTION),
+]
+add_multiline_textbox(slide, Inches(0.8), Inches(1.7), Inches(5.4), Inches(1.85),
+ obs1_lines, line_spacing=1.35)
+
+add_rect(slide, Inches(7.0), Inches(1.3), Inches(5.7), Inches(2.4), fill_color=WARN_BG)
+add_textbox(slide, Inches(7.2), Inches(1.35), Inches(5.3), Inches(0.35),
+ text="观察 2: 高 k 扇形阴影区网格偏粗", font_size=Pt(14), font_color=ACCENT_WARM, bold=True)
+obs2_lines = [
+ ("k in [2,20] 训练,小 k 尚可,大 k 效果不佳", False, Pt(11), BODY_GRAY),
+ ("介质后方 +x 方向扇形区域网格偏粗,误差较大", False, Pt(11), BODY_GRAY),
+ ("根本原因: 污染效应 -> 初始 kh > 0.5 时 FEM 解定性错误 (GIGO)", False, Pt(11), BODY_GRAY),
+ ("粗网格 -> 错误解 -> 不可靠 eta -> 垃圾 GNN 特征 -> 垃圾动作", False, Pt(11), BODY_GRAY),
+ ("2 层 GNN 感受野仅约 10 个单元,网络不知道自己在介质后方", False, Pt(11), BODY_GRAY),
+]
+add_multiline_textbox(slide, Inches(7.2), Inches(1.7), Inches(5.3), Inches(1.85),
+ obs2_lines, line_spacing=1.35)
+
+add_textbox(slide, Inches(0.6), Inches(4.0), Inches(12.1), Inches(0.3),
+ text="训练日志解读 (k in [2,20], 随机 PDE, 4 步 rollout)", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+log_lines = [
+ ("loss ~ 0.10-0.18, explained_var ~ 0.65-0.78", "Critic 对价值函数的解释力中等偏上,尚可但非极强"),
+ ("reward 间歇性 spike (0 -> 13 -> 60 -> 0)", "随机探索 + GAE 信度传播,信号稀疏但偶尔强正奖励"),
+ ("agent 数量在 100-3500 间大幅波动", "取决于 PDE 随机采样和细化触发情况"),
+ ("loss/ev 趋于平台期", "可能是 k^2 与 N=15 互斥的问题(已用 k^1.5 修复)"),
+]
+for i, (log, interpret) in enumerate(log_lines):
+ add_textbox(slide, Inches(0.8), Inches(4.35 + i * 0.42), Inches(4.0), Inches(0.35),
+ text=log, font_size=Pt(11), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, Inches(5.0), Inches(4.35 + i * 0.42), Inches(7.7), Inches(0.35),
+ text=interpret, font_size=Pt(11), font_color=BODY_GRAY)
+
+add_takeaway_bar(slide, "训练瓶颈非算法设计问题,而是物理前提 (污染效应 GIGO) 和多步细化粒度 (1-to-4 太粗) 的工程限制")
+add_slide_number(slide, 12)
+
+
+# ======================================================================
+# SLIDE 13: INNOVATION SUMMARY
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "创新点汇总与可复用价值")
+
+innovations = [
+ ("[1]", "无量纲化\n残差误差估计",
+ "k_local 归一化三项残差分量\n消除纯几何尺度偏差\nGNN 输入与 Reward 公式物理一致",
+ ACCENT_BLUE),
+ ("[2]", "Score-based\n连续尺寸场",
+ "score = -x_i 纯排序\n物理预算 N_budget 约束\nDoerfler-P95 双过滤器掩码",
+ ACCENT_TEAL),
+ ("[3]", "L2 聚合\n奖励设计",
+ "sqrt(sum eta_child^2) <= eta_parent 天然成立\n永不惩罚细化 (r_local >= 0)\nint 主导区强正奖励约 +0.69",
+ ACCENT_GREEN),
+ ("[4]", "尺度不变性\n架构",
+ "N_init x domain_area 缩放\nlambda 无量纲化全部特征\nsign·ln 对数压缩 + 前渐近区约束",
+ ACCENT_WARM),
+ ("[5]", "双 GNN +\n变长拓扑 RL",
+ "Policy/Value 独立 GNN 基座\nscatter_add 单路 GAE\nDiagGaussian + log_std clamp",
+ RGBColor(0x5B, 0x3A, 0x8B)),
+]
+
+for i, (num, title, desc, clr) in enumerate(innovations):
+ x = Inches(0.6 + i * 2.5)
+ add_rect(slide, x, Inches(1.35), Inches(2.3), Inches(3.1), fill_color=HIGHLIGHT_BG)
+ add_rect(slide, x, Inches(1.35), Inches(2.3), Pt(3), fill_color=clr)
+ add_textbox(slide, x + Inches(0.15), Inches(1.5), Inches(2.0), Inches(0.7),
+ text=title, font_size=Pt(13), font_color=clr, bold=True,
+ alignment=PP_ALIGN.LEFT, line_spacing=1.2)
+ add_textbox(slide, x + Inches(0.15), Inches(2.3), Inches(2.0), Inches(2.0),
+ text=desc, font_size=Pt(10), font_color=BODY_GRAY, line_spacing=1.4)
+
+add_textbox(slide, Inches(0.6), Inches(4.7), Inches(12.1), Inches(0.3),
+ text="可复用价值(超越本项目的通用方法贡献)", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+reuse_items = [
+ ("L2 聚合 + 父子映射", "适用于任何分裂型变长 agent RL 场景(网格细化、树搜索、层次化决策)"),
+ ("k_local 无量纲化方法", "适用于具有特征尺度的任何 PDE 问题:跨介质、跨频率、跨几何的统一误差度量"),
+ ("Score-based + 预算约束选择", "适用于资源受限的排序-选择问题:传感器部署、计算资源分配、实验设计优化"),
+ ("Doerfler-P95 动作掩码", "P95 锚定物理尺度的思想可推广到任何需要排除低信号样本的场景"),
+]
+for i, (tag, desc) in enumerate(reuse_items):
+ add_textbox(slide, Inches(0.8), Inches(5.05 + i * 0.42), Inches(2.8), Inches(0.35),
+ text=tag, font_size=Pt(11), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, Inches(3.7), Inches(5.05 + i * 0.42), Inches(9.0), Inches(0.35),
+ text=desc, font_size=Pt(11), font_color=BODY_GRAY)
+
+add_slide_number(slide, 13)
+
+
+# ======================================================================
+# SLIDE 14: LIMITATIONS
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_slide_title(slide, "局限性与未解决问题")
+
+limitations = [
+ ("污染效应 (GIGO: Garbage-In-Garbage-Out)",
+ [
+ "高 k 下初始 kh > 0.5 时 FEM 解定性错误,误差指示子 eta_K 完全不可靠",
+ "RL 无法在错误解的基础上学到正确策略 -- 这是物理前提而非算法问题",
+ "缓解: N_init x domaine_area 使真空始终 >= 12 单元/lambda,但高 k 下余量有限",
+ ]),
+ ("GNN 感受野受限",
+ [
+ "2 层消息传递,每个节点感受野仅约 10 个单元,无法感知全局几何结构",
+ "介质后方扇形阴影区:GNN 不知道自己在介质背后,小 k 学到的真空不需细化被错误泛化",
+ "需要: 更多几何上下文特征(入射波方向、与介质相对位置)或更深的 GNN",
+ ]),
+ ("1-to-4 切分粒度",
+ [
+ "一步细化即可达标 (每波长单元数 >= N=15 参考线),多步 rollout 中 75% 步骤零 reward",
+ "高 eps_r 介质区可能需要 2-3 步细化,但 PPO GAE 在 4 步序列中传播稀疏信号效率极低",
+ "需要: 更细粒度的切分方案(如 1-to-2 边切分)或递减的 N_per_wavelength 目标",
+ ]),
+ ("泛化到更多散射体配置",
+ [
+ "当前仅在单个圆形介质柱上训练;多散射体、非圆形、复杂材料的泛化未经验证",
+ "训练波数 [2,20] 覆盖范围有限,更高 k 需要更深的初始网格和更强的特征表达",
+ "需要: 更丰富的 PDE 问题分布、课程学习、域随机化策略",
+ ]),
+]
+
+for i, (title, points) in enumerate(limitations):
+ x = Inches(0.6 + (i % 2) * 6.3)
+ y = Inches(1.3 + (i // 2) * 2.8)
+ add_rect(slide, x, y, Inches(5.9), Inches(2.45), fill_color=None,
+ line_color=LIGHT_LINE, line_width=Pt(1))
+ add_rect(slide, x + Pt(1), y + Pt(1), Pt(3), Inches(0.35), fill_color=ACCENT_WARM)
+ add_textbox(slide, x + Inches(0.2), y + Inches(0.05), Inches(5.3), Inches(0.35),
+ text=title, font_size=Pt(14), font_color=ACCENT_WARM, bold=True)
+ for j, point in enumerate(points):
+ add_textbox(slide, x + Inches(0.2), y + Inches(0.45 + j * 0.45), Inches(5.3), Inches(0.4),
+ text=f"- {point}", font_size=Pt(10), font_color=BODY_GRAY)
+
+add_slide_number(slide, 14)
+
+
+# ======================================================================
+# SLIDE 15: SUMMARY & DISCUSSION
+# ======================================================================
+slide = add_blank_slide()
+set_slide_bg(slide, WHITE)
+add_top_bar(slide)
+add_rect(slide, Inches(0.6), Inches(2.0), Pt(4), Inches(4.0), fill_color=ACCENT_BLUE)
+
+add_textbox(slide, Inches(0.85), Inches(2.0), Inches(11.5), Inches(1.0),
+ text="总 结", font_size=Pt(36), font_color=BLACK, bold=True)
+
+summary_points = [
+ "提出了一套完整的 RL 自适应网格细化框架:从物理建模、误差估计、状态表征、动作空间到奖励设计的全链路创新",
+ "无量纲化残差误差估计 (k_local 归一化) 使误差指示子具有跨介质、跨频率的物理一致性",
+ "Score-based 尺寸场 + 物理预算约束 + Doerfler-P95 掩码实现了资源感知的细化单元选择",
+ "L2 聚合奖励设计从数学上保证了细化奖励非负,从根本上避免了 L1 sum 的结构性负偏置",
+ "sign(d)*ln(1+|d|/lambda) 对数压缩 + lambda 归一化全部特征实现了域尺寸的尺度不变泛化",
+]
+
+for i, point in enumerate(summary_points):
+ add_textbox(slide, Inches(0.85), Inches(3.1 + i * 0.42), Inches(0.4), Inches(0.35),
+ text=f"{i+1}.", font_size=Pt(14), font_color=ACCENT_BLUE, bold=True)
+ add_textbox(slide, Inches(1.25), Inches(3.1 + i * 0.42), Inches(11.2), Inches(0.35),
+ text=point, font_size=Pt(13), font_color=BODY_GRAY)
+
+add_rect(slide, Inches(0.85), Inches(5.4), Inches(11.5), Inches(0.05), fill_color=LIGHT_LINE)
+add_textbox(slide, Inches(0.85), Inches(5.6), Inches(11.5), Inches(0.4),
+ text="讨论与后续方向", font_size=SUBHEAD_SIZE, font_color=BLACK, bold=True)
+
+discussion_points = [
+ "如何处理污染效应 (GIGO)?-> 更高阶 FEM (p-refinement) + 显式 kh 特征 + 更深的初始网格",
+ "如何提升多步细化效率?-> 递减的 N_per_wavelength 目标 + 更细粒度切分 (1-to-2) + 课程学习",
+ "如何拓展到更复杂场景?-> 多散射体、三维 Helmholtz、Maxwell 方程组、时域问题",
+]
+
+for i, point in enumerate(discussion_points):
+ add_textbox(slide, Inches(0.85), Inches(6.05 + i * 0.35), Inches(0.35), Inches(0.3),
+ text=">", font_size=Pt(12), font_color=ACCENT_TEAL, bold=True)
+ add_textbox(slide, Inches(1.2), Inches(6.05 + i * 0.35), Inches(11.2), Inches(0.3),
+ text=point, font_size=Pt(12), font_color=BODY_GRAY)
+
+add_textbox(slide, Inches(8.5), Inches(7.0), Inches(4.5), Inches(0.4),
+ text="谢谢!欢迎讨论。", font_size=Pt(18), font_color=ACCENT_BLUE, bold=True,
+ alignment=PP_ALIGN.RIGHT)
+add_slide_number(slide, 15)
+
+# Save
+output_path = "/public/home/dxw/Codes/afem/output/final_presentation_cn.pptx"
+prs.save(output_path)
+print(f"PPTX saved to {output_path}")
+print(f"Slides: {len(prs.slides)}")
diff --git a/output/final_presentation_cn.pptx b/output/final_presentation_cn.pptx
new file mode 100644
index 0000000..72d43dd
Binary files /dev/null and b/output/final_presentation_cn.pptx differ
diff --git a/output/qa_report.md b/output/qa_report.md
new file mode 100644
index 0000000..2b92f54
--- /dev/null
+++ b/output/qa_report.md
@@ -0,0 +1,50 @@
+# QA Report: AFEM 组会汇报 PPTX
+
+## 构建状态
+- **状态**: OK
+- **文件**: `output/final_presentation_cn.pptx`
+- **大小**: 70.4 KB
+- **页数**: 15
+- **格式**: 16:9 宽屏 (13.3 x 7.5 inches)
+- **语言**: 中文(全中文标题与正文,英文保留技术术语)
+
+## 验证结果
+- python-pptx 重新打开: OK
+- 全部 15 页均有中文文本内容
+- 幻灯片结构符合大纲设计
+
+## 15 页结构
+
+| # | 标题 | 类型 |
+|---|------|------|
+| 1 | AFEM:基于 GNN + PPO 强化学习的自适应网格细化方法 | 标题页 |
+| 2 | 研究背景:为什么自适应网格细化很重要 | 背景 |
+| 3 | 知识缺口与技术瓶颈 | 缺口/动机 |
+| 4 | 系统架构:RL 自适应网格细化闭环管线 | 技术路线 |
+| 5 | 创新 [1]:无量纲化残差误差估计 -- 消除几何尺度偏差 | 创新 |
+| 6 | 创新 [2]:12 维增强输入特征 -- 赋予 GNN 几何与物理感知 | 创新 |
+| 7 | 创新 [3]:Score-based 连续尺寸场 + 物理预算约束 + 动作掩码 | 创新 |
+| 8 | 创新 [4]:L2 聚合奖励设计 -- 保证非负,永不惩罚细化 | 创新 |
+| 9 | 奖励标度校准:随机策略下各分量量级实测 | 证据 |
+| 10 | 创新 [5]:尺度不变性架构 -- 从 1x1 到 2x2 的泛化 | 创新 |
+| 11 | 双 GNN 架构与 PPO 训练细节 | 架构 |
+| 12 | 训练观察与诊断:奖励稀疏性与大波数泛化 | 诊断 |
+| 13 | 创新点汇总与可复用价值 | 综合 |
+| 14 | 局限性与未解决问题 | 局限 |
+| 15 | 总结 | 总结 |
+
+## 图片/资源
+- 未提取外部图片(纯 python-pptx 绘制)
+- 所有视觉效果为原生 PPTX 图形和文本框
+- `output/assets/figures/` 目录已创建(空)
+
+## 已知局限
+1. **无渲染预览** -- 环境中无可用的无头渲染器 (LibreOffice),未做逐页视觉 QA
+2. **无外部图片** -- 建议后续将 `result/visualization*.png` 的网格截图添加到 slides 5-8 的关键证据页
+3. **字体依赖** -- 使用 'Microsoft YaHei',在 macOS/Linux 上可能回退到系统默认无衬线字体
+4. **技术词汇混用** -- 关键术语 (eta_K, k_local, GNN, PPO, GAE 等) 保留英文,其余为中文
+
+## 建议手动补充
+1. 将 `result/visualization*.png` 中的网格对比截图添加到对应的创新页
+2. 在汇报机器上验证字体渲染效果
+3. 如有需要,为关键证据页添加口头讲稿备注
diff --git a/pyrightconfig.json b/pyrightconfig.json
new file mode 100644
index 0000000..bbb25fa
--- /dev/null
+++ b/pyrightconfig.json
@@ -0,0 +1,7 @@
+{
+ "venvPath": ".",
+ "venv": ".venv",
+ "typeCheckingMode": "off",
+ "reportPrivateImportUsage": false,
+ "reportMissingImports": true
+}
diff --git a/result/init400.png b/result/init400.png
new file mode 100644
index 0000000..9751448
Binary files /dev/null and b/result/init400.png differ
diff --git a/result/mie.py b/result/mie.py
new file mode 100644
index 0000000..19cac0d
--- /dev/null
+++ b/result/mie.py
@@ -0,0 +1,97 @@
+clc; clear; close all;
+
+% ================= 1. 物理参数定义 =================
+r = 0.1; % 圆柱半径
+eps_r = 5.0; % 相对介电常数
+m = sqrt(eps_r); % 相对折射率 m = ~1.414
+k0 = 50; % 背景真空中波数 (k=6)
+k1 = k0 * m; % 圆柱内部波数
+x_size = k0 * r; % 尺寸参数 x = k0*a
+
+% ================= 2. 计算域网格设置 =================
+x_range = 1;
+y_range = 1;
+Nx = 500;
+Ny = 500;
+x_vec = linspace(0, x_range, Nx);
+y_vec = linspace(0, y_range, Ny);
+[X, Y] = meshgrid(x_vec, y_vec);
+
+xc = 0.5; yc = 0.5;
+[Phi, R] = cart2pol(X - xc, Y - yc); % 转换为极坐标
+
+% ================= 3. 场初始化 =================
+E_scat = zeros(size(X)); % 散射场
+E_int = zeros(size(X)); % 内部场
+
+% Wiscombe 截断准则(决定级数展开需要算到第几阶)
+N_trunc = round(x_size + 4.05 * x_size^(1/3) + 2);
+
+% ================= 4. 2D Mie 级数展开计算 =================
+% 2D 圆柱级数从 -N 到 +N
+for n = -N_trunc : N_trunc
+
+ % 边界处的贝塞尔函数值
+ J_nx = besselj(n, x_size);
+ J_nmx = besselj(n, k1 * r);
+ H_nx = besselh(n, 1, x_size);
+
+ % 边界处的导数值 (利用递推公式 Z_n' = 0.5 * (Z_{n-1} - Z_{n+1}))
+ J_nx_p = 0.5 * (besselj(n-1, x_size) - besselj(n+1, x_size));
+ J_nmx_p = 0.5 * (besselj(n-1, k1*r) - besselj(n+1, k1*r));
+ H_nx_p = 0.5 * (besselh(n-1, 1, x_size) - besselh(n+1, 1, x_size));
+
+ % 计算 TM 偏振下的散射系数 a_n (对应 E_z)
+ num_a = m .* J_nx .* J_nmx_p - J_nx_p .* J_nmx;
+ den_a = J_nmx .* H_nx_p - m .* J_nmx_p .* H_nx;
+ a_n = num_a ./ den_a;
+
+ % 计算内部透射系数 c_n
+ num_c = J_nx .* H_nx_p - J_nx_p .* H_nx; % 这其实是 Wronskian
+ c_n = num_c ./ den_a;
+
+ % 空间相位因子: i^n * exp(i*n*phi)
+ phase = (1i)^n * exp(1i * n * Phi);
+
+ % 累加外部散射场 (仅在 R >= r 区域有效)
+ out_idx = R >= r;
+ E_scat(out_idx) = E_scat(out_idx) + a_n .* besselh(n, 1, k0 * R(out_idx)) .* phase(out_idx);
+
+ % 累加内部总场 (仅在 R < r 区域有效)
+ in_idx = R < r;
+ E_int(in_idx) = E_int(in_idx) + c_n .* besselj(n, k1 * R(in_idx)) .* phase(in_idx);
+end
+
+% ================= 5. 组装全场并绘图 =================
+% 入射平面波: u_inc = exp(i*k0*x)
+phase_shift = exp(1i * k0 * xc);
+E_scat = E_scat .* phase_shift;
+E_int = E_int .* phase_shift;
+
+E_inc = exp(1i * k0 * X);
+
+% 总场 = 外部(入射 + 散射) + 内部场
+% 组装总场
+E_total = zeros(size(X));
+E_total(R >= r) = E_inc(R >= r) + E_scat(R >= r);
+E_total(R < r) = E_int(R < r);
+%
+% % 提取最大场强做对比
+% max_E_val = max(abs(E_total(:)));
+% fprintf('2D 理论解析解中心区域最大场强 (max |E_total|): %.4f\n', max_E_val);
+
+% 绘图
+figure('Color','w');
+pcolor(X, Y, abs(E_total-E_inc));
+max_E_real = max(max(abs(E_total-E_inc)));
+shading interp;
+axis equal tight;
+colorbar;
+colormap jet;
+title(sprintf('2D Cylinder Mie Scattering |E_{scatter}| (Max = %.4f)', max_E_real));
+
+% 绘制圆柱边界
+hold on;
+theta_circle = linspace(0, 2*pi, 100);
+plot(xc + r * cos(theta_circle), yc + r * sin(theta_circle), 'k--', 'LineWidth', 1.5);
+hold off;
diff --git a/result/visualization.png b/result/visualization.png
new file mode 100644
index 0000000..a439e0b
Binary files /dev/null and b/result/visualization.png differ
diff --git a/result/visualization_steps/init400.png b/result/visualization_steps/init400.png
new file mode 100644
index 0000000..9687d88
Binary files /dev/null and b/result/visualization_steps/init400.png differ
diff --git a/result/visualization_steps/step00.png b/result/visualization_steps/step00.png
new file mode 100644
index 0000000..861ef94
Binary files /dev/null and b/result/visualization_steps/step00.png differ
diff --git a/result/visualization_steps/step01.png b/result/visualization_steps/step01.png
new file mode 100644
index 0000000..8c3ca72
Binary files /dev/null and b/result/visualization_steps/step01.png differ
diff --git a/result/visualization_steps/step02.png b/result/visualization_steps/step02.png
new file mode 100644
index 0000000..81ca912
Binary files /dev/null and b/result/visualization_steps/step02.png differ
diff --git a/result/visualization_steps/step03.png b/result/visualization_steps/step03.png
new file mode 100644
index 0000000..7763c2a
Binary files /dev/null and b/result/visualization_steps/step03.png differ
diff --git a/result/visualization_steps/step04.png b/result/visualization_steps/step04.png
new file mode 100644
index 0000000..19a8b06
Binary files /dev/null and b/result/visualization_steps/step04.png differ
diff --git a/src/__pycache__/main.cpython-310.pyc b/src/__pycache__/main.cpython-310.pyc
new file mode 100644
index 0000000..0e83454
Binary files /dev/null and b/src/__pycache__/main.cpython-310.pyc differ
diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc
new file mode 100644
index 0000000..771668b
Binary files /dev/null and b/src/__pycache__/main.cpython-313.pyc differ
diff --git a/src/__pycache__/model.cpython-310.pyc b/src/__pycache__/model.cpython-310.pyc
new file mode 100644
index 0000000..271350c
Binary files /dev/null and b/src/__pycache__/model.cpython-310.pyc differ
diff --git a/src/__pycache__/network.cpython-310.pyc b/src/__pycache__/network.cpython-310.pyc
new file mode 100644
index 0000000..226e71c
Binary files /dev/null and b/src/__pycache__/network.cpython-310.pyc differ
diff --git a/src/__pycache__/network.cpython-313.pyc b/src/__pycache__/network.cpython-313.pyc
new file mode 100644
index 0000000..7b6363e
Binary files /dev/null and b/src/__pycache__/network.cpython-313.pyc differ
diff --git a/src/__pycache__/ppo.cpython-310.pyc b/src/__pycache__/ppo.cpython-310.pyc
new file mode 100644
index 0000000..535d699
Binary files /dev/null and b/src/__pycache__/ppo.cpython-310.pyc differ
diff --git a/src/__pycache__/ppo.cpython-313.pyc b/src/__pycache__/ppo.cpython-313.pyc
new file mode 100644
index 0000000..68b59e6
Binary files /dev/null and b/src/__pycache__/ppo.cpython-313.pyc differ
diff --git a/src/__pycache__/sizing_field.cpython-310.pyc b/src/__pycache__/sizing_field.cpython-310.pyc
new file mode 100644
index 0000000..fa43e78
Binary files /dev/null and b/src/__pycache__/sizing_field.cpython-310.pyc differ
diff --git a/src/__pycache__/sizing_field.cpython-313.pyc b/src/__pycache__/sizing_field.cpython-313.pyc
new file mode 100644
index 0000000..fc26768
Binary files /dev/null and b/src/__pycache__/sizing_field.cpython-313.pyc differ
diff --git a/src/__pycache__/trainer.cpython-310.pyc b/src/__pycache__/trainer.cpython-310.pyc
new file mode 100644
index 0000000..bbc3f97
Binary files /dev/null and b/src/__pycache__/trainer.cpython-310.pyc differ
diff --git a/src/__pycache__/utils.cpython-310.pyc b/src/__pycache__/utils.cpython-310.pyc
new file mode 100644
index 0000000..2add8d9
Binary files /dev/null and b/src/__pycache__/utils.cpython-310.pyc differ
diff --git a/src/__pycache__/utils.cpython-313.pyc b/src/__pycache__/utils.cpython-313.pyc
new file mode 100644
index 0000000..a715579
Binary files /dev/null and b/src/__pycache__/utils.cpython-313.pyc differ
diff --git a/src/__pycache__/visualize.cpython-310.pyc b/src/__pycache__/visualize.cpython-310.pyc
new file mode 100644
index 0000000..9ef8919
Binary files /dev/null and b/src/__pycache__/visualize.cpython-310.pyc differ
diff --git a/src/__pycache__/visualize.cpython-313.pyc b/src/__pycache__/visualize.cpython-313.pyc
new file mode 100644
index 0000000..523896e
Binary files /dev/null and b/src/__pycache__/visualize.cpython-313.pyc differ
diff --git a/src/config.yaml b/src/config.yaml
new file mode 100644
index 0000000..aa462ad
--- /dev/null
+++ b/src/config.yaml
@@ -0,0 +1,108 @@
+#############################
+# 训练:
+# CUDA_VISIBLE_DEVICES=7 python src/main.py --mode train --config src/config.yaml
+# 测试:
+# python src/main.py --mode test --checkpoint checkpoints/model_final.pt --k-test 6.0
+# python src/main.py --mode test --checkpoint checkpoints/model_final.pt --k-test 6.0 --center 0.3,0.6 --radius 0.15
+
+# 可视化:
+# python src/main.py --mode viz --checkpoint checkpoints/model_iter0400.pt
+# python src/main.py --mode viz --checkpoint checkpoints/model_iter0100.pt --k-test 8.0 --center 0.6,0.5 --radius 0.1
+###########################
+
+algorithm:
+ batch_size: 32
+ discount_factor: 1.0
+ ppo:
+ clip_range: 0.2
+ entropy_coefficient: 0.001
+ epochs_per_iteration: 5 # 每轮迭代对同一批 rollout 数据重复训练几个 epoch
+ gae_lambda: 0.95
+ initial_log_std: -2.0 # 初始动作 log 标准差,exp(-2)≈0.135
+ max_grad_norm: 0.5
+ num_rollout_steps: 256
+ value_function_coefficient: 0.5
+ use_gpu: true
+environment:
+ mesh_refinement:
+ edge_features:
+ euclidean_distance: true
+ element_features:
+ element_penalty: true
+ is_sbc_boundary: true
+ k_local_sqrt_vol: true
+ solution_std: true
+ timestep: true
+ volume: true
+ wave_number: true
+ x_position: false
+ y_position: false
+ dist_to_interface: true
+ element_limit_penalty: 10000
+ element_penalty:
+ sample_penalty: false
+ value: 0.06
+ fem:
+ domain:
+ boundary:
+ - 0
+ - 0
+ - 3
+ - 3
+ initial_num_elements: 75
+ helmholtz:
+ k_ref: 6.0
+ k_exponent: 2.0
+ scatterer:
+ cx: 1.5
+ cx_max: 0.8
+ cx_min: 0.2
+ cy: 1.5
+ cy_max: 0.8
+ cy_min: 0.2
+ eps_r: 5.0
+ eps_r_max: 8.0
+ eps_r_min: 2.0
+ mode: random_uniform
+ radius: 0.2
+ radius_max: 0.2
+ radius_min: 0.05
+ wave_number: 30.0
+ wave_number_max: 3.0
+ wave_number_min: 15.0
+ wave_number_mode: random_uniform
+ num_pdes: 100
+ pde_type: helmholtz
+ pre_asymptotic_N: 1.5
+ maximum_elements: 50000
+ num_timesteps: 4
+ refinement_strategy: continuous_sizing_field
+ reward_type: spatial
+ global_reward_alpha: 0.5 # 全局奖励权重
+ # rho_weights:
+ # w_int: 0.0 # ρ_int 权重 (代码自动除以 k²)
+ # w_jump: 1.0 # ρ_jump 权重 (代码自动除以 k)
+ # w_sbc: 20.0 # ρ_sbc 权重 (代码自动除以 k)
+iterations: 401
+network:
+ actor:
+ mlp:
+ activation_function: tanh
+ num_layers: 2
+ base:
+ edge_dropout: 0.1
+ scatter_reduce: mean
+ stack:
+ mlp:
+ activation_function: leakyrelu
+ num_layers: 2
+ num_steps: 2
+ critic:
+ mlp:
+ activation_function: tanh
+ num_layers: 2
+ latent_dimension: 64
+ training:
+ learning_rate: 0.0003
+ lr_decay: 0.995
+ optimizer: adam
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..3e37230
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,159 @@
+import argparse
+import logging
+import os
+import sys
+import time
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch_geometric.data import Batch
+
+logging.getLogger("skfem").setLevel(logging.ERROR)
+
+_project_root = Path(__file__).resolve().parent.parent
+if str(_project_root) not in sys.path:
+ sys.path.insert(0, str(_project_root))
+
+from src.network import create_model
+from src.ppo import PPOTrainer
+from src.utils import load_checkpoint, load_config, parse_center, save_checkpoint, setup_helmholtz_config
+from src.visualize import visualize
+
+
+def train(config: dict, iterations: int, checkpoint_dir: str = "checkpoints", save_freq: int = 50):
+ t0 = time.time()
+ algo = config.get("algorithm", {})
+ dev = torch.device("cuda" if torch.cuda.is_available() and algo.get("use_gpu") else "cpu")
+ print(f"[Device] {dev}")
+
+ from environment.mesh_refinement import MeshRefinement
+
+ env = MeshRefinement(
+ environment_config=config.get("environment", {}).get("mesh_refinement", {}),
+ seed=42,
+ )
+ print(f"[Env] node_feats={env.num_node_features} edge_feats={env.num_edge_features} act_dim={env.action_dimension}")
+
+ model = create_model(env, config.get("network", {}), algo.get("ppo", {}), device=dev)
+ print(f"[Model] params={sum(p.numel() for p in model.parameters()):,}")
+
+ trainer = PPOTrainer(model, env, algo, device=dev)
+ os.makedirs(checkpoint_dir, exist_ok=True)
+
+ for it in range(1, iterations + 1):
+ t1 = time.time()
+ metrics = trainer.fit_iteration()
+ print(
+ f" {it:4d}/{iterations} | loss={metrics['loss']:.4f} ev={metrics['explained_variance']:.3f} "
+ f"agents={metrics['num_agents']:.0f} avg_r={metrics['avg_reward']:.4f} sum_r={metrics['sum_reward']:.2f} "
+ f"x<0={metrics.get('neg_action_ratio', 0):.2f} "
+ f"elig={metrics.get('eligible_ratio', 0):.2f} "
+ f"sel={metrics.get('selected_count', 0):.0f} "
+ f"{time.time() - t1:.1f}s"
+ )
+ if it % save_freq == 0 or it == iterations:
+ save_checkpoint(model, model.optimizer, it, os.path.join(checkpoint_dir, f"model_iter{it:04d}.pt"))
+
+ save_checkpoint(model, model.optimizer, iterations, os.path.join(checkpoint_dir, "model_final.pt"))
+ print(f"[Train] done, total time {time.time() - t0:.1f}s")
+
+
+def _eval_mie_error_test(env) -> float:
+ """Compute relative L2 error of FEM vs Mie analytical solution."""
+ fp = getattr(env.fem_problem, "fem_problem", None)
+ if fp is None:
+ return float("nan")
+ _eps_r = getattr(fp, "_eps_r", None)
+ _radius = getattr(fp, "_radius", None)
+ _cx = getattr(fp, "_cx", None)
+ _cy = getattr(fp, "_cy", None)
+ _k = getattr(fp, "_k", None)
+ if any(v is None for v in [_eps_r, _radius, _cx, _cy, _k]):
+ return float("nan")
+
+ from environment.mie_solution import mie_scattered_field
+ pts = env.mesh.p.T
+ u_mie = mie_scattered_field(pts, k0=_k, eps_r=_eps_r, radius=_radius, cx=_cx, cy=_cy)
+ u_fem = env.scalar_solution
+ diff = np.abs(u_fem - u_mie)
+ denom = np.linalg.norm(np.abs(u_mie))
+ if denom < 1e-12:
+ denom = 1.0
+ return float(np.linalg.norm(diff) / denom)
+
+
+def test(config: dict, checkpoint_path: str, k_test=None, center=None, radius=None, eps_test=None):
+ setup_helmholtz_config(config, k_test=k_test, center=center, radius=radius, eps_test=eps_test)
+ algo = config.get("algorithm", {})
+
+ from environment.mesh_refinement import MeshRefinement
+
+ env = MeshRefinement(
+ environment_config=config.get("environment", {}).get("mesh_refinement", {}),
+ seed=99,
+ )
+ model = create_model(env, config.get("network", {}), algo.get("ppo", {}))
+ load_checkpoint(model, checkpoint_path)
+ model.eval()
+
+ obs = env.reset()
+ done = False
+ step = 0
+ n_elem_init = getattr(env, "_num_elements", env.num_agents)
+ mie_err_0 = _eval_mie_error_test(env)
+ print(f" Step {step:2d}: reward=--- mie_err={mie_err_0:.4f} elements={n_elem_init}"
+ f" budget={getattr(env, '_n_budget', '?')}")
+
+ total_reward = 0.0
+ while not done:
+ with torch.no_grad():
+ actions, _, _ = model(Batch.from_data_list([obs]), deterministic=True)
+ obs, reward, done, info = env.step(actions.cpu().numpy())
+ step_r = float(np.sum(reward))
+ total_reward += step_r
+ step += 1
+ mie_err = _eval_mie_error_test(env)
+ print(f" Step {step:2d}: reward={step_r:+.4f} mie_err={mie_err:.4f}"
+ f" elements={info.get('num_elements', '?')} "
+ f"x<0={info.get('neg_action_ratio', 0):.2f} sel={info.get('selected_count', 0)}")
+
+ print(f"\n[Test] total_reward={total_reward:.4f} final_mie_error={mie_err:.4f}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="AFEM — Adaptive FEM with PPO RL")
+ parser.add_argument("--mode", required=True, choices=["train", "test", "viz"])
+ parser.add_argument("--config", default="src/config.yaml")
+ parser.add_argument("--iterations", type=int, default=None)
+ parser.add_argument("--checkpoint", default="checkpoints/model_final.pt")
+ parser.add_argument("--checkpoint-dir", default="checkpoints")
+ parser.add_argument("--save-freq", type=int, default=50)
+ parser.add_argument("--output", default="result/visualization.png")
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--k-test", type=float, default=None)
+ parser.add_argument("--center", type=str, default=None)
+ parser.add_argument("--radius", type=float, default=None)
+ parser.add_argument("--eps-test", type=float, default=None)
+
+ args = parser.parse_args()
+ torch.manual_seed(args.seed)
+ np.random.seed(args.seed)
+
+ cfg_path = args.config if os.path.isabs(args.config) else os.path.join(_project_root, args.config)
+ config = load_config(cfg_path)
+ if args.iterations is not None:
+ config["iterations"] = args.iterations
+
+ center = parse_center(args.center)
+
+ if args.mode == "train":
+ train(config, config.get("iterations", 100), args.checkpoint_dir, args.save_freq)
+ elif args.mode == "test":
+ test(config, args.checkpoint, k_test=args.k_test, center=center, radius=args.radius, eps_test=args.eps_test)
+ elif args.mode == "viz":
+ visualize(config, args.checkpoint, output_path=args.output, k_test=args.k_test, center=center, radius=args.radius, eps_test=args.eps_test)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/network.py b/src/network.py
new file mode 100644
index 0000000..e05a186
--- /dev/null
+++ b/src/network.py
@@ -0,0 +1,419 @@
+import copy
+
+import gym
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.optim as optim
+from torch_geometric.data import Data
+from torch_geometric.utils import dropout_edge
+from torch_scatter import scatter_mean
+
+
+def get_scatter_reduce(name: str):
+ name = name.lower()
+ if name == "mean":
+ from torch_scatter import scatter_mean
+ return scatter_mean
+ if name == "sum":
+ from torch_scatter import scatter_add
+ return scatter_add
+ if name == "max":
+ from torch_scatter import scatter_max
+ return lambda *a, **kw: scatter_max(*a, **kw)[0]
+ if name == "min":
+ from torch_scatter import scatter_min
+ return lambda *a, **kw: scatter_min(*a, **kw)[0]
+ if name == "std":
+ from torch_scatter import scatter_std
+ return scatter_std
+ raise ValueError(f"Unknown scatter reduce '{name}'")
+
+
+# ──
+# 1. LatentMLP — GNN 内部使用的 MLP(保持隐层维度不变)
+# ──
+class LatentMLP(nn.Module):
+ """
+ MLP that operates entirely in latent space (dim in == dim out == latent_dim).
+ Used inside EdgeModule and NodeModule.
+ """
+
+ def __init__(self, in_features: int, latent_dim: int, config: dict):
+ super().__init__()
+ num_layers = config.get("num_layers", 2)
+ activation = config.get("activation_function", "leakyrelu").lower()
+ add_output = config.get("add_output_layer", False)
+
+ layers = []
+ prev_dim = in_features
+ for i in range(num_layers):
+ layers.append(nn.Linear(prev_dim, latent_dim))
+ layers.append(_get_activation(activation))
+ prev_dim = latent_dim
+
+ if add_output:
+ layers.append(nn.Linear(prev_dim, latent_dim))
+
+ self.mlp = nn.Sequential(*layers)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.mlp(x)
+
+
+def _get_activation(name: str) -> nn.Module:
+ name = name.lower()
+ if name == "relu":
+ return nn.ReLU()
+ elif name == "leakyrelu":
+ return nn.LeakyReLU()
+ elif name == "elu":
+ return nn.ELU()
+ elif name in ("swish", "silu"):
+ return nn.SiLU()
+ elif name == "mish":
+ return nn.Mish()
+ elif name == "gelu":
+ return nn.GELU()
+ elif name == "tanh":
+ return nn.Tanh()
+ raise ValueError(f"Unknown activation '{name}'")
+
+
+# ──
+# 2. EdgeModule — 边更新:MLP([src_node | dst_node | edge_attr])
+# ──
+class EdgeModule(nn.Module):
+ """Update edge features from sender node, receiver node, and existing edge features."""
+
+ def __init__(self, latent_dim: int, mlp_config: dict):
+ super().__init__()
+ in_features = 3 * latent_dim # [src_node, dst_node, edge_attr]
+ self.mlp = LatentMLP(in_features, latent_dim, mlp_config)
+
+ def forward(self, graph: Data):
+ src, dst = graph.edge_index
+ agg = torch.cat([graph.x[src], graph.x[dst], graph.edge_attr], dim=-1)
+ graph.edge_attr = self.mlp(agg)
+
+
+# ──
+# 4. NodeModule — 节点更新:MLP([node | scatter(入边)])
+# ──
+class NodeModule(nn.Module):
+ """Update node features from own features and aggregated incoming edge features."""
+
+ def __init__(self, latent_dim: int, mlp_config: dict, scatter_reducer):
+ super().__init__()
+ in_features = 2 * latent_dim # [node, aggregated_edges]
+ self.mlp = LatentMLP(in_features, latent_dim, mlp_config)
+ self.scatter = scatter_reducer
+
+ def forward(self, graph: Data):
+ _, dst = graph.edge_index
+ agg_edges = self.scatter(
+ graph.edge_attr, dst, dim=0, dim_size=graph.x.shape[0]
+ )
+ agg = torch.cat([graph.x, agg_edges], dim=-1)
+ graph.x = self.mlp(agg)
+
+
+# ──
+# 5. MessagePassingStep — 单步消息传递
+# ──
+class MessagePassingStep(nn.Module):
+ """
+ One full message-passing step:
+ 1. Edge update
+ 2. Edge inner residual + LayerNorm
+ 3. Node update
+ 4. Node inner residual + LayerNorm
+ """
+
+ def __init__(self, latent_dim: int, stack_config: dict, scatter_reducer):
+ super().__init__()
+ mlp_config = stack_config["mlp"]
+
+ self.edge_module = EdgeModule(latent_dim, mlp_config)
+ self.node_module = NodeModule(latent_dim, mlp_config, scatter_reducer)
+
+ self.node_ln = nn.LayerNorm(latent_dim)
+ self.edge_ln = nn.LayerNorm(latent_dim)
+
+ def forward(self, graph: Data):
+ old_x = graph.x
+ old_edge = graph.edge_attr
+
+ # Edge update
+ self.edge_module(graph)
+ graph.edge_attr = self.edge_ln(graph.edge_attr + old_edge)
+
+ # Node update
+ self.node_module(graph)
+ graph.x = self.node_ln(graph.x + old_x)
+
+
+# ──
+# 6. MessagePassingStack — 堆叠 N 个 Step
+# ──
+class MessagePassingStack(nn.Module):
+ """Stack of multiple MessagePassingSteps with optional step repeats."""
+
+ def __init__(self, latent_dim: int, stack_config: dict, scatter_reducer):
+ super().__init__()
+ num_steps = stack_config.get("num_steps", 2)
+ self.num_step_repeats = stack_config.get("num_step_repeats", 1)
+ self.steps = nn.ModuleList(
+ [
+ MessagePassingStep(latent_dim, stack_config, scatter_reducer)
+ for _ in range(num_steps)
+ ]
+ )
+
+ def forward(self, graph: Data):
+ for step in self.steps:
+ for _ in range(self.num_step_repeats):
+ step(graph)
+
+
+# ──
+# 7. MessagePassingBase — GNN 基座
+# ──
+class MessagePassingBase(nn.Module):
+ """
+ Full GNN base: Linear → Stack → unpacked output.
+ Returns (node_features_dict, edge_features, None, batch_dict)
+ """
+
+ def __init__(
+ self,
+ in_node_features: int,
+ in_edge_features: int,
+ latent_dim: int,
+ base_config: dict,
+ device=None,
+ ):
+ super().__init__()
+ self.edge_dropout = base_config.get("edge_dropout", 0.0)
+ self.create_copy = base_config.get("create_graph_copy", True)
+
+ scatter_name = base_config.get("scatter_reduce", "mean")
+ self.scatter_reducer = get_scatter_reduce(scatter_name)
+
+ self.node_embedding = nn.Linear(in_node_features, latent_dim)
+ self.edge_embedding = nn.Linear(in_edge_features, latent_dim)
+
+ # Stack
+ stack_config = base_config.get("stack", {})
+ self.stack = MessagePassingStack(latent_dim, stack_config, self.scatter_reducer)
+
+ if device is not None:
+ self.to(device)
+
+ def forward(self, graph: Data):
+ if self.create_copy:
+ graph = copy.deepcopy(graph)
+
+ # Edge dropout (training only)
+ if self.edge_dropout > 0 and self.training:
+ graph.edge_index, mask = dropout_edge(
+ graph.edge_index, p=self.edge_dropout, training=True
+ )
+ graph.edge_attr = graph.edge_attr[mask]
+
+ # Embed
+ graph.x = self.node_embedding(graph.x)
+ graph.edge_attr = self.edge_embedding(graph.edge_attr)
+
+ # Message passing
+ self.stack(graph)
+
+ # Unpack
+ node_name = "element" # homogeneous graph node type for mesh refinement
+ batch = (
+ graph.batch
+ if hasattr(graph, "batch") and graph.batch is not None
+ else torch.zeros(graph.x.shape[0], dtype=torch.long, device=graph.x.device)
+ )
+
+ edge_key = f"{node_name}2{node_name}"
+ return (
+ {node_name: graph.x},
+ {
+ edge_key: {
+ "edge_index": graph.edge_index.long(),
+ "edge_attr": graph.edge_attr,
+ }
+ },
+ None,
+ {node_name: batch},
+ )
+
+
+# ──
+# 8. MLP — Actor/Critic 头使用的 MLP
+# ──
+class MLP(nn.Module):
+ """Feedforward MLP for actor/critic heads."""
+
+ def __init__(
+ self,
+ in_features: int,
+ config: dict,
+ latent_dim: int = None,
+ out_features: int = None,
+ device=None,
+ ):
+ super().__init__()
+ activation = config.get("activation_function", "tanh").lower()
+ num_layers = config.get("num_layers", 2)
+
+ layers = []
+ prev = in_features
+ dim = latent_dim or 64
+ for _ in range(num_layers):
+ layers.append(nn.Linear(prev, dim))
+ layers.append(_get_activation(activation))
+ prev = dim
+
+ if out_features is not None:
+ layers.append(nn.Linear(prev, out_features))
+ self._out_features = out_features
+ else:
+ self._out_features = prev
+
+ self.net = nn.Sequential(*layers)
+ if device is not None:
+ self.to(device)
+
+ @property
+ def out_features(self) -> int:
+ return self._out_features
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.net(x)
+
+
+# ──
+# 9. ActorCritic — PPO Actor-Critic 网络
+# ──
+def create_model(env, network_config: dict, ppo_config: dict, device=None):
+ """Factory function: create Actor-Critic model from environment and configs."""
+ return ActorCritic(
+ environment=env,
+ network_config=network_config,
+ ppo_config=ppo_config,
+ device=device,
+ )
+
+
+class ActorCritic(nn.Module):
+
+ def __init__(
+ self, environment, network_config: dict, ppo_config: dict, device=None
+ ):
+ super().__init__()
+ latent_dim = network_config.get("latent_dimension", 64)
+ base_config = network_config.get("base", {})
+ train_config = network_config.get("training", {})
+ actor_cfg = network_config.get("actor", {}).get("mlp", {})
+ critic_cfg = network_config.get("critic", {}).get("mlp", {})
+
+ self.value_function_aggr = ppo_config.get("value_function_aggr", "spatial")
+ self.agent_node_type = "element"
+
+ self.base = MessagePassingBase(
+ in_node_features=environment.num_node_features,
+ in_edge_features=environment.num_edge_features,
+ latent_dim=latent_dim,
+ base_config=base_config,
+ device=device,
+ )
+
+ self.policy_mlp = MLP(
+ latent_dim, actor_cfg, latent_dim=latent_dim, device=device
+ )
+ action_dim = environment.action_dimension
+ if isinstance(environment._action_space, gym.spaces.Box):
+ from stable_baselines3.common.distributions import DiagGaussianDistribution
+
+ self.action_dist = DiagGaussianDistribution(action_dim)
+ self.action_out, self.log_std = self.action_dist.proba_distribution_net(
+ latent_dim=self.policy_mlp.out_features,
+ log_std_init=ppo_config.get("initial_log_std", 0.0),
+ )
+ else:
+ from stable_baselines3.common.distributions import CategoricalDistribution
+
+ self.action_dist = CategoricalDistribution(action_dim)
+ self.action_out = self.action_dist.proba_distribution_net(
+ latent_dim=self.policy_mlp.out_features
+ )
+ self.log_std = None
+
+ self.value_mlp = MLP(
+ latent_dim, critic_cfg, latent_dim=latent_dim, out_features=1, device=device
+ )
+
+ self._setup_optimizer(train_config)
+
+ if device is not None:
+ self.to(device)
+
+ def _setup_optimizer(self, train_config: dict):
+ lr = train_config.get("learning_rate", 3e-4)
+ wd = train_config.get("l2_norm", 0)
+ params = list(self.parameters())
+ if self.log_std is not None and not any(p is self.log_std for p in params):
+ params.append(self.log_std)
+ self.optimizer = optim.Adam(params, lr=lr, weight_decay=wd)
+ sched_rate = train_config.get("lr_decay", train_config.get("lr_scheduling_rate", 1))
+ self.lr_scheduler = (
+ optim.lr_scheduler.ExponentialLR(self.optimizer, gamma=sched_rate)
+ if sched_rate is not None and sched_rate < 1
+ else None
+ )
+
+ @property
+ def device(self):
+ return next(self.parameters()).device
+
+ def _encode(self, observations):
+ """Run shared GNN backbone once, return (shared_features, batch_indices)."""
+ observations = observations.to(self.device)
+ node_feats, _, _, batches = self.base(observations)
+ batch = batches[self.agent_node_type]
+ feats = node_feats[self.agent_node_type]
+ return feats, batch
+
+ def _make_distribution(self, latent_pi):
+ mean_actions = self.action_out(latent_pi)
+ if self.log_std is not None:
+ return self.action_dist.proba_distribution(mean_actions, self.log_std)
+ return self.action_dist.proba_distribution(mean_actions)
+
+ def _aggregate_values(self, values, batch):
+ if self.value_function_aggr == "mean":
+ return scatter_mean(values, batch, dim=0)
+ elif self.value_function_aggr == "sum":
+ from torch_scatter import scatter_add
+ return scatter_add(values, batch, dim=0)
+ elif self.value_function_aggr == "max":
+ from torch_scatter import scatter_max
+ return scatter_max(values, batch, dim=0)[0]
+ return values
+
+ def forward(self, observations, deterministic: bool = False):
+ shared_feats, batch = self._encode(observations)
+ dist = self._make_distribution(self.policy_mlp(shared_feats))
+ actions = dist.get_actions(deterministic=deterministic)
+ log_probs = dist.log_prob(actions)
+ values = self._aggregate_values(self.value_mlp(shared_feats).squeeze(-1), batch)
+ return actions, values, log_probs
+
+ def evaluate_actions(self, observations, actions):
+ actions = actions.to(self.device)
+ shared_feats, batch = self._encode(observations)
+ dist = self._make_distribution(self.policy_mlp(shared_feats))
+ values = self._aggregate_values(self.value_mlp(shared_feats).squeeze(-1), batch)
+ return values, dist.log_prob(actions), dist.entropy()
diff --git a/src/ppo.py b/src/ppo.py
new file mode 100644
index 0000000..bd1d2c4
--- /dev/null
+++ b/src/ppo.py
@@ -0,0 +1,274 @@
+import numpy as np
+import torch
+import torch.nn.functional as F
+from torch_geometric.data import Batch
+from torch_scatter import scatter_add
+
+
+class RolloutBuffer:
+
+ def __init__(self, buffer_size: int,
+ gae_lambda: float,
+ discount_factor: float,
+ device=None,
+ ):
+ self.buffer_size = buffer_size
+ self.gae_lambda = gae_lambda
+ self.discount_factor = discount_factor
+ self.device = device
+ self.reset()
+
+ def reset(self):
+ self.observations = []
+ self.actions = []
+ self.log_probs = []
+ self.rewards = [] # per-agent rewards (list of tensors, varying shapes)
+ self.values = [] # per-agent values (list of tensors, varying shapes)
+ self.dones = []
+ self.agent_mappings = [] # mapping from new → old agent indices per step
+ self.pos = 0
+
+ def add(
+ self, observation, actions, reward, done, value, log_probs,
+ agent_mapping=None,
+ ):
+ dev = self.device
+ self.observations.append(observation.to(dev))
+ self.actions.append(actions.to(dev))
+ self.log_probs.append(log_probs.to(dev))
+ self.rewards.append(torch.as_tensor(reward, dtype=torch.float32, device=dev).flatten())
+ self.values.append(value.flatten().to(dev))
+ self.dones.append(float(done))
+ self.agent_mappings.append(
+ torch.as_tensor(agent_mapping, dtype=torch.long, device=dev).flatten()
+ )
+ self.pos += 1
+
+ def compute_returns_and_advantage(self, last_value):
+ """Single-path GAE: potential-shaped per-agent reward with scatter_add for mesh refinement."""
+ last_value = last_value.to(self.device).flatten()
+ n = self.buffer_size
+
+ dones = torch.as_tensor(self.dones, device=self.device)
+
+ # ---- 0. Normalize rewards to unit scale ----
+ all_rews = torch.cat([r.flatten() for r in self.rewards])
+ rew_mean = all_rews.mean()
+ rew_std = all_rews.std()
+ if rew_std > 1e-8:
+ self.rewards = [(r - rew_mean) / rew_std for r in self.rewards]
+
+ # ---- 1. Per-agent GAE (scatter_add for mesh refinement) ----
+ advantages = [None] * n
+ deltas = []
+ next_values = self.values[1:] + [last_value]
+
+ for step in range(n):
+ if dones[step]:
+ next_val = self.values[step]
+ else:
+ next_val = scatter_add(next_values[step], self.agent_mappings[step], dim=0)
+ delta = self.rewards[step] + (0 if dones[step] else self.discount_factor * next_val) - self.values[step]
+ deltas.append(delta)
+
+ last_gae = torch.zeros_like(self.agent_mappings[-1], dtype=torch.float32, device=self.device)
+ for step in reversed(range(n)):
+ if dones[step]:
+ last_gae = deltas[step]
+ else:
+ last_gae = deltas[step] + self.discount_factor * self.gae_lambda * scatter_add(last_gae, self.agent_mappings[step], dim=0)
+ advantages[step] = last_gae
+
+ self.returns = [adv + val for adv, val in zip(advantages, self.values)]
+
+ # ---- 2. Normalize advantages (per-batch, zero-mean unit-std) ----
+ all_advs = torch.cat([a.flatten() for a in advantages])
+ adv_mean = all_advs.mean()
+ adv_std = all_advs.std()
+ if adv_std > 1e-8:
+ advantages = [(a - adv_mean) / adv_std for a in advantages]
+ # NOTE: returns and values keep their original scale — no unit-scale normalization,
+ # so the value network sees a stable regression target across iterations.
+
+ self.advantages = [ret - val for ret, val in zip(self.returns, self.values)]
+
+ def get(self, batch_size: int):
+ """Yield random minibatches from the buffer."""
+ indices = np.random.permutation(self.buffer_size)
+ start = 0
+ while start < self.buffer_size:
+ batch_idx = indices[start : start + batch_size]
+ start += batch_size
+
+ obs_batch = Batch.from_data_list([self.observations[i] for i in batch_idx])
+ acts = torch.cat([self.actions[i] for i in batch_idx], dim=0)
+ lps = torch.cat([self.log_probs[i].flatten() for i in batch_idx], dim=0)
+ vals = torch.cat([self.values[i].flatten() for i in batch_idx], dim=0)
+ advs = torch.cat([self.advantages[i].flatten() for i in batch_idx], dim=0)
+ rets = torch.cat([self.returns[i].flatten() for i in batch_idx], dim=0)
+
+ obs_batch, acts, lps, vals, advs, rets = (
+ x.to(self.device) for x in (obs_batch, acts, lps, vals, advs, rets)
+ )
+ yield obs_batch, acts, lps, vals, advs, rets
+
+ @property
+ def full(self):
+ return self.pos >= self.buffer_size
+
+ @property
+ def explained_variance(self):
+ all_vals = torch.cat([v.flatten() for v in self.values])
+ all_rets = torch.cat([r.flatten() for r in self.returns])
+ var_ret = torch.var(all_rets)
+ if var_ret < 1e-12:
+ return 0.0
+ return float(1.0 - torch.var(all_rets - all_vals) / var_ret)
+
+
+# ── PPO losses ────────────────────────────────────────────
+def policy_loss(advantages: torch.Tensor, ratio: torch.Tensor, clip_range: float) -> torch.Tensor:
+ """Clipped PPO policy loss."""
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
+ loss1 = advantages * ratio
+ loss2 = advantages * torch.clamp(ratio, 1.0 - clip_range, 1.0 + clip_range)
+ return -torch.min(loss1, loss2).mean()
+
+
+def value_loss(
+ returns: torch.Tensor, values: torch.Tensor,
+ old_values: torch.Tensor, clip_range: float,
+) -> torch.Tensor:
+ """Clipped value function loss."""
+ vf_loss = F.mse_loss(returns, values)
+ if clip_range > 0:
+ v_clipped = old_values + (values - old_values).clamp(-clip_range, clip_range)
+ vf_loss = torch.max(vf_loss, F.mse_loss(returns, v_clipped))
+ return vf_loss
+
+
+def entropy_loss(entropy) -> torch.Tensor:
+ """Entropy bonus for exploration."""
+ return -torch.mean(entropy)
+
+
+class PPOTrainer:
+
+ def __init__(self, actor_critic, environment, config: dict, device=None):
+ self.policy = actor_critic
+ self.env = environment
+ self.device = device
+
+ ppo_cfg = config.get("ppo", {})
+ self.num_rollout_steps = ppo_cfg.get("num_rollout_steps", 256)
+ self.epochs_per_iteration = ppo_cfg.get("epochs_per_iteration", 5)
+ self.batch_size = config.get("batch_size", 32)
+ self.clip_range = ppo_cfg.get("clip_range", 0.2)
+ self.max_grad_norm = ppo_cfg.get("max_grad_norm", 0.5)
+ self.entropy_coef = ppo_cfg.get("entropy_coefficient", 0.0)
+ self.vf_coef = ppo_cfg.get("value_function_coefficient", 0.5)
+ self.vf_clip_range = ppo_cfg.get("value_function_clip_range", 0.2)
+ self.gae_lambda = ppo_cfg.get("gae_lambda", 0.95)
+ self.discount_factor = config.get("discount_factor", 1.0)
+
+ self.buffer = RolloutBuffer(
+ buffer_size=self.num_rollout_steps,
+ gae_lambda=self.gae_lambda,
+ discount_factor=self.discount_factor,
+ device=device,
+ )
+
+ def collect_rollouts(self):
+ self.policy.eval()
+ self.buffer.reset()
+ obs = self.env.reset()
+ step_rewards, step_num_agents = [], []
+ _rho_keys = ("rho_int_mean", "rho_jump_mean", "rho_sbc_mean",
+ "w_rho_int", "w_rho_jump", "w_rho_sbc")
+ rho_accum = {k: 0.0 for k in _rho_keys}
+ diag_keys = ("neg_action_ratio", "eligible_ratio", "selected_count")
+ diag_accum = {k: 0.0 for k in diag_keys}
+ diag_steps = 0
+
+ for _ in range(self.num_rollout_steps):
+ with torch.no_grad():
+ actions, values, log_probs = self.policy(
+ Batch.from_data_list([obs]), deterministic=False
+ )
+ values = values.flatten()
+ next_obs, reward, done, info = self.env.step(actions.cpu().numpy())
+ step_rewards.append(float(np.sum(reward)))
+ step_num_agents.append(int(len(reward)))
+ for k in _rho_keys:
+ if k in info:
+ rho_accum[k] += float(info[k])
+ for k in diag_keys:
+ if k in info:
+ diag_accum[k] += float(info[k])
+ diag_steps += 1
+
+ self.buffer.add(
+ observation=obs, actions=actions, reward=reward,
+ done=float(done), value=values, log_probs=log_probs,
+ agent_mapping=self.env.agent_mapping,
+ )
+ obs = self.env.reset() if done else next_obs
+
+ with torch.no_grad():
+ _, last_value, _ = self.policy(Batch.from_data_list([obs]), deterministic=True)
+ last_value = last_value.squeeze(-1).flatten()
+ self.buffer.compute_returns_and_advantage(last_value)
+
+ n = max(1, self.num_rollout_steps)
+ metrics = {
+ "num_agents": step_num_agents[-1], "reward": step_rewards[-1],
+ "avg_agents": np.mean(step_num_agents),
+ "avg_reward": np.mean(step_rewards),
+ "min_reward": np.min(step_rewards),
+ "max_reward": np.max(step_rewards),
+ "sum_reward": np.sum(step_rewards),
+ }
+ # rho diagnostics for weight calibration (averaged over rollout)
+ for k in _rho_keys:
+ metrics[k] = rho_accum[k] / n
+ # score-based refinement diagnostics
+ n_diag = max(1, diag_steps)
+ for k in diag_keys:
+ metrics[k] = diag_accum[k] / n_diag
+ return metrics
+
+ def train_step(self):
+ self.policy.train()
+ total_losses = []
+ for _ in range(self.epochs_per_iteration):
+ for obs_batch, acts, old_lp, old_vals, advs, rets in self.buffer.get(self.batch_size):
+ values, log_probs, entropy = self.policy.evaluate_actions(obs_batch, acts)
+ values = values.squeeze(-1)
+
+ ratio = torch.exp(log_probs - old_lp)
+
+ pl = policy_loss(advs, ratio, self.clip_range)
+ vl = self.vf_coef * value_loss(rets, values, old_vals, self.vf_clip_range)
+ el = self.entropy_coef * entropy_loss(entropy)
+ loss = pl + vl + el
+
+ self.policy.optimizer.zero_grad()
+ loss.backward()
+ torch.nn.utils.clip_grad_norm_(self.policy.parameters(), self.max_grad_norm)
+ self.policy.optimizer.step()
+ if self.policy.log_std is not None:
+ self.policy.log_std.data.clamp_(-4.0, -1.0)
+ total_losses.append(loss.item())
+
+ if self.policy.lr_scheduler is not None:
+ self.policy.lr_scheduler.step()
+
+ return {
+ "loss": np.mean(total_losses) if total_losses else 0.0,
+ "explained_variance": self.buffer.explained_variance,
+ }
+
+ def fit_iteration(self):
+ metrics = self.collect_rollouts()
+ metrics.update(self.train_step())
+ return metrics
diff --git a/src/utils.py b/src/utils.py
new file mode 100644
index 0000000..d957212
--- /dev/null
+++ b/src/utils.py
@@ -0,0 +1,63 @@
+import os
+from pathlib import Path
+from typing import Optional, Tuple
+
+import torch
+import yaml
+
+
+def load_config(path: str) -> dict:
+ with open(path, "r", encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+
+def save_checkpoint(model, optimizer: torch.optim.Optimizer, iteration: int, path: str):
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ torch.save(
+ {
+ "iteration": iteration,
+ "model_state_dict": model.state_dict(),
+ "optimizer_state_dict": optimizer.state_dict(),
+ },
+ path,
+ )
+ print(f"[Checkpoint] saved → {path}")
+
+
+def load_checkpoint(model, path: str, device=None) -> int:
+ ckpt = torch.load(path, map_location=device or "cpu")
+ model.load_state_dict(ckpt["model_state_dict"], strict=False)
+ if "optimizer_state_dict" in ckpt and hasattr(model, "optimizer"):
+ try:
+ model.optimizer.load_state_dict(ckpt["optimizer_state_dict"])
+ except Exception:
+ pass
+ it = ckpt.get("iteration", 0)
+ print(f"[Checkpoint] loaded ← {path} (iter {it})")
+ return it
+
+
+def setup_helmholtz_config(config: dict, k_test=None, center=None, radius=None, eps_test=None) -> float:
+ """Lock scatterer/helmholtz config for test/viz. Returns wave number k."""
+ hc = config.setdefault("environment", {}).setdefault("mesh_refinement", {}).setdefault("fem", {}).setdefault("helmholtz", {})
+ sc = hc.setdefault("scatterer", {})
+ sc["mode"] = "fixed"
+ if center is not None:
+ sc["cx"], sc["cy"] = center[0], center[1]
+ if radius is not None:
+ sc["radius"] = radius
+ if eps_test is not None:
+ sc["eps_r"] = eps_test
+ if k_test is not None:
+ hc["wave_number_mode"] = "fixed"
+ hc["wave_number"] = k_test
+ return hc.get("wave_number", 6.0)
+
+
+def parse_center(center_str: Optional[str]) -> Optional[Tuple[float, float]]:
+ if center_str is None:
+ return None
+ parts = center_str.split(",")
+ if len(parts) != 2:
+ raise ValueError(f"Invalid --center format (expected 'cx,cy'): {center_str}")
+ return (float(parts[0].strip()), float(parts[1].strip()))
diff --git a/src/visualize.py b/src/visualize.py
new file mode 100644
index 0000000..06b2068
--- /dev/null
+++ b/src/visualize.py
@@ -0,0 +1,293 @@
+import os
+
+import numpy as np
+import torch
+from torch_geometric.data import Batch
+
+
+# ── 高分辨率 FEM 参考解(保留作为回退) ──────────────────────────
+def _compute_fem_reference(env):
+ from skfem import Basis, ElementTriP1
+
+ fp = env.fem_problem.fem_problem
+ ref_mesh = fp._domain.get_integration_mesh()
+ ref_basis = Basis(ref_mesh, ElementTriP1())
+ ref_sol = fp.calculate_solution(ref_basis, cache=False)
+ return ref_mesh, ref_sol
+
+
+# ── Mie 解析参考解 ──────────────────────────────────────────────
+def _compute_mie_reference(env):
+ """Return Mie scattered field sampled at FEM mesh vertices.
+
+ Falls back to FEM reference if scatterer is non-circular.
+ """
+ from environment.mie_solution import mie_scattered_field
+
+ fp = getattr(env.fem_problem, "fem_problem", None)
+ if fp is None:
+ return _compute_fem_reference(env), None
+
+ _eps_r = getattr(fp, "_eps_r", None)
+ _radius = getattr(fp, "_radius", None)
+ _cx = getattr(fp, "_cx", None)
+ _cy = getattr(fp, "_cy", None)
+ _k = getattr(fp, "_k", None)
+
+ if any(v is None for v in [_eps_r, _radius, _cx, _cy, _k]):
+ return _compute_fem_reference(env), None
+
+ pts = env.mesh.p.T
+ u_mie = mie_scattered_field(pts, k0=_k, eps_r=_eps_r, radius=_radius, cx=_cx, cy=_cy)
+
+ from environment.mie_solution import mie_grid_solution
+ import matplotlib.tri as tri
+
+ xlim = (pts[:, 0].min(), pts[:, 0].max())
+ ylim = (pts[:, 1].min(), pts[:, 1].max())
+ grid = mie_grid_solution(_k, _eps_r, _radius, _cx, _cy,
+ x_range=xlim, y_range=ylim, Nx=500, Ny=500)
+
+ mie_info = {
+ "grid": grid,
+ "eps_r": _eps_r, "radius": _radius,
+ "cx": _cx, "cy": _cy, "k": _k,
+ }
+ return u_mie, mie_info
+
+
+# ── 渲染辅助 ─────────────────────────────────────────────────────
+def _render_field(ax, x, y, triang, values, title, vmin, vmax, show_mesh=True, cmap="jet"):
+ tcf = ax.tripcolor(triang, values, shading="gouraud", cmap=cmap, vmin=vmin, vmax=vmax)
+ if show_mesh and triang is not None:
+ n = triang.triangles.shape[0]
+ ax.triplot(triang, lw=(0.5 if n < 500 else 0.3), color="black",
+ alpha=(0.7 if n < 2000 else 0.5))
+ ax.set_xlim(x.min(), x.max())
+ ax.set_ylim(y.min(), y.max())
+ ax.set_aspect("equal")
+ ax.set_title(title, fontsize=9)
+ ax.set_xticks([])
+ ax.set_yticks([])
+ return tcf
+
+
+# ── 保存 PNG ─────────────────────────────────────────────────────
+def _save_png(steps, stem, checkpoint_path, k, cx=0.5, cy=0.5, radius=0.2, eps_r=2.0,
+ mie_info=None):
+ import matplotlib
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+ import matplotlib.tri as tri
+
+ per_step_dir = f"{stem}_steps"
+ os.makedirs(os.path.dirname(stem) or ".", exist_ok=True)
+ os.makedirs(per_step_dir, exist_ok=True)
+
+ n = len(steps)
+ ncols = min(n, 4)
+ nrows = (n + ncols - 1) // ncols
+ fig, axes = plt.subplots(nrows, ncols, figsize=(4 * ncols, 3.5 * nrows))
+ if nrows * ncols == 1:
+ axes = np.array([axes])
+ else:
+ axes = np.array(axes).flatten()
+
+ for i, step_data in enumerate(steps):
+ mesh, scalar, err_val, n_elem = step_data[:4]
+ pts = mesh.p.T
+ tg = tri.Triangulation(pts[:, 0], pts[:, 1], mesh.t.T)
+ s = np.abs(scalar) if np.iscomplexobj(scalar) else scalar
+ lmin, lmax = s.min(), s.max()
+ if lmax - lmin < 1e-12:
+ lmin, lmax = lmin - 0.5, lmax + 0.5
+ tcf = _render_field(axes[i], pts[:, 0], pts[:, 1], tg, s,
+ f"Step {i}: {n_elem} elem, err={err_val:.4f}",
+ lmin, lmax, cmap="jet")
+ fig.colorbar(tcf, ax=axes[i], fraction=0.046, pad=0.04)
+ axes[i].add_patch(plt.Circle((cx, cy), radius, fill=False,
+ edgecolor="cyan", linewidth=1.5, linestyle="--"))
+
+ for j in range(n, len(axes)):
+ axes[j].set_visible(False)
+
+ fig.subplots_adjust(left=0.04, right=0.90, top=0.90, bottom=0.06, wspace=0.15, hspace=0.30)
+ k_str = f"k={k:.1f}" if k is not None else "k=?"
+ ref_tag = " [Mie ref]" if mie_info is not None else ""
+ fig.suptitle(
+ f"Helmholtz |E_scat|{ref_tag} — {checkpoint_path}\n"
+ f"{k_str}, eps_r={eps_r:.1f} at ({cx:.2f},{cy:.2f}) r={radius:.2f}",
+ fontsize=12,
+ )
+ fig.savefig(f"{stem}.png", dpi=200, bbox_inches="tight")
+ plt.close(fig)
+ print(f"[Viz] Overview → {stem}.png")
+
+ for i, step_data in enumerate(steps):
+ mesh, scalar, err_val, n_elem = step_data[:4]
+ u_mie_at_verts = step_data[4] if len(step_data) > 4 else None
+
+ pts = mesh.p.T
+ tg_coarse = tri.Triangulation(pts[:, 0], pts[:, 1], mesh.t.T)
+ coarse_val = np.abs(scalar) if np.iscomplexobj(scalar) else scalar
+
+ has_mie = u_mie_at_verts is not None
+ ncols = 3 if has_mie else 1
+ fig2, axes2 = plt.subplots(1, ncols, figsize=(6 * ncols, 6))
+ axes2 = [axes2] if ncols == 1 else list(np.atleast_1d(axes2))
+
+ # ── Panel 1: FEM scattered field ──
+ cvmin, cvmax = coarse_val.min(), coarse_val.max()
+ if cvmax - cvmin < 1e-12:
+ cvmin, cvmax = cvmin - 0.5, cvmax + 0.5
+ tcf1 = _render_field(axes2[0], pts[:, 0], pts[:, 1], tg_coarse, coarse_val,
+ f"Step {i}: FEM |E_scat| ({n_elem} elem) max={cvmax:.4f}",
+ cvmin, cvmax, cmap="jet")
+ axes2[0].add_patch(plt.Circle((cx, cy), radius, fill=False,
+ edgecolor="cyan", linewidth=1.5, linestyle="--"))
+ fig2.colorbar(tcf1, ax=axes2[0], fraction=0.046, pad=0.04)
+
+ im2 = None
+ if has_mie:
+ # ── Panel 2: Mie scattered field (smooth grid, not FEM vertices) ──
+ if mie_info is not None and "grid" in mie_info:
+ g = mie_info["grid"]
+ gm = np.abs(g["E_scat"])
+ mvmin, mvmax = gm.min(), gm.max()
+ if mvmax - mvmin < 1e-12:
+ mvmin, mvmax = mvmin - 0.5, mvmax + 0.5
+ im2 = axes2[1].pcolormesh(g["X"], g["Y"], gm,
+ shading="gouraud", cmap="jet",
+ vmin=mvmin, vmax=mvmax)
+ axes2[1].set_title(f"Mie |E_scat| max={mvmax:.4f}", fontsize=9)
+ else:
+ mie_abs = np.abs(u_mie_at_verts)
+ mvmin, mvmax = mie_abs.min(), mie_abs.max()
+ if mvmax - mvmin < 1e-12:
+ mvmin, mvmax = mvmin - 0.5, mvmax + 0.5
+ im2 = _render_field(axes2[1], pts[:, 0], pts[:, 1], tg_coarse, mie_abs,
+ f"Mie |E_scat| max={mvmax:.4f}",
+ mvmin, mvmax, show_mesh=False, cmap="jet")
+ axes2[1].set_aspect("equal")
+ axes2[1].set_xticks([])
+ axes2[1].set_yticks([])
+ axes2[1].add_patch(plt.Circle((cx, cy), radius, fill=False,
+ edgecolor="cyan", linewidth=1.5, linestyle="--"))
+ if im2 is not None:
+ fig2.colorbar(im2, ax=axes2[1], fraction=0.046, pad=0.04)
+
+ # ── Panel 3: ||FEM| - |Mie|| error ──
+ mie_abs = np.abs(u_mie_at_verts)
+ error_abs = np.abs(coarse_val - mie_abs)
+ evmin, evmax = 0.0, error_abs.max() or 1.0
+ if evmax - evmin < 1e-12:
+ evmax = evmin + 1.0
+ tcf3 = _render_field(axes2[2], pts[:, 0], pts[:, 1], tg_coarse, error_abs,
+ f"||FEM|-|Mie|| L2={err_val:.4f} max={error_abs.max():.4f}",
+ evmin, evmax, show_mesh=True, cmap="hot")
+ axes2[2].add_patch(plt.Circle((cx, cy), radius, fill=False,
+ edgecolor="cyan", linewidth=1.5, linestyle="--"))
+ fig2.colorbar(tcf3, ax=axes2[2], fraction=0.046, pad=0.04)
+
+ fig2.tight_layout()
+ fig2.savefig(f"{per_step_dir}/step{i:02d}.png", dpi=150, bbox_inches="tight")
+ plt.close(fig2)
+
+ print(f"[Viz] Per-step PNGs → {per_step_dir}/ ({n} files)")
+
+
+# ── Viz 模式入口 ──────────────────────────────────────────────────
+def visualize(config: dict, checkpoint_path: str, output_path: str = "result/visualization.png",
+ k_test=None, center=None, radius=None, eps_test=None):
+ from src.network import create_model
+ from src.utils import load_checkpoint, setup_helmholtz_config
+
+ k = setup_helmholtz_config(config, k_test=k_test, center=center, radius=radius,
+ eps_test=eps_test)
+ algo = config.get("algorithm", {})
+
+ from environment.mesh_refinement import MeshRefinement
+
+ env = MeshRefinement(
+ environment_config=config.get("environment", {}).get("mesh_refinement", {}),
+ seed=99,
+ )
+ model = create_model(env, config.get("network", {}), algo.get("ppo", {}))
+ load_checkpoint(model, checkpoint_path)
+ model.eval()
+
+ stem = output_path.rsplit(".", 1)[0] if "." in output_path else output_path
+
+ print(f"\n[Viz] Initializing...")
+ obs = env.reset()
+
+ _fp = getattr(env.fem_problem, "fem_problem", None)
+ _cx = getattr(_fp, "_cx", 0.5) if _fp is not None else 0.5
+ _cy = getattr(_fp, "_cy", 0.5) if _fp is not None else 0.5
+ _radius = getattr(_fp, "_radius", 0.2) if _fp is not None else 0.2
+ _eps_r = getattr(_fp, "_eps_r", 2.0) if _fp is not None else 2.0
+
+ print(f"[Viz] Helmholtz params: k={k:.3f} eps_r={_eps_r:.2f} "
+ f"center=({_cx:.3f}, {_cy:.3f}) radius={_radius:.3f}")
+
+ # ── Mie analytical reference ──
+ print(f"[Viz] Computing Mie reference solution...")
+ u_mie_ref, mie_info = _compute_mie_reference(env)
+ if mie_info is not None:
+ print(f"[Viz] Mie reference ready (analytical, no domain truncation error)")
+
+ # ── Initial step ──
+ init_mesh = env.mesh
+ init_sol = env.scalar_solution
+ init_err = _compute_step_error(env, u_mie_ref)
+ steps = [(init_mesh, init_sol, init_err, env.num_agents, u_mie_ref)]
+
+ print(f"[Viz] Running inference...")
+ done = False
+ step_idx = 0
+ while not done:
+ with torch.no_grad():
+ actions, _, _ = model(Batch.from_data_list([obs]), deterministic=True)
+ obs, _, done, _ = env.step(actions.cpu().numpy())
+ step_idx += 1
+ sol = env.scalar_solution
+ n_elem = env.num_agents
+ u_mie_current = _eval_mie_on_mesh(env, mie_info)
+ step_err = _compute_step_error(env, u_mie_current)
+
+ diag_n_sel = getattr(env, "_diag_selected_count", -1)
+ diag_n_elig = int(getattr(env, "_diag_eligible_ratio", 0) * env.num_agents)
+ diag_n_mask = int(getattr(env, "_diag_masked_ratio", 0) * env.num_agents)
+ remaining = getattr(env, "_n_budget", 0) - env.num_agents
+ print(f" Step {step_idx}: verts={env.mesh.p.shape[1]} elem={n_elem} "
+ f"mie_err={step_err:.4f} "
+ f"sel={diag_n_sel} elig={diag_n_elig} masked={diag_n_mask} "
+ f"remaining={remaining} done={done}")
+
+ steps.append((env.mesh, sol, step_err, n_elem, u_mie_current))
+
+ _save_png(steps, stem, checkpoint_path, k, cx=_cx, cy=_cy, radius=_radius,
+ eps_r=_eps_r, mie_info=mie_info)
+ print(f"[Viz] Done → {output_path}")
+
+
+def _compute_step_error(env, u_mie_ref) -> float:
+ """相对 L₂ 误差: ||u_fem − u_mie||₂ / ||u_mie||₂ (复数,含幅值+相位)。"""
+ if u_mie_ref is None:
+ return float("nan")
+ u_fem = env.scalar_solution # complex scattered field
+ diff = np.abs(u_fem - u_mie_ref) # pointwise |complex difference|
+ denom = np.linalg.norm(np.abs(u_mie_ref))
+ if denom < 1e-12:
+ denom = 1.0
+ return float(np.linalg.norm(diff) / denom)
+
+
+def _eval_mie_on_mesh(env, mie_info):
+ """Re-evaluate Mie scattered field on current FEM mesh vertices."""
+ if mie_info is None:
+ return None
+ from environment.mie_solution import mie_scattered_field
+ pts = env.mesh.p.T
+ return mie_scattered_field(pts, k0=mie_info["k"], eps_r=mie_info["eps_r"],
+ radius=mie_info["radius"], cx=mie_info["cx"], cy=mie_info["cy"])
diff --git a/sync.ps1 b/sync.ps1
new file mode 100644
index 0000000..0d0f9be
--- /dev/null
+++ b/sync.ps1
@@ -0,0 +1,22 @@
+# ================= 配置区 =================
+$ServerA_User = "dxw"
+$ServerA_IP = "222.20.97.222"
+$RemotePath = "/public/home/dxw/Codes/afem" # 服务器A上项目的绝对路径
+$LocalPath = "F:\ASMRplusplus-main" # 本地项目路径
+# ==========================================
+
+Write-Host ">>> Step 1: Downloading code from Server A..." -ForegroundColor Cyan
+scp -r "${ServerA_User}@${ServerA_IP}:${RemotePath}/*" $LocalPath
+
+Write-Host ">>> Step 2: Preparing to commit to Git..." -ForegroundColor Cyan
+Set-Location $LocalPath
+git add .
+
+$date = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+git commit -m "Auto-sync from Server A at $date"
+
+Write-Host ">>> Step 3: Pushing to Git Server B..." -ForegroundColor Cyan
+git push origin main
+
+Write-Host "`n[Success] All operations completed!" -ForegroundColor Green
+Pause
\ No newline at end of file
diff --git a/流程.txt b/流程.txt
new file mode 100644
index 0000000..e69de29