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>
This commit is contained in:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

@@ -7,7 +7,7 @@ They provide validation and type safety for the unified configuration system.
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from pydantic import BaseModel, Field, field_validator, model_validator
import re
@@ -16,17 +16,34 @@ import re
# Enums
# ============================================================================
class SpecCreatedBy(str, Enum):
"""Who/what created the spec."""
CANVAS = "canvas"
CLAUDE = "claude"
API = "api"
MIGRATION = "migration"
MANUAL = "manual"
DASHBOARD_INTAKE = "dashboard_intake"
class SpecStatus(str, Enum):
"""Study lifecycle status."""
DRAFT = "draft"
INTROSPECTED = "introspected"
CONFIGURED = "configured"
VALIDATED = "validated"
READY = "ready"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class SolverType(str, Enum):
"""Supported solver types."""
NASTRAN = "nastran"
NX_NASTRAN = "NX_Nastran"
ABAQUS = "abaqus"
@@ -34,6 +51,7 @@ class SolverType(str, Enum):
class SubcaseType(str, Enum):
"""Subcase analysis types."""
STATIC = "static"
MODAL = "modal"
THERMAL = "thermal"
@@ -42,6 +60,7 @@ class SubcaseType(str, Enum):
class DesignVariableType(str, Enum):
"""Design variable types."""
CONTINUOUS = "continuous"
INTEGER = "integer"
CATEGORICAL = "categorical"
@@ -49,6 +68,7 @@ class DesignVariableType(str, Enum):
class ExtractorType(str, Enum):
"""Physics extractor types."""
DISPLACEMENT = "displacement"
FREQUENCY = "frequency"
STRESS = "stress"
@@ -62,18 +82,21 @@ class ExtractorType(str, Enum):
class OptimizationDirection(str, Enum):
"""Optimization direction."""
MINIMIZE = "minimize"
MAXIMIZE = "maximize"
class ConstraintType(str, Enum):
"""Constraint types."""
HARD = "hard"
SOFT = "soft"
class ConstraintOperator(str, Enum):
"""Constraint comparison operators."""
LE = "<="
GE = ">="
LT = "<"
@@ -83,6 +106,7 @@ class ConstraintOperator(str, Enum):
class PenaltyMethod(str, Enum):
"""Penalty methods for constraints."""
LINEAR = "linear"
QUADRATIC = "quadratic"
EXPONENTIAL = "exponential"
@@ -90,6 +114,7 @@ class PenaltyMethod(str, Enum):
class AlgorithmType(str, Enum):
"""Optimization algorithm types."""
TPE = "TPE"
CMA_ES = "CMA-ES"
NSGA_II = "NSGA-II"
@@ -100,6 +125,7 @@ class AlgorithmType(str, Enum):
class SurrogateType(str, Enum):
"""Surrogate model types."""
MLP = "MLP"
GNN = "GNN"
ENSEMBLE = "ensemble"
@@ -109,58 +135,104 @@ class SurrogateType(str, Enum):
# Position Model
# ============================================================================
class CanvasPosition(BaseModel):
"""Canvas position for nodes."""
x: float = 0
y: float = 0
# ============================================================================
# Introspection Models (for intake workflow)
# ============================================================================
class ExpressionInfo(BaseModel):
"""Information about an NX expression from introspection."""
name: str = Field(..., description="Expression name in NX")
value: Optional[float] = Field(default=None, description="Current value")
units: Optional[str] = Field(default=None, description="Physical units")
formula: Optional[str] = Field(default=None, description="Expression formula if any")
is_candidate: bool = Field(
default=False, description="Whether this is a design variable candidate"
)
confidence: float = Field(
default=0.0, ge=0.0, le=1.0, description="Confidence that this is a DV"
)
class BaselineData(BaseModel):
"""Results from baseline FEA solve."""
timestamp: datetime = Field(..., description="When baseline was run")
solve_time_seconds: float = Field(..., description="How long the solve took")
mass_kg: Optional[float] = Field(default=None, description="Computed mass from BDF/FEM")
max_displacement_mm: Optional[float] = Field(
default=None, description="Max displacement result"
)
max_stress_mpa: Optional[float] = Field(default=None, description="Max von Mises stress")
success: bool = Field(default=True, description="Whether baseline solve succeeded")
error: Optional[str] = Field(default=None, description="Error message if failed")
class IntrospectionData(BaseModel):
"""Model introspection results stored in the spec."""
timestamp: datetime = Field(..., description="When introspection was run")
solver_type: Optional[str] = Field(default=None, description="Detected solver type")
mass_kg: Optional[float] = Field(
default=None, description="Mass from expressions or properties"
)
volume_mm3: Optional[float] = Field(default=None, description="Volume from mass properties")
expressions: List[ExpressionInfo] = Field(
default_factory=list, description="Discovered expressions"
)
baseline: Optional[BaselineData] = Field(default=None, description="Baseline solve results")
warnings: List[str] = Field(default_factory=list, description="Warnings from introspection")
def get_design_candidates(self) -> List[ExpressionInfo]:
"""Return expressions marked as design variable candidates."""
return [e for e in self.expressions if e.is_candidate]
# ============================================================================
# Meta Models
# ============================================================================
class SpecMeta(BaseModel):
"""Metadata about the spec."""
version: str = Field(
...,
pattern=r"^2\.\d+$",
description="Schema version (e.g., '2.0')"
)
created: Optional[datetime] = Field(
default=None,
description="When the spec was created"
)
version: str = Field(..., pattern=r"^2\.\d+$", description="Schema version (e.g., '2.0')")
created: Optional[datetime] = Field(default=None, description="When the spec was created")
modified: Optional[datetime] = Field(
default=None,
description="When the spec was last modified"
default=None, description="When the spec was last modified"
)
created_by: Optional[SpecCreatedBy] = Field(
default=None,
description="Who/what created the spec"
)
modified_by: Optional[str] = Field(
default=None,
description="Who/what last modified the spec"
default=None, description="Who/what created the spec"
)
modified_by: Optional[str] = Field(default=None, description="Who/what last modified the spec")
study_name: str = Field(
...,
min_length=3,
max_length=100,
pattern=r"^[a-z0-9_]+$",
description="Unique study identifier (snake_case)"
description="Unique study identifier (snake_case)",
)
description: Optional[str] = Field(
default=None,
max_length=1000,
description="Human-readable description"
)
tags: Optional[List[str]] = Field(
default=None,
description="Tags for categorization"
default=None, max_length=1000, description="Human-readable description"
)
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization")
engineering_context: Optional[str] = Field(
default=None, description="Real-world engineering context"
)
status: SpecStatus = Field(default=SpecStatus.DRAFT, description="Study lifecycle status")
topic: Optional[str] = Field(
default=None,
description="Real-world engineering context"
pattern=r"^[A-Za-z0-9_]+$",
description="Topic folder for grouping related studies",
)
@@ -168,15 +240,20 @@ class SpecMeta(BaseModel):
# Model Configuration Models
# ============================================================================
class NxPartConfig(BaseModel):
"""NX geometry part file configuration."""
path: Optional[str] = Field(default=None, description="Path to .prt file")
hash: Optional[str] = Field(default=None, description="File hash for change detection")
idealized_part: Optional[str] = Field(default=None, description="Idealized part filename (_i.prt)")
idealized_part: Optional[str] = Field(
default=None, description="Idealized part filename (_i.prt)"
)
class FemConfig(BaseModel):
"""FEM mesh file configuration."""
path: Optional[str] = Field(default=None, description="Path to .fem file")
element_count: Optional[int] = Field(default=None, description="Number of elements")
node_count: Optional[int] = Field(default=None, description="Number of nodes")
@@ -184,6 +261,7 @@ class FemConfig(BaseModel):
class Subcase(BaseModel):
"""Simulation subcase definition."""
id: int
name: Optional[str] = None
type: Optional[SubcaseType] = None
@@ -191,18 +269,18 @@ class Subcase(BaseModel):
class SimConfig(BaseModel):
"""Simulation file configuration."""
path: str = Field(..., description="Path to .sim file")
solver: SolverType = Field(..., description="Solver type")
solution_type: Optional[str] = Field(
default=None,
pattern=r"^SOL\d+$",
description="Solution type (e.g., SOL101)"
default=None, pattern=r"^SOL\d+$", description="Solution type (e.g., SOL101)"
)
subcases: Optional[List[Subcase]] = Field(default=None, description="Defined subcases")
class NxSettings(BaseModel):
"""NX runtime settings."""
nx_install_path: Optional[str] = None
simulation_timeout_s: Optional[int] = Field(default=None, ge=60, le=7200)
auto_start_nx: Optional[bool] = None
@@ -210,23 +288,31 @@ class NxSettings(BaseModel):
class ModelConfig(BaseModel):
"""NX model files and configuration."""
nx_part: Optional[NxPartConfig] = None
fem: Optional[FemConfig] = None
sim: SimConfig
sim: Optional[SimConfig] = Field(
default=None, description="Simulation file config (required for optimization)"
)
nx_settings: Optional[NxSettings] = None
introspection: Optional[IntrospectionData] = Field(
default=None, description="Model introspection results from intake"
)
# ============================================================================
# Design Variable Models
# ============================================================================
class DesignVariableBounds(BaseModel):
"""Design variable bounds."""
min: float
max: float
@model_validator(mode='after')
def validate_bounds(self) -> 'DesignVariableBounds':
@model_validator(mode="after")
def validate_bounds(self) -> "DesignVariableBounds":
if self.min >= self.max:
raise ValueError(f"min ({self.min}) must be less than max ({self.max})")
return self
@@ -234,16 +320,13 @@ class DesignVariableBounds(BaseModel):
class DesignVariable(BaseModel):
"""A design variable to optimize."""
id: str = Field(
...,
pattern=r"^dv_\d{3}$",
description="Unique identifier (pattern: dv_XXX)"
)
id: str = Field(..., pattern=r"^dv_\d{3}$", description="Unique identifier (pattern: dv_XXX)")
name: str = Field(..., description="Human-readable name")
expression_name: str = Field(
...,
pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$",
description="NX expression name (must match model)"
description="NX expression name (must match model)",
)
type: DesignVariableType = Field(..., description="Variable type")
bounds: DesignVariableBounds = Field(..., description="Value bounds")
@@ -259,8 +342,10 @@ class DesignVariable(BaseModel):
# Extractor Models
# ============================================================================
class ExtractorConfig(BaseModel):
"""Type-specific extractor configuration."""
inner_radius_mm: Optional[float] = None
outer_radius_mm: Optional[float] = None
n_modes: Optional[int] = None
@@ -279,6 +364,7 @@ class ExtractorConfig(BaseModel):
class CustomFunction(BaseModel):
"""Custom function definition for custom_function extractors."""
name: Optional[str] = Field(default=None, description="Function name")
module: Optional[str] = Field(default=None, description="Python module path")
signature: Optional[str] = Field(default=None, description="Function signature")
@@ -287,32 +373,33 @@ class CustomFunction(BaseModel):
class ExtractorOutput(BaseModel):
"""Output definition for an extractor."""
name: str = Field(..., description="Output name (used by objectives/constraints)")
metric: Optional[str] = Field(default=None, description="Specific metric (max, total, rms, etc.)")
metric: Optional[str] = Field(
default=None, description="Specific metric (max, total, rms, etc.)"
)
subcase: Optional[int] = Field(default=None, description="Subcase ID for this output")
units: Optional[str] = None
class Extractor(BaseModel):
"""Physics extractor that computes outputs from FEA."""
id: str = Field(
...,
pattern=r"^ext_\d{3}$",
description="Unique identifier (pattern: ext_XXX)"
)
id: str = Field(..., pattern=r"^ext_\d{3}$", description="Unique identifier (pattern: ext_XXX)")
name: str = Field(..., description="Human-readable name")
type: ExtractorType = Field(..., description="Extractor type")
builtin: bool = Field(default=True, description="Whether this is a built-in extractor")
config: Optional[ExtractorConfig] = Field(default=None, description="Type-specific configuration")
config: Optional[ExtractorConfig] = Field(
default=None, description="Type-specific configuration"
)
function: Optional[CustomFunction] = Field(
default=None,
description="Custom function definition (for custom_function type)"
default=None, description="Custom function definition (for custom_function type)"
)
outputs: List[ExtractorOutput] = Field(..., min_length=1, description="Output values")
canvas_position: Optional[CanvasPosition] = None
@model_validator(mode='after')
def validate_custom_function(self) -> 'Extractor':
@model_validator(mode="after")
def validate_custom_function(self) -> "Extractor":
if self.type == ExtractorType.CUSTOM_FUNCTION and self.function is None:
raise ValueError("custom_function extractor requires function definition")
return self
@@ -322,19 +409,18 @@ class Extractor(BaseModel):
# Objective Models
# ============================================================================
class ObjectiveSource(BaseModel):
"""Source reference for objective value."""
extractor_id: str = Field(..., description="Reference to extractor")
output_name: str = Field(..., description="Which output from the extractor")
class Objective(BaseModel):
"""Optimization objective."""
id: str = Field(
...,
pattern=r"^obj_\d{3}$",
description="Unique identifier (pattern: obj_XXX)"
)
id: str = Field(..., pattern=r"^obj_\d{3}$", description="Unique identifier (pattern: obj_XXX)")
name: str = Field(..., description="Human-readable name")
direction: OptimizationDirection = Field(..., description="Optimization direction")
weight: float = Field(default=1.0, ge=0, description="Weight for weighted sum")
@@ -349,14 +435,17 @@ class Objective(BaseModel):
# Constraint Models
# ============================================================================
class ConstraintSource(BaseModel):
"""Source reference for constraint value."""
extractor_id: str
output_name: str
class PenaltyConfig(BaseModel):
"""Penalty method configuration for constraints."""
method: Optional[PenaltyMethod] = None
weight: Optional[float] = None
margin: Optional[float] = Field(default=None, description="Soft margin before penalty kicks in")
@@ -364,11 +453,8 @@ class PenaltyConfig(BaseModel):
class Constraint(BaseModel):
"""Hard or soft constraint."""
id: str = Field(
...,
pattern=r"^con_\d{3}$",
description="Unique identifier (pattern: con_XXX)"
)
id: str = Field(..., pattern=r"^con_\d{3}$", description="Unique identifier (pattern: con_XXX)")
name: str
type: ConstraintType = Field(..., description="Constraint type")
operator: ConstraintOperator = Field(..., description="Comparison operator")
@@ -383,8 +469,10 @@ class Constraint(BaseModel):
# Optimization Models
# ============================================================================
class AlgorithmConfig(BaseModel):
"""Algorithm-specific settings."""
population_size: Optional[int] = None
n_generations: Optional[int] = None
mutation_prob: Optional[float] = None
@@ -399,22 +487,24 @@ class AlgorithmConfig(BaseModel):
class Algorithm(BaseModel):
"""Optimization algorithm configuration."""
type: AlgorithmType
config: Optional[AlgorithmConfig] = None
class OptimizationBudget(BaseModel):
"""Computational budget for optimization."""
max_trials: Optional[int] = Field(default=None, ge=1, le=10000)
max_time_hours: Optional[float] = None
convergence_patience: Optional[int] = Field(
default=None,
description="Stop if no improvement for N trials"
default=None, description="Stop if no improvement for N trials"
)
class SurrogateConfig(BaseModel):
"""Neural surrogate model configuration."""
n_models: Optional[int] = None
architecture: Optional[List[int]] = None
train_every_n_trials: Optional[int] = None
@@ -425,6 +515,7 @@ class SurrogateConfig(BaseModel):
class Surrogate(BaseModel):
"""Surrogate model settings."""
enabled: Optional[bool] = None
type: Optional[SurrogateType] = None
config: Optional[SurrogateConfig] = None
@@ -432,6 +523,7 @@ class Surrogate(BaseModel):
class OptimizationConfig(BaseModel):
"""Optimization algorithm configuration."""
algorithm: Algorithm
budget: OptimizationBudget
surrogate: Optional[Surrogate] = None
@@ -442,8 +534,10 @@ class OptimizationConfig(BaseModel):
# Workflow Models
# ============================================================================
class WorkflowStage(BaseModel):
"""A stage in a multi-stage optimization workflow."""
id: str
name: str
algorithm: Optional[str] = None
@@ -453,6 +547,7 @@ class WorkflowStage(BaseModel):
class WorkflowTransition(BaseModel):
"""Transition between workflow stages."""
from_: str = Field(..., alias="from")
to: str
condition: Optional[str] = None
@@ -463,6 +558,7 @@ class WorkflowTransition(BaseModel):
class Workflow(BaseModel):
"""Multi-stage optimization workflow."""
stages: Optional[List[WorkflowStage]] = None
transitions: Optional[List[WorkflowTransition]] = None
@@ -471,8 +567,10 @@ class Workflow(BaseModel):
# Reporting Models
# ============================================================================
class InsightConfig(BaseModel):
"""Insight-specific configuration."""
include_html: Optional[bool] = None
show_pareto_evolution: Optional[bool] = None
@@ -482,6 +580,7 @@ class InsightConfig(BaseModel):
class Insight(BaseModel):
"""Reporting insight definition."""
type: Optional[str] = None
for_trials: Optional[str] = None
config: Optional[InsightConfig] = None
@@ -489,6 +588,7 @@ class Insight(BaseModel):
class ReportingConfig(BaseModel):
"""Reporting configuration."""
auto_report: Optional[bool] = None
report_triggers: Optional[List[str]] = None
insights: Optional[List[Insight]] = None
@@ -498,8 +598,10 @@ class ReportingConfig(BaseModel):
# Canvas Models
# ============================================================================
class CanvasViewport(BaseModel):
"""Canvas viewport settings."""
x: float = 0
y: float = 0
zoom: float = 1.0
@@ -507,6 +609,7 @@ class CanvasViewport(BaseModel):
class CanvasEdge(BaseModel):
"""Connection between canvas nodes."""
source: str
target: str
sourceHandle: Optional[str] = None
@@ -515,6 +618,7 @@ class CanvasEdge(BaseModel):
class CanvasGroup(BaseModel):
"""Grouping of canvas nodes."""
id: str
name: str
node_ids: List[str]
@@ -522,6 +626,7 @@ class CanvasGroup(BaseModel):
class CanvasConfig(BaseModel):
"""Canvas UI state (persisted for reconstruction)."""
layout_version: Optional[str] = None
viewport: Optional[CanvasViewport] = None
edges: Optional[List[CanvasEdge]] = None
@@ -532,6 +637,7 @@ class CanvasConfig(BaseModel):
# Main AtomizerSpec Model
# ============================================================================
class AtomizerSpec(BaseModel):
"""
AtomizerSpec v2.0 - The unified configuration schema for Atomizer optimization studies.
@@ -542,36 +648,32 @@ class AtomizerSpec(BaseModel):
- Claude Assistant (reading and modifying)
- Optimization Engine (execution)
"""
meta: SpecMeta = Field(..., description="Metadata about the spec")
model: ModelConfig = Field(..., description="NX model files and configuration")
design_variables: List[DesignVariable] = Field(
...,
min_length=1,
default_factory=list,
max_length=50,
description="Design variables to optimize"
description="Design variables to optimize (required for running)",
)
extractors: List[Extractor] = Field(
...,
min_length=1,
description="Physics extractors"
default_factory=list, description="Physics extractors (required for running)"
)
objectives: List[Objective] = Field(
...,
min_length=1,
default_factory=list,
max_length=5,
description="Optimization objectives"
description="Optimization objectives (required for running)",
)
constraints: Optional[List[Constraint]] = Field(
default=None,
description="Hard and soft constraints"
default=None, description="Hard and soft constraints"
)
optimization: OptimizationConfig = Field(..., description="Algorithm configuration")
workflow: Optional[Workflow] = Field(default=None, description="Multi-stage workflow")
reporting: Optional[ReportingConfig] = Field(default=None, description="Reporting config")
canvas: Optional[CanvasConfig] = Field(default=None, description="Canvas UI state")
@model_validator(mode='after')
def validate_references(self) -> 'AtomizerSpec':
@model_validator(mode="after")
def validate_references(self) -> "AtomizerSpec":
"""Validate that all references are valid."""
# Collect valid extractor IDs and their outputs
extractor_outputs: Dict[str, set] = {}
@@ -638,13 +740,44 @@ class AtomizerSpec(BaseModel):
"""Check if this is a multi-objective optimization."""
return len(self.objectives) > 1
def is_ready_for_optimization(self) -> Tuple[bool, List[str]]:
"""
Check if spec is complete enough to run optimization.
Returns:
Tuple of (is_ready, list of missing requirements)
"""
missing = []
# Check required fields for optimization
if not self.model.sim:
missing.append("No simulation file (.sim) configured")
if not self.design_variables:
missing.append("No design variables defined")
if not self.extractors:
missing.append("No extractors defined")
if not self.objectives:
missing.append("No objectives defined")
# Check that enabled DVs have valid bounds
for dv in self.get_enabled_design_variables():
if dv.bounds.min >= dv.bounds.max:
missing.append(f"Design variable '{dv.name}' has invalid bounds")
return len(missing) == 0, missing
# ============================================================================
# Validation Response Models
# ============================================================================
class ValidationError(BaseModel):
"""A validation error."""
type: str # 'schema', 'semantic', 'reference'
path: List[str]
message: str
@@ -652,6 +785,7 @@ class ValidationError(BaseModel):
class ValidationWarning(BaseModel):
"""A validation warning."""
type: str
path: List[str]
message: str
@@ -659,6 +793,7 @@ class ValidationWarning(BaseModel):
class ValidationSummary(BaseModel):
"""Summary of spec contents."""
design_variables: int
extractors: int
objectives: int
@@ -668,6 +803,7 @@ class ValidationSummary(BaseModel):
class ValidationReport(BaseModel):
"""Full validation report."""
valid: bool
errors: List[ValidationError]
warnings: List[ValidationWarning]