Coatings

Needle Synthesis for Thin Film Design

Automatically discover optimal thin-film stack configurations using the needle synthesis algorithm.

AdvancedCoatings & PolarizationNumPy backend15 min read

Introduction

This tutorial demonstrates the needle synthesis algorithm for discovering optimal thin film stack configurations, using real dispersive materials from the optiland catalog on N-BK7 glass.

Two design examples of increasing complexity:

  • Part 1 — Broadband AR coating: R < 1% across 420–680 nm, starting from a single MgF2 layer
  • Part 2 — Dichroic beamsplitter: R > 95% for 420–540 nm, T > 95% for 560–680 nm, starting from a (HL)³ quarter-wave stack

Reference: Tikhonravov & Trubetskov, Development of the needle optimization technique and new features of OptiLayer design software, SPIE Vol. 2253, 1994.

Core concepts used

NeedleSynthesis(stack, candidate_materials)
The core class for running the needle synthesis algorithm. It takes a starting stack and a list of materials it can use for insertions.
needle_thickness_nm=1.0
The thickness of the "needle" layer inserted at each trial position.
ns.add_spectral_target('R', wavelengths, 'equal', 0.0)
Defines the performance targets for the synthesis process.
result.stack
The final, optimized ThinFilmStack found by the algorithm.

Step-by-step build

1

Import Required Modules

python
import numpy as np
import matplotlib.pyplot as plt
from optiland.materials import Material, IdealMaterial
from optiland.thin_film import ThinFilmStack, SpectralAnalyzer
from optiland.thin_film.optimization import NeedleSynthesis
from optiland.thin_film.optimization.operand.thin_film import ThinFilmOperand

Part 1: Broadband AR Coating (R < 1%)

1

Define materials and starting design

We use real dispersive materials from the optiland catalog:

MaterialRolen @ 550 nmReference
N-BK7Substrate1.519Schott
SiO2Low-index layer1.460Malitson
TiO2High-index layer2.648Devore
MgF2Very low-index layer1.379Dodge
Al2O3Mid-index layer1.770Malitson

We start from a single MgF2 quarter-wave layer — the simplest possible AR coating. A single low-index layer on N-BK7 gives roughly 1.4% reflectance, which already violates our < 1% spec at the band edges.

python
# Incident medium
air = IdealMaterial(n=1.0)

# Substrate
nbk7 = Material("N-BK7")

# Candidate coating materials (dispersive)
sio2 = Material("SiO2", reference="Malitson")
tio2 = Material("TiO2", reference="Devore-o")
mgf2 = Material("MgF2", reference="Dodge-o")
al2o3 = Material("Al2O3", reference="Malitson")

# Print refractive indices at 550 nm
for name, mat in [("N-BK7", nbk7), ("SiO2", sio2), ("TiO2", tio2),
                ("MgF2", mgf2), ("Al2O3", al2o3)]:
 print(f"{name:6s}  n(550nm) = {np.asarray(mat.n(0.55)).item():.4f}")

# Starting design: single MgF2 layer
stack = ThinFilmStack(incident_material=air, substrate_material=nbk7)
stack.add_layer_nm(mgf2, 100.0, name="MgF2")
print("\nStarting design:")
print(stack)
2

Starting performance

The single MgF2 layer gives ~1.4% average reflectance. The 1% spec line (dashed red) shows the design fails across most of the band.

python
wl_plot = np.linspace(400, 720, 300)
R_start = np.array([ThinFilmOperand.reflectance(stack, wl) for wl in wl_plot])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_plot, R_start * 100, "-", color="gray",
     linewidth=2, label="Single MgF2 layer")
ax.axhline(1.0, color="red", linestyle="--",
        linewidth=1, alpha=0.7, label="Spec: R < 1%")
ax.axvspan(420, 680, alpha=0.06, color="blue", label="Target band (420–680 nm)")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Starting Design — Single MgF2 Layer on N-BK7")
ax.set_ylim(0, 5)
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)
fig.tight_layout();
2. Starting performance
3

Configure needle synthesis

The algorithm will:

  1. Screen trial needle insertions at sampled positions within every layer
  2. Try all four candidate materials at each position
  3. Insert the needle that reduces the merit function the most
  4. Re-optimize all layer thicknesses after each insertion
  5. Repeat until no further improvement is found

We target R = 0 at 30 wavelengths across 420–680 nm, driving the optimizer to push reflectance as low as possible everywhere.

python
ns = NeedleSynthesis(
 stack=stack,
 candidate_materials=[sio2, tio2, mgf2, al2o3],
 needle_thickness_nm=1.0,
 min_thickness_nm=2.0,
 max_iterations=12,
 num_positions_per_layer=8,
 optimizer_max_iter=200,
)

# Dense broadband target: minimize R across 420-680 nm
wavelengths = np.linspace(420, 680, 30).tolist()
ns.add_spectral_target("R", wavelengths, "equal", 0.0)
4

Run needle synthesis

The verbose output shows each iteration: which material was inserted, the needle thickness, and how the merit function evolves.

python
result = ns.run(verbose=True)
5

Results summary

python
print(f"Success:       {result.success}")
print(f"Iterations:    {result.num_iterations}")
print(f"Layers added:  {result.num_layers_added}")
print(f"Initial merit: {result.initial_merit:.6e}")
print(f"Final merit:   {result.final_merit:.6e}")
print(f"Improvement:   {(1 - result.final_merit / result.initial_merit) * 100:.1f}%")
print(f"\nFinal stack:")
print(result.stack)

# Check against spec: R < 1% everywhere in 420-680 nm
wl_check = np.linspace(420, 680, 100)
R_vals = np.array([ThinFilmOperand.reflectance(result.stack, wl) for wl in wl_check])
print(f"\nAverage R (420-680 nm): {np.mean(R_vals)*100:.3f}%")
print(f"Peak R (420-680 nm):   {np.max(R_vals)*100:.3f}%")
print(f"R < 1% across full band: {np.all(R_vals < 0.01)}")
6

Merit function convergence

Each accepted needle insertion reduces the merit function. The rollback mechanism ensures rejected insertions (those that worsen performance after re-optimization) are never kept.

python
merits = [result.initial_merit] + [h.merit_after for h in result.history]
materials_used = [h.material_name for h in result.history]

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(range(len(merits)), merits, "o-", color="steelblue", linewidth=2, markersize=8)

# Annotate each insertion with material name
for i, (m, name) in enumerate(zip(merits[1:], materials_used), 1):
 ax.annotate(name, (i, m), textcoords="offset points",
             xytext=(5, 10), fontsize=9, color="darkred")

ax.set_xlabel("Accepted Iteration")
ax.set_ylabel("Merit Function")
ax.set_title("Needle Synthesis Convergence")
ax.set_yscale("log")
ax.grid(True, alpha=0.3)
fig.tight_layout();
6. Merit function convergence
7

Final stack structure

The algorithm autonomously selected which materials to use and determined the optimal layer thicknesses.

python
fig, ax = result.stack.plot_structure()
7. Final stack structure
8

Before vs. after comparison

The final design meets the stringent R < 1% spec across the entire 420–680 nm band — something impossible with a single-layer coating on N-BK7.

python
# Rebuild starting design for comparison
stack_initial = ThinFilmStack(incident_material=air, substrate_material=nbk7)
stack_initial.add_layer_nm(mgf2, 100.0, name="MgF2")

wl_eval = np.linspace(400, 720, 300)
R_initial = np.array([
 ThinFilmOperand.reflectance(stack_initial, wl) for wl in wl_eval
])
R_final = np.array([ThinFilmOperand.reflectance(result.stack, wl) for wl in wl_eval])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_eval, R_initial * 100, "--", color="gray",
     linewidth=1.5, label="Starting design (1 layer)")
ax.plot(wl_eval, R_final * 100, "-", color="steelblue",
     linewidth=2,
     label=f"After needle synthesis ({len(result.stack.layers)} layers)")
ax.axhline(1.0, color="red", linestyle="--",
        linewidth=1, alpha=0.7, label="Spec: R < 1%")
ax.axvspan(420, 680, alpha=0.06, color="blue")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Broadband AR on N-BK7 — Needle Synthesis Result")
ax.set_ylim(0, 5)
ax.legend()
ax.grid(True, alpha=0.3)
fig.tight_layout();
8. Before vs. after comparison

Part 2: Dichroic Beamsplitter

1

Starting design: (HL)³ quarter-wave stack

We start from a simple 6-layer TiO2/SiO2 quarter-wave stack tuned to 500 nm. This gives some initial reflectance in the blue but is far from meeting spec.

python
# (HL)^3 quarter-wave stack at 500 nm
# QW optical thickness: n*d = 500/4 = 125 nm
# TiO2 (n~2.65): d = 125/2.65 ~ 47 nm
# SiO2 (n~1.46): d = 125/1.46 ~ 86 nm
stack_d = ThinFilmStack(incident_material=air, substrate_material=nbk7)
for _ in range(3):
 stack_d.add_layer_nm(tio2, 47.0, name="TiO2")
 stack_d.add_layer_nm(sio2, 86.0, name="SiO2")
print(stack_d)

# Plot starting performance
wl_plot = np.linspace(400, 720, 300)
R_start_d = np.array([ThinFilmOperand.reflectance(stack_d, wl) for wl in wl_plot])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_plot, R_start_d * 100, "-", color="gray", linewidth=2)
ax.axvspan(420, 540, alpha=0.08, color="blue", label="Reflect: R > 95%")
ax.axvspan(560, 680, alpha=0.08, color="red", label="Transmit: T > 95%")
ax.axhline(95, color="blue", linestyle="--", linewidth=1, alpha=0.5)
ax.axhline(5, color="red", linestyle="--", linewidth=1, alpha=0.5)
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Starting Design — (HL)³ Quarter-Wave Stack on N-BK7")
ax.set_ylim(0, 100)
ax.legend(loc="center right")
ax.grid(True, alpha=0.3)
fig.tight_layout();
Part 2 Dichroic Beamsplitter at 550 nm
2

Run needle synthesis for the dichroic

python
ns_d = NeedleSynthesis(
 stack=stack_d,
 candidate_materials=[sio2, tio2, mgf2, al2o3],
 needle_thickness_nm=1.0,
 min_thickness_nm=2.0,
 max_iterations=8,
 num_positions_per_layer=3,
 optimizer_max_iter=80,
)

# Reflection band: R → 1 for 420-540 nm
wl_reflect = np.linspace(420, 540, 10).tolist()
ns_d.add_spectral_target("R", wl_reflect, "equal", 1.0)

# Transmission band: R → 0 for 560-680 nm
wl_transmit = np.linspace(560, 680, 10).tolist()
ns_d.add_spectral_target("R", wl_transmit, "equal", 0.0)

result_d = ns_d.run(verbose=True)
3

Dichroic results summary

python
print(f"Success:       {result_d.success}")
print(f"Layers added:  {result_d.num_layers_added}")
print(f"Initial merit: {result_d.initial_merit:.6e}")
print(f"Final merit:   {result_d.final_merit:.6e}")
improvement_pct = (1 - result_d.final_merit / result_d.initial_merit) * 100
print(f"Improvement:   {improvement_pct:.1f}%")
print(f"\nFinal stack ({len(result_d.stack.layers)} layers):")
print(result_d.stack)

# Check against specs
wl_r = np.linspace(420, 540, 50)
wl_t = np.linspace(560, 680, 50)
R_reflect = np.array([ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_r])
R_transmit = np.array([ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_t])
print(f"\nReflection band (420-540 nm):")
print(f"  Average R: {np.mean(R_reflect)*100:.1f}%")
print(f"  Min R:     {np.min(R_reflect)*100:.1f}%")
print(f"\nTransmission band (560-680 nm):")
print(f"  Average T: {(1-np.mean(R_transmit))*100:.1f}%")
print(f"  Min T:     {(1-np.max(R_transmit))*100:.1f}%")
4

Dichroic convergence analysis

python
merits_d = [result_d.initial_merit] + [h.merit_after for h in result_d.history]
materials_d = [h.material_name for h in result_d.history]

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(
 range(len(merits_d)), merits_d,
 "o-", color="steelblue", linewidth=2, markersize=8
)

for i, (m, name) in enumerate(zip(merits_d[1:], materials_d), 1):
 ax.annotate(name, (i, m), textcoords="offset points",
             xytext=(5, 10), fontsize=8, color="darkred", rotation=30)

ax.set_xlabel("Accepted Iteration")
ax.set_ylabel("Merit Function")
ax.set_title("Dichroic Beamsplitter — Needle Synthesis Convergence")
ax.set_yscale("log")
ax.grid(True, alpha=0.3)
fig.tight_layout();
12. Dichroic convergence
5

Optimized stack structure

python
fig, ax = result_d.stack.plot_structure()
13. Stack structure
6

Final spectral performance

The final comparison shows the starting (HL)³ stack vs. the optimized dichroic. The algorithm built a sharp transition edge at 550 nm using all four candidate materials.

python
# Rebuild starting design for comparison
stack_d_initial = ThinFilmStack(incident_material=air, substrate_material=nbk7)
for _ in range(3):
 stack_d_initial.add_layer_nm(tio2, 47.0, name="TiO2")
 stack_d_initial.add_layer_nm(sio2, 86.0, name="SiO2")

wl_eval = np.linspace(390, 730, 400)
R_d_initial = np.array([
 ThinFilmOperand.reflectance(stack_d_initial, wl) for wl in wl_eval
])
R_d_final = np.array([
 ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_eval
])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_eval, R_d_initial * 100, "--", color="gray",
     linewidth=1.5, label="Starting design (6 layers)")
ax.plot(wl_eval, R_d_final * 100, "-", color="steelblue",
     linewidth=2,
     label=f"After needle synthesis ({len(result_d.stack.layers)} layers)")
ax.axvspan(420, 540, alpha=0.06, color="blue")
ax.axvspan(560, 680, alpha=0.06, color="red")
ax.axhline(95, color="blue", linestyle=":",
        linewidth=1, alpha=0.5, label="R > 95% spec")
ax.axhline(5, color="red", linestyle=":", linewidth=1, alpha=0.5, label="R < 5% spec")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Dichroic Beamsplitter on N-BK7 — Needle Synthesis Result")
ax.set_ylim(0, 100)
ax.legend(loc="center right")
ax.grid(True, alpha=0.3)
fig.tight_layout();
14. Dichroic spectral performance
Show full code listing
python
import numpy as np
import matplotlib.pyplot as plt
from optiland.materials import Material, IdealMaterial
from optiland.thin_film import ThinFilmStack, SpectralAnalyzer
from optiland.thin_film.optimization import NeedleSynthesis
from optiland.thin_film.optimization.operand.thin_film import ThinFilmOperand

# Incident medium
air = IdealMaterial(n=1.0)

# Substrate
nbk7 = Material("N-BK7")

# Candidate coating materials (dispersive)
sio2 = Material("SiO2", reference="Malitson")
tio2 = Material("TiO2", reference="Devore-o")
mgf2 = Material("MgF2", reference="Dodge-o")
al2o3 = Material("Al2O3", reference="Malitson")

# Print refractive indices at 550 nm
for name, mat in [("N-BK7", nbk7), ("SiO2", sio2), ("TiO2", tio2),
                 ("MgF2", mgf2), ("Al2O3", al2o3)]:
  print(f"{name:6s}  n(550nm) = {np.asarray(mat.n(0.55)).item():.4f}")

# Starting design: single MgF2 layer
stack = ThinFilmStack(incident_material=air, substrate_material=nbk7)
stack.add_layer_nm(mgf2, 100.0, name="MgF2")
print("\nStarting design:")
print(stack)

wl_plot = np.linspace(400, 720, 300)
R_start = np.array([ThinFilmOperand.reflectance(stack, wl) for wl in wl_plot])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_plot, R_start * 100, "-", color="gray",
      linewidth=2, label="Single MgF2 layer")
ax.axhline(1.0, color="red", linestyle="--",
         linewidth=1, alpha=0.7, label="Spec: R < 1%")
ax.axvspan(420, 680, alpha=0.06, color="blue", label="Target band (420–680 nm)")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Starting Design — Single MgF2 Layer on N-BK7")
ax.set_ylim(0, 5)
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)
fig.tight_layout();

ns = NeedleSynthesis(
  stack=stack,
  candidate_materials=[sio2, tio2, mgf2, al2o3],
  needle_thickness_nm=1.0,
  min_thickness_nm=2.0,
  max_iterations=12,
  num_positions_per_layer=8,
  optimizer_max_iter=200,
)

# Dense broadband target: minimize R across 420-680 nm
wavelengths = np.linspace(420, 680, 30).tolist()
ns.add_spectral_target("R", wavelengths, "equal", 0.0)

result = ns.run(verbose=True)

print(f"Success:       {result.success}")
print(f"Iterations:    {result.num_iterations}")
print(f"Layers added:  {result.num_layers_added}")
print(f"Initial merit: {result.initial_merit:.6e}")
print(f"Final merit:   {result.final_merit:.6e}")
print(f"Improvement:   {(1 - result.final_merit / result.initial_merit) * 100:.1f}%")
print(f"\nFinal stack:")
print(result.stack)

# Check against spec: R < 1% everywhere in 420-680 nm
wl_check = np.linspace(420, 680, 100)
R_vals = np.array([ThinFilmOperand.reflectance(result.stack, wl) for wl in wl_check])
print(f"\nAverage R (420-680 nm): {np.mean(R_vals)*100:.3f}%")
print(f"Peak R (420-680 nm):   {np.max(R_vals)*100:.3f}%")
print(f"R < 1% across full band: {np.all(R_vals < 0.01)}")

merits = [result.initial_merit] + [h.merit_after for h in result.history]
materials_used = [h.material_name for h in result.history]

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(range(len(merits)), merits, "o-", color="steelblue", linewidth=2, markersize=8)

# Annotate each insertion with material name
for i, (m, name) in enumerate(zip(merits[1:], materials_used), 1):
  ax.annotate(name, (i, m), textcoords="offset points",
              xytext=(5, 10), fontsize=9, color="darkred")

ax.set_xlabel("Accepted Iteration")
ax.set_ylabel("Merit Function")
ax.set_title("Needle Synthesis Convergence")
ax.set_yscale("log")
ax.grid(True, alpha=0.3)
fig.tight_layout();

fig, ax = result.stack.plot_structure()

# Rebuild starting design for comparison
stack_initial = ThinFilmStack(incident_material=air, substrate_material=nbk7)
stack_initial.add_layer_nm(mgf2, 100.0, name="MgF2")

wl_eval = np.linspace(400, 720, 300)
R_initial = np.array([
  ThinFilmOperand.reflectance(stack_initial, wl) for wl in wl_eval
])
R_final = np.array([ThinFilmOperand.reflectance(result.stack, wl) for wl in wl_eval])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_eval, R_initial * 100, "--", color="gray",
      linewidth=1.5, label="Starting design (1 layer)")
ax.plot(wl_eval, R_final * 100, "-", color="steelblue",
      linewidth=2,
      label=f"After needle synthesis ({len(result.stack.layers)} layers)")
ax.axhline(1.0, color="red", linestyle="--",
         linewidth=1, alpha=0.7, label="Spec: R < 1%")
ax.axvspan(420, 680, alpha=0.06, color="blue")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Broadband AR on N-BK7 — Needle Synthesis Result")
ax.set_ylim(0, 5)
ax.legend()
ax.grid(True, alpha=0.3)
fig.tight_layout();

# (HL)^3 quarter-wave stack at 500 nm
# QW optical thickness: n*d = 500/4 = 125 nm
# TiO2 (n~2.65): d = 125/2.65 ~ 47 nm
# SiO2 (n~1.46): d = 125/1.46 ~ 86 nm
stack_d = ThinFilmStack(incident_material=air, substrate_material=nbk7)
for _ in range(3):
  stack_d.add_layer_nm(tio2, 47.0, name="TiO2")
  stack_d.add_layer_nm(sio2, 86.0, name="SiO2")
print(stack_d)

# Plot starting performance
wl_plot = np.linspace(400, 720, 300)
R_start_d = np.array([ThinFilmOperand.reflectance(stack_d, wl) for wl in wl_plot])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_plot, R_start_d * 100, "-", color="gray", linewidth=2)
ax.axvspan(420, 540, alpha=0.08, color="blue", label="Reflect: R > 95%")
ax.axvspan(560, 680, alpha=0.08, color="red", label="Transmit: T > 95%")
ax.axhline(95, color="blue", linestyle="--", linewidth=1, alpha=0.5)
ax.axhline(5, color="red", linestyle="--", linewidth=1, alpha=0.5)
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Starting Design — (HL)³ Quarter-Wave Stack on N-BK7")
ax.set_ylim(0, 100)
ax.legend(loc="center right")
ax.grid(True, alpha=0.3)
fig.tight_layout();

ns_d = NeedleSynthesis(
  stack=stack_d,
  candidate_materials=[sio2, tio2, mgf2, al2o3],
  needle_thickness_nm=1.0,
  min_thickness_nm=2.0,
  max_iterations=8,
  num_positions_per_layer=3,
  optimizer_max_iter=80,
)

# Reflection band: R → 1 for 420-540 nm
wl_reflect = np.linspace(420, 540, 10).tolist()
ns_d.add_spectral_target("R", wl_reflect, "equal", 1.0)

# Transmission band: R → 0 for 560-680 nm
wl_transmit = np.linspace(560, 680, 10).tolist()
ns_d.add_spectral_target("R", wl_transmit, "equal", 0.0)

result_d = ns_d.run(verbose=True)

print(f"Success:       {result_d.success}")
print(f"Layers added:  {result_d.num_layers_added}")
print(f"Initial merit: {result_d.initial_merit:.6e}")
print(f"Final merit:   {result_d.final_merit:.6e}")
improvement_pct = (1 - result_d.final_merit / result_d.initial_merit) * 100
print(f"Improvement:   {improvement_pct:.1f}%")
print(f"\nFinal stack ({len(result_d.stack.layers)} layers):")
print(result_d.stack)

# Check against specs
wl_r = np.linspace(420, 540, 50)
wl_t = np.linspace(560, 680, 50)
R_reflect = np.array([ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_r])
R_transmit = np.array([ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_t])
print(f"\nReflection band (420-540 nm):")
print(f"  Average R: {np.mean(R_reflect)*100:.1f}%")
print(f"  Min R:     {np.min(R_reflect)*100:.1f}%")
print(f"\nTransmission band (560-680 nm):")
print(f"  Average T: {(1-np.mean(R_transmit))*100:.1f}%")
print(f"  Min T:     {(1-np.max(R_transmit))*100:.1f}%")

merits_d = [result_d.initial_merit] + [h.merit_after for h in result_d.history]
materials_d = [h.material_name for h in result_d.history]

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(
  range(len(merits_d)), merits_d,
  "o-", color="steelblue", linewidth=2, markersize=8
)

for i, (m, name) in enumerate(zip(merits_d[1:], materials_d), 1):
  ax.annotate(name, (i, m), textcoords="offset points",
              xytext=(5, 10), fontsize=8, color="darkred", rotation=30)

ax.set_xlabel("Accepted Iteration")
ax.set_ylabel("Merit Function")
ax.set_title("Dichroic Beamsplitter — Needle Synthesis Convergence")
ax.set_yscale("log")
ax.grid(True, alpha=0.3)
fig.tight_layout();

fig, ax = result_d.stack.plot_structure()

# Rebuild starting design for comparison
stack_d_initial = ThinFilmStack(incident_material=air, substrate_material=nbk7)
for _ in range(3):
  stack_d_initial.add_layer_nm(tio2, 47.0, name="TiO2")
  stack_d_initial.add_layer_nm(sio2, 86.0, name="SiO2")

wl_eval = np.linspace(390, 730, 400)
R_d_initial = np.array([
  ThinFilmOperand.reflectance(stack_d_initial, wl) for wl in wl_eval
])
R_d_final = np.array([
  ThinFilmOperand.reflectance(result_d.stack, wl) for wl in wl_eval
])

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(wl_eval, R_d_initial * 100, "--", color="gray",
      linewidth=1.5, label="Starting design (6 layers)")
ax.plot(wl_eval, R_d_final * 100, "-", color="steelblue",
      linewidth=2,
      label=f"After needle synthesis ({len(result_d.stack.layers)} layers)")
ax.axvspan(420, 540, alpha=0.06, color="blue")
ax.axvspan(560, 680, alpha=0.06, color="red")
ax.axhline(95, color="blue", linestyle=":",
         linewidth=1, alpha=0.5, label="R > 95% spec")
ax.axhline(5, color="red", linestyle=":", linewidth=1, alpha=0.5, label="R < 5% spec")
ax.set_xlabel("Wavelength (nm)")
ax.set_ylabel("Reflectance (%)")
ax.set_title("Dichroic Beamsplitter on N-BK7 — Needle Synthesis Result")
ax.set_ylim(0, 100)
ax.legend(loc="center right")
ax.grid(True, alpha=0.3)
fig.tight_layout();

Conclusions

  • The needle synthesis algorithm automatically grows a multilayer stack by inserting thin "needle" layers one at a time, selecting the material and position that most reduces the merit function at each iteration — removing the need to specify the number of layers in advance.
  • Starting from a single MgF2 quarter-wave layer, the algorithm reached the stringent R < 1% broadband AR specification across 420–680 nm on N-BK7, a target that is impossible to meet with any single-layer design.
  • For the much harder dichroic beamsplitter problem, the algorithm built a sharp 550 nm transition edge from a 6-layer quarter-wave seed, autonomously selecting from four candidate materials (SiO2, TiO2, MgF2, Al2O3) to simultaneously satisfy R > 95% and T > 95% specs in separate spectral bands.
  • Logging the merit function after each accepted insertion produces a convergence history that reveals which materials the algorithm preferred and how quickly the design improved, providing interpretable insight into the layer-growth process.
  • The NeedleSynthesis class integrates seamlessly with the ThinFilmStack ecosystem: the result.stack output is a standard stack object that can be passed directly to SpectralAnalyzer, plotted with plot_structure, or handed to ThinFilmOptimizer for further refinement.

Next tutorials

Original notebook: Tutorial_6h_Needle_Synthesis.ipynb on GitHub · ReadTheDocs