feat(canvas): add AtomizerSpec→ReactFlow converters

This commit is contained in:
2026-01-29 02:37:32 +00:00
parent bb27f3fb00
commit 4a7422c620
2 changed files with 329 additions and 0 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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<ModelNodeData> {
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<SolverNodeData> {
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<AlgorithmNodeData> {
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<SurrogateNodeData> {
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<DesignVarNodeData> {
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<ExtractorNodeData> {
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<ObjectiveNodeData> {
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<ConstraintNodeData> {
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<CanvasNodeData>[] {
if (!spec) return [];
const nodes: Node<CanvasNodeData>[] = [];
// Structural nodes
nodes.push(makeModelNode(spec) as Node<CanvasNodeData>);
nodes.push(makeSolverNode(spec) as Node<CanvasNodeData>);
nodes.push(makeAlgorithmNode(spec) as Node<CanvasNodeData>);
nodes.push(makeSurrogateNode(spec) as Node<CanvasNodeData>);
// Array nodes
spec.design_variables?.forEach((dv, i) => nodes.push(makeDesignVarNode(dv, i) as Node<CanvasNodeData>));
spec.extractors?.forEach((ext, i) => nodes.push(makeExtractorNode(ext, i) as Node<CanvasNodeData>));
spec.objectives?.forEach((obj, i) => nodes.push(makeObjectiveNode(obj, i) as Node<CanvasNodeData>));
spec.constraints?.forEach((con, i) => nodes.push(makeConstraintNode(con, i) as Node<CanvasNodeData>));
return nodes;
}
export function specToEdges(spec: AtomizerSpec | null | undefined): Edge[] {
if (!spec) return [];
const edges: Edge[] = [];
const seen = new Set<string>();
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;
}