1515 lines
62 KiB
Python
1515 lines
62 KiB
Python
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
|