## Protocol 13: Adaptive Multi-Objective Optimization - Iterative FEA + Neural Network surrogate workflow - Initial FEA sampling, NN training, NN-accelerated search - FEA validation of top NN predictions, retraining loop - adaptive_state.json tracks iteration history and best values - M1 mirror study (V11) with 103 FEA, 3000 NN trials ## Dashboard Visualization Enhancements - Added Plotly.js interactive charts (parallel coords, Pareto, convergence) - Lazy loading with React.lazy() for performance - Code splitting: plotly.js-basic-dist (~1MB vs 3.5MB) - Chart library toggle (Recharts default, Plotly on-demand) - ExpandableChart component for full-screen modal views - ConsoleOutput component for real-time log viewing ## Documentation - Protocol 13 detailed documentation - Dashboard visualization guide - Plotly components README - Updated run-optimization skill with Mode 5 (adaptive) ## Bug Fixes - Fixed TypeScript errors in dashboard components - Fixed Card component to accept ReactNode title - Removed unused imports across components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
630 lines
21 KiB
Python
630 lines
21 KiB
Python
"""
|
|
Model Validator for Atomizer
|
|
============================
|
|
|
|
Validates NX model files and simulation setup before running optimizations.
|
|
Checks file existence, structure, and configuration compatibility.
|
|
|
|
Usage:
|
|
from optimization_engine.validators import validate_model, validate_model_files
|
|
|
|
# Validate model directory
|
|
result = validate_model("studies/my_study/1_setup/model")
|
|
|
|
# Validate specific files
|
|
result = validate_model_files(
|
|
prt_file="Beam.prt",
|
|
sim_file="Beam_sim1.sim",
|
|
model_dir=Path("studies/my_study/1_setup/model")
|
|
)
|
|
|
|
if result.is_valid:
|
|
print("Model is ready!")
|
|
else:
|
|
for error in result.errors:
|
|
print(f"ERROR: {error}")
|
|
"""
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Union
|
|
|
|
|
|
@dataclass
|
|
class ModelError:
|
|
"""Represents a model validation error that blocks execution."""
|
|
component: str
|
|
message: str
|
|
suggestion: Optional[str] = None
|
|
|
|
def __str__(self):
|
|
msg = f"[{self.component}] {self.message}"
|
|
if self.suggestion:
|
|
msg += f" (Suggestion: {self.suggestion})"
|
|
return msg
|
|
|
|
|
|
@dataclass
|
|
class ModelWarning:
|
|
"""Represents a model validation warning."""
|
|
component: str
|
|
message: str
|
|
suggestion: Optional[str] = None
|
|
|
|
def __str__(self):
|
|
msg = f"[{self.component}] {self.message}"
|
|
if self.suggestion:
|
|
msg += f" (Suggestion: {self.suggestion})"
|
|
return msg
|
|
|
|
|
|
@dataclass
|
|
class ModelValidationResult:
|
|
"""Result of model validation."""
|
|
errors: List[ModelError] = field(default_factory=list)
|
|
warnings: List[ModelWarning] = field(default_factory=list)
|
|
|
|
# Discovered files
|
|
prt_file: Optional[Path] = None
|
|
sim_file: Optional[Path] = None
|
|
fem_file: Optional[Path] = None
|
|
|
|
# Model info
|
|
model_name: Optional[str] = None
|
|
model_dir: Optional[Path] = None
|
|
file_sizes: Dict[str, int] = field(default_factory=dict)
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
"""Model is valid if there are no errors."""
|
|
return len(self.errors) == 0
|
|
|
|
@property
|
|
def has_simulation(self) -> bool:
|
|
"""Check if simulation file exists."""
|
|
return self.sim_file is not None
|
|
|
|
@property
|
|
def has_fem(self) -> bool:
|
|
"""Check if FEM mesh file exists."""
|
|
return self.fem_file is not None
|
|
|
|
def __str__(self):
|
|
lines = []
|
|
lines.append(f"Model: {self.model_name or 'Unknown'}")
|
|
lines.append(f"Directory: {self.model_dir or 'Unknown'}")
|
|
lines.append("")
|
|
|
|
lines.append("Files:")
|
|
if self.prt_file:
|
|
size = self.file_sizes.get('prt', 0)
|
|
lines.append(f" [OK] Part file: {self.prt_file.name} ({_format_size(size)})")
|
|
else:
|
|
lines.append(" [X] Part file: NOT FOUND")
|
|
|
|
if self.sim_file:
|
|
size = self.file_sizes.get('sim', 0)
|
|
lines.append(f" [OK] Simulation: {self.sim_file.name} ({_format_size(size)})")
|
|
else:
|
|
lines.append(" [X] Simulation: NOT FOUND")
|
|
|
|
if self.fem_file:
|
|
size = self.file_sizes.get('fem', 0)
|
|
lines.append(f" [OK] FEM mesh: {self.fem_file.name} ({_format_size(size)})")
|
|
else:
|
|
lines.append(" ? FEM mesh: Not found (will be created on first solve)")
|
|
|
|
if self.errors:
|
|
lines.append("")
|
|
lines.append(f"ERRORS ({len(self.errors)}):")
|
|
for e in self.errors:
|
|
lines.append(f" - {e}")
|
|
|
|
if self.warnings:
|
|
lines.append("")
|
|
lines.append(f"WARNINGS ({len(self.warnings)}):")
|
|
for w in self.warnings:
|
|
lines.append(f" - {w}")
|
|
|
|
if self.is_valid:
|
|
lines.append("")
|
|
lines.append("[OK] Model validation passed!")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_size(size_bytes: int) -> str:
|
|
"""Format file size for display."""
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
elif size_bytes < 1024 * 1024:
|
|
return f"{size_bytes / 1024:.1f} KB"
|
|
else:
|
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
|
|
|
|
def validate_model(model_dir: Union[str, Path],
|
|
expected_model_name: Optional[str] = None) -> ModelValidationResult:
|
|
"""
|
|
Validate an NX model directory.
|
|
|
|
Args:
|
|
model_dir: Path to the model directory
|
|
expected_model_name: Expected base name of the model (optional)
|
|
|
|
Returns:
|
|
ModelValidationResult with errors, warnings, and discovered files
|
|
"""
|
|
model_dir = Path(model_dir)
|
|
result = ModelValidationResult(model_dir=model_dir)
|
|
|
|
# Check directory exists
|
|
if not model_dir.exists():
|
|
result.errors.append(ModelError(
|
|
component="directory",
|
|
message=f"Model directory not found: {model_dir}",
|
|
suggestion="Create the directory and add NX model files"
|
|
))
|
|
return result
|
|
|
|
if not model_dir.is_dir():
|
|
result.errors.append(ModelError(
|
|
component="directory",
|
|
message=f"Path is not a directory: {model_dir}",
|
|
suggestion="Provide path to the model directory, not a file"
|
|
))
|
|
return result
|
|
|
|
# Find model files
|
|
prt_files = list(model_dir.glob("*.prt"))
|
|
sim_files = list(model_dir.glob("*.sim"))
|
|
fem_files = list(model_dir.glob("*.fem"))
|
|
|
|
# Check for part file
|
|
if len(prt_files) == 0:
|
|
result.errors.append(ModelError(
|
|
component="part",
|
|
message="No .prt file found in model directory",
|
|
suggestion="Add your NX part file to the model directory"
|
|
))
|
|
elif len(prt_files) > 1:
|
|
# Filter out internal files (often have _i suffix)
|
|
main_prt_files = [f for f in prt_files if not f.stem.endswith('_i')]
|
|
if len(main_prt_files) == 1:
|
|
prt_files = main_prt_files
|
|
elif expected_model_name:
|
|
matching = [f for f in prt_files if f.stem == expected_model_name]
|
|
if matching:
|
|
prt_files = matching
|
|
else:
|
|
result.warnings.append(ModelWarning(
|
|
component="part",
|
|
message=f"Multiple .prt files found, none match expected name '{expected_model_name}'",
|
|
suggestion="Specify the correct model name in configuration"
|
|
))
|
|
else:
|
|
result.warnings.append(ModelWarning(
|
|
component="part",
|
|
message=f"Multiple .prt files found: {[f.name for f in prt_files]}",
|
|
suggestion="Consider keeping only the main model file in the directory"
|
|
))
|
|
|
|
if prt_files:
|
|
result.prt_file = prt_files[0]
|
|
result.model_name = result.prt_file.stem
|
|
result.file_sizes['prt'] = result.prt_file.stat().st_size
|
|
|
|
# Validate part file
|
|
_validate_prt_file(result.prt_file, result)
|
|
|
|
# Check for simulation file
|
|
if len(sim_files) == 0:
|
|
result.errors.append(ModelError(
|
|
component="simulation",
|
|
message="No .sim file found in model directory",
|
|
suggestion="Create a simulation in NX and save it to this directory"
|
|
))
|
|
elif len(sim_files) > 1:
|
|
if result.model_name:
|
|
# Try to find matching sim file
|
|
expected_sim = f"{result.model_name}_sim1.sim"
|
|
matching = [f for f in sim_files if f.name.lower() == expected_sim.lower()]
|
|
if matching:
|
|
sim_files = matching
|
|
else:
|
|
result.warnings.append(ModelWarning(
|
|
component="simulation",
|
|
message=f"Multiple .sim files found: {[f.name for f in sim_files]}",
|
|
suggestion=f"Expected: {expected_sim}"
|
|
))
|
|
else:
|
|
result.warnings.append(ModelWarning(
|
|
component="simulation",
|
|
message=f"Multiple .sim files found: {[f.name for f in sim_files]}",
|
|
suggestion="Keep only one simulation file"
|
|
))
|
|
|
|
if sim_files:
|
|
result.sim_file = sim_files[0]
|
|
result.file_sizes['sim'] = result.sim_file.stat().st_size
|
|
|
|
# Validate simulation file
|
|
_validate_sim_file(result.sim_file, result)
|
|
|
|
# Check for FEM file
|
|
if len(fem_files) == 0:
|
|
result.warnings.append(ModelWarning(
|
|
component="fem",
|
|
message="No .fem file found",
|
|
suggestion="FEM mesh will be created automatically on first solve"
|
|
))
|
|
else:
|
|
if result.model_name:
|
|
expected_fem = f"{result.model_name}_fem1.fem"
|
|
matching = [f for f in fem_files if f.name.lower() == expected_fem.lower()]
|
|
if matching:
|
|
fem_files = matching
|
|
|
|
result.fem_file = fem_files[0]
|
|
result.file_sizes['fem'] = result.fem_file.stat().st_size
|
|
|
|
# Cross-validate files
|
|
_validate_file_relationships(result)
|
|
|
|
return result
|
|
|
|
|
|
def validate_model_files(prt_file: Union[str, Path],
|
|
sim_file: Union[str, Path],
|
|
model_dir: Optional[Union[str, Path]] = None) -> ModelValidationResult:
|
|
"""
|
|
Validate specific model files.
|
|
|
|
Args:
|
|
prt_file: Name or path to the part file
|
|
sim_file: Name or path to the simulation file
|
|
model_dir: Base directory (optional, will be inferred if full paths given)
|
|
|
|
Returns:
|
|
ModelValidationResult
|
|
"""
|
|
prt_path = Path(prt_file)
|
|
sim_path = Path(sim_file)
|
|
|
|
# If paths are relative and model_dir provided, resolve them
|
|
if model_dir:
|
|
model_dir = Path(model_dir)
|
|
if not prt_path.is_absolute():
|
|
prt_path = model_dir / prt_path
|
|
if not sim_path.is_absolute():
|
|
sim_path = model_dir / sim_path
|
|
else:
|
|
# Infer model_dir from prt_file
|
|
if prt_path.is_absolute():
|
|
model_dir = prt_path.parent
|
|
else:
|
|
model_dir = Path.cwd()
|
|
|
|
result = ModelValidationResult(model_dir=model_dir)
|
|
|
|
# Check part file
|
|
if not prt_path.exists():
|
|
result.errors.append(ModelError(
|
|
component="part",
|
|
message=f"Part file not found: {prt_path}",
|
|
suggestion="Check the file path and name"
|
|
))
|
|
else:
|
|
result.prt_file = prt_path
|
|
result.model_name = prt_path.stem
|
|
result.file_sizes['prt'] = prt_path.stat().st_size
|
|
_validate_prt_file(prt_path, result)
|
|
|
|
# Check simulation file
|
|
if not sim_path.exists():
|
|
result.errors.append(ModelError(
|
|
component="simulation",
|
|
message=f"Simulation file not found: {sim_path}",
|
|
suggestion="Check the file path and name"
|
|
))
|
|
else:
|
|
result.sim_file = sim_path
|
|
result.file_sizes['sim'] = sim_path.stat().st_size
|
|
_validate_sim_file(sim_path, result)
|
|
|
|
# Check for FEM file
|
|
if result.model_name:
|
|
fem_path = model_dir / f"{result.model_name}_fem1.fem"
|
|
if fem_path.exists():
|
|
result.fem_file = fem_path
|
|
result.file_sizes['fem'] = fem_path.stat().st_size
|
|
else:
|
|
# Try alternative naming
|
|
fem_files = list(model_dir.glob("*.fem")) if model_dir.exists() else []
|
|
if fem_files:
|
|
result.fem_file = fem_files[0]
|
|
result.file_sizes['fem'] = result.fem_file.stat().st_size
|
|
|
|
_validate_file_relationships(result)
|
|
|
|
return result
|
|
|
|
|
|
def _validate_prt_file(prt_path: Path, result: ModelValidationResult):
|
|
"""Validate a part file."""
|
|
# Check file size
|
|
size = prt_path.stat().st_size
|
|
|
|
if size == 0:
|
|
result.errors.append(ModelError(
|
|
component="part",
|
|
message="Part file is empty",
|
|
suggestion="Re-save the part file in NX"
|
|
))
|
|
return
|
|
|
|
if size < 1024:
|
|
result.warnings.append(ModelWarning(
|
|
component="part",
|
|
message=f"Part file is very small ({_format_size(size)})",
|
|
suggestion="Verify the file contains valid geometry"
|
|
))
|
|
|
|
# Check for NX file signature (basic validation)
|
|
try:
|
|
with open(prt_path, 'rb') as f:
|
|
header = f.read(8)
|
|
# NX files typically start with a specific signature
|
|
# This is a basic check - real NX files have more complex headers
|
|
if len(header) < 8:
|
|
result.warnings.append(ModelWarning(
|
|
component="part",
|
|
message="Part file appears incomplete",
|
|
suggestion="Re-save the file in NX"
|
|
))
|
|
except PermissionError:
|
|
result.errors.append(ModelError(
|
|
component="part",
|
|
message="Cannot read part file - permission denied",
|
|
suggestion="Close NX if the file is open, or check file permissions"
|
|
))
|
|
except Exception as e:
|
|
result.warnings.append(ModelWarning(
|
|
component="part",
|
|
message=f"Could not verify part file: {e}",
|
|
suggestion="Ensure file is a valid NX part"
|
|
))
|
|
|
|
|
|
def _validate_sim_file(sim_path: Path, result: ModelValidationResult):
|
|
"""Validate a simulation file."""
|
|
size = sim_path.stat().st_size
|
|
|
|
if size == 0:
|
|
result.errors.append(ModelError(
|
|
component="simulation",
|
|
message="Simulation file is empty",
|
|
suggestion="Re-save the simulation in NX"
|
|
))
|
|
return
|
|
|
|
if size < 512:
|
|
result.warnings.append(ModelWarning(
|
|
component="simulation",
|
|
message=f"Simulation file is very small ({_format_size(size)})",
|
|
suggestion="Verify simulation setup in NX"
|
|
))
|
|
|
|
# Check for assembly FEM - detect missing assembly .prt file
|
|
_validate_assembly_components(sim_path, result)
|
|
|
|
|
|
def _validate_assembly_components(sim_path: Path, result: ModelValidationResult):
|
|
"""
|
|
Validate that all .prt files required by an assembly FEM are present.
|
|
|
|
Assembly FEM (.afm) files reference:
|
|
- The assembly .prt file (e.g., ASSY_M1.prt)
|
|
- Component part files (e.g., M1_Blank.prt)
|
|
- Component FEM idealized parts (e.g., M1_Blank_fem1_i.prt)
|
|
|
|
This function detects when the main assembly .prt file is missing.
|
|
"""
|
|
model_dir = sim_path.parent
|
|
sim_stem = sim_path.stem # e.g., "ASSY_M1_assyfem1_sim1"
|
|
|
|
# Check if this is an assembly FEM by looking for _assyfem pattern
|
|
if '_assyfem' not in sim_stem.lower():
|
|
return # Not an assembly FEM
|
|
|
|
# Extract assembly name from sim file: ASSY_M1_assyfem1_sim1 -> ASSY_M1
|
|
# Pattern: {ASSY_NAME}_assyfem{N}_sim{M}.sim
|
|
import re
|
|
match = re.match(r'^(.+?)_assyfem\d+', sim_stem, re.IGNORECASE)
|
|
if not match:
|
|
return
|
|
|
|
assembly_name = match.group(1) # e.g., "ASSY_M1"
|
|
expected_assy_prt = f"{assembly_name}.prt"
|
|
expected_assy_path = model_dir / expected_assy_prt
|
|
|
|
# Check if assembly .prt file exists
|
|
if not expected_assy_path.exists():
|
|
# Try case-insensitive search
|
|
all_prts = list(model_dir.glob("*.prt"))
|
|
matching = [p for p in all_prts if p.stem.lower() == assembly_name.lower()]
|
|
|
|
if not matching:
|
|
result.errors.append(ModelError(
|
|
component="assembly",
|
|
message=f"CRITICAL: Assembly part file missing: {expected_assy_prt}",
|
|
suggestion=f"Copy {expected_assy_prt} from the source model directory to {model_dir}"
|
|
))
|
|
|
|
# Also warn about potential related files
|
|
result.warnings.append(ModelWarning(
|
|
component="assembly",
|
|
message=f"Assembly FEM requires all component .prt and .fem files",
|
|
suggestion="Ensure ALL .prt, .fem, and _i.prt files from the source are copied"
|
|
))
|
|
else:
|
|
# Assembly .prt exists - verify it's readable
|
|
if not os.access(expected_assy_path, os.R_OK):
|
|
result.errors.append(ModelError(
|
|
component="assembly",
|
|
message=f"Assembly part file not readable: {expected_assy_prt}",
|
|
suggestion="Check file permissions"
|
|
))
|
|
|
|
# Also check for the .afm file
|
|
afm_name = f"{assembly_name}_assyfem1.afm" # Common pattern
|
|
# Try to find any .afm file
|
|
afm_files = list(model_dir.glob("*.afm"))
|
|
if not afm_files:
|
|
result.warnings.append(ModelWarning(
|
|
component="assembly",
|
|
message="No .afm file found for assembly FEM",
|
|
suggestion="The .afm file may be created on first solve"
|
|
))
|
|
|
|
|
|
def _validate_file_relationships(result: ModelValidationResult):
|
|
"""Validate relationships between model files."""
|
|
if not result.prt_file or not result.sim_file:
|
|
return
|
|
|
|
# Check naming convention
|
|
prt_stem = result.prt_file.stem
|
|
sim_stem = result.sim_file.stem
|
|
|
|
expected_sim_stem = f"{prt_stem}_sim1"
|
|
if sim_stem != expected_sim_stem and not sim_stem.startswith(prt_stem):
|
|
result.warnings.append(ModelWarning(
|
|
component="naming",
|
|
message=f"Simulation name '{sim_stem}' doesn't match part name '{prt_stem}'",
|
|
suggestion=f"Expected simulation name: {expected_sim_stem}.sim"
|
|
))
|
|
|
|
# Check FEM naming if present
|
|
if result.fem_file:
|
|
fem_stem = result.fem_file.stem
|
|
expected_fem_stem = f"{prt_stem}_fem1"
|
|
if fem_stem != expected_fem_stem and not fem_stem.startswith(prt_stem):
|
|
result.warnings.append(ModelWarning(
|
|
component="naming",
|
|
message=f"FEM name '{fem_stem}' doesn't match part name '{prt_stem}'",
|
|
suggestion=f"Expected FEM name: {expected_fem_stem}.fem"
|
|
))
|
|
|
|
# Check files are in same directory
|
|
if result.prt_file.parent != result.sim_file.parent:
|
|
result.warnings.append(ModelWarning(
|
|
component="directory",
|
|
message="Part and simulation files are in different directories",
|
|
suggestion="Keep all model files in the same directory"
|
|
))
|
|
|
|
|
|
def validate_study_model(study_name: str,
|
|
studies_dir: str = "studies",
|
|
config: Optional[Dict[str, Any]] = None) -> ModelValidationResult:
|
|
"""
|
|
Validate model for a complete study.
|
|
|
|
Args:
|
|
study_name: Name of the study folder (e.g., "uav_arm_optimization")
|
|
studies_dir: Base directory for studies (default: "studies")
|
|
config: Optional optimization_config.json contents (loaded dict, not path)
|
|
|
|
Returns:
|
|
ModelValidationResult
|
|
"""
|
|
study_path = Path(studies_dir) / study_name
|
|
model_dir = study_path / "1_setup" / "model"
|
|
|
|
# Load config if not provided
|
|
if config is None:
|
|
config_path = study_path / "1_setup" / "optimization_config.json"
|
|
if config_path.exists():
|
|
import json
|
|
try:
|
|
with open(config_path, 'r') as f:
|
|
config = json.load(f)
|
|
except (json.JSONDecodeError, IOError):
|
|
config = None
|
|
|
|
# Get expected file names from config if available
|
|
expected_model_name = None
|
|
if config and isinstance(config, dict) and 'simulation' in config:
|
|
sim_config = config['simulation']
|
|
if 'model_file' in sim_config:
|
|
expected_model_name = Path(sim_config['model_file']).stem
|
|
|
|
result = validate_model(model_dir, expected_model_name)
|
|
|
|
# Additional study-specific validations
|
|
if config and isinstance(config, dict):
|
|
_validate_config_model_match(config, result)
|
|
|
|
return result
|
|
|
|
|
|
def _validate_config_model_match(config: Dict[str, Any], result: ModelValidationResult):
|
|
"""Check that config matches discovered model files."""
|
|
sim_config = config.get('simulation', {})
|
|
|
|
# Check model file name matches
|
|
if 'model_file' in sim_config and result.prt_file:
|
|
config_model = Path(sim_config['model_file']).name
|
|
actual_model = result.prt_file.name
|
|
if config_model.lower() != actual_model.lower():
|
|
result.warnings.append(ModelWarning(
|
|
component="config",
|
|
message=f"Config specifies '{config_model}' but found '{actual_model}'",
|
|
suggestion="Update config to match actual file name"
|
|
))
|
|
|
|
# Check sim file name matches
|
|
if 'sim_file' in sim_config and result.sim_file:
|
|
config_sim = Path(sim_config['sim_file']).name
|
|
actual_sim = result.sim_file.name
|
|
if config_sim.lower() != actual_sim.lower():
|
|
result.warnings.append(ModelWarning(
|
|
component="config",
|
|
message=f"Config specifies '{config_sim}' but found '{actual_sim}'",
|
|
suggestion="Update config to match actual file name"
|
|
))
|
|
|
|
|
|
# CLI interface for direct execution
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python model_validator.py <path_to_model_directory>")
|
|
print(" python model_validator.py <path_to_study_directory>")
|
|
sys.exit(1)
|
|
|
|
path = Path(sys.argv[1])
|
|
|
|
# Check if it's a study directory or model directory
|
|
if (path / "1_setup" / "model").exists():
|
|
# It's a study directory
|
|
result = validate_study_model(path)
|
|
elif path.is_dir():
|
|
# It's a model directory
|
|
result = validate_model(path)
|
|
else:
|
|
print(f"ERROR: Path not found or not a directory: {path}")
|
|
sys.exit(1)
|
|
|
|
print(result)
|
|
|
|
if result.is_valid:
|
|
print("\n✓ Model validation passed!")
|
|
sys.exit(0)
|
|
else:
|
|
print(f"\n✗ Model has {len(result.errors)} error(s)")
|
|
sys.exit(1)
|