""" Extract SPC Forces (Reaction Forces) from Structural Analysis ============================================================= Extracts single-point constraint (SPC) forces from OP2 files. These are the reaction forces at boundary conditions. Pattern: spc_forces Node Type: Constrained grid points Result Type: reaction force API: model.spc_forces[subcase] Phase 2 Task 2.5 - NX Open Automation Roadmap """ from pathlib import Path from typing import Dict, Any, Optional, List, Literal import numpy as np from pyNastran.op2.op2 import OP2 def extract_spc_forces( op2_file: Path, subcase: int = 1, component: Literal['total', 'fx', 'fy', 'fz', 'mx', 'my', 'mz', 'force', 'moment'] = 'total' ) -> Dict[str, Any]: """ Extract SPC (reaction) forces from boundary conditions. SPC forces are the reaction forces at constrained nodes. They balance the applied loads and indicate load path through the structure. Args: op2_file: Path to .op2 file subcase: Subcase ID (default 1) component: Which component(s) to return: - 'total': Resultant force magnitude (sqrt(fx^2+fy^2+fz^2)) - 'fx', 'fy', 'fz': Individual force components - 'mx', 'my', 'mz': Individual moment components - 'force': Vector sum of forces only - 'moment': Vector sum of moments only Returns: dict: { 'total_reaction': Total reaction force magnitude, 'max_reaction': Maximum nodal reaction, 'max_reaction_node': Node ID with max reaction, 'sum_fx': Sum of Fx at all nodes, 'sum_fy': Sum of Fy at all nodes, 'sum_fz': Sum of Fz at all nodes, 'sum_mx': Sum of Mx at all nodes, 'sum_my': Sum of My at all nodes, 'sum_mz': Sum of Mz at all nodes, 'node_reactions': Dict of {node_id: [fx,fy,fz,mx,my,mz]}, 'num_constrained_nodes': Number of nodes with SPCs, 'subcase': Subcase ID, 'units': 'N, N-mm (model units)' } Example: >>> result = extract_spc_forces('model.op2') >>> print(f"Total reaction: {result['total_reaction']:.2f} N") >>> print(f"Sum Fz: {result['sum_fz']:.2f} N") """ model = OP2() model.read_op2(str(op2_file)) # Check for SPC forces spc_dict = None # Try op2_results container first if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'force'): force_container = model.op2_results.force if hasattr(force_container, 'spc_forces'): spc_dict = force_container.spc_forces # Fallback to direct model attribute if spc_dict is None and hasattr(model, 'spc_forces') and model.spc_forces: spc_dict = model.spc_forces if spc_dict is None or not spc_dict: raise ValueError( "No SPC forces found in OP2 file. " "Ensure SPCFORCE=ALL is in the Nastran case control." ) # Get subcase available_subcases = list(spc_dict.keys()) if subcase in available_subcases: actual_subcase = subcase else: actual_subcase = available_subcases[0] spc_result = spc_dict[actual_subcase] # Extract data # SPC forces: data[itime, inode, 6] where columns are [fx, fy, fz, mx, my, mz] itime = 0 if hasattr(spc_result, 'data'): data = spc_result.data[itime] # Shape: (nnodes, 6) node_ids = spc_result.node_gridtype[:, 0] if hasattr(spc_result, 'node_gridtype') else np.arange(len(data)) else: raise ValueError("Unexpected SPC forces data format") # Force components (columns 0-2) fx = data[:, 0] fy = data[:, 1] fz = data[:, 2] # Moment components (columns 3-5) mx = data[:, 3] my = data[:, 4] mz = data[:, 5] # Resultant force at each node force_mag = np.sqrt(fx**2 + fy**2 + fz**2) moment_mag = np.sqrt(mx**2 + my**2 + mz**2) # Total resultant (force + moment contribution) # Note: Forces and moments have different units, so total is just force magnitude total_mag = force_mag # Statistics max_idx = np.argmax(total_mag) max_reaction = float(total_mag[max_idx]) max_node = int(node_ids[max_idx]) # Sum of components (should balance applied loads in static analysis) sum_fx = float(np.sum(fx)) sum_fy = float(np.sum(fy)) sum_fz = float(np.sum(fz)) sum_mx = float(np.sum(mx)) sum_my = float(np.sum(my)) sum_mz = float(np.sum(mz)) # Total reaction force magnitude total_reaction = float(np.sqrt(sum_fx**2 + sum_fy**2 + sum_fz**2)) # Per-node reactions dictionary node_reactions = {} for i, nid in enumerate(node_ids): node_reactions[int(nid)] = [ float(fx[i]), float(fy[i]), float(fz[i]), float(mx[i]), float(my[i]), float(mz[i]) ] # Component-specific return values component_result = None if component == 'fx': component_result = float(np.max(np.abs(fx))) elif component == 'fy': component_result = float(np.max(np.abs(fy))) elif component == 'fz': component_result = float(np.max(np.abs(fz))) elif component == 'mx': component_result = float(np.max(np.abs(mx))) elif component == 'my': component_result = float(np.max(np.abs(my))) elif component == 'mz': component_result = float(np.max(np.abs(mz))) elif component == 'force': component_result = float(np.max(force_mag)) elif component == 'moment': component_result = float(np.max(moment_mag)) elif component == 'total': component_result = total_reaction return { 'total_reaction': total_reaction, 'max_reaction': max_reaction, 'max_reaction_node': max_node, 'component_max': component_result, 'component': component, 'sum_fx': sum_fx, 'sum_fy': sum_fy, 'sum_fz': sum_fz, 'sum_mx': sum_mx, 'sum_my': sum_my, 'sum_mz': sum_mz, 'max_fx': float(np.max(np.abs(fx))), 'max_fy': float(np.max(np.abs(fy))), 'max_fz': float(np.max(np.abs(fz))), 'max_mx': float(np.max(np.abs(mx))), 'max_my': float(np.max(np.abs(my))), 'max_mz': float(np.max(np.abs(mz))), 'node_reactions': node_reactions, 'num_constrained_nodes': len(node_ids), 'subcase': actual_subcase, 'units': 'N, N-mm (model units)', } def extract_total_reaction_force( op2_file: Path, subcase: int = 1 ) -> float: """ Convenience function to extract total reaction force magnitude. Args: op2_file: Path to .op2 file subcase: Subcase ID Returns: Total reaction force magnitude (N) """ result = extract_spc_forces(op2_file, subcase) return result['total_reaction'] def extract_reaction_component( op2_file: Path, component: str = 'fz', subcase: int = 1 ) -> float: """ Extract maximum absolute value of a specific reaction component. Args: op2_file: Path to .op2 file component: 'fx', 'fy', 'fz', 'mx', 'my', 'mz' subcase: Subcase ID Returns: Maximum absolute value of the specified component """ result = extract_spc_forces(op2_file, subcase, component) return result['component_max'] def check_force_equilibrium( op2_file: Path, applied_load: Optional[Dict[str, float]] = None, tolerance: float = 1.0 ) -> Dict[str, Any]: """ Check if reaction forces balance applied loads (equilibrium check). In a valid static analysis, sum of reactions should equal applied loads. Args: op2_file: Path to .op2 file applied_load: Optional dict of applied loads {'fx': N, 'fy': N, 'fz': N} tolerance: Tolerance for equilibrium check (default 1.0 N) Returns: dict: { 'in_equilibrium': Boolean, 'reaction_sum': [fx, fy, fz], 'imbalance': [dx, dy, dz] (if applied_load provided), 'max_imbalance': Maximum component imbalance } """ result = extract_spc_forces(op2_file) reaction_sum = [result['sum_fx'], result['sum_fy'], result['sum_fz']] equilibrium_result = { 'reaction_sum': reaction_sum, 'moment_sum': [result['sum_mx'], result['sum_my'], result['sum_mz']], } if applied_load: applied = [ applied_load.get('fx', 0.0), applied_load.get('fy', 0.0), applied_load.get('fz', 0.0) ] # Reactions should be opposite to applied loads imbalance = [ reaction_sum[0] + applied[0], reaction_sum[1] + applied[1], reaction_sum[2] + applied[2] ] max_imbalance = max(abs(i) for i in imbalance) equilibrium_result['applied_load'] = applied equilibrium_result['imbalance'] = imbalance equilibrium_result['max_imbalance'] = max_imbalance equilibrium_result['in_equilibrium'] = max_imbalance <= tolerance else: # Without applied load, just check if reactions are non-zero equilibrium_result['total_reaction'] = result['total_reaction'] equilibrium_result['in_equilibrium'] = True # Can't check without applied load return equilibrium_result if __name__ == '__main__': import sys if len(sys.argv) > 1: op2_file = Path(sys.argv[1]) try: result = extract_spc_forces(op2_file) print(f"\nSPC Forces (Reaction Forces):") print(f" Total reaction: {result['total_reaction']:.2f} N") print(f" Max nodal reaction: {result['max_reaction']:.2f} N") print(f" Node: {result['max_reaction_node']}") print(f"\n Force sums (should balance applied loads):") print(f" ΣFx: {result['sum_fx']:.2f} N") print(f" ΣFy: {result['sum_fy']:.2f} N") print(f" ΣFz: {result['sum_fz']:.2f} N") print(f"\n Moment sums:") print(f" ΣMx: {result['sum_mx']:.2f} N-mm") print(f" ΣMy: {result['sum_my']:.2f} N-mm") print(f" ΣMz: {result['sum_mz']:.2f} N-mm") print(f"\n Constrained nodes: {result['num_constrained_nodes']}") # Show a few node reactions print(f"\n Sample node reactions:") for i, (nid, forces) in enumerate(result['node_reactions'].items()): if i >= 3: print(f" ... ({result['num_constrained_nodes'] - 3} more)") break print(f" Node {nid}: Fx={forces[0]:.2f}, Fy={forces[1]:.2f}, Fz={forces[2]:.2f}") except ValueError as e: print(f"Error: {e}") else: print(f"Usage: python {sys.argv[0]} ")