Files
Atomizer/optimization_engine/intake/config.py
Anto01 a26914bbe8 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>
2026-01-27 12:02:30 -05:00

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