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>
372 lines
11 KiB
Python
372 lines
11 KiB
Python
"""
|
|
Intake Configuration Schema
|
|
===========================
|
|
|
|
Pydantic models for intake.yaml configuration files.
|
|
|
|
These models define the structure of pre-configuration that users can
|
|
provide to skip interview questions and speed up study setup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Optional, List, Literal, Union, Any, Dict
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
import yaml
|
|
|
|
|
|
class ObjectiveConfig(BaseModel):
|
|
"""Configuration for an optimization objective."""
|
|
|
|
goal: Literal["minimize", "maximize"]
|
|
target: str = Field(
|
|
description="What to optimize: mass, displacement, stress, frequency, stiffness, or custom name"
|
|
)
|
|
weight: float = Field(default=1.0, ge=0.0, le=10.0)
|
|
extractor: Optional[str] = Field(
|
|
default=None, description="Custom extractor function name (auto-detected if not specified)"
|
|
)
|
|
|
|
@field_validator("target")
|
|
@classmethod
|
|
def validate_target(cls, v: str) -> str:
|
|
"""Normalize target names."""
|
|
known_targets = {
|
|
"mass",
|
|
"weight",
|
|
"displacement",
|
|
"deflection",
|
|
"stress",
|
|
"frequency",
|
|
"stiffness",
|
|
"strain_energy",
|
|
"volume",
|
|
}
|
|
normalized = v.lower().strip()
|
|
# Map common aliases
|
|
aliases = {
|
|
"weight": "mass",
|
|
"deflection": "displacement",
|
|
}
|
|
return aliases.get(normalized, normalized)
|
|
|
|
|
|
class ConstraintConfig(BaseModel):
|
|
"""Configuration for an optimization constraint."""
|
|
|
|
type: str = Field(
|
|
description="Constraint type: max_stress, max_displacement, min_frequency, etc."
|
|
)
|
|
threshold: float
|
|
units: str = ""
|
|
description: Optional[str] = None
|
|
|
|
@field_validator("type")
|
|
@classmethod
|
|
def normalize_type(cls, v: str) -> str:
|
|
"""Normalize constraint type names."""
|
|
return v.lower().strip().replace(" ", "_")
|
|
|
|
|
|
class DesignVariableConfig(BaseModel):
|
|
"""Configuration for a design variable."""
|
|
|
|
name: str = Field(description="NX expression name")
|
|
bounds: tuple[float, float] = Field(description="(min, max) bounds")
|
|
units: Optional[str] = None
|
|
description: Optional[str] = None
|
|
step: Optional[float] = Field(default=None, description="Step size for discrete variables")
|
|
|
|
@field_validator("bounds")
|
|
@classmethod
|
|
def validate_bounds(cls, v: tuple[float, float]) -> tuple[float, float]:
|
|
"""Ensure bounds are valid."""
|
|
if len(v) != 2:
|
|
raise ValueError("Bounds must be a tuple of (min, max)")
|
|
if v[0] >= v[1]:
|
|
raise ValueError(f"Lower bound ({v[0]}) must be less than upper bound ({v[1]})")
|
|
return v
|
|
|
|
@property
|
|
def range(self) -> float:
|
|
"""Get the range of the design variable."""
|
|
return self.bounds[1] - self.bounds[0]
|
|
|
|
@property
|
|
def range_ratio(self) -> float:
|
|
"""Get the ratio of upper to lower bound."""
|
|
if self.bounds[0] == 0:
|
|
return float("inf")
|
|
return self.bounds[1] / self.bounds[0]
|
|
|
|
|
|
class BudgetConfig(BaseModel):
|
|
"""Configuration for optimization budget."""
|
|
|
|
max_trials: int = Field(default=100, ge=1, le=10000)
|
|
timeout_per_trial: int = Field(default=300, ge=10, le=7200, description="Seconds per FEA solve")
|
|
target_runtime: Optional[str] = Field(
|
|
default=None, description="Target total runtime (e.g., '2h', '30m')"
|
|
)
|
|
|
|
def get_target_runtime_seconds(self) -> Optional[int]:
|
|
"""Parse target_runtime string to seconds."""
|
|
if not self.target_runtime:
|
|
return None
|
|
|
|
runtime = self.target_runtime.lower().strip()
|
|
|
|
if runtime.endswith("h"):
|
|
return int(float(runtime[:-1]) * 3600)
|
|
elif runtime.endswith("m"):
|
|
return int(float(runtime[:-1]) * 60)
|
|
elif runtime.endswith("s"):
|
|
return int(float(runtime[:-1]))
|
|
else:
|
|
# Assume seconds
|
|
return int(float(runtime))
|
|
|
|
|
|
class AlgorithmConfig(BaseModel):
|
|
"""Configuration for optimization algorithm."""
|
|
|
|
method: Literal["auto", "TPE", "CMA-ES", "NSGA-II", "random"] = "auto"
|
|
neural_acceleration: bool = Field(
|
|
default=False, description="Enable surrogate model for speedup"
|
|
)
|
|
priority: Literal["speed", "accuracy", "balanced"] = "balanced"
|
|
seed: Optional[int] = Field(default=None, description="Random seed for reproducibility")
|
|
|
|
|
|
class MaterialConfig(BaseModel):
|
|
"""Configuration for material properties."""
|
|
|
|
name: str
|
|
yield_stress: Optional[float] = Field(default=None, ge=0, description="Yield stress in MPa")
|
|
ultimate_stress: Optional[float] = Field(
|
|
default=None, ge=0, description="Ultimate stress in MPa"
|
|
)
|
|
density: Optional[float] = Field(default=None, ge=0, description="Density in kg/m3")
|
|
youngs_modulus: Optional[float] = Field(
|
|
default=None, ge=0, description="Young's modulus in GPa"
|
|
)
|
|
poissons_ratio: Optional[float] = Field(
|
|
default=None, ge=0, le=0.5, description="Poisson's ratio"
|
|
)
|
|
|
|
|
|
class ObjectivesConfig(BaseModel):
|
|
"""Configuration for all objectives."""
|
|
|
|
primary: ObjectiveConfig
|
|
secondary: Optional[List[ObjectiveConfig]] = None
|
|
|
|
@property
|
|
def is_multi_objective(self) -> bool:
|
|
"""Check if this is a multi-objective problem."""
|
|
return self.secondary is not None and len(self.secondary) > 0
|
|
|
|
@property
|
|
def all_objectives(self) -> List[ObjectiveConfig]:
|
|
"""Get all objectives as a flat list."""
|
|
objectives = [self.primary]
|
|
if self.secondary:
|
|
objectives.extend(self.secondary)
|
|
return objectives
|
|
|
|
|
|
class StudyConfig(BaseModel):
|
|
"""Configuration for study metadata."""
|
|
|
|
name: Optional[str] = Field(
|
|
default=None, description="Study name (auto-generated from folder if omitted)"
|
|
)
|
|
type: Literal["single_objective", "multi_objective"] = "single_objective"
|
|
description: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
|
|
|
|
class IntakeConfig(BaseModel):
|
|
"""
|
|
Complete intake.yaml configuration schema.
|
|
|
|
All fields are optional - anything not specified will be asked
|
|
in the interview or auto-detected from introspection.
|
|
"""
|
|
|
|
study: Optional[StudyConfig] = None
|
|
objectives: Optional[ObjectivesConfig] = None
|
|
constraints: Optional[List[ConstraintConfig]] = None
|
|
design_variables: Optional[List[DesignVariableConfig]] = None
|
|
budget: Optional[BudgetConfig] = None
|
|
algorithm: Optional[AlgorithmConfig] = None
|
|
material: Optional[MaterialConfig] = None
|
|
notes: Optional[str] = None
|
|
|
|
@classmethod
|
|
def from_yaml(cls, yaml_path: Union[str, Path]) -> "IntakeConfig":
|
|
"""Load configuration from a YAML file."""
|
|
yaml_path = Path(yaml_path)
|
|
|
|
if not yaml_path.exists():
|
|
raise FileNotFoundError(f"Intake config not found: {yaml_path}")
|
|
|
|
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if data is None:
|
|
return cls()
|
|
|
|
return cls.model_validate(data)
|
|
|
|
@classmethod
|
|
def from_yaml_safe(cls, yaml_path: Union[str, Path]) -> Optional["IntakeConfig"]:
|
|
"""Load configuration from YAML, returning None if file doesn't exist."""
|
|
yaml_path = Path(yaml_path)
|
|
|
|
if not yaml_path.exists():
|
|
return None
|
|
|
|
try:
|
|
return cls.from_yaml(yaml_path)
|
|
except Exception:
|
|
return None
|
|
|
|
def to_yaml(self, yaml_path: Union[str, Path]) -> None:
|
|
"""Save configuration to a YAML file."""
|
|
yaml_path = Path(yaml_path)
|
|
|
|
data = self.model_dump(exclude_none=True)
|
|
|
|
with open(yaml_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
def get_value(self, key: str) -> Optional[Any]:
|
|
"""
|
|
Get a configuration value by dot-notation key.
|
|
|
|
Examples:
|
|
config.get_value("study.name")
|
|
config.get_value("budget.max_trials")
|
|
config.get_value("objectives.primary.goal")
|
|
"""
|
|
parts = key.split(".")
|
|
value: Any = self
|
|
|
|
for part in parts:
|
|
if value is None:
|
|
return None
|
|
if hasattr(value, part):
|
|
value = getattr(value, part)
|
|
elif isinstance(value, dict):
|
|
value = value.get(part)
|
|
else:
|
|
return None
|
|
|
|
return value
|
|
|
|
def is_complete(self) -> bool:
|
|
"""Check if all required configuration is provided."""
|
|
return (
|
|
self.objectives is not None
|
|
and self.design_variables is not None
|
|
and len(self.design_variables) > 0
|
|
)
|
|
|
|
def get_missing_fields(self) -> List[str]:
|
|
"""Get list of fields that still need to be configured."""
|
|
missing = []
|
|
|
|
if self.objectives is None:
|
|
missing.append("objectives")
|
|
if self.design_variables is None or len(self.design_variables) == 0:
|
|
missing.append("design_variables")
|
|
if self.constraints is None:
|
|
missing.append("constraints (recommended)")
|
|
if self.budget is None:
|
|
missing.append("budget")
|
|
|
|
return missing
|
|
|
|
@model_validator(mode="after")
|
|
def validate_consistency(self) -> "IntakeConfig":
|
|
"""Validate consistency between configuration sections."""
|
|
# Check study type matches objectives
|
|
if self.study and self.objectives:
|
|
is_multi = self.objectives.is_multi_objective
|
|
declared_multi = self.study.type == "multi_objective"
|
|
|
|
if is_multi and not declared_multi:
|
|
# Auto-correct study type
|
|
self.study.type = "multi_objective"
|
|
|
|
return self
|
|
|
|
|
|
# Common material presets
|
|
MATERIAL_PRESETS: Dict[str, MaterialConfig] = {
|
|
"aluminum_6061_t6": MaterialConfig(
|
|
name="Aluminum 6061-T6",
|
|
yield_stress=276,
|
|
ultimate_stress=310,
|
|
density=2700,
|
|
youngs_modulus=68.9,
|
|
poissons_ratio=0.33,
|
|
),
|
|
"aluminum_7075_t6": MaterialConfig(
|
|
name="Aluminum 7075-T6",
|
|
yield_stress=503,
|
|
ultimate_stress=572,
|
|
density=2810,
|
|
youngs_modulus=71.7,
|
|
poissons_ratio=0.33,
|
|
),
|
|
"steel_a36": MaterialConfig(
|
|
name="Steel A36",
|
|
yield_stress=250,
|
|
ultimate_stress=400,
|
|
density=7850,
|
|
youngs_modulus=200,
|
|
poissons_ratio=0.26,
|
|
),
|
|
"stainless_304": MaterialConfig(
|
|
name="Stainless Steel 304",
|
|
yield_stress=215,
|
|
ultimate_stress=505,
|
|
density=8000,
|
|
youngs_modulus=193,
|
|
poissons_ratio=0.29,
|
|
),
|
|
"titanium_6al4v": MaterialConfig(
|
|
name="Titanium Ti-6Al-4V",
|
|
yield_stress=880,
|
|
ultimate_stress=950,
|
|
density=4430,
|
|
youngs_modulus=113.8,
|
|
poissons_ratio=0.342,
|
|
),
|
|
}
|
|
|
|
|
|
def get_material_preset(name: str) -> Optional[MaterialConfig]:
|
|
"""
|
|
Get a material preset by name (fuzzy matching).
|
|
|
|
Examples:
|
|
get_material_preset("6061") # Returns aluminum_6061_t6
|
|
get_material_preset("steel") # Returns steel_a36
|
|
"""
|
|
name_lower = name.lower().replace("-", "_").replace(" ", "_")
|
|
|
|
# Direct match
|
|
if name_lower in MATERIAL_PRESETS:
|
|
return MATERIAL_PRESETS[name_lower]
|
|
|
|
# Partial match
|
|
for key, material in MATERIAL_PRESETS.items():
|
|
if name_lower in key or name_lower in material.name.lower():
|
|
return material
|
|
|
|
return None
|