"""
pyserep.io.mesh_writer
==========================
Export SEREP results (master DOFs, mode shapes) to finite element
post-processing formats for visualisation in Ansys, ParaView, etc.
Formats supported
-----------------
csv — simple CSV: dof_index, node, direction, x, y, z
vtk_points — VTK legacy format (ParaView)
ansys_node — Ansys APDL *GET loop list for selected nodes
UFF58 — Universal File Format dataset 58 (SDyPy compatible)
"""
from __future__ import annotations
import os
from typing import Optional
import numpy as np
# ─────────────────────────────────────────────────────────────────────────────
# CSV
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def write_master_dofs_csv(
master_dofs: np.ndarray,
path: str,
node_coords: Optional[np.ndarray] = None,
verbose: bool = True,
) -> None:
"""
Write master DOF indices to a CSV file.
Columns: dof_index, node_number, direction, [x, y, z]
Parameters
----------
master_dofs : np.ndarray of int — 0-based DOF indices
path : str — output .csv path
node_coords : np.ndarray, shape (N_nodes, 3), optional
Node coordinate array (indexed by node_number − 1).
If provided, x, y, z columns are written.
verbose : bool
"""
rows = []
for dof in master_dofs:
node = int(dof) // 3 + 1
dirn = int(dof) % 3
dir_label = ["UX", "UY", "UZ"][dirn]
if node_coords is not None and node - 1 < len(node_coords):
x, y, z = node_coords[node - 1]
rows.append(f"{dof},{node},{dir_label},{x:.6f},{y:.6f},{z:.6f}")
else:
rows.append(f"{dof},{node},{dir_label}")
header = "dof_index,node_number,direction"
if node_coords is not None:
header += ",x,y,z"
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w") as f:
f.write(header + "\n")
f.write("\n".join(rows) + "\n")
if verbose:
print(f"[mesh_writer] CSV → {path} ({len(master_dofs)} DOFs)")
# ─────────────────────────────────────────────────────────────────────────────
# VTK points (ParaView)
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def write_master_dofs_vtk(
master_dofs: np.ndarray,
node_coords: np.ndarray,
path: str,
scalar_data: Optional[np.ndarray] = None,
scalar_name: str = "eid_score",
verbose: bool = True,
) -> None:
"""
Write master DOF locations as a VTK unstructured point cloud.
Opens in ParaView, MayaVi, VisIt, etc.
Parameters
----------
master_dofs : np.ndarray of int
node_coords : np.ndarray, shape (N_nodes, 3)
path : str — output .vtk path
scalar_data : np.ndarray, shape (n_master,), optional
Per-DOF scalar field (e.g. EID score, KE contribution).
scalar_name : str
verbose : bool
"""
nodes = np.array([int(d) // 3 for d in master_dofs]) # 0-based node indices
pts = node_coords[nodes] # (n_master, 3)
n = len(master_dofs)
lines = [
"# vtk DataFile Version 3.0",
"SEREP master DOFs",
"ASCII",
"DATASET UNSTRUCTURED_GRID",
f"POINTS {n} float",
]
for x, y, z in pts:
lines.append(f"{x:.6f} {y:.6f} {z:.6f}")
lines += [
f"CELLS {n} {2*n}",
]
for i in range(n):
lines.append(f"1 {i}")
lines += [
f"CELL_TYPES {n}",
] + ["1"] * n
if scalar_data is not None:
lines += [
f"POINT_DATA {n}",
f"SCALARS {scalar_name} float 1",
"LOOKUP_TABLE default",
]
for v in scalar_data:
lines.append(f"{float(v):.8f}")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
if verbose:
print(f"[mesh_writer] VTK → {path} ({n} points)")
# ─────────────────────────────────────────────────────────────────────────────
# Ansys APDL node list
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def write_ansys_node_list(
master_dofs: np.ndarray,
path: str,
component_name: str = "MASTER_NODES",
verbose: bool = True,
) -> None:
"""
Write an Ansys APDL input file that selects the master DOF nodes.
Usage in Ansys: /INPUT, master_nodes.inp
Parameters
----------
master_dofs : np.ndarray of int
path : str — output .inp path
component_name : str — Ansys component name
verbose : bool
"""
nodes = sorted(set(int(d) // 3 + 1 for d in master_dofs))
lines = [
f"! SEREP master DOF node list — {len(nodes)} nodes",
"! Generated by pyserep",
"NSEL,NONE",
]
for node in nodes:
lines.append(f"NSEL,A,NODE,,{node}")
lines.append(f"CM,{component_name},NODE")
lines.append("NSEL,ALL")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
if verbose:
print(f"[mesh_writer] APDL → {path} ({len(nodes)} nodes, "
f"component '{component_name}')")
# ─────────────────────────────────────────────────────────────────────────────
# UFF58 mode shape export (compatible with SDyPy / pyFRF / ME'scopeVES)
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def write_uff58_mode_shapes(
phi: np.ndarray,
freqs_hz: np.ndarray,
selected_modes: np.ndarray,
master_dofs: np.ndarray,
node_coords: Optional[np.ndarray],
path: str,
zeta: float = 0.001,
verbose: bool = True,
) -> None:
"""
Write mode shapes to a UFF (Universal File Format) dataset 58 file.
Compatible with SDyPy, pyFRF, ME'scopeVES, nModal, and other
EMA/OMA post-processors.
Parameters
----------
phi : np.ndarray, shape (N, n_all_modes)
freqs_hz : np.ndarray
selected_modes : np.ndarray of int
master_dofs : np.ndarray of int — only these DOFs are exported
node_coords : np.ndarray, shape (N_nodes, 3), optional
path : str — output .uff path
zeta : float — damping ratio for all modes
verbose : bool
"""
lines = []
for mode_num, mode_idx in enumerate(selected_modes):
fn = freqs_hz[mode_idx]
node_ids = [int(d) // 3 + 1 for d in master_dofs]
dirs = [int(d) % 3 + 1 for d in master_dofs]
lines += [
" -1",
" 58",
f"SEREP_Mode_{mode_num+1}",
"NONE",
f"Freq={fn:.4f}Hz",
"NONE",
"NONE",
"NONE",
" 3 2 3 8 2 6", # func type = normal mode
f" {len(master_dofs)} 1 1",
f" {fn:.6e} {zeta:.6e} 0.000000e+00 0.000000e+00 0.000000e+00",
]
phi_m = phi[master_dofs, mode_idx] # real amplitudes
for node, dirn, val in zip(node_ids, dirs, phi_m):
lines.append(f" {node:>8} {dirn} {float(val):>15.8e}")
lines.append(" -1")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
if verbose:
print(f"[mesh_writer] UFF58 → {path} "
f"({len(selected_modes)} modes × {len(master_dofs)} DOFs)")