feat: Add panel management, validation, and error handling to canvas
Phase 1 - Panel Management System: - Create usePanelStore.ts for centralized panel state management - Add PanelContainer.tsx for draggable floating panels - Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click) - Create ResultsPanel.tsx for trial result details - Refactor NodeConfigPanelV2 to use panel store for introspection - Integrate PanelContainer into CanvasView Phase 2 - Pre-run Validation: - Create specValidator.ts with comprehensive validation rules - Add ValidationPanel (enhanced version with error navigation) - Add Validate button to SpecRenderer with status indicator - Block run if validation fails - Check for: design vars, objectives, extractors, bounds, connections Phase 3 - Error Handling & Recovery: - Create ErrorPanel.tsx for displaying optimization errors - Add error classification (nx_crash, solver_fail, extractor_error, etc.) - Add recovery suggestions based on error type - Update status endpoint to return error info - Add _get_study_error_info helper to check error_status.json and DB - Integrate error detection into status polling Documentation: - Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
This commit is contained in:
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Spec Validator - Validate AtomizerSpec v2.0 before running optimization
|
||||
*
|
||||
* This validator checks the spec for completeness and correctness,
|
||||
* returning structured errors that can be displayed in the ValidationPanel.
|
||||
*/
|
||||
|
||||
import { AtomizerSpec, DesignVariable, Extractor, Objective, Constraint } from '../../types/atomizer-spec';
|
||||
import { ValidationError, ValidationData } from '../../hooks/usePanelStore';
|
||||
|
||||
// ============================================================================
|
||||
// Validation Rules
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationRule {
|
||||
code: string;
|
||||
check: (spec: AtomizerSpec) => ValidationError | null;
|
||||
}
|
||||
|
||||
const validationRules: ValidationRule[] = [
|
||||
// ---- Critical Errors (must fix) ----
|
||||
|
||||
{
|
||||
code: 'NO_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return {
|
||||
code: 'NO_DESIGN_VARS',
|
||||
severity: 'error',
|
||||
path: 'design_variables',
|
||||
message: 'No design variables defined',
|
||||
suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_OBJECTIVES',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length === 0) {
|
||||
return {
|
||||
code: 'NO_OBJECTIVES',
|
||||
severity: 'error',
|
||||
path: 'objectives',
|
||||
message: 'No objectives defined',
|
||||
suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_EXTRACTORS',
|
||||
check: (spec) => {
|
||||
if (spec.extractors.length === 0) {
|
||||
return {
|
||||
code: 'NO_EXTRACTORS',
|
||||
severity: 'error',
|
||||
path: 'extractors',
|
||||
message: 'No extractors defined',
|
||||
suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_MODEL',
|
||||
check: (spec) => {
|
||||
if (!spec.model.sim?.path) {
|
||||
return {
|
||||
code: 'NO_MODEL',
|
||||
severity: 'error',
|
||||
path: 'model.sim.path',
|
||||
message: 'No simulation file configured',
|
||||
suggestion: 'Select a .sim file in the study\'s model directory.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Design Variable Validation ----
|
||||
|
||||
{
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return {
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has invalid bounds (min >= max)`,
|
||||
suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`,
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (!dv.expression_name || dv.expression_name.trim() === '') {
|
||||
return {
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has no NX expression name`,
|
||||
suggestion: 'Set the expression_name to match an NX expression in the model.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Extractor Validation ----
|
||||
|
||||
{
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (!ext.type || ext.type.trim() === '') {
|
||||
return {
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Extractor "${ext.name}" has no type selected`,
|
||||
suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) {
|
||||
return {
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Custom extractor "${ext.name}" has no code defined`,
|
||||
suggestion: 'Open the code editor and write the extraction function.',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Objective Validation ----
|
||||
|
||||
{
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
check: (spec) => {
|
||||
for (const obj of spec.objectives) {
|
||||
// Check if objective is connected to an extractor via canvas edges
|
||||
const hasSource = spec.canvas?.edges?.some(
|
||||
edge => edge.target === obj.id && edge.source.startsWith('ext_')
|
||||
);
|
||||
|
||||
// Also check if source_extractor_id is set
|
||||
const hasDirectSource = obj.source_extractor_id &&
|
||||
spec.extractors.some(e => e.id === obj.source_extractor_id);
|
||||
|
||||
if (!hasSource && !hasDirectSource) {
|
||||
return {
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
severity: 'error',
|
||||
path: `objectives.${obj.id}`,
|
||||
message: `Objective "${obj.name}" has no connected extractor`,
|
||||
suggestion: 'Connect an extractor to this objective or set source_extractor_id.',
|
||||
nodeId: obj.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Constraint Validation ----
|
||||
|
||||
{
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
check: (spec) => {
|
||||
for (const con of spec.constraints || []) {
|
||||
if (con.threshold === undefined || con.threshold === null) {
|
||||
return {
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
severity: 'error',
|
||||
path: `constraints.${con.id}`,
|
||||
message: `Constraint "${con.name}" has no threshold value`,
|
||||
suggestion: 'Set a threshold value for the constraint.',
|
||||
nodeId: con.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Warnings (can proceed but risky) ----
|
||||
|
||||
{
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials > 500) {
|
||||
return {
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: `High trial count (${maxTrials}) may take several hours to complete`,
|
||||
suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'SINGLE_TRIAL',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials === 1) {
|
||||
return {
|
||||
code: 'SINGLE_TRIAL',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: 'Only 1 trial configured - this will just run a single evaluation',
|
||||
suggestion: 'Increase max_trials to explore the design space.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
const range = dv.bounds.max - dv.bounds.min;
|
||||
const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2;
|
||||
const relativeRange = range / Math.abs(baseline || 1);
|
||||
|
||||
if (relativeRange < 0.01) { // Less than 1% variation
|
||||
return {
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
severity: 'warning',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`,
|
||||
suggestion: 'Consider widening the bounds for more meaningful exploration.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length > 10) {
|
||||
return {
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
severity: 'warning',
|
||||
path: 'design_variables',
|
||||
message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`,
|
||||
suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length > 1) {
|
||||
const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null);
|
||||
if (!hasWeights) {
|
||||
return {
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
severity: 'warning',
|
||||
path: 'objectives',
|
||||
message: 'Multi-objective optimization without explicit weights',
|
||||
suggestion: 'Consider setting weights to control the trade-off between objectives.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Main Validation Function
|
||||
// ============================================================================
|
||||
|
||||
export function validateSpec(spec: AtomizerSpec): ValidationData {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationError[] = [];
|
||||
|
||||
for (const rule of validationRules) {
|
||||
const result = rule.check(spec);
|
||||
if (result) {
|
||||
if (result.severity === 'error') {
|
||||
errors.push(result);
|
||||
} else {
|
||||
warnings.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Validation (just checks if can run)
|
||||
// ============================================================================
|
||||
|
||||
export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } {
|
||||
// Check critical requirements only
|
||||
if (!spec.model.sim?.path) {
|
||||
return { canRun: false, reason: 'No simulation file configured' };
|
||||
}
|
||||
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return { canRun: false, reason: 'No design variables defined' };
|
||||
}
|
||||
|
||||
if (spec.objectives.length === 0) {
|
||||
return { canRun: false, reason: 'No objectives defined' };
|
||||
}
|
||||
|
||||
if (spec.extractors.length === 0) {
|
||||
return { canRun: false, reason: 'No extractors defined' };
|
||||
}
|
||||
|
||||
// Check for invalid bounds
|
||||
for (const dv of enabledDVs) {
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return { canRun: false, reason: `Invalid bounds for "${dv.name}"` };
|
||||
}
|
||||
}
|
||||
|
||||
return { canRun: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export validation result type for backward compatibility
|
||||
// ============================================================================
|
||||
|
||||
export interface LegacyValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult {
|
||||
return {
|
||||
valid: data.valid,
|
||||
errors: data.errors.map(e => e.message),
|
||||
warnings: data.warnings.map(w => w.message),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user