5. Executables#
Generating executables with NaxToPy is really easy, as only one method need to be called. This method uses PyInstaller to generate a self-contained executable that can be run in any windows machine (no NaxTo neither Python) is needed.
5.1 Code of the App#
"""
FEA Envelope Result Processor
===============================
This program creates envelope loadcases from multiple FEA result files and
exports Von Mises stresses to CSV format.
Requirements:
- NaxToPy: FEA model processing library
- tkinter: GUI framework
- numpy: Numerical operations
IMPORTANT - Threading Model Conflict:
When pythonnet loads CLR, it initializes the main Python thread as MTA
(Multi-Threaded Apartment). However, Windows file dialogs are COM objects
that require STA (Single-Threaded Apartment) threading model.
Solution: We create a separate STA thread specifically for directory dialogs.
The thread runs the dialog, stores the result, and returns it to the main thread.
"""
import NaxToPy as n2p
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import numpy as np
import sys
import os
# Required for .NET integration and threading model management
# pythonnet allows Python to use C# libraries (NaxToPy core is written in C#)
from System.Threading import Thread, ThreadStart, ApartmentState
global _selected_directory_path
def _tk_dialog_thread():
"""
Thread function to display directory dialog in STA thread.
Creates a temporary Tk root to avoid conflicts with the main GUI thread.
"""
global _selected_directory_path
# Create a new, temporary Tk root for this dialog
# This is necessary because Tkinter is not thread-safe
temp_root = tk.Tk()
temp_root.withdraw() # Hide the temporary root window
# Now call the dialog - it will use this temporary root
_selected_directory_path = filedialog.askdirectory(
parent=temp_root,
title="Select Output Directory"
)
# Destroy the temporary root
temp_root.destroy()
class FileProcessorGUI:
"""
Main GUI application for processing FEA envelope loadcases.
Workflow:
1. Select input model file (.bdf or .dat)
2. Select one or more result files (.op2)
3. Choose output directory for results
4. Read files to load model and available loadcases
5. Select one or more loadcases from the list
6. Choose envelope criteria (ExtremeMax, ExtremeMin, etc.)
7. Execute to create envelope and export Von Mises stresses
"""
def __init__(self, root):
"""
Initialize the GUI application.
Args:
root: tkinter root window
"""
self.root = root
self.root.title("FEA Envelope Result Processor")
self.root.geometry("700x700")
# Model data storage
self.model = None # Will hold the loaded NaxToPy model
# UI Variables - using StringVar for automatic UI updates
self.single_file_path = tk.StringVar() # Path to input model file
self.multiple_files_paths = tk.StringVar() # Paths to result files (semicolon-separated)
self.output_directory = tk.StringVar() # Output directory path
# Thread-safe storage for directory dialog result
# This is set by the STA thread and read by the main thread
self._selected_directory = None
# Build the user interface
self._create_widgets(root)
def _create_widgets(self, root):
"""
Create and layout all GUI widgets.
Args:
root: tkinter root window
"""
# Create main container with padding
main_frame = ttk.Frame(root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configure grid weights for responsive layout
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1) # Middle column expands
# ===== INPUT MODEL FILE SELECTION =====
# Single model file (.bdf or .dat) that defines the FE model structure
ttk.Label(main_frame, text="Input Model File:").grid(
row=0, column=0, sticky=tk.W, pady=5
)
ttk.Entry(main_frame, textvariable=self.single_file_path, width=60).grid(
row=0, column=1, sticky=(tk.W, tk.E), pady=5, padx=5
)
ttk.Button(main_frame, text="Browse File", command=self.browse_single_file).grid(
row=0, column=2, pady=5, padx=5
)
# ===== RESULT FILES SELECTION =====
# Multiple result files (.op2) containing stress/strain data for different loadcases
ttk.Label(main_frame, text="Result Files (.op2):").grid(
row=1, column=0, sticky=tk.W, pady=5
)
ttk.Entry(main_frame, textvariable=self.multiple_files_paths, width=60).grid(
row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=5
)
ttk.Button(main_frame, text="Browse Files", command=self.browse_multiple_files).grid(
row=1, column=2, pady=5, padx=5
)
# ===== OUTPUT DIRECTORY SELECTION =====
# Directory where the envelope results CSV will be saved
ttk.Label(main_frame, text="Output Directory:").grid(
row=2, column=0, sticky=tk.W, pady=5
)
ttk.Entry(main_frame, textvariable=self.output_directory, width=60).grid(
row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=5
)
ttk.Button(main_frame, text="Browse Directory", command=self.browse_directory).grid(
row=2, column=2, pady=5, padx=5
)
# ===== READ FILES BUTTON =====
# Loads the model and imports results, populating the loadcase list
ttk.Button(
main_frame,
text="Read Files",
command=self.read_files,
width=20
).grid(
row=3, column=0, columnspan=3, pady=15
)
# ===== LOADCASE SELECTION LIST =====
# Shows available loadcases after reading files
ttk.Label(main_frame, text="Available Loadcases (Select one or more):").grid(
row=4, column=0, columnspan=3, sticky=tk.W, pady=(10, 5)
)
# Frame for listbox with scrollbar
list_frame = ttk.Frame(main_frame)
list_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
# Vertical scrollbar for the listbox
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL)
# Listbox with EXTENDED selection mode (allows multiple selections with Ctrl+Click)
self.loadcases_listbox = tk.Listbox(
list_frame,
height=10,
selectmode=tk.EXTENDED,
yscrollcommand=scrollbar.set
)
scrollbar.config(command=self.loadcases_listbox.yview)
self.loadcases_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Configure row weight for listbox expansion
main_frame.rowconfigure(5, weight=1)
# ===== ENVELOPE CRITERIA SELECTION =====
# Dropdown to select how envelope is computed from multiple loadcases
ttk.Label(main_frame, text="Envelope Criteria:").grid(
row=6, column=0, sticky=tk.W, pady=(15, 5)
)
self.option_combobox = ttk.Combobox(
main_frame,
values=['ByContour', 'ByLoadCaseID'],
state="readonly", # Prevent manual text entry
width=20
)
self.option_combobox.grid(row=6, column=1, sticky=tk.W, pady=(15, 5), padx=5)
self.option_combobox.current(0) # Set default to 'ExtremeMax'
# Add tooltip/help text for envelope criteria
ttk.Label(
main_frame,
text="(Criteria for combining selected loadcases)",
font=("Arial", 8),
foreground="gray"
).grid(row=6, column=2, sticky=tk.W, pady=(15, 5))
# ===== MAIN EXECUTE BUTTON =====
# Green button to create envelope and export results
self.execute_button = tk.Button(
main_frame,
text="CREATE ENVELOPE & EXPORT",
command=self.execute_main_function,
bg="#4CAF50", # Green background (success color)
fg="white", # White text
font=("Arial", 12, "bold"),
height=2,
cursor="hand2", # Hand cursor on hover
relief=tk.RAISED # 3D effect
)
self.execute_button.grid(
row=7, column=0, columnspan=3, pady=20, sticky=(tk.W, tk.E)
)
self._add_logo(main_frame, row=8)
def browse_single_file(self):
"""
Open file dialog to select a single input model file.
Accepts .bdf (Bulk Data File) or .dat (Data) formats.
Note: This dialog works normally because askopenfilename() doesn't have
the same COM threading requirements as askdirectory().
"""
filename = filedialog.askopenfilename(
title="Select Input Model File",
filetypes=[
("Bulk Data Files", "*.bdf"),
("Data Files", "*.dat"),
("All Files", "*.*")
]
)
if filename:
self.single_file_path.set(filename)
def browse_multiple_files(self):
"""
Open file dialog to select multiple result files.
Allows selection of multiple .op2 (Output2) files at once.
Multiple file paths are joined with semicolons.
Note: This dialog works normally because askopenfilenames() doesn't have
the same COM threading requirements as askdirectory().
"""
filenames = filedialog.askopenfilenames(
title="Select Result Files (.op2)",
filetypes=[
("Output2 Files", "*.op2"),
("All Files", "*.*")
]
)
if filenames:
# Join multiple file paths with semicolon separator
self.multiple_files_paths.set("; ".join(filenames))
def browse_directory(self):
"""
Open directory dialog to select output directory.
Uses STA thread with isolated Tk root to avoid threading conflicts.
"""
global _selected_directory_path
# Reset the global variable
_selected_directory_path = None
# Create STA thread
thread = Thread(ThreadStart(_tk_dialog_thread))
thread.SetApartmentState(ApartmentState.STA)
thread.Start()
thread.Join()
# Update UI if directory was selected
if _selected_directory_path:
self.output_directory.set(_selected_directory_path)
def _run_directory_dialog(self):
"""
Execute the directory selection dialog in an STA thread.
This method runs in a separate STA thread created by browse_directory().
It displays the dialog, stores the result in self._selected_directory,
and then the thread terminates.
Thread Safety:
- This method is called from a different thread than the main GUI thread
- Writing to self._selected_directory is safe because:
1. The main thread is blocked (waiting in thread.Join())
2. We only write once and never modify after
3. The main thread only reads after Join() completes
Error Handling:
- If the dialog fails, we catch the exception and show an error
- The error dialog will appear in the main thread context
"""
try:
# Display the directory selection dialog
# This works because we're in an STA thread
selected_path = filedialog.askdirectory(title="Select Output Directory")
# Store the result if user selected a directory (didn't cancel)
if selected_path:
self._selected_directory = selected_path
except Exception as e:
# If the dialog fails for any reason, show an error
# We print to console for debugging and show a message box for the user
print(f"Error in directory dialog: {e}")
# Schedule the error message to show in the main thread
# This is safer than showing it directly from the STA thread
self.root.after(0, lambda: messagebox.showerror(
"Dialog Error",
f"Failed to open directory dialog:\n{str(e)}"
))
def read_files(self):
"""
Load the FEA model and import result files.
Process:
1. Validate that input model file is selected
2. Load the model using NaxToPy
3. Validate that at least one result file is selected
4. Import results from all selected .op2 files
5. Populate the loadcase listbox with available loadcase IDs
The loaded model is stored in self.model for later use.
"""
# Clear previous loadcase list
self.loadcases_listbox.delete(0, tk.END)
# Validate input model file
model_path = self.single_file_path.get()
if not model_path:
messagebox.showwarning("Missing Input", "An input model file must be selected")
return
# Validate that file exists
if not Path(model_path).exists():
messagebox.showerror("File Not Found", f"Model file not found:\n{model_path}")
return
try:
# Load the FEA model using NaxToPy
n2p.N2PLog.Info.user(f"I1000: Loading model from: {model_path}")
self.model = n2p.load_model(model_path)
n2p.N2PLog.Info.user("I1001: Model loaded successfully")
except Exception as e:
n2p.N2PLog.Error.user(f"E1000: Failed to load model: {str(e)}")
messagebox.showerror("Model Load Error", f"Failed to load model:\n{str(e)}")
return
# Validate result files
if not self.multiple_files_paths.get():
n2p.N2PLog.Warning.user("W1000: No result files selected")
messagebox.showwarning("Missing Results", "At least one result file must be selected")
return
# Parse result file paths (split by semicolon or newline)
result_files = [
f.strip()
for f in self.multiple_files_paths.get().replace(";", "\n").split("\n")
if f.strip()
]
# Validate that result files exist
missing_files = [f for f in result_files if not Path(f).exists()]
if missing_files:
n2p.N2PLog.Error.user(f"E1001: Result files not found: {', '.join(missing_files)}")
messagebox.showerror(
"Files Not Found",
f"The following result files were not found:\n" + "\n".join(missing_files)
)
return
try:
# Import results from all selected .op2 files
n2p.N2PLog.Info.user(f"I1002: Importing results from {len(result_files)} file(s)")
self.model.import_results_from_files(result_files)
n2p.N2PLog.Info.user("I1003: Results imported successfully")
# Get available loadcases from the model
loadcases = self.model.LoadCases
if not loadcases or len(loadcases) == 0:
n2p.N2PLog.Warning.user("W1001: No loadcases found in the result files")
messagebox.showwarning(
"No Loadcases",
"No loadcases found in the result files"
)
return
# Populate the listbox with loadcase IDs
for lc in loadcases:
self.loadcases_listbox.insert(tk.END, str(lc.ID))
n2p.N2PLog.Info.user(f"I1004: Found {len(loadcases)} loadcase(s)")
messagebox.showinfo(
"Success",
f"Model loaded successfully!\nFound {len(loadcases)} loadcase(s)"
)
except Exception as e:
n2p.N2PLog.Error.user(f"E1002: Error importing results: {str(e)}")
messagebox.showerror("Import Error", f"Error importing results:\n{str(e)}")
def get_selected_loadcases(self):
"""
Get the loadcase IDs currently selected in the listbox.
Returns:
list: List of selected loadcase ID strings
"""
selected_indices = self.loadcases_listbox.curselection()
selected_items = [self.loadcases_listbox.get(i) for i in selected_indices]
return selected_items
def execute_main_function(self):
"""
Main execution function - creates envelope loadcase and exports Von Mises stresses.
Process:
1. Validate all inputs (output dir, loadcases, model)
2. Create envelope loadcase with selected criteria
3. Calculate Von Mises stresses using derived component
4. Extract results for all elements
5. Filter out NaN values and connectors
6. Export results to CSV file
Output CSV Format:
ElementID, VonMisses
1001, 125.67
1002, 98.42
...
Von Mises Stress Formula:
For plane stress state:
σ_vm = √(σ_xx² + σ_yy² - σ_xx·σ_yy + 3·τ_xy²)
This is a scalar value representing the equivalent stress that can be
compared to material yield strength for failure prediction.
"""
# Validate output directory
output_dir = self.output_directory.get()
if not output_dir:
n2p.N2PLog.Warning.user("W1002: No output directory selected")
messagebox.showwarning("Missing Output", "Please select an output directory")
return
# Validate output directory exists
if not Path(output_dir).exists():
n2p.N2PLog.Error.user(f"E1003: Output directory not found: {output_dir}")
messagebox.showerror("Directory Not Found", f"Output directory not found:\n{output_dir}")
return
# Validate model is loaded
if self.model is None:
n2p.N2PLog.Warning.user("W1003: No model loaded")
messagebox.showwarning("No Model", "Please read files first to load the model")
return
# Validate loadcase selection
selected_loadcases = self.get_selected_loadcases()
if not selected_loadcases:
n2p.N2PLog.Warning.user("W1004: No loadcases selected")
messagebox.showwarning("No Selection", "Please select at least one loadcase")
return
# Get envelope criteria
selected_criteria = self.option_combobox.get()
try:
# Create envelope formula string
# Format: "<LC1:FR1>,<LC2:FR1>,<LC3:FR1>"
# FR1 typically represents frequency/frame 1 (static analysis)
formula = ",".join(f"<LC{lc}:FR1>" for lc in selected_loadcases)
n2p.N2PLog.Info.user(f"I1005: Creating envelope with formula: {formula}")
n2p.N2PLog.Info.user(f"I1006: Envelope criteria: {selected_criteria}")
# Create new envelope loadcase in the model
# The envelope combines multiple loadcases according to the selected criteria
env = self.model.new_envelope_loadcase(
"envelope",
formula,
envelgroup=selected_criteria
)
n2p.N2PLog.Info.user("I1007: Envelope loadcase created")
# Calculate Von Mises stress as a derived component
# Von Mises formula for plane stress:
# σ_vm = √(σ_xx² + σ_yy² - σ_xx·σ_yy + 3·τ_xy²)
#
# This formula converts the stress tensor into a single scalar value
# that can be compared to material yield strength for failure assessment
vonmises = env.Results["STRESSES"].new_derived_component(
"vonmises",
"sqrt(<CMPT_STRESSES:XX>^2+<CMPT_STRESSES:YY>^2-<CMPT_STRESSES:XX>*<CMPT_STRESSES:YY>+3*<CMPT_STRESSES:XY>^2)"
)
n2p.N2PLog.Info.user("I1008: Von Mises stress calculated")
# Get results as numpy array
# Returns tuple: (results_array, metadata)
# We only need the first element (the actual data)
results = vonmises.get_result_ndarray()[0]
# Build output filename with loadcase identifiers and criteria
loadcase_str = "-".join(str(lc) for lc in selected_loadcases)
out_file_name = Path(output_dir) / f"envelope_{loadcase_str}_{selected_criteria}.csv"
# Get all elements from the model
elements = self.model.get_elements()
# IMPORTANT: The result array size = elements + connectors
# We only want element results, so we:
# 1. Slice the array to only include elements
# 2. Filter out NaN values (elements without stress results)
num_elements = len(elements)
element_results = results[:num_elements]
# Create boolean mask: True where values are NOT NaN
nan_mask = ~np.isnan(element_results)
# Apply mask to get clean data (only valid element results)
results_clean = element_results[nan_mask]
elements_id_clean = np.array([ele.ID for ele in elements])[nan_mask]
n2p.N2PLog.Info.user(f"I1009: Exporting {len(results_clean)} element results to CSV")
# Write results to CSV file
# Format: ElementID, VonMisses (one row per element)
if selected_criteria == "ByLoadCaseID":
with open(out_file_name, "w") as f:
# Write header
f.write("ElementID,LoadCase\n")
# Write data rows with integer loadcase IDs
for ele_id, vm_stress in zip(elements_id_clean, results_clean):
f.write(f"{ele_id},{int(vm_stress)}\n")
else:
with open(out_file_name, "w") as f:
# Write header
f.write("ElementID,VonMisses\n")
# Write data rows with 6 decimal places for stress values
for ele_id, vm_stress in zip(elements_id_clean, results_clean):
f.write(f"{ele_id},{vm_stress:.6f}\n")
n2p.N2PLog.Info.user(f"I1010: Results written to: {out_file_name}")
# Show success message with summary information
messagebox.showinfo(
"Success",
f"Envelope results exported successfully!\n\n"
f"File: {out_file_name.name}\n"
f"Elements: {len(results_clean)}\n"
f"Criteria: {selected_criteria}\n"
f"Loadcases: {', '.join(selected_loadcases)}"
)
except Exception as e:
# Catch and display any errors during execution
error_msg = f"E1004: Error during envelope creation: {str(e)}"
n2p.N2PLog.Error.user(error_msg)
messagebox.showerror("Execution Error", error_msg)
def _add_logo(self, main_frame, row):
"""Add company logo at the bottom of the window."""
try:
BASE_DIR = self._get_base_dir()
if hasattr(sys, '_MEIPASS'):
MEDIA_DIR = os.path.join(BASE_DIR, 'bin')
else:
MEDIA_DIR = BASE_DIR
self.logo_photo = tk.PhotoImage(file=os.path.join(MEDIA_DIR, 'logo.png'))
logo_label = ttk.Label(main_frame, image=self.logo_photo)
logo_label.grid(row=row, column=0, columnspan=3, pady=(10, 5))
except Exception as e:
ttk.Label(
main_frame,
text="[Idaero Solutions]",
foreground="gray"
).grid(row=row, column=0, columnspan=3, pady=(10, 5))
def _get_base_dir(self):
if hasattr(sys, '_MEIPASS'):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
# All the paths must use this first path to generate an absolute path
def main():
"""
Main entry point for the application.
Creates the tkinter root window and starts the GUI event loop.
The application will continue running until the user closes the window.
"""
# Closes the splash window
if hasattr(sys, '_MEIPASS'):
import pyi_splash
pyi_splash.close()
# Create root window
root = tk.Tk()
# Initialize the application
app = FileProcessorGUI(root)
# Start the GUI event loop
# This blocks until the window is closed
root.mainloop()
if __name__ == "__main__":
main()
Warning
If a splash image is used, the splash is shown by a secondary processor. It has to be killed by the main processor using this commands:
import pyi_splash
pyi_splash.close()
5.2 Code for Executable Generation#
import NaxToPy as n2p
import PIL # pillow package transform the splash jpg image into a png image. It is installed using `pip install pillow`
# n2ptoexe is the function that creates a selfcontained executable from a python script.
# The exe file runs in any windows 10/11 PC. No python and no NaxTo is needed.
n2p.n2ptoexe(
r"C:\Data\Examples\9_Executables\app.py", # path to the script
console=False, # As this script has a tkinter gui, no console is needed
solver="NASTRAN", # As it works only for Nastran, we are saying only nastran libraries
splash="C:\\Data\\splash.jpg", # path to the splash image (image shown during exe initialization)
extra_files=[r"C:\Data\Examples\9_Executables\logo.png"] # Extra files that are saved in {temp_exe}\bin\
)
Warning
The pillow package is not usually needed. Only when the splash image is a jpg file must be used.
Note
Some extra packages must be added explicitly. This is the case of the color theme Sun-Valley-ttk-theme:
n2p.n2ptoexe(
r"C:\Data\Examples\9_Executables\app_with_sv_ttk.py",
console=False,
extra_packages=['sv_ttk']
)
© 2025 Idaero Solutions S.L.
All rights reserved. This document is licensed under the terms of the LICENSE of the NaxToPy package.