thermoelasticsim.elastic.wave.visualization 源代码

#!/usr/bin/env python3
"""
弹性波各向异性可视化

提供在指定晶面内采样传播方向,计算纵/横波速并绘制极坐标图的工具。

功能
----
- `sample_plane_directions`:在给定面("001"/"110"/"111")上等角采样方向
- `compute_velocities_over_directions`:批量计算 v_L, v_T1, v_T2
- `plot_polar_plane`:生成各向异性极图(matplotlib)

说明
----
- 仅依赖 matplotlib,默认使用项目统一的中文字体与Agg后端。
- 角度 θ ∈ [0, 2π),方向向量 n(θ) = u cosθ + v sinθ,其中 {u, v} 为该平面
  的正交基,法向量为平面的米勒指数方向。
"""

from __future__ import annotations

import contextlib
import math
import os
from collections.abc import Iterable, Sequence
from multiprocessing import Process

import numpy as np

from ...elastic.wave.analytical import ElasticWaveAnalyzer
from ...utils.plot_config import plt


def _canonical_plane_basis(plane: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    为指定晶面返回规范化的正交基向量组。

    Parameters
    ----------
    plane : str
        晶面标识符("001", "110", "111")。

    Returns
    -------
    tuple[ndarray, ndarray, ndarray]
        (n, u, v) 三个单位向量:
        - n: 面法向量
        - u, v: 面内正交基向量

    Notes
    -----
    高对称晶面的规范基选择:
    - (001): n=[0,0,1], u=[1,0,0]([100]), v=[0,1,0]([010])
    - (110): n=[1,1,0]/√2, u=[1,-1,0]/√2, v=[0,0,1]([001])
    - (111): n=[1,1,1]/√3, u=[1,-1,0]/√2, v=n×u(归一化)
    """
    plane = plane.strip().strip("[](){}")
    if plane == "001":
        n = np.array([0.0, 0.0, 1.0])
        u = np.array([1.0, 0.0, 0.0])
        v = np.array([0.0, 1.0, 0.0])
        return n, u, v
    if plane == "110":
        n = np.array([1.0, 1.0, 0.0])
        n = n / np.linalg.norm(n)
        u = np.array([1.0, -1.0, 0.0])
        u = u / np.linalg.norm(u)
        v = np.array([0.0, 0.0, 1.0])
        return n, u, v
    if plane == "111":
        n = np.array([1.0, 1.0, 1.0])
        n = n / np.linalg.norm(n)
        u = np.array([1.0, -1.0, 0.0])
        u = u / np.linalg.norm(u)
        v = np.cross(n, u)
        v = v / np.linalg.norm(v)
        return n, u, v
    # 回退到通用算法
    n = _plane_normal(plane)
    u, v = _orthonormal_basis_for_plane(n)
    return n, u, v


def _plane_normal(plane: str) -> np.ndarray:
    """将平面标识(如"001")解析为单位法向量。

    Parameters
    ----------
    plane : str
        平面标识,仅支持 "001"、"110"、"111"。

    Returns
    -------
    ndarray, shape (3,)
        单位法向量。
    """
    plane = plane.strip().strip("[](){}")
    if plane not in {"001", "110", "111"}:
        raise ValueError(f"不支持的平面: {plane}")
    h, k, l_idx = (int(ch) for ch in plane)
    v = np.array([h, k, l_idx], dtype=float)
    n = np.linalg.norm(v)
    if n == 0:
        raise ValueError("非法平面索引")
    return v / n


def _orthonormal_basis_for_plane(normal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """
    为给定法向量的平面构造一组正交基向量。

    Parameters
    ----------
    normal : ndarray
        平面法向量(非零)。

    Returns
    -------
    tuple[ndarray, ndarray]
        (u, v) 两个单位正交向量,满足 u⊥v⊥normal。

    Notes
    -----
    使用叉积构造正交基,选择合适的参考向量避免平行退化。
    """
    n = normal / np.linalg.norm(normal)
    # 选择与 n 不平行的参考向量
    ref = np.array([0.0, 0.0, 1.0]) if abs(n[2]) < 0.9 else np.array([1.0, 0.0, 0.0])
    u = np.cross(n, ref)
    un = np.linalg.norm(u)
    if un == 0:
        # 极罕见,换一个参考向量
        ref = np.array([0.0, 1.0, 0.0])
        u = np.cross(n, ref)
        un = np.linalg.norm(u)
    u = u / un
    v = np.cross(n, u)
    v = v / np.linalg.norm(v)
    return u, v


[文档] def sample_plane_directions( plane: str, n_angles: int = 360 ) -> tuple[np.ndarray, np.ndarray]: """ 在指定晶面上等角采样传播方向。 Parameters ---------- plane : str 平面标识("001"/"110"/"111")。 n_angles : int, optional 角度采样数(默认360)。 Returns ------- tuple[ndarray, ndarray] (theta, directions),其中 theta 形状为 (n_angles,), directions 形状为 (n_angles, 3),均为单位长度方向向量。 """ normal, u, v = _canonical_plane_basis(plane) thetas = np.linspace(0.0, 2.0 * math.pi, int(n_angles), endpoint=False) dirs = np.cos(thetas)[:, None] * u[None, :] + np.sin(thetas)[:, None] * v[None, :] # 标准化以去除数值误差 dirs = dirs / np.linalg.norm(dirs, axis=1, keepdims=True) return thetas, dirs
[文档] def compute_velocities_over_directions( analyzer: ElasticWaveAnalyzer, directions: Iterable[Iterable[float]], plane: str | None = None, ) -> dict[str, np.ndarray]: """ 批量计算多个方向上的纵/横波速。 Parameters ---------- analyzer : ElasticWaveAnalyzer 解析计算器实例。 directions : Iterable[Iterable[float]] 多个单位方向向量。 Returns ------- dict 包含 'vL', 'vT1', 'vT2' 三个键,对应等长的速度数组(km/s)。 """ vL, vT1, vT2 = [], [], [] vT_para, vT_perp = [], [] use_classify = plane is not None n_plane = None if use_classify: n_plane = _canonical_plane_basis(plane)[0] for n in directions: r = analyzer.calculate_wave_velocities(n) vL.append(r["longitudinal"]) # 横波按升序返回,保持一致 v1 = r["transverse1"] v2 = r["transverse2"] vT1.append(v1) vT2.append(v2) if use_classify: pol = r["polarizations"] e1 = np.asarray(pol["transverse1"], dtype=float) e2 = np.asarray(pol["transverse2"], dtype=float) # 计算与平面法向的夹角,法向分量更大者为 T_perp d1 = abs(float(np.dot(e1, n_plane))) d2 = abs(float(np.dot(e2, n_plane))) if d1 >= d2: vT_perp.append(v1) vT_para.append(v2) else: vT_perp.append(v2) vT_para.append(v1) result = { "vL": np.asarray(vL, dtype=float), "vT1": np.asarray(vT1, dtype=float), "vT2": np.asarray(vT2, dtype=float), } if use_classify: result["vT_para"] = np.asarray(vT_para, dtype=float) result["vT_perp"] = np.asarray(vT_perp, dtype=float) return result
[文档] def plot_polar_plane( analyzer: ElasticWaveAnalyzer, plane: str = "001", n_angles: int = 360, outpath: str | None = None, dpi: int = 300, annotate_hkls: Sequence[Sequence[int]] | None = None, ) -> str: """ 绘制给定晶面内的声速各向异性极图。 Parameters ---------- analyzer : ElasticWaveAnalyzer 解析计算器实例。 plane : str, optional 平面标识("001"/"110"/"111"),默认"001"。 n_angles : int, optional 角度采样点数,默认360。 outpath : str, optional 输出文件路径;若为None则使用当前目录下的 'anisotropy_polar.png'。 dpi : int, optional 保存DPI,默认300。 Returns ------- str 生成的图像路径。 """ theta, dirs = sample_plane_directions(plane, n_angles) v = compute_velocities_over_directions(analyzer, dirs, plane=plane) fig = plt.figure(figsize=(6.4, 6.4)) ax = fig.add_subplot(111, projection="polar") ax.plot(theta, v["vL"], label="纵波 v_L", color="#1f77b4", lw=1.8) if "vT_para" in v and "vT_perp" in v: ax.plot( theta, v["vT_perp"], label="横波 v_T⊥ (法向偏振)", color="#2ca02c", lw=1.4 ) ax.plot( theta, v["vT_para"], label="横波 v_T∥ (面内偏振)", color="#ff7f0e", lw=1.4 ) else: ax.plot(theta, v["vT1"], label="横波 v_T1", color="#ff7f0e", lw=1.4) ax.plot(theta, v["vT2"], label="横波 v_T2", color="#2ca02c", lw=1.4) ax.set_title(f"{plane} 平面声速各向异性 (km/s)") ax.grid(True, alpha=0.3) ax.legend(loc="upper right", bbox_to_anchor=(1.15, 1.15)) # 注记指定方向(仅对位于该平面的方向有效) if annotate_hkls is None: if plane == "001": annotate_hkls = ([1, 0, 0], [1, 1, 0]) # [100], [110] elif plane == "110": annotate_hkls = ([0, 0, 1], [1, -1, 0]) # [001], [1-10] elif plane == "111": annotate_hkls = ([1, -1, 0], [1, 1, -2]) # [1-10], [11-2] if annotate_hkls: n, u, v_basis = _canonical_plane_basis(plane) for hkl in annotate_hkls: d = np.array(hkl, dtype=float) d = d / (np.linalg.norm(d) or 1.0) d_proj = d - np.dot(d, n) * n norm = np.linalg.norm(d_proj) if norm < 1e-8: continue # 方向不在平面内,跳过 d_proj /= norm ang = math.atan2(np.dot(d_proj, v_basis), np.dot(d_proj, u)) if ang < 0: ang += 2 * math.pi # 画径向线与文本(靠外半径) rmax = float(np.nanmax([v["vL"].max(), v["vT1"].max(), v["vT2"].max()])) ax.plot([ang, ang], [0, rmax * 1.02], color="#444444", lw=0.8, alpha=0.5) ax.text( ang, rmax * 1.08, f"[{hkl[0]}{hkl[1]}{hkl[2]}]", ha="center", va="bottom", fontsize=10, fontweight="bold", color="#2c2c2c", ) # 信息框:材料参数与物理说明 C11, C12, C44 = analyzer.C11, analyzer.C12, analyzer.C44 rho = analyzer.density A = 2.0 * C44 / (C11 - C12) if (C11 - C12) != 0 else np.inf # 计算各向异性程度说明 aniso_desc = ( "各向同性" if abs(A - 1.0) < 0.1 else ("强各向异性" if abs(A - 1.0) > 0.5 else "弱各向异性") ) lines = [ f"C11={C11:.1f} GPa, C12={C12:.1f} GPa, C44={C44:.1f} GPa, ρ={rho:.2f} g/cm³", f"各向异性因子 A=2C44/(C11−C12)={A:.2f} ({aniso_desc})", ] if plane == "001": lines.append("(001)平面: 法向偏振横波v_T⊥=√(C44/ρ)恒定,面内横波呈四重对称") elif plane == "110": lines.append("(110)平面: 纵波沿[110]方向最快,横波呈现明显各向异性") elif plane == "111": lines.append("(111)平面: 高对称面,横波简并,呈现三重旋转对称") # 将说明放置在图下方,避免与雷达图重叠 fig.text( 0.02, 0.02, "\n".join(lines), fontsize=9, va="bottom", ha="left", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#aaaaaa", alpha=0.9), ) out = outpath or "analytic_anisotropy_polar.png" os.makedirs(os.path.dirname(out) or ".", exist_ok=True) fig.savefig(out, dpi=dpi, bbox_inches="tight") return out
[文档] def plot_velocity_surface_3d( analyzer: ElasticWaveAnalyzer, mode: str = "L", n_theta: int = 60, n_phi: int = 120, output_html: str | None = None, output_png: str | None = None, ) -> tuple[str | None, str | None]: """ 绘制三维速度面(plotly,可交互)。 Parameters ---------- analyzer : ElasticWaveAnalyzer 解析计算器实例。 mode : {"L", "Tmin", "Tmax"} 绘制纵波面或两支横波中的较小/较大。 n_theta, n_phi : int 角度网格分辨率(θ: [0,π], φ: [0,2π))。 output_html : str, optional 交互版HTML文件路径(推荐)。 output_png : str, optional 静态PNG文件路径(需要安装kaleido)。 Returns ------- (html_path, png_path) 实际生成的文件路径;若依赖缺失则返回 (None, None)。 """ try: import plotly.graph_objects as go # type: ignore except Exception: return None, None thetas = np.linspace(0.0, math.pi, int(n_theta)) phis = np.linspace(0.0, 2.0 * math.pi, int(n_phi), endpoint=False) T, P = np.meshgrid(thetas, phis, indexing="ij") # 方向向量 nx = np.sin(T) * np.cos(P) ny = np.sin(T) * np.sin(P) nz = np.cos(T) shape = T.shape v = np.zeros(shape, dtype=float) for i in range(shape[0]): for j in range(shape[1]): r = analyzer.calculate_wave_velocities((nx[i, j], ny[i, j], nz[i, j])) if mode == "L": v[i, j] = r["longitudinal"] elif mode == "Tmin": v[i, j] = min(r["transverse1"], r["transverse2"]) else: # Tmax v[i, j] = max(r["transverse1"], r["transverse2"]) # 球半径 = v,转换到直角坐标 X = v * nx Y = v * ny Z = v * nz # 为避免φ=0与2π处出现开口(seam),在列方向拼接首列副本闭合表面 X = np.concatenate([X, X[:, :1]], axis=1) Y = np.concatenate([Y, Y[:, :1]], axis=1) Z = np.concatenate([Z, Z[:, :1]], axis=1) v = np.concatenate([v, v[:, :1]], axis=1) surface = go.Surface( x=X, y=Y, z=Z, surfacecolor=v, colorscale="Viridis", showscale=True, colorbar=dict(title="v (km/s)"), ) # 标注 [100]/[110]/[111] 散点(按当前模式的半径) def _v_mode_at(dir_vec: np.ndarray) -> float: res = analyzer.calculate_wave_velocities(dir_vec) if mode == "L": return float(res["longitudinal"]) elif mode == "Tmin": return float(min(res["transverse1"], res["transverse2"])) else: return float(max(res["transverse1"], res["transverse2"])) pts = { "[100]": np.array([1.0, 0.0, 0.0]), "[110]": (np.array([1.0, 1.0, 0.0]) / np.sqrt(2.0)), "[111]": (np.array([1.0, 1.0, 1.0]) / np.sqrt(3.0)), } xs, ys, zs, texts = [], [], [], [] for label, nvec in pts.items(): r_ = _v_mode_at(nvec) p = r_ * nvec xs.append(p[0]) ys.append(p[1]) zs.append(p[2]) texts.append(label) scatter = go.Scatter3d( x=xs, y=ys, z=zs, mode="markers+text", marker=dict(size=5, color="red"), text=texts, textposition="top center", name="高对称点", ) fig = go.Figure(data=[surface, scatter]) fig.update_layout( title=f"3D 速度面 - {mode}", scene=dict(xaxis_title="x", yaxis_title="y", zaxis_title="z"), ) html_path = output_html or f"analytic_velocity_surface_{mode}.html" fig.write_html(html_path) png_path = None if output_png: if _write_image_with_timeout(fig, output_png, timeout=30): png_path = output_png else: png_path = None return html_path, png_path
def _worker_write_image_figjson(fig_json: str, path: str) -> None: try: import plotly.io as pio # type: ignore fig = pio.from_json(fig_json) fig.write_image(path) except Exception: pass def _write_image_with_timeout(fig, output_png: str, timeout: int = 30) -> bool: """在子进程中导出PNG,并设置超时,防止阻塞。""" try: fig_json = fig.to_json() except Exception: return False proc = Process( target=_worker_write_image_figjson, args=(fig_json, output_png), daemon=True ) proc.start() proc.join(timeout) if proc.is_alive(): with contextlib.suppress(Exception): proc.terminate() return False return os.path.exists(output_png) and os.path.getsize(output_png) > 0 __all__ = [ "sample_plane_directions", "compute_velocities_over_directions", "plot_polar_plane", "plot_velocity_surface_3d", ]