Files
Atomizer/optimization_engine/validators/model_validator.py
Anto01 e3bdb08a22 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>
2025-11-25 19:23:58 -05:00

558 lines
18 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"
))
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)