Tutorial 4: Multi-Domain Optimization¶
Learn how to optimize process parameters across multiple geometries simultaneously to find universal parameter sets.
Estimated time: 35-45 minutes
What You'll Learn¶
- Add multiple domains to a project
- Write multi-domain process sequences
- Run multi-domain optimization
- Interpret aggregated metrics
- Visualize per-domain results
- Find robust parameter sets
Prerequisites¶
- Completed Tutorial 1
- Understanding of Core Concepts - Multi-Domain
- Familiarity with ViennaPS domain creation
Why Multi-Domain Optimization?¶
Single-domain limitations: - Parameters optimized for one specific geometry - May not work well for different feature sizes - Overfitting to specific test structure
Multi-domain benefits: - Find universal parameters that work across geometries - More robust to geometry variations - Better process window - Closer to real manufacturing scenarios
Use cases: - Calibrate across different feature sizes (dense vs isolated) - Match multiple experimental samples - Find process parameters robust to pattern density - Optimize for both 2D and 3D structures
Scenario¶
You have etch profiles from two different structures: 1. Dense trenches (narrow spacing) 2. Isolated trenches (wide spacing)
Goal: Find etch parameters that match both profiles simultaneously.
Step 1: Project Setup¶
import viennafit as fit
import viennaps as vps
import viennals as vls
import json
import os
# Setup
vps.setDimension(2)
vls.setDimension(2)
# Create project for multi-domain optimization
project = fit.Project("multiDomainEtch", "./projects")
project.initialize()
print(f"Multi-domain project created at: {project.projectPath}")
Step 2: Create Multiple Initial Domains¶
Create two different initial geometries:
gridDelta = 5.0
# === Domain 1: Dense Trenches ===
print("\nCreating Domain 1: Dense trenches")
dense_domain = vps.Domain(
gridDelta=gridDelta,
xExtent=600.0,
yExtent=400.0,
boundary=vps.BoundaryType.REFLECTIVE_BOUNDARY
)
# Substrate
dense_substrate = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
vls.MakeGeometry(dense_substrate, vls.lsBox([0, -200], [600, 50])).apply()
# Mask with narrow openings (100nm openings, 50nm spacing)
dense_mask = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
vls.MakeGeometry(dense_mask, vls.lsBox([0, 50], [600, 100])).apply()
# Create openings
for x_pos in [75, 225, 375, 525]: # 4 narrow openings
vls.BooleanOperation(
dense_mask,
vls.lsBox([x_pos - 50, 50], [100, 50]),
vls.BooleanOperationEnum.RELATIVE_COMPLEMENT
).apply()
dense_domain.insertNextLevelSetAsMaterial(dense_substrate, vps.Material.Si)
dense_domain.insertNextLevelSetAsMaterial(dense_mask, vps.Material.SiO2)
# Add to project with name "dense"
project.addInitialDomain("dense", dense_domain)
dense_domain.saveSurfaceMesh(
os.path.join(project.projectPath, "domains/initialDomain/dense.vtp"),
True
)
# === Domain 2: Isolated Trench ===
print("Creating Domain 2: Isolated trench")
isolated_domain = vps.Domain(
gridDelta=gridDelta,
xExtent=600.0,
yExtent=400.0,
boundary=vps.BoundaryType.REFLECTIVE_BOUNDARY
)
# Substrate (same as dense)
isolated_substrate = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
vls.MakeGeometry(isolated_substrate, vls.lsBox([0, -200], [600, 50])).apply()
# Mask with single wide opening (300nm opening, isolated)
isolated_mask = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
vls.MakeGeometry(isolated_mask, vls.lsBox([0, 50], [600, 100])).apply()
# Single wide opening
vls.BooleanOperation(
isolated_mask,
vls.lsBox([150, 50], [300, 50]),
vls.BooleanOperationEnum.RELATIVE_COMPLEMENT
).apply()
isolated_domain.insertNextLevelSetAsMaterial(isolated_substrate, vps.Material.Si)
isolated_domain.insertNextLevelSetAsMaterial(isolated_mask, vps.Material.SiO2)
# Add to project with name "isolated"
project.addInitialDomain("isolated", isolated_domain)
isolated_domain.saveSurfaceMesh(
os.path.join(project.projectPath, "domains/initialDomain/isolated.vtp"),
True
)
print("\nBoth initial domains added to project")
print(f" - 'dense': {len(dense_domain.getLevelSets())} level sets")
print(f" - 'isolated': {len(isolated_domain.getLevelSets())} level sets")
Step 3: Create Multiple Target Domains¶
Create target profiles for each geometry:
# === Target 1: Dense Trenches Result ===
print("\nCreating Target 1: Dense trench profiles")
dense_target = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
# Etched trenches (simulating experimental result)
vls.MakeGeometry(dense_target, vls.lsBox([0, -200], [600, 50])).apply()
# Remove material for each trench
for x_pos in [75, 225, 375, 525]:
vls.BooleanOperation(
dense_target,
vls.lsBox([x_pos - 40, -200], [80, 180]), # Etched regions
vls.BooleanOperationEnum.RELATIVE_COMPLEMENT
).apply()
project.addTargetLevelSet("dense", dense_target)
dense_target_mesh = vls.Mesh()
vls.ToSurfaceMesh(dense_target, dense_target_mesh).apply()
writer = vls.VTKWriter(dense_target_mesh)
writer.setFileName(
os.path.join(project.projectPath, "domains/targetDomain/dense_target.vtp")
)
writer.apply()
# === Target 2: Isolated Trench Result ===
print("Creating Target 2: Isolated trench profile")
isolated_target = vls.Domain(
[-300, 300, -200, 200],
[vls.BoundaryConditionEnum.REFLECTIVE_BOUNDARY,
vls.BoundaryConditionEnum.INFINITE_BOUNDARY],
gridDelta
)
vls.MakeGeometry(isolated_target, vls.lsBox([0, -200], [600, 50])).apply()
# Single wide etched trench
vls.BooleanOperation(
isolated_target,
vls.lsBox([150, -200], [300, 200]), # Deeper/wider etch
vls.BooleanOperationEnum.RELATIVE_COMPLEMENT
).apply()
project.addTargetLevelSet("isolated", isolated_target)
isolated_target_mesh = vls.Mesh()
vls.ToSurfaceMesh(isolated_target, isolated_target_mesh).apply()
writer = vls.VTKWriter(isolated_target_mesh)
writer.setFileName(
os.path.join(project.projectPath, "domains/targetDomain/isolated_target.vtp")
)
writer.apply()
print("\nBoth target domains added to project")
print(f"Domain pairing:")
print(f" 'dense' initial ↔ 'dense' target")
print(f" 'isolated' initial ↔ 'isolated' target")
Domain Pairing
Domains are paired by name. The process will simulate both: - "dense" initial → compare to "dense" target - "isolated" initial → compare to "isolated" target
Distance metrics are summed across all domain pairs.
Step 4: Multi-Domain Process Sequence¶
Critical: Multi-domain process sequences have different signature!
def multiDomainEtchProcess(
domains: dict[str, vps.Domain], # Dictionary of domains
params: dict[str, float] # Parameters (same for all)
) -> dict[str, vls.Domain]: # Dictionary of results
"""
Multi-domain SF6/C4F8 etching process.
Applies same parameters to all domains (dense and isolated).
Demonstrates loading effect: isolated features etch differently than dense.
Parameters:
- ionFlux: Ion flux density
- etchantFlux: Neutral etchant flux
- ionEnergy: Ion mean energy
- neutralStickP: Neutral sticking probability
"""
results = {}
# Process each domain with same parameters
for domain_name, domain in domains.items():
print(f" Processing domain: {domain_name}")
# Create model
model = vps.MultiParticleProcess()
# Add particles
model.addIonParticle(
sourcePower=params["ionFlux"],
meanEnergy=params["ionEnergy"],
label="ion"
)
sticking = {
vps.Material.Si: params["neutralStickP"],
vps.Material.SiO2: 0.01
}
model.addNeutralParticle(sticking=sticking, label="etchant")
# Rate function (same for all domains)
def rateFunction(fluxes, material):
if material == vps.Material.Si:
return (fluxes["ion"] * 0.5 +
fluxes["etchant"] * params["etchantFlux"] * 0.01)
elif material == vps.Material.SiO2:
return fluxes["ion"] * 0.05
return 0.0
model.setRateFunction(rateFunction)
# Run process
process = vps.Process()
process.setDomain(domain)
process.setProcessModel(model)
process.setProcessDuration(1.0)
rayTracing = vps.RayTracingParameters()
rayTracing.raysPerPoint = 300
process.setParameters(rayTracing)
process.apply()
# Store result
results[domain_name] = domain.getLevelSets()[0]
return results
Process Sequence Signature
Multi-domain signature:
Single-domain signature:
ViennaFit auto-detects based on type annotations.
Step 5: Configure Multi-Domain Optimization¶
# Create optimization
opt = fit.Optimization(project)
opt.setProcessSequence(multiDomainEtchProcess)
# Multi-domain mode is auto-detected from:
# 1. Process sequence signature (dict[str, Domain])
# 2. Project having multiple domains
print(f"\nMulti-domain mode detected: {opt.isMultiDomainProcess}")
# Define parameters (same as single-domain)
opt.setParameterNames([
"ionFlux",
"etchantFlux",
"ionEnergy",
"neutralStickP"
])
opt.setVariableParameters({
"ionFlux": (10.0, 100.0),
"etchantFlux": (100.0, 1000.0),
"ionEnergy": (20.0, 200.0),
"neutralStickP": (0.01, 0.9)
})
# Distance metric is applied to each domain, then summed
opt.setDistanceMetrics(
primaryMetric="CCH",
additionalMetrics=["CA"]
)
opt.setName("run1_multiDomain")
opt.setNotes(
"Multi-domain optimization across dense and isolated trenches. "
"Finding universal parameters that work for both geometries."
)
print("\nMulti-domain optimization configured")
print(f"Parameters: {opt.getParameterNames()}")
print(f"Domains: {list(project.initialDomains.keys())}")
Step 6: Run Multi-Domain Optimization¶
print("\n" + "="*60)
print("STARTING MULTI-DOMAIN OPTIMIZATION")
print("="*60)
print("Note: Twice as many simulations per evaluation!")
opt.apply(
numEvaluations=100,
saveVisualization=True
)
print("\n" + "="*60)
print("MULTI-DOMAIN OPTIMIZATION COMPLETE")
print("="*60)
Runtime: 2× single-domain (processes both geometries per evaluation)
Objective function:
Step 7: Analyze Multi-Domain Results¶
# Load results
results_dir = os.path.join(
project.projectPath,
"optimizationRuns",
"run1_multiDomain"
)
with open(os.path.join(results_dir, "run1_multiDomain-final-results.json")) as f:
results = json.load(f)
print("\n" + "="*60)
print("MULTI-DOMAIN OPTIMIZATION RESULTS")
print("="*60)
print(f"\nTotal Objective Value: {results['bestScore']:.4f}")
print(" (Sum across all domains)")
print(f"\nBest Parameters:")
for param, value in results['bestParameters'].items():
print(f" {param:20s}: {value:.4f}")
# Check per-domain results if available
progress = pd.read_csv(os.path.join(results_dir, "progressBest.csv"))
# If multi-domain metrics tracked separately
if 'dense_CCH_value' in progress.columns and 'isolated_CCH_value' in progress.columns:
last_eval = progress.iloc[-1]
print(f"\nPer-Domain Performance:")
print(f" Dense trenches: {last_eval['dense_CCH_value']:.4f}")
print(f" Isolated trench: {last_eval['isolated_CCH_value']:.4f}")
Visualize Per-Domain Results¶
# Load progress data
progress = pd.read_csv(os.path.join(results_dir, "progressBest.csv"))
# Plot convergence
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(progress['evaluationNumber'], progress['objectiveValue'], 'b-', linewidth=2)
plt.xlabel('Evaluation Number')
plt.ylabel('Total Objective Value')
plt.title('Multi-Domain Convergence')
plt.grid(True, alpha=0.3)
plt.subplot(1, 2, 2)
if 'dense_CCH_value' in progress.columns:
plt.plot(progress['evaluationNumber'], progress['dense_CCH_value'],
'o-', label='Dense', alpha=0.7)
plt.plot(progress['evaluationNumber'], progress['isolated_CCH_value'],
's-', label='Isolated', alpha=0.7)
plt.xlabel('Evaluation Number')
plt.ylabel('Per-Domain Objective')
plt.title('Individual Domain Performance')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "multi_domain_convergence.png"), dpi=150)
print(f"\nConvergence plots saved")
Step 8: Validate on Each Domain¶
Test best parameters separately on each domain:
best_params = results['bestParameters']
print("\nValidating on each domain separately...")
# === Validate Dense ===
dense_val_domain = vps.Domain(project.initialDomains["dense"])
result_dense = multiDomainEtchProcess(
{"dense": dense_val_domain},
best_params
)
dense_val_mesh = vls.Mesh()
vls.ToSurfaceMesh(result_dense["dense"], dense_val_mesh).apply()
writer = vls.VTKWriter(dense_val_mesh)
writer.setFileName(os.path.join(results_dir, "validation_dense.vtp"))
writer.apply()
# === Validate Isolated ===
isolated_val_domain = vps.Domain(project.initialDomains["isolated"])
result_isolated = multiDomainEtchProcess(
{"isolated": isolated_val_domain},
best_params
)
isolated_val_mesh = vls.Mesh()
vls.ToSurfaceMesh(result_isolated["isolated"], isolated_val_mesh).apply()
writer = vls.VTKWriter(isolated_val_mesh)
writer.setFileName(os.path.join(results_dir, "validation_isolated.vtp"))
writer.apply()
print(f"\nValidation results saved:")
print(f" Dense: {results_dir}/validation_dense.vtp")
print(f" Isolated: {results_dir}/validation_isolated.vtp")
print("\nOpen in ParaView to compare with targets")
Step 9: Compare with Single-Domain Results¶
Compare universal parameters with domain-specific optimizations:
# If you have single-domain results for comparison
single_domain_results = {
"dense": {
"score": 25.3, # From single-domain optimization
"params": {"ionFlux": 45.2, "etchantFlux": 720.1}
},
"isolated": {
"score": 22.1,
"params": {"ionFlux": 41.8, "etchantFlux": 680.5}
}
}
multi_total = results['bestScore']
print("\nSingle-Domain vs Multi-Domain Comparison:")
print(f"\nSingle-domain (optimized separately):")
print(f" Dense score: {single_domain_results['dense']['score']:.2f}")
print(f" Isolated score: {single_domain_results['isolated']['score']:.2f}")
print(f" Total: {sum(r['score'] for r in single_domain_results.values()):.2f}")
print(f"\nMulti-domain (universal parameters):")
print(f" Total score: {multi_total:.2f}")
print(f"\nTrade-off:")
if multi_total < sum(r['score'] for r in single_domain_results.values()):
print(f" ✓ Multi-domain found BETTER universal parameters!")
else:
diff = multi_total - sum(r['score'] for r in single_domain_results.values())
print(f" Multi-domain score is {diff:.2f} higher (expected)")
print(f" But parameters work for BOTH geometries!")
Complete Example Script¶
import viennafit as fit
import viennaps as vps
import viennals as vls
# Setup
vps.setDimension(2)
vls.setDimension(2)
# Create project
project = fit.Project("multiDomainEtch", "./projects").initialize()
# Add multiple domains (code from steps 2-3)
# ... domain creation ...
# Multi-domain process sequence
def multiDomainEtchProcess(domains: dict[str, vps.Domain],
params: dict[str, float]) -> dict[str, vls.Domain]:
results = {}
for name, domain in domains.items():
# Process each domain
# ... (code from step 4) ...
results[name] = result_levelset
return results
# Configure and run optimization
opt = fit.Optimization(project)
opt.setProcessSequence(multiDomainEtchProcess)
opt.setParameterNames(["ionFlux", "etchantFlux", "ionEnergy", "neutralStickP"])
opt.setVariableParameters({
"ionFlux": (10.0, 100.0),
"etchantFlux": (100.0, 1000.0),
"ionEnergy": (20.0, 200.0),
"neutralStickP": (0.01, 0.9)
})
opt.setDistanceMetrics(primaryMetric="CCH")
opt.setName("run1_multiDomain")
# Run
opt.apply(numEvaluations=100, saveVisualization=True)
Key Takeaways¶
✅ Multi-domain finds universal parameters - Work across geometries
✅ Signature change required - dict[str, Domain] → dict[str, Domain]
✅ Metrics are summed - Total objective across all domains
✅ More robust - Better process window than single-domain
✅ Computational cost - N×domains simulations per evaluation
Common Questions¶
How many domains should I use?
Guidelines: - Start with 2-3 representative geometries - Too many → slow convergence, high cost - Too few → may miss important variations
Choose domains that represent: - Different feature sizes (dense vs isolated) - Different aspect ratios - Critical process corners
Can I weight domains differently?
Currently, all domains weighted equally (sum). For custom weighting:
What if parameters don't work for all domains?
Possible causes: - Physics prevents universal solution (e.g., loading effects) - Targets inconsistent (different etch depths) - Need process adjustments per geometry
Solutions: - Relax targets for some domains - Add geometry-specific parameters - Use process adaptation (e.g., time per geometry)
Next Steps¶
You've completed all tutorials! 🎉
What to do next: - Apply to your real process calibration problems - Combine techniques (sensitivity + multi-domain) - Explore advanced optimizers (Bayesian optimization) - Contribute examples to ViennaFit repository
Advanced Topics: - Time-dependent multi-domain (different durations per geometry) - Hierarchical optimization (coarse then fine) - Multi-objective optimization (multiple metrics) - Uncertainty quantification across domains
Get involved: - Share your results with the ViennaFit community - Report issues on GitHub - Contribute improvements via pull requests