Files
Atomizer/docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md
Anto01 a26914bbe8 feat: Add Studio UI, intake system, and extractor improvements
Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:02:30 -05:00

29 KiB

SYS_12: Extractor Library

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

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

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

from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress

# RECOMMENDED: Check ALL solid element types (returns max across all)
result = extract_solid_stress(op2_file, subcase=1)

# Or specify single element type
result = extract_solid_stress(op2_file, subcase=1, element_type='chexa')

# Returns: {
#     'max_von_mises': float,      # MPa (auto-converted from kPa)
#     'max_stress_element': int,
#     'element_type': str,         # e.g., 'CHEXA', 'CTETRA'
#     'units': 'MPa'
# }

max_stress = result['max_von_mises']  # MPa

IMPORTANT (Updated 2026-01-22):

  • By default, checks ALL solid types: CTETRA, CHEXA, CPENTA, CPYRAM
  • CHEXA elements often have highest stress (not CTETRA!)
  • Auto-converts from kPa to MPa (NX kg-mm-s unit system outputs kPa)
  • Returns Elemental Nodal stress (peak), not Elemental Centroid (averaged)

E4: BDF Mass Extraction

Module: optimization_engine.extractors.extract_mass_from_bdf

from optimization_engine.extractors import extract_mass_from_bdf

result = extract_mass_from_bdf(bdf_file)
# Returns: {
#     'total_mass': float,    # kg (primary key)
#     'mass_kg': float,       # kg
#     'mass_g': float,        # grams
#     'cg': [x, y, z],        # center of gravity
#     'num_elements': int
# }

mass_kg = result['mass_kg']  # kg

Note: Uses BDFMassExtractor internally. Reads mass from element geometry and material density in BDF/DAT file. NX kg-mm-s unit system - mass is directly in kg.

E5: CAD Expression Mass

Module: optimization_engine.extractors.extract_mass_from_expression

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:

run_journal.exe nx_journals/extract_part_mass_material.py model.prt
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:

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

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

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):

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

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

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

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.

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.

from optimization_engine.extractors import compare_zernike_methods

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

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
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:

# ❌ 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():

# ✅ 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:

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

# ✅ 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']
# ❌ 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
  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


Phase 2 Extractors (2025-12-06)

E12: Principal Stress Extraction

Module: optimization_engine.extractors.extract_principal_stress

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

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

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.

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

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

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.

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).

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):

# 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)