# Nastran Visualization Research: OP2/BDF/DAT File Processing

**Research Date**: 2025-11-21
**Purpose**: Investigate methods to visualize geometry/mesh and generate images of FEA metrics from Nastran files across optimization iterations

---

## Executive Summary

**Recommendation**: Use **pyNastran + PyVista** combination for Atomizer visualization needs.

- **pyNastran**: Read OP2/BDF files, extract results (stress, displacement, eigenvalues)
- **PyVista**: Generate 3D visualizations and save images programmatically

This approach provides:
✅ **Programmatic image generation** (no GUI needed)
✅ **Full automation** for optimization iterations
✅ **Rich visualization** (mesh, stress contours, displacement plots)
✅ **Dashboard integration ready** (save PNGs for React dashboard)
✅ **Lightweight** (no commercial FEA software required)

---

## 1. pyNastran Overview

### What It Does

pyNastran is a **Python library for reading/writing/processing Nastran files**:

- **BDF (Input Files)**: Geometry, mesh, materials, boundary conditions
- **OP2 (Results Files)**: Stress, strain, displacement, eigenvalues, etc.
- **F06 (Text Output)**: Less structured, slower to parse

**GitHub**: https://github.com/SteveDoyle2/pyNastran
**Docs**: https://pynastran-git.readthedocs.io/

### Key Features

✅ **Fast OP2 Reading**: Vectorized, optimized for large files
✅ **427+ Supported Cards**: Comprehensive BDF support
✅ **HDF5 Export**: For massive files (reduces memory usage)
✅ **Result Extraction**: Displacement, stress, strain, eigenvalues, SPC/MPC forces
✅ **Built-in GUI**: VTK-based viewer (optional, not needed for automation)
✅ **SORT2 Support**: Handles frequency/time-domain results

### Supported Results

From OP2 files:
- Displacement, velocity, acceleration
- Temperature
- Eigenvectors & eigenvalues
- Element stress/strain (CQUAD4, CTRIA3, CBAR, CBEAM, CTETRA, etc.)
- SPC/MPC forces
- Grid point forces
- Strain energy

### Installation

```bash
pip install pyNastran
```

**Dependencies**:
- numpy, scipy
- h5py (for HDF5 support)
- matplotlib (optional, for basic plotting)
- vtk (optional, for GUI only)
- PyQt5/PySide2 (optional, for GUI only)

**Python Support**: 3.9-3.12

---

## 2. Reading OP2 Files with pyNastran

### Basic Usage

```python
from pyNastran.op2.op2 import read_op2

# Read OP2 file (with optional pandas DataFrames)
op2 = read_op2('simulation.op2', build_dataframe=True, debug=False)

# Quick overview
print(op2.get_op2_stats())
```

### Accessing Results

**Displacement Results:**
```python
# Get displacements for subcase 1
disp = op2.displacements[1]  # subcase ID

# NumPy array: [n_times, n_nodes, 6] (tx, ty, tz, rx, ry, rz)
displacement_data = disp.data

# Node IDs
node_ids = disp.node_gridtype[:, 0]

# Pandas DataFrame (if build_dataframe=True)
disp_df = disp.data_frame
```

**Stress Results:**
```python
# Element stress (e.g., CQUAD4 plate elements)
plate_stress = op2.cquad4_stress[1]  # subcase ID

# Data array: [n_times, n_elements, n_values]
# For CQUAD4: [fiber_distance, oxx, oyy, txy, angle, omax, omin, von_mises]
von_mises = plate_stress.data[itime, :, 7]  # Von Mises stress

# Element IDs
element_ids = plate_stress.element_node[:, 0]
```

**Eigenvalue Results:**
```python
# Eigenvectors
eig1 = op2.eigenvectors[1]

# Extract mode 2
mode2 = eig1.data[imode2, :, :]

# Frequencies
eigenvalues = op2.eigenvalues[1]
frequencies = eigenvalues.freqs
```

### Reading Geometry from BDF

```python
from pyNastran.bdf.bdf import read_bdf

# Read geometry
model = read_bdf('model.bdf')

# Access nodes
for nid, node in model.nodes.items():
    xyz = node.get_position()
    print(f"Node {nid}: {xyz}")

# Access elements
for eid, element in model.elements.items():
    node_ids = element.node_ids
    print(f"Element {eid}: nodes {node_ids}")
```

### Reading Geometry from OP2 (with OP2Geom)

```python
from pyNastran.op2.op2_geom import read_op2_geom

# Read OP2 with embedded geometry
model = read_op2_geom('simulation.op2')

# Now model has both geometry and results
nodes = model.nodes
elements = model.elements
displacements = model.displacements[1]
```

---

## 3. PyVista for 3D Visualization

### What It Does

PyVista is a **Python wrapper for VTK** providing:
- 3D mesh visualization
- Scalar field mapping (stress, temperature)
- Vector field plotting (displacement)
- **Programmatic screenshot generation** (no GUI needed)

**GitHub**: https://github.com/pyvista/pyvista
**Docs**: https://docs.pyvista.org/

### Installation

```bash
pip install pyvista
```

### Creating Mesh from Nastran Data

```python
import pyvista as pv
import numpy as np

# Example: Create mesh from pyNastran nodes/elements
def create_pyvista_mesh(model, op2, subcase=1):
    """Create PyVista mesh with displacement and stress data."""

    # Get nodes
    node_ids = sorted(model.nodes.keys())
    points = np.array([model.nodes[nid].get_position() for nid in node_ids])

    # Get quad elements (CQUAD4)
    cells = []
    for eid, element in model.elements.items():
        if element.type == 'CQUAD4':
            # PyVista quad: [4, node1, node2, node3, node4]
            nids = element.node_ids
            cells.extend([4] + nids)

    cells = np.array(cells)
    celltypes = np.full(len(cells)//5, pv.CellType.QUAD, dtype=np.uint8)

    # Create unstructured grid
    mesh = pv.UnstructuredGrid(cells, celltypes, points)

    # Add displacement field
    disp = op2.displacements[subcase]
    disp_vectors = disp.data[0, :, :3]  # tx, ty, tz
    mesh['displacement'] = disp_vectors

    # Add stress (if available)
    if subcase in op2.cquad4_stress:
        stress = op2.cquad4_stress[subcase]
        von_mises = stress.data[0, :, 7]  # Von Mises stress
        mesh['von_mises_stress'] = von_mises

    return mesh
```

### Programmatic Visualization & Screenshot

```python
def generate_stress_plot(mesh, output_file='stress_plot.png'):
    """Generate stress contour plot and save as image."""

    # Create off-screen plotter (no GUI window)
    plotter = pv.Plotter(off_screen=True, window_size=[1920, 1080])

    # Add mesh with stress coloring
    plotter.add_mesh(
        mesh,
        scalars='von_mises_stress',
        cmap='jet',  # Color map
        show_edges=True,
        edge_color='black',
        scalar_bar_args={
            'title': 'Von Mises Stress (MPa)',
            'vertical': True,
            'position_x': 0.85,
            'position_y': 0.1
        }
    )

    # Set camera view
    plotter.camera_position = 'iso'  # Isometric view
    plotter.camera.zoom(1.2)

    # Add title
    plotter.add_text('Stress Analysis - Trial #5', position='upper_left', font_size=14)

    # Save screenshot
    plotter.screenshot(output_file, return_img=False, scale=2)
    plotter.close()

    return output_file
```

### Deformed Mesh Visualization

```python
def generate_deformed_mesh_plot(mesh, scale_factor=100.0, output_file='deformed.png'):
    """Plot deformed mesh with displacement."""

    # Warp mesh by displacement vector
    warped = mesh.warp_by_vector('displacement', factor=scale_factor)

    plotter = pv.Plotter(off_screen=True, window_size=[1920, 1080])

    # Original mesh (transparent)
    plotter.add_mesh(mesh, opacity=0.2, color='gray', show_edges=True)

    # Deformed mesh (colored by displacement magnitude)
    plotter.add_mesh(
        warped,
        scalars='displacement',
        cmap='rainbow',
        show_edges=True,
        scalar_bar_args={'title': 'Displacement Magnitude (mm)'}
    )

    plotter.camera_position = 'iso'
    plotter.screenshot(output_file, scale=2)
    plotter.close()
```

---

## 4. Recommended Architecture for Atomizer

### Integration Approach

**Option A: Lightweight (Recommended)**
- Use **pyNastran** to read OP2 files after each trial
- Use **PyVista** to generate static PNG images
- Store images in `studies/my_study/2_results/visualizations/trial_XXX_stress.png`
- Display images in React dashboard via image gallery

**Option B: Full 3D (Advanced)**
- Export PyVista mesh to VTK/glTF format
- Use Three.js or react-three-fiber in dashboard
- Interactive 3D viewer in browser

### Proposed Workflow

```
Trial Completion
    ↓
NX Solver writes OP2 file
    ↓
pyNastran reads OP2 + BDF
    ↓
Extract: stress, displacement, geometry
    ↓
PyVista creates mesh + applies results
    ↓
Generate images:
  - stress_contour.png
  - displacement.png
  - deformed_shape.png
    ↓
Store in 2_results/visualizations/trial_XXX/
    ↓
Dashboard polls for new images
    ↓
Display in React gallery component
```

### File Structure

```
studies/my_optimization/
├── 1_setup/
│   └── model/
│       ├── model.prt
│       ├── model.sim
│       └── model.bdf  ← BDF for geometry
├── 2_results/
│   ├── study.db
│   ├── trial_log.json
│   └── visualizations/  ← NEW: Generated images
│       ├── trial_000/
│       │   ├── stress_vonmises.png
│       │   ├── displacement_magnitude.png
│       │   └── deformed_shape.png
│       ├── trial_001/
│       │   └── ...
│       └── pareto_front/  ← Best designs
│           ├── trial_009_stress.png
│           └── trial_042_stress.png
```

---

## 5. Implementation Example for Atomizer

### Visualization Module

```python
# optimization_engine/visualizer.py

"""
Nastran Visualization Module

Generates FEA result visualizations from OP2/BDF files using pyNastran + PyVista.
"""

from pathlib import Path
import numpy as np
import pyvista as pv
from pyNastran.op2.op2_geom import read_op2_geom
from pyNastran.bdf.bdf import read_bdf


class NastranVisualizer:
    """Generate visualizations from Nastran results."""

    def __init__(self, bdf_path: Path, output_dir: Path):
        """
        Initialize visualizer.

        Args:
            bdf_path: Path to BDF file (for geometry)
            output_dir: Directory to save images
        """
        self.bdf_path = bdf_path
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)

        # Load geometry once
        self.model = read_bdf(str(bdf_path))

    def generate_trial_visualizations(self, op2_path: Path, trial_number: int, subcase: int = 1):
        """
        Generate all visualizations for a trial.

        Args:
            op2_path: Path to OP2 results file
            trial_number: Trial number
            subcase: Nastran subcase ID

        Returns:
            dict: Paths to generated images
        """
        # Read results
        op2 = read_op2_geom(str(op2_path))

        # Create trial output directory
        trial_dir = self.output_dir / f"trial_{trial_number:03d}"
        trial_dir.mkdir(exist_ok=True)

        # Generate visualizations
        images = {}

        # 1. Stress contour
        if subcase in op2.cquad4_stress:
            images['stress'] = self._plot_stress(op2, subcase, trial_dir / 'stress_vonmises.png', trial_number)

        # 2. Displacement magnitude
        if subcase in op2.displacements:
            images['displacement'] = self._plot_displacement(op2, subcase, trial_dir / 'displacement.png', trial_number)

        # 3. Deformed shape
        if subcase in op2.displacements:
            images['deformed'] = self._plot_deformed_shape(op2, subcase, trial_dir / 'deformed_shape.png', trial_number)

        return images

    def _create_mesh(self, op2, subcase):
        """Create PyVista mesh from Nastran data."""
        # Get nodes
        node_ids = sorted(self.model.nodes.keys())
        points = np.array([self.model.nodes[nid].get_position() for nid in node_ids])

        # Get CQUAD4 elements
        cells = []
        cell_data = []

        for eid, element in self.model.elements.items():
            if element.type == 'CQUAD4':
                nids = [self.model.nodes.get_index(nid) for nid in element.node_ids]
                cells.extend([4] + nids)

        if not cells:
            raise ValueError("No CQUAD4 elements found in model")

        cells = np.array(cells)
        celltypes = np.full(len(cells)//5, pv.CellType.QUAD, dtype=np.uint8)

        mesh = pv.UnstructuredGrid(cells, celltypes, points)

        # Add result data
        if subcase in op2.displacements:
            disp = op2.displacements[subcase]
            disp_vectors = disp.data[0, :, :3]  # tx, ty, tz
            mesh['displacement'] = disp_vectors
            mesh['displacement_magnitude'] = np.linalg.norm(disp_vectors, axis=1)

        if subcase in op2.cquad4_stress:
            stress = op2.cquad4_stress[subcase]
            von_mises = stress.data[0, :, 7]
            mesh.cell_data['von_mises_stress'] = von_mises

        return mesh

    def _plot_stress(self, op2, subcase, output_path, trial_number):
        """Plot Von Mises stress contour."""
        mesh = self._create_mesh(op2, subcase)

        plotter = pv.Plotter(off_screen=True, window_size=[1920, 1080])
        plotter.add_mesh(
            mesh,
            scalars='von_mises_stress',
            cmap='jet',
            show_edges=True,
            edge_color='black',
            scalar_bar_args={
                'title': 'Von Mises Stress (MPa)',
                'vertical': True,
                'position_x': 0.85,
                'position_y': 0.1,
                'fmt': '%.1f'
            }
        )

        plotter.camera_position = 'iso'
        plotter.camera.zoom(1.2)
        plotter.add_text(f'Stress Analysis - Trial #{trial_number}',
                        position='upper_left', font_size=14, color='black')

        plotter.screenshot(str(output_path), scale=2)
        plotter.close()

        return output_path

    def _plot_displacement(self, op2, subcase, output_path, trial_number):
        """Plot displacement magnitude."""
        mesh = self._create_mesh(op2, subcase)

        plotter = pv.Plotter(off_screen=True, window_size=[1920, 1080])
        plotter.add_mesh(
            mesh,
            scalars='displacement_magnitude',
            cmap='rainbow',
            show_edges=True,
            edge_color='gray',
            scalar_bar_args={
                'title': 'Displacement (mm)',
                'vertical': True,
                'position_x': 0.85,
                'position_y': 0.1
            }
        )

        plotter.camera_position = 'iso'
        plotter.camera.zoom(1.2)
        plotter.add_text(f'Displacement - Trial #{trial_number}',
                        position='upper_left', font_size=14, color='black')

        plotter.screenshot(str(output_path), scale=2)
        plotter.close()

        return output_path

    def _plot_deformed_shape(self, op2, subcase, output_path, trial_number, scale_factor=100.0):
        """Plot deformed vs undeformed shape."""
        mesh = self._create_mesh(op2, subcase)
        warped = mesh.warp_by_vector('displacement', factor=scale_factor)

        plotter = pv.Plotter(off_screen=True, window_size=[1920, 1080])

        # Original (transparent)
        plotter.add_mesh(mesh, opacity=0.2, color='gray', show_edges=True, edge_color='black')

        # Deformed (colored)
        plotter.add_mesh(
            warped,
            scalars='displacement_magnitude',
            cmap='rainbow',
            show_edges=True,
            edge_color='black',
            scalar_bar_args={
                'title': f'Displacement (mm) [Scale: {scale_factor}x]',
                'vertical': True
            }
        )

        plotter.camera_position = 'iso'
        plotter.camera.zoom(1.2)
        plotter.add_text(f'Deformed Shape - Trial #{trial_number}',
                        position='upper_left', font_size=14, color='black')

        plotter.screenshot(str(output_path), scale=2)
        plotter.close()

        return output_path
```

### Usage in Optimization Loop

```python
# In optimization_engine/intelligent_optimizer.py

from optimization_engine.visualizer import NastranVisualizer

class IntelligentOptimizer:
    def __init__(self, ...):
        # ... existing code ...

        # Initialize visualizer
        bdf_path = self.study_dir.parent / "1_setup" / "model" / f"{self.config['model_name']}.bdf"
        viz_dir = self.study_dir / "visualizations"

        self.visualizer = NastranVisualizer(bdf_path, viz_dir)

    def _run_trial(self, trial):
        # ... existing code: update model, solve, extract results ...

        # NEW: Generate visualizations after successful solve
        if trial.state == optuna.trial.TrialState.COMPLETE:
            op2_path = self.get_op2_path(trial.number)

            try:
                images = self.visualizer.generate_trial_visualizations(
                    op2_path=op2_path,
                    trial_number=trial.number,
                    subcase=1
                )

                # Store image paths in trial user_attrs for dashboard access
                trial.set_user_attr('visualization_images', {
                    'stress': str(images.get('stress', '')),
                    'displacement': str(images.get('displacement', '')),
                    'deformed': str(images.get('deformed', ''))
                })

            except Exception as e:
                print(f"Warning: Visualization failed for trial {trial.number}: {e}")
```

---

## 6. Alternative: Headless pyNastran GUI

pyNastran has a built-in GUI, but it can also be used **programmatically** for screenshots:

```python
from pyNastran.gui.main_window import MainWindow

# This requires VTK + PyQt5/PySide2 (heavier dependencies)
# Not recommended for automation - use PyVista instead
```

**Verdict**: PyVista is simpler and more flexible for automation.

---

## 7. From Scratch Alternative

### Pros
- Full control
- No external dependencies beyond numpy/matplotlib

### Cons
- **Reinventing the wheel** (mesh handling, element connectivity)
- **2D plots only** (matplotlib doesn't do 3D well)
- **Labor intensive** (weeks of development)
- **Limited features** (no proper stress contours, deformed shapes)

**Verdict**: Not recommended. pyNastran + PyVista is mature, well-tested, and saves months of development.

---

## 8. Comparison Matrix

| Feature | pyNastran GUI | pyNastran + PyVista | From Scratch |
|---------|---------------|---------------------|--------------|
| **Programmatic** | ❌ (requires GUI) | ✅ Off-screen rendering | ✅ |
| **Automation** | ❌ | ✅ | ✅ |
| **3D Visualization** | ✅ | ✅ | ❌ (2D only) |
| **Stress Contours** | ✅ | ✅ | ⚠️ (basic) |
| **Deformed Shapes** | ✅ | ✅ | ❌ |
| **Development Time** | N/A | ~1-2 days | ~3-4 weeks |
| **Dependencies** | Heavy (VTK, Qt) | Light (numpy, vtk) | Minimal |
| **Dashboard Ready** | ❌ | ✅ PNG images | ✅ |
| **Maintenance** | N/A | Low | High |

---

## 9. Recommended Implementation Plan

### Phase 1: Basic Visualization (1-2 days)
1. Install pyNastran + PyVista
2. Create `NastranVisualizer` class
3. Integrate into `IntelligentOptimizer` post-trial callback
4. Generate 3 images per trial: stress, displacement, deformed shape
5. Test with bracket study

### Phase 2: Dashboard Integration (1 day)
1. Add `visualizations/` directory to study structure
2. Store image paths in `trial.user_attrs`
3. Create React component: `TrialVisualizationGallery`
4. Display images in dashboard trial detail view

### Phase 3: Advanced Features (optional, 2-3 days)
1. Eigenmode animation (GIF generation)
2. Section cut views
3. Multiple camera angles
4. Custom color scales
5. Comparison view (overlay 2 trials)

---

## 10. Installation & Testing

### Install Dependencies

```bash
# Install pyNastran
pip install pyNastran

# Install PyVista
pip install pyvista

# Optional: For HDF5 support
pip install h5py
```

### Quick Test

```python
# test_visualization.py
from pathlib import Path
from optimization_engine.visualizer import NastranVisualizer

# Paths (adjust to your study)
bdf_path = Path("studies/bracket_stiffness_optimization/1_setup/model/Bracket.bdf")
op2_path = Path("studies/bracket_stiffness_optimization/1_setup/model/Bracket.op2")
output_dir = Path("test_visualizations")

# Create visualizer
viz = NastranVisualizer(bdf_path, output_dir)

# Generate images
images = viz.generate_trial_visualizations(op2_path, trial_number=0, subcase=1)

print(f"Generated images: {images}")
```

---

## 11. Key Takeaways

✅ **pyNastran + PyVista** is the optimal solution
✅ **Programmatic image generation** without GUI
✅ **Production-ready** libraries with active development
✅ **Dashboard integration** via PNG images
✅ **Fast implementation** (1-2 days vs weeks from scratch)
✅ **Extensible** for future 3D viewer (Three.js)

**Next Steps**:
1. Install pyNastran + PyVista in Atomizer environment
2. Implement `NastranVisualizer` class
3. Integrate visualization callback in optimization loop
4. Test with existing bracket study
5. Add image gallery to React dashboard

---

## 12. References

**pyNastran**:
- GitHub: https://github.com/SteveDoyle2/pyNastran
- Docs: https://pynastran-git.readthedocs.io/
- OP2 Demo: https://pynastran-git.readthedocs.io/en/latest/quick_start/op2_demo.html

**PyVista**:
- GitHub: https://github.com/pyvista/pyvista
- Docs: https://docs.pyvista.org/
- Screenshot Examples: https://docs.pyvista.org/examples/02-plot/screenshot.html

**Alternative Libraries**:
- OP_Map: https://github.com/felixrlopezm/NASTRAN-OP_Map (built on pyNastran, for Excel export)
- FeResPost: https://ferespost.eu/ (Ruby/Python, commercial)

---

**Document Maintained By**: Atomizer Development Team
**Last Updated**: 2025-11-21
