feat: Major update with validators, skills, dashboard, and docs reorganization
- Add validation framework (config, model, results, study validators) - Add Claude Code skills (create-study, run-optimization, generate-report, troubleshoot, analyze-model) - Add Atomizer Dashboard (React frontend + FastAPI backend) - Reorganize docs into structured directories (00-09) - Add neural surrogate modules and training infrastructure - Add multi-objective optimization support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
557
optimization_engine/validators/model_validator.py
Normal file
557
optimization_engine/validators/model_validator.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""
|
||||
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"
|
||||
))
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user