diff --git a/.gitignore b/.gitignore index 22a01ef6..76d21f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ lib64/ parts/ sdist/ var/ + +# NOTE: This repo includes a React frontend that legitimately uses src/lib/. +# The broad Python ignore `lib/` would ignore that. Re-include it: +!atomizer-dashboard/frontend/src/lib/ +!atomizer-dashboard/frontend/src/lib/** wheels/ *.egg-info/ .installed.cfg diff --git a/atomizer-dashboard/frontend/src/lib/spec.ts b/atomizer-dashboard/frontend/src/lib/spec.ts new file mode 100644 index 00000000..ecce7896 --- /dev/null +++ b/atomizer-dashboard/frontend/src/lib/spec.ts @@ -0,0 +1,324 @@ +/** + * Spec ↔ Canvas converters + * + * AtomizerSpec v2.0 is the single source of truth. + * This module converts AtomizerSpec → ReactFlow nodes/edges for visualization. + * + * NOTE: Canvas edges are primarily for visual validation. + * The computation truth lives in objective.source / constraint.source. + */ + +import type { Node, Edge } from 'reactflow'; + +import type { AtomizerSpec, CanvasPosition, DesignVariable, Extractor, Objective, Constraint } from '../types/atomizer-spec'; +import type { + CanvasNodeData, + ModelNodeData, + SolverNodeData, + AlgorithmNodeData, + SurrogateNodeData, + DesignVarNodeData, + ExtractorNodeData, + ObjectiveNodeData, + ConstraintNodeData, +} from './canvas/schema'; + +// --------------------------------------------------------------------------- +// Layout defaults (deterministic) +// --------------------------------------------------------------------------- + +const DEFAULT_LAYOUT = { + startX: 80, + startY: 80, + colWidth: 260, + rowHeight: 110, + cols: { + designVar: 0, + model: 1, + solver: 2, + extractor: 3, + objective: 4, + constraint: 4, + algorithm: 5, + surrogate: 6, + } as const, +}; + +function toCanvasPosition(pos: CanvasPosition | undefined | null, fallback: CanvasPosition): CanvasPosition { + if (!pos) return fallback; + if (typeof pos.x !== 'number' || typeof pos.y !== 'number') return fallback; + return { x: pos.x, y: pos.y }; +} + +function makeFallbackPosition(col: number, row: number): CanvasPosition { + return { + x: DEFAULT_LAYOUT.startX + col * DEFAULT_LAYOUT.colWidth, + y: DEFAULT_LAYOUT.startY + row * DEFAULT_LAYOUT.rowHeight, + }; +} + +// --------------------------------------------------------------------------- +// Synthetic nodes (always present) +// --------------------------------------------------------------------------- + +export function isSyntheticNodeId(id: string): boolean { + return id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate'; +} + +function makeModelNode(spec: AtomizerSpec): Node { + const pos = toCanvasPosition( + spec.model?.sim?.path ? (spec.model as any)?.canvas_position : undefined, + makeFallbackPosition(DEFAULT_LAYOUT.cols.model, 0) + ); + + return { + id: 'model', + type: 'model', + position: pos, + data: { + type: 'model', + label: spec.meta?.study_name || 'Model', + configured: Boolean(spec.model?.sim?.path), + filePath: spec.model?.sim?.path, + fileType: 'sim', + }, + }; +} + +function makeSolverNode(spec: AtomizerSpec): Node { + const sim = spec.model?.sim; + const pos = makeFallbackPosition(DEFAULT_LAYOUT.cols.solver, 0); + + return { + id: 'solver', + type: 'solver', + position: pos, + data: { + type: 'solver', + label: sim?.engine ? `Solver (${sim.engine})` : 'Solver', + configured: Boolean(sim?.engine || sim?.solution_type), + engine: sim?.engine as any, + solverType: sim?.solution_type as any, + scriptPath: sim?.script_path, + }, + }; +} + +function makeAlgorithmNode(spec: AtomizerSpec): Node { + const algo = spec.optimization?.algorithm; + const budget = spec.optimization?.budget; + const pos = toCanvasPosition( + spec.optimization?.canvas_position, + makeFallbackPosition(DEFAULT_LAYOUT.cols.algorithm, 0) + ); + + return { + id: 'algorithm', + type: 'algorithm', + position: pos, + data: { + type: 'algorithm', + label: algo?.type || 'Algorithm', + configured: Boolean(algo?.type), + method: (algo?.type as any) || 'TPE', + maxTrials: budget?.max_trials, + sigma0: (algo?.config as any)?.sigma0, + restartStrategy: (algo?.config as any)?.restart_strategy, + }, + }; +} + +function makeSurrogateNode(spec: AtomizerSpec): Node { + const surrogate = spec.optimization?.surrogate; + const pos = makeFallbackPosition(DEFAULT_LAYOUT.cols.surrogate, 0); + + const enabled = Boolean(surrogate?.enabled); + + return { + id: 'surrogate', + type: 'surrogate', + position: pos, + data: { + type: 'surrogate', + label: enabled ? 'Surrogate (enabled)' : 'Surrogate', + configured: true, + enabled, + modelType: (surrogate?.type as any) || 'MLP', + minTrials: surrogate?.config?.min_training_samples, + }, + }; +} + +// --------------------------------------------------------------------------- +// Array-backed nodes +// --------------------------------------------------------------------------- + +function makeDesignVarNode(dv: DesignVariable, index: number): Node { + const pos = toCanvasPosition( + dv.canvas_position, + makeFallbackPosition(DEFAULT_LAYOUT.cols.designVar, index) + ); + + return { + id: dv.id, + type: 'designVar', + position: pos, + data: { + type: 'designVar', + label: dv.name, + configured: Boolean(dv.expression_name), + expressionName: dv.expression_name, + minValue: dv.bounds?.min, + maxValue: dv.bounds?.max, + baseline: dv.baseline, + unit: dv.units, + enabled: dv.enabled, + }, + }; +} + +function makeExtractorNode(ext: Extractor, index: number): Node { + const pos = toCanvasPosition( + ext.canvas_position, + makeFallbackPosition(DEFAULT_LAYOUT.cols.extractor, index) + ); + + return { + id: ext.id, + type: 'extractor', + position: pos, + data: { + type: 'extractor', + label: ext.name, + configured: true, + extractorId: ext.id, + extractorName: ext.name, + extractorType: ext.type as any, + config: ext.config as any, + outputNames: (ext.outputs || []).map((o) => o.name), + // Convenience fields + innerRadius: (ext.config as any)?.inner_radius_mm, + nModes: (ext.config as any)?.n_modes, + subcases: (ext.config as any)?.subcases, + extractMethod: (ext.config as any)?.extract_method, + }, + }; +} + +function makeObjectiveNode(obj: Objective, index: number): Node { + const pos = toCanvasPosition( + obj.canvas_position, + makeFallbackPosition(DEFAULT_LAYOUT.cols.objective, index) + ); + + return { + id: obj.id, + type: 'objective', + position: pos, + data: { + type: 'objective', + label: obj.name, + configured: Boolean(obj.source?.extractor_id && obj.source?.output_name), + name: obj.name, + direction: obj.direction, + weight: obj.weight, + extractorRef: obj.source?.extractor_id, + outputName: obj.source?.output_name, + }, + }; +} + +function makeConstraintNode(con: Constraint, index: number): Node { + const pos = toCanvasPosition( + con.canvas_position, + makeFallbackPosition(DEFAULT_LAYOUT.cols.constraint, index) + ); + + return { + id: con.id, + type: 'constraint', + position: pos, + data: { + type: 'constraint', + label: con.name, + configured: Boolean(con.source?.extractor_id && con.source?.output_name), + name: con.name, + operator: con.operator, + value: con.threshold, + }, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function specToNodes(spec: AtomizerSpec | null | undefined): Node[] { + if (!spec) return []; + + const nodes: Node[] = []; + + // Structural nodes + nodes.push(makeModelNode(spec) as Node); + nodes.push(makeSolverNode(spec) as Node); + nodes.push(makeAlgorithmNode(spec) as Node); + nodes.push(makeSurrogateNode(spec) as Node); + + // Array nodes + spec.design_variables?.forEach((dv, i) => nodes.push(makeDesignVarNode(dv, i) as Node)); + spec.extractors?.forEach((ext, i) => nodes.push(makeExtractorNode(ext, i) as Node)); + spec.objectives?.forEach((obj, i) => nodes.push(makeObjectiveNode(obj, i) as Node)); + spec.constraints?.forEach((con, i) => nodes.push(makeConstraintNode(con, i) as Node)); + + return nodes; +} + +export function specToEdges(spec: AtomizerSpec | null | undefined): Edge[] { + if (!spec) return []; + + const edges: Edge[] = []; + const seen = new Set(); + + const add = (source: string, target: string, sourceHandle?: string, targetHandle?: string) => { + const id = `${source}__${target}${sourceHandle ? `__${sourceHandle}` : ''}${targetHandle ? `__${targetHandle}` : ''}`; + if (seen.has(id)) return; + seen.add(id); + edges.push({ + id, + source, + target, + sourceHandle, + targetHandle, + }); + }; + + // Prefer explicit canvas edges if present + if (spec.canvas?.edges && spec.canvas.edges.length > 0) { + for (const e of spec.canvas.edges) { + add(e.source, e.target, e.sourceHandle, e.targetHandle); + } + return edges; + } + + // Fallback: build a minimal visual graph from spec fields (deterministic) + // DV → model + for (const dv of spec.design_variables || []) add(dv.id, 'model'); + // model → solver + add('model', 'solver'); + // solver → each extractor + for (const ext of spec.extractors || []) add('solver', ext.id); + // extractor → objective/constraint based on source + for (const obj of spec.objectives || []) { + if (obj.source?.extractor_id) add(obj.source.extractor_id, obj.id); + } + for (const con of spec.constraints || []) { + if (con.source?.extractor_id) add(con.source.extractor_id, con.id); + } + // objective/constraint → algorithm + for (const obj of spec.objectives || []) add(obj.id, 'algorithm'); + for (const con of spec.constraints || []) add(con.id, 'algorithm'); + // algorithm → surrogate + add('algorithm', 'surrogate'); + + return edges; +}