feat(canvas): add AtomizerSpec→ReactFlow converters
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
324
atomizer-dashboard/frontend/src/lib/spec.ts
Normal file
324
atomizer-dashboard/frontend/src/lib/spec.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user