# SYS_12: Extractor Library

<!--
PROTOCOL: Centralized Extractor Library
LAYER: System
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->

## Overview

The Extractor Library provides centralized, reusable functions for extracting physics results from FEA output files. **Always use these extractors instead of writing custom extraction code** in studies.

**Key Principle**: If you're writing >20 lines of extraction code in `run_optimization.py`, stop and check this library first.

---

## When to Use

| Trigger | Action |
|---------|--------|
| Need to extract displacement | Use E1 `extract_displacement` |
| Need to extract frequency | Use E2 `extract_frequency` |
| Need to extract stress | Use E3 `extract_solid_stress` |
| Need to extract mass | Use E4 or E5 |
| Need Zernike/wavefront | Use E8, E9, or E10 |
| Need custom physics | Check library first, then EXT_01 |

---

## Quick Reference

| ID | Physics | Function | Input | Output |
|----|---------|----------|-------|--------|
| E1 | Displacement | `extract_displacement()` | .op2 | mm |
| E2 | Frequency | `extract_frequency()` | .op2 | Hz |
| E3 | Von Mises Stress | `extract_solid_stress()` | .op2 | MPa |
| E4 | BDF Mass | `extract_mass_from_bdf()` | .bdf/.dat | kg |
| E5 | CAD Expression Mass | `extract_mass_from_expression()` | .prt | kg |
| E6 | Field Data | `FieldDataExtractor()` | .fld/.csv | varies |
| E7 | Stiffness | `StiffnessCalculator()` | .fld + .op2 | N/mm |
| E8 | Zernike WFE | `extract_zernike_from_op2()` | .op2 + .bdf | nm |
| E9 | Zernike Relative | `extract_zernike_relative_rms()` | .op2 + .bdf | nm |
| E10 | Zernike Builder | `ZernikeObjectiveBuilder()` | .op2 | nm |
| E11 | Part Mass & Material | `extract_part_mass_material()` | .prt | kg + dict |
| **Phase 2 (2025-12-06)** | | | | |
| E12 | Principal Stress | `extract_principal_stress()` | .op2 | MPa |
| E13 | Strain Energy | `extract_strain_energy()` | .op2 | J |
| E14 | SPC Forces | `extract_spc_forces()` | .op2 | N |
| **Phase 3 (2025-12-06)** | | | | |
| E15 | Temperature | `extract_temperature()` | .op2 | K/°C |
| E16 | Thermal Gradient | `extract_temperature_gradient()` | .op2 | K/mm |
| E17 | Heat Flux | `extract_heat_flux()` | .op2 | W/mm² |
| E18 | Modal Mass | `extract_modal_mass()` | .f06 | kg |
| **Phase 4 (2025-12-19)** | | | | |
| E19 | Part Introspection | `introspect_part()` | .prt | dict |
| **Phase 5 (2025-12-22)** | | | | |
| E20 | Zernike Analytic (Parabola) | `extract_zernike_analytic()` | .op2 + .bdf | nm |
| E21 | Zernike Method Comparison | `compare_zernike_methods()` | .op2 + .bdf | dict |
| E22 | **Zernike OPD (RECOMMENDED)** | `extract_zernike_opd()` | .op2 + .bdf | nm |

---

## Extractor Details

### E1: Displacement Extraction

**Module**: `optimization_engine.extractors.extract_displacement`

```python
from optimization_engine.extractors.extract_displacement import extract_displacement

result = extract_displacement(op2_file, subcase=1)
# Returns: {
#     'max_displacement': float,  # mm
#     'max_disp_node': int,
#     'max_disp_x': float,
#     'max_disp_y': float,
#     'max_disp_z': float
# }

max_displacement = result['max_displacement']  # mm
```

### E2: Frequency Extraction

**Module**: `optimization_engine.extractors.extract_frequency`

```python
from optimization_engine.extractors.extract_frequency import extract_frequency

result = extract_frequency(op2_file, subcase=1, mode_number=1)
# Returns: {
#     'frequency': float,      # Hz
#     'mode_number': int,
#     'eigenvalue': float,
#     'all_frequencies': list  # All modes
# }

frequency = result['frequency']  # Hz
```

### E3: Von Mises Stress Extraction

**Module**: `optimization_engine.extractors.extract_von_mises_stress`

```python
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress

# For shell elements (CQUAD4, CTRIA3)
result = extract_solid_stress(op2_file, subcase=1, element_type='cquad4')

# For solid elements (CTETRA, CHEXA)
result = extract_solid_stress(op2_file, subcase=1, element_type='ctetra')

# Returns: {
#     'max_von_mises': float,    # MPa
#     'max_stress_element': int
# }

max_stress = result['max_von_mises']  # MPa
```

### E4: BDF Mass Extraction

**Module**: `optimization_engine.extractors.bdf_mass_extractor`

```python
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf

mass_kg = extract_mass_from_bdf(str(bdf_file))  # kg
```

**Note**: Reads mass directly from BDF/DAT file material and element definitions.

### E5: CAD Expression Mass

**Module**: `optimization_engine.extractors.extract_mass_from_expression`

```python
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression

mass_kg = extract_mass_from_expression(model_file, expression_name="p173")  # kg
```

**Note**: Requires `_temp_mass.txt` to be written by solve journal. Uses NX expression system.

### E11: Part Mass & Material Extraction

**Module**: `optimization_engine.extractors.extract_part_mass_material`

Extracts mass, volume, surface area, center of gravity, and material properties directly from NX .prt files using NXOpen.MeasureManager.

**Prerequisites**: Run the NX journal first to create the temp file:
```bash
run_journal.exe nx_journals/extract_part_mass_material.py model.prt
```

```python
from optimization_engine.extractors import extract_part_mass_material, extract_part_mass

# Full extraction with all properties
result = extract_part_mass_material(prt_file)
# Returns: {
#     'mass_kg': float,              # Mass in kg
#     'mass_g': float,               # Mass in grams
#     'volume_mm3': float,           # Volume in mm^3
#     'surface_area_mm2': float,     # Surface area in mm^2
#     'center_of_gravity_mm': [x, y, z],  # CoG in mm
#     'moments_of_inertia': {'Ixx', 'Iyy', 'Izz', 'unit'},  # or None
#     'material': {
#         'name': str or None,       # Material name if assigned
#         'density': float or None,  # Density in kg/mm^3
#         'density_unit': str
#     },
#     'num_bodies': int
# }

mass = result['mass_kg']  # kg
material_name = result['material']['name']  # e.g., "Aluminum_6061"

# Simple mass-only extraction
mass_kg = extract_part_mass(prt_file)  # kg
```

**Class-based version** for caching:
```python
from optimization_engine.extractors import PartMassExtractor

extractor = PartMassExtractor(prt_file)
mass = extractor.mass_kg         # Extracts and caches
material = extractor.material_name
```

**NX Open APIs Used** (by journal):
- `NXOpen.MeasureManager.NewMassProperties()`
- `NXOpen.MeasureBodies`
- `NXOpen.Body.GetBodies()`
- `NXOpen.PhysicalMaterial`

**IMPORTANT - Mass Accuracy Note**:
> **Always prefer E11 (geometry-based) over E4 (BDF-based) for mass extraction.**
>
> Testing on hex-dominant meshes with tet/pyramid fill elements revealed that:
> - **E11 from .prt**: 97.66 kg (accurate - matches NX GUI)
> - **E4 pyNastran get_mass_breakdown()**: 90.73 kg (~7% under-reported)
> - **E4 pyNastran sum(elem.Volume())*rho**: 100.16 kg (~2.5% over-reported)
>
> The `get_mass_breakdown()` function in pyNastran has known issues with mixed-element
> meshes (CHEXA + CPENTA + CPYRAM + CTETRA). Use E11 with the NX journal for reliable
> mass values. Only use E4 if material properties are overridden at FEM level.

### E6: Field Data Extraction

**Module**: `optimization_engine.extractors.field_data_extractor`

```python
from optimization_engine.extractors.field_data_extractor import FieldDataExtractor

extractor = FieldDataExtractor(
    field_file="results.fld",
    result_column="Temperature",
    aggregation="max"  # or "min", "mean", "std"
)
result = extractor.extract()
# Returns: {
#     'value': float,
#     'stats': dict
# }
```

### E7: Stiffness Calculation

**Module**: `optimization_engine.extractors.stiffness_calculator`

```python
from optimization_engine.extractors.stiffness_calculator import StiffnessCalculator

calculator = StiffnessCalculator(
    field_file=field_file,
    op2_file=op2_file,
    force_component="FZ",
    displacement_component="UZ"
)
result = calculator.calculate()
# Returns: {
#     'stiffness': float,      # N/mm
#     'displacement': float,
#     'force': float
# }
```

**Simple Alternative** (when force is known):
```python
applied_force = 1000.0  # N - MUST MATCH MODEL'S APPLIED LOAD
stiffness = applied_force / max(abs(max_displacement), 1e-6)  # N/mm
```

### E8: Zernike Wavefront Error (Single Subcase)

**Module**: `optimization_engine.extractors.extract_zernike`

```python
from optimization_engine.extractors.extract_zernike import extract_zernike_from_op2

result = extract_zernike_from_op2(
    op2_file,
    bdf_file=None,  # Auto-detect from op2 location
    subcase="20",   # Subcase label (e.g., "20" = 20 deg elevation)
    displacement_unit="mm"
)
# Returns: {
#     'global_rms_nm': float,      # Total surface RMS in nm
#     'filtered_rms_nm': float,    # RMS with low orders removed
#     'coefficients': list,        # 50 Zernike coefficients
#     'r_squared': float,
#     'subcase': str
# }

filtered_rms = result['filtered_rms_nm']  # nm
```

### E9: Zernike Relative RMS (Between Subcases)

**Module**: `optimization_engine.extractors.extract_zernike`

```python
from optimization_engine.extractors.extract_zernike import extract_zernike_relative_rms

result = extract_zernike_relative_rms(
    op2_file,
    bdf_file=None,
    target_subcase="40",      # Target orientation
    reference_subcase="20",   # Reference (usually polishing orientation)
    displacement_unit="mm"
)
# Returns: {
#     'relative_filtered_rms_nm': float,  # Differential WFE in nm
#     'delta_coefficients': list,         # Coefficient differences
#     'target_subcase': str,
#     'reference_subcase': str
# }

relative_rms = result['relative_filtered_rms_nm']  # nm
```

### E10: Zernike Objective Builder (Multi-Subcase)

**Module**: `optimization_engine.extractors.zernike_helpers`

```python
from optimization_engine.extractors.zernike_helpers import ZernikeObjectiveBuilder

builder = ZernikeObjectiveBuilder(
    op2_finder=lambda: model_dir / "ASSY_M1-solution_1.op2"
)

# Add relative objectives (target vs reference)
builder.add_relative_objective("40", "20", metric="relative_filtered_rms_nm", weight=5.0)
builder.add_relative_objective("60", "20", metric="relative_filtered_rms_nm", weight=5.0)

# Add absolute objective for polishing orientation
builder.add_subcase_objective("90", metric="rms_filter_j1to3", weight=1.0)

# Evaluate all at once (efficient - parses OP2 only once)
results = builder.evaluate_all()
# Returns: {'rel_40_vs_20': 4.2, 'rel_60_vs_20': 8.7, 'rms_90': 15.3}
```

### E20: Zernike Analytic (Parabola-Based with Lateral Correction)

**Module**: `optimization_engine.extractors.extract_zernike_opd`

Uses an analytical parabola formula to account for lateral (X, Y) displacements. Requires knowing the focal length.

**Use when**: You know the optical prescription and want to compare against theoretical parabola.

```python
from optimization_engine.extractors import extract_zernike_analytic, ZernikeAnalyticExtractor

# Full extraction with lateral displacement diagnostics
result = extract_zernike_analytic(
    op2_file,
    subcase="20",
    focal_length=5000.0,  # Required for analytic method
)

# Class-based usage
extractor = ZernikeAnalyticExtractor(op2_file, focal_length=5000.0)
result = extractor.extract_subcase('20')
```

### E21: Zernike Method Comparison

**Module**: `optimization_engine.extractors.extract_zernike_opd`

Compare standard (Z-only) vs analytic (parabola) methods.

```python
from optimization_engine.extractors import compare_zernike_methods

comparison = compare_zernike_methods(op2_file, subcase="20", focal_length=5000.0)
print(comparison['recommendation'])
```

### E22: Zernike OPD (RECOMMENDED - Most Rigorous)

**Module**: `optimization_engine.extractors.extract_zernike_figure`

**MOST RIGOROUS METHOD** for computing WFE. Uses the actual BDF geometry (filtered to OP2 nodes) as the reference surface instead of assuming a parabolic shape.

**Advantages over E20 (Analytic)**:
- No need to know focal length or optical prescription
- Works with **any surface shape**: parabola, hyperbola, asphere, freeform
- Uses the actual mesh geometry as the "ideal" surface reference
- Interpolates `z_figure` at deformed `(x+dx, y+dy)` position for true OPD

**How it works**:
1. Load BDF geometry for nodes present in OP2 (figure surface nodes)
2. Build 2D interpolator `z_figure(x, y)` from undeformed coordinates
3. For each deformed node at `(x0+dx, y0+dy, z0+dz)`:
   - Interpolate `z_figure` at the deformed (x,y) position
   - Surface error = `(z0 + dz) - z_interpolated`
4. Fit Zernike polynomials to the surface error map

```python
from optimization_engine.extractors import (
    ZernikeOPDExtractor,
    extract_zernike_opd,
    extract_zernike_opd_filtered_rms,
)

# Full extraction with diagnostics
result = extract_zernike_opd(op2_file, subcase="20")
# Returns: {
#     'global_rms_nm': float,
#     'filtered_rms_nm': float,
#     'max_lateral_displacement_um': float,
#     'rms_lateral_displacement_um': float,
#     'coefficients': list,           # 50 Zernike coefficients
#     'method': 'opd',
#     'figure_file': 'BDF (filtered to OP2)',
#     ...
# }

# Simple usage for optimization objective
rms = extract_zernike_opd_filtered_rms(op2_file, subcase="20")

# Class-based for multi-subcase analysis
extractor = ZernikeOPDExtractor(op2_file)
results = extractor.extract_all_subcases()
```

#### Relative WFE (CRITICAL for Optimization)

**Use `extract_relative()` for computing relative WFE between subcases!**

> **BUG WARNING (V10 Fix - 2025-12-22)**: The WRONG way to compute relative WFE is:
> ```python
> # ❌ WRONG: Difference of RMS values
> result_40 = extractor.extract_subcase("3")
> result_ref = extractor.extract_subcase("2")
> rel_40 = abs(result_40['filtered_rms_nm'] - result_ref['filtered_rms_nm'])  # WRONG!
> ```
>
> This computes `|RMS(WFE_40) - RMS(WFE_20)|`, which is NOT the same as `RMS(WFE_40 - WFE_20)`.
> The difference can be **3-4x lower** than the correct value, leading to false "too good to be true" results.

**The CORRECT approach uses `extract_relative()`:**

```python
# ✅ CORRECT: Computes node-by-node WFE difference, then fits Zernike, then RMS
extractor = ZernikeOPDExtractor(op2_file)

rel_40 = extractor.extract_relative("3", "2")  # 40 deg vs 20 deg
rel_60 = extractor.extract_relative("4", "2")  # 60 deg vs 20 deg
rel_90 = extractor.extract_relative("1", "2")  # 90 deg vs 20 deg

# Returns: {
#     'target_subcase': '3',
#     'reference_subcase': '2',
#     'method': 'figure_opd_relative',
#     'relative_global_rms_nm': float,     # RMS of the difference field
#     'relative_filtered_rms_nm': float,   # Use this for optimization!
#     'relative_rms_filter_j1to3': float,  # For manufacturing/optician workload
#     'max_lateral_displacement_um': float,
#     'rms_lateral_displacement_um': float,
#     'delta_coefficients': list,          # Zernike coeffs of difference
# }

# Use in optimization objectives:
objectives = {
    'rel_filtered_rms_40_vs_20': rel_40['relative_filtered_rms_nm'],
    'rel_filtered_rms_60_vs_20': rel_60['relative_filtered_rms_nm'],
    'mfg_90_optician_workload': rel_90['relative_rms_filter_j1to3'],
}
```

**Mathematical Difference**:
```
WRONG:  |RMS(WFE_40) - RMS(WFE_20)| = |6.14 - 8.13| = 1.99 nm  ← FALSE!
CORRECT: RMS(WFE_40 - WFE_20)      = RMS(diff_field) = 6.59 nm ← TRUE!
```

The Standard `ZernikeExtractor` also has `extract_relative()` if you don't need the OPD method:
```python
from optimization_engine.extractors import ZernikeExtractor

extractor = ZernikeExtractor(op2_file, n_modes=50, filter_orders=4)
rel_40 = extractor.extract_relative("3", "2")  # Z-only method
```

**Backwards compatibility**: The old names (`ZernikeFigureExtractor`, `extract_zernike_figure`, `extract_zernike_figure_rms`) still work but are deprecated.

**When to use which Zernike method**:

| Method | Class | When to Use | Assumptions |
|--------|-------|-------------|-------------|
| Standard (E8) | `ZernikeExtractor` | Quick analysis, negligible lateral displacement | Z-only at original (x,y) |
| Analytic (E20) | `ZernikeAnalyticExtractor` | Known focal length, parabolic surface | Parabola shape |
| **OPD (E22)** | `ZernikeOPDExtractor` | **Any surface, most rigorous** | None - uses actual geometry |

**IMPORTANT**: Do NOT provide a figure.dat file unless you're certain it matches your BDF geometry exactly. The default behavior (using BDF geometry filtered to OP2 nodes) is the safest option.

---

## Code Reuse Protocol

### The 20-Line Rule

If you're writing a function longer than ~20 lines in `run_optimization.py`:

1. **STOP** - This is a code smell
2. **SEARCH** - Check this library
3. **IMPORT** - Use existing extractor
4. **Only if truly new** - Create via EXT_01

### Correct Pattern

```python
# ✅ CORRECT: Import and use
from optimization_engine.extractors import extract_displacement, extract_frequency

def objective(trial):
    # ... run simulation ...
    disp_result = extract_displacement(op2_file)
    freq_result = extract_frequency(op2_file)
    return disp_result['max_displacement']
```

```python
# ❌ WRONG: Duplicate code in study
def objective(trial):
    # ... run simulation ...

    # Don't write 50 lines of OP2 parsing here
    from pyNastran.op2.op2 import OP2
    op2 = OP2()
    op2.read_op2(str(op2_file))
    # ... 40 more lines ...
```

---

## Adding New Extractors

If needed physics isn't in library:

1. Check [EXT_01_CREATE_EXTRACTOR](../extensions/EXT_01_CREATE_EXTRACTOR.md)
2. Create in `optimization_engine/extractors/new_extractor.py`
3. Add to `optimization_engine/extractors/__init__.py`
4. Update this document

**Do NOT** add extraction code directly to `run_optimization.py`.

---

## Troubleshooting

| Symptom | Cause | Solution |
|---------|-------|----------|
| "No displacement data found" | Wrong subcase number | Check subcase in OP2 |
| "OP2 file not found" | Solve failed | Check NX logs |
| "Unknown element type: auto" | Element type not specified | Specify `element_type='cquad4'` or `'ctetra'` |
| "No stress results in OP2" | Wrong element type specified | Use correct type for your mesh |
| Import error | Module not exported | Check `__init__.py` exports |

### Element Type Selection Guide

**Critical**: You must specify the correct element type for stress extraction based on your mesh:

| Mesh Type | Elements | `element_type=` |
|-----------|----------|-----------------|
| **Shell** (thin structures) | CQUAD4, CTRIA3 | `'cquad4'` or `'ctria3'` |
| **Solid** (3D volumes) | CTETRA, CHEXA | `'ctetra'` or `'chexa'` |

**How to check your mesh type:**
1. Open .dat/.bdf file
2. Search for element cards (CQUAD4, CTETRA, etc.)
3. Use the dominant element type

**Common models:**
- **Bracket (solid)**: Uses CTETRA → `element_type='ctetra'`
- **Beam (shell)**: Uses CQUAD4 → `element_type='cquad4'`
- **Mirror (shell)**: Uses CQUAD4 → `element_type='cquad4'`

**Von Mises column mapping** (handled automatically):
- Shell elements (8 columns): von Mises at column 7
- Solid elements (10 columns): von Mises at column 9

---

## Cross-References

- **Depends On**: pyNastran for OP2 parsing
- **Used By**: All optimization studies
- **Extended By**: [EXT_01_CREATE_EXTRACTOR](../extensions/EXT_01_CREATE_EXTRACTOR.md)
- **See Also**: [modules/extractors-catalog.md](../../.claude/skills/modules/extractors-catalog.md)

---

## Phase 2 Extractors (2025-12-06)

### E12: Principal Stress Extraction

**Module**: `optimization_engine.extractors.extract_principal_stress`

```python
from optimization_engine.extractors import extract_principal_stress

result = extract_principal_stress(op2_file, subcase=1, element_type='ctetra')
# Returns: {
#     'success': bool,
#     'sigma1_max': float,     # Maximum principal stress (MPa)
#     'sigma2_max': float,     # Intermediate principal stress
#     'sigma3_min': float,     # Minimum principal stress
#     'element_count': int
# }
```

### E13: Strain Energy Extraction

**Module**: `optimization_engine.extractors.extract_strain_energy`

```python
from optimization_engine.extractors import extract_strain_energy, extract_total_strain_energy

result = extract_strain_energy(op2_file, subcase=1)
# Returns: {
#     'success': bool,
#     'total_strain_energy': float,  # J
#     'max_element_energy': float,
#     'max_element_id': int
# }

# Convenience function
total_energy = extract_total_strain_energy(op2_file)  # J
```

### E14: SPC Forces (Reaction Forces)

**Module**: `optimization_engine.extractors.extract_spc_forces`

```python
from optimization_engine.extractors import extract_spc_forces, extract_total_reaction_force

result = extract_spc_forces(op2_file, subcase=1)
# Returns: {
#     'success': bool,
#     'total_force_magnitude': float,  # N
#     'total_force_x': float,
#     'total_force_y': float,
#     'total_force_z': float,
#     'node_count': int
# }

# Convenience function
total_reaction = extract_total_reaction_force(op2_file)  # N
```

---

## Phase 3 Extractors (2025-12-06)

### E15: Temperature Extraction

**Module**: `optimization_engine.extractors.extract_temperature`

For SOL 153 (Steady-State) and SOL 159 (Transient) thermal analyses.

```python
from optimization_engine.extractors import extract_temperature, get_max_temperature

result = extract_temperature(op2_file, subcase=1, return_field=False)
# Returns: {
#     'success': bool,
#     'max_temperature': float,  # K or °C
#     'min_temperature': float,
#     'avg_temperature': float,
#     'max_node_id': int,
#     'node_count': int,
#     'unit': str
# }

# Convenience function for constraints
max_temp = get_max_temperature(op2_file)  # Returns inf on failure
```

### E16: Thermal Gradient Extraction

**Module**: `optimization_engine.extractors.extract_temperature`

```python
from optimization_engine.extractors import extract_temperature_gradient

result = extract_temperature_gradient(op2_file, subcase=1)
# Returns: {
#     'success': bool,
#     'max_gradient': float,        # K/mm (approximation)
#     'temperature_range': float,   # Max - Min temperature
#     'gradient_location': tuple    # (max_node, min_node)
# }
```

### E17: Heat Flux Extraction

**Module**: `optimization_engine.extractors.extract_temperature`

```python
from optimization_engine.extractors import extract_heat_flux

result = extract_heat_flux(op2_file, subcase=1)
# Returns: {
#     'success': bool,
#     'max_heat_flux': float,   # W/mm²
#     'avg_heat_flux': float,
#     'element_count': int
# }
```

### E18: Modal Mass Extraction

**Module**: `optimization_engine.extractors.extract_modal_mass`

For SOL 103 (Normal Modes) F06 files with MEFFMASS output.

```python
from optimization_engine.extractors import (
    extract_modal_mass,
    extract_frequencies,
    get_first_frequency,
    get_modal_mass_ratio
)

# Get all modes
result = extract_modal_mass(f06_file, mode=None)
# Returns: {
#     'success': bool,
#     'mode_count': int,
#     'frequencies': list,  # Hz
#     'modes': list of mode dicts
# }

# Get specific mode
result = extract_modal_mass(f06_file, mode=1)
# Returns: {
#     'success': bool,
#     'frequency': float,      # Hz
#     'modal_mass_x': float,   # kg
#     'modal_mass_y': float,
#     'modal_mass_z': float,
#     'participation_x': float  # 0-1
# }

# Convenience functions
freq = get_first_frequency(f06_file)  # Hz
ratio = get_modal_mass_ratio(f06_file, direction='z', n_modes=10)  # 0-1
```

---

## Phase 4 Extractors (2025-12-19)

### E19: Part Introspection (Comprehensive)

**Module**: `optimization_engine.extractors.introspect_part`

Comprehensive introspection of NX .prt files. Extracts everything available from a part in a single call.

**Prerequisites**: Uses PowerShell with proper license server setup (see LAC workaround).

```python
from optimization_engine.extractors import (
    introspect_part,
    get_expressions_dict,
    get_expression_value,
    print_introspection_summary
)

# Full introspection
result = introspect_part("path/to/model.prt")
# Returns: {
#     'success': bool,
#     'part_file': str,
#     'expressions': {
#         'user': [{'name', 'value', 'rhs', 'units', 'type'}, ...],
#         'internal': [...],
#         'user_count': int,
#         'total_count': int
#     },
#     'mass_properties': {
#         'mass_kg': float,
#         'mass_g': float,
#         'volume_mm3': float,
#         'surface_area_mm2': float,
#         'center_of_gravity_mm': [x, y, z]
#     },
#     'materials': {
#         'assigned': [{'name', 'body', 'properties': {...}}],
#         'available': [...]
#     },
#     'bodies': {
#         'solid_bodies': [{'name', 'is_solid', 'attributes': [...]}],
#         'sheet_bodies': [...],
#         'counts': {'solid', 'sheet', 'total'}
#     },
#     'attributes': [{'title', 'type', 'value'}, ...],
#     'groups': [{'name', 'member_count', 'members': [...]}],
#     'features': {
#         'total_count': int,
#         'by_type': {'Extrude': 5, 'Revolve': 2, ...}
#     },
#     'datums': {
#         'planes': [...],
#         'csys': [...],
#         'axes': [...]
#     },
#     'units': {
#         'base_units': {'Length': 'MilliMeter', ...},
#         'system': 'Metric (mm)'
#     },
#     'linked_parts': {
#         'loaded_parts': [...],
#         'fem_parts': [...],
#         'sim_parts': [...],
#         'idealized_parts': [...]
#     }
# }

# Convenience functions
expr_dict = get_expressions_dict(result)  # {'name': value, ...}
pocket_radius = get_expression_value(result, 'Pocket_Radius')  # float

# Print formatted summary
print_introspection_summary(result)
```

**What It Extracts**:
- **Expressions**: All user and internal expressions with values, RHS formulas, units
- **Mass Properties**: Mass, volume, surface area, center of gravity
- **Materials**: Material names and properties (density, Young's modulus, etc.)
- **Bodies**: Solid and sheet bodies with their attributes
- **Part Attributes**: All NX_* system attributes plus user attributes
- **Groups**: Named groups and their members
- **Features**: Feature tree summary by type
- **Datums**: Datum planes, coordinate systems, axes
- **Units**: Base units and unit system
- **Linked Parts**: FEM, SIM, idealized parts loaded in session

**Use Cases**:
- Study setup: Extract actual expression values for baseline
- Debugging: Verify model state before optimization
- Documentation: Generate part specifications
- Validation: Compare expected vs actual parameter values

**NX Journal Execution** (LAC Workaround):
```python
# CRITICAL: Use PowerShell with [Environment]::SetEnvironmentVariable()
# NOT cmd /c SET or $env: syntax (these fail)
powershell -Command "[Environment]::SetEnvironmentVariable('SPLM_LICENSE_SERVER', '28000@server', 'Process'); & 'run_journal.exe' 'introspect_part.py' -args 'model.prt' 'output_dir'"
```

---

## Implementation Files

```
optimization_engine/extractors/
├── __init__.py                     # Exports all extractors
├── extract_displacement.py         # E1
├── extract_frequency.py            # E2
├── extract_von_mises_stress.py     # E3
├── bdf_mass_extractor.py           # E4
├── extract_mass_from_expression.py # E5
├── field_data_extractor.py         # E6
├── stiffness_calculator.py         # E7
├── extract_zernike.py              # E8, E9 (Standard Z-only)
├── extract_zernike_opd.py          # E20, E21 (Parabola OPD)
├── extract_zernike_figure.py       # E22 (Figure OPD - most rigorous)
├── zernike_helpers.py              # E10
├── extract_part_mass_material.py   # E11 (Part mass & material)
├── extract_zernike_surface.py      # Surface utilities
├── op2_extractor.py                # Low-level OP2 access
├── extract_principal_stress.py     # E12 (Phase 2)
├── extract_strain_energy.py        # E13 (Phase 2)
├── extract_spc_forces.py           # E14 (Phase 2)
├── extract_temperature.py          # E15, E16, E17 (Phase 3)
├── extract_modal_mass.py           # E18 (Phase 3)
├── introspect_part.py              # E19 (Phase 4)
├── test_phase2_extractors.py       # Phase 2 tests
└── test_phase3_extractors.py       # Phase 3 tests

nx_journals/
├── extract_part_mass_material.py   # E11 NX journal (prereq)
└── introspect_part.py              # E19 NX journal (comprehensive introspection)
```

---

## Version History

| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial consolidation from scattered docs |
| 1.1 | 2025-12-06 | Added Phase 2: E12 (principal stress), E13 (strain energy), E14 (SPC forces) |
| 1.2 | 2025-12-06 | Added Phase 3: E15-E17 (thermal), E18 (modal mass) |
| 1.3 | 2025-12-07 | Added Element Type Selection Guide; documented shell vs solid stress columns |
| 1.4 | 2025-12-19 | Added Phase 4: E19 (comprehensive part introspection) |
| 1.5 | 2025-12-22 | Added Phase 5: E20 (Parabola OPD), E21 (comparison), E22 (Figure OPD - most rigorous) |
