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
2026-01-21 21:35:31 -05:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-21 21:48:35 -05:00
|
|
|
import { AtomizerSpec } from '../../types/atomizer-spec';
|
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
2026-01-21 21:35:31 -05:00
|
|
|
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_')
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-21 21:48:35 -05:00
|
|
|
// Also check if source.extractor_id is set
|
|
|
|
|
const hasDirectSource = obj.source?.extractor_id &&
|
|
|
|
|
spec.extractors.some(e => e.id === obj.source.extractor_id);
|
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
2026-01-21 21:35:31 -05:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|