Thin Films
Thin-Film Optimization
Automate layer thickness design to meet spectral performance targets.
AdvancedCoatings & PolarizationNumPy backend15 min read
Introduction
We optimize a thin-film stack to create a dichroic mirror that separates s and p polarizations near 600 nm.
This version demonstrates the operand-centric architecture in ThinFilmOptimizer with a single API entry point:
- standard spectral operands with add_operand(...)
- a custom user-defined operand registered with register_operand(...) and used through add_operand(...)
Core concepts used
ThinFilmOptimizer(stack)
The optimization engine for thin-film designs. It connects a stack to local and global solvers.
optimizer.add_variable(index, min_nm, max_nm)
Defines a layer's physical thickness as a design parameter, subject to manufacturing constraints.
register_operand(name, func)
Registers a custom user-defined merit function to prioritize non-standard targets (like polarization contrast).
optimizer.optimize(method='L-BFGS-B')
Launches the solver. L-BFGS-B is ideal for constrained optimization where layer thickness must remain positive.
Step-by-step build
1
Import libraries and build the dichroic stack
python
from optiland.thin_film.optimization import ThinFilmOptimizer
import optiland.backend as be
from optiland.thin_film import ThinFilmStack, SpectralAnalyzer
from optiland.materials import Material, IdealMaterial
SiO2 = Material("SiO2", reference="Gao")
TiO2 = Material("TiO2", reference="Zhukovsky")
BK7 = Material("N-BK7", reference="SCHOTT")
air = IdealMaterial(n=1.0)
dichroic_stack = ThinFilmStack(
incident_material=air,
substrate_material=BK7,
reference_wl_um=0.6,
reference_AOI_deg=45.0,
)
for i in range(10): # 10 pairs = 20 layers
dichroic_stack.add_layer_qwot(material=TiO2, qwot_thickness=1.0, name=f"$TiO_2$")
dichroic_stack.add_layer_qwot(material=SiO2, qwot_thickness=1.0, name=f"$SiO_2$")2
Register a custom polarization-contrast operand and configure variables
python
optimizer = ThinFilmOptimizer(dichroic_stack)
# Add all thicknesses as optimization variables
for i in range(len(dichroic_stack.layers)):
optimizer.add_variable(
layer_index=i,
min_nm=30,
max_nm=300
)
# we want to maximize the polarization contrast (Rs-Rp) at 600 nm and 45° AOI, so we
# minimize the negative contrast, averaged over a wavelength range to make it more
# robust to fabrication variations. The contrast is normalized to be between 0 and 1,
# where 1 corresponds to Rs=1 and Rp=0
wl_nm = be.linspace(595, 605, 11)
# Register and add a custom operand through add_operand
def polarization_contrast(
stack: ThinFilmStack, wavelength_nm: be.ndarray, aoi_deg: float
):
"""Calculate the polarization contrast (Rs-Rp) averaged over a wavelength range.
The contrast is normalized to be between 0 and 1,
where 1 corresponds to Rs=1 and Rp=0."""
rs = stack.reflectance_nm_deg(wavelength_nm, aoi_deg, "s")
rp = stack.reflectance_nm_deg(wavelength_nm, aoi_deg, "p")
return (1 + be.mean(rs - rp))/2
ThinFilmOptimizer.register_operand(
"polarization_contrast", polarization_contrast, overwrite=True
)
optimizer.add_operand(
property="polarization_contrast",
min_val=0.99,
input_data={"wavelength_nm": wl_nm, "aoi_deg": 45.0},
label="Rs-Rp @ 595-605nm, 45deg",
)
# Display optimization information
optimizer.info()
contrast_initial=optimizer.rss()
print(f"Initial RSS: {contrast_initial:.6e}")3
Run the L-BFGS-B optimizer
python
# Launch optimization
result = optimizer.optimize(
method="L-BFGS-B",
max_iterations=1000,
)
print(
f"Merit: {result['initial_merit']:.15f} -> "
f"{result['final_merit']:.15f} in {result['iterations']} iterations"
)
print(f"Improvement: {result['improvement']:.15f}")4
Generate the optimization summary report
python
from optiland.thin_film.optimization import ThinFilmReport
report = ThinFilmReport(optimizer, result)
report.summary_table()5
Plot spectral performance, polarization contrast, and optimized stack structure
python
import matplotlib.pyplot as plt
analyzer = SpectralAnalyzer(dichroic_stack)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
wl_range = be.linspace(500, 700, 201) # Around 600 nm
# Calculate before optimization (reset and recalculate)
optimizer.reset()
Rs_before = dichroic_stack.reflectance_nm_deg(wl_range, 45, 's')
Rp_before = dichroic_stack.reflectance_nm_deg(wl_range, 45, 'p')
# Restore optimized state
for i, var_info in enumerate(optimizer.variables):
final_thickness = result['thickness_changes'][i]['final_nm'] / 1000 # nm → μm
dichroic_stack.layers[i].update_thickness(final_thickness)
Rs_after = dichroic_stack.reflectance_nm_deg(wl_range, 45, 's')
Rp_after = dichroic_stack.reflectance_nm_deg(wl_range, 45, 'p')
axes[0].plot(wl_range, Rs_before, 'b:', label='$R_s$ before', alpha=0.7)
axes[0].plot(wl_range, Rp_before, 'g:', label='$P_p$ before', alpha=0.7)
axes[0].plot(wl_range, Rs_after, 'b-', label='$R_s$ after', linewidth=2)
axes[0].plot(wl_range, Rp_after, 'g-', label='$P_p$ after', linewidth=2)
axes[0].set_xlabel('$\lambda$ (nm)')
axes[0].set_ylabel('Reflectance')
axes[0].set_title('Spectral Performance (AOI=45°)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].fill_betweenx([0, 1], 595, 605,
color='gray', alpha=0.2, label='Optimization Range')
# 2. Polarization contrast (Rs - Rp)
contrast_before = Rs_before - Rp_before
contrast_after = Rs_after - Rp_after
axes[1].plot(wl_range, contrast_before, 'g--', label='Before', alpha=0.7)
axes[1].plot(wl_range, contrast_after, 'g-', label='After', linewidth=2)
axes[1].set_xlabel('$\lambda$ (nm)')
axes[1].set_ylabel('Contrast ($R_s - R_p$)')
axes[1].set_title('Polarization Contrast')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# 3. Optimized stack structure
dichroic_stack.plot_structure_thickness(axes[2])
axes[2].set_title('Optimized Stack Structure')
fig.tight_layout()
Show full code listing
python
from optiland.thin_film.optimization import ThinFilmOptimizer
import optiland.backend as be
from optiland.thin_film import ThinFilmStack, SpectralAnalyzer
from optiland.materials import Material, IdealMaterial
SiO2 = Material("SiO2", reference="Gao")
TiO2 = Material("TiO2", reference="Zhukovsky")
BK7 = Material("N-BK7", reference="SCHOTT")
air = IdealMaterial(n=1.0)
dichroic_stack = ThinFilmStack(
incident_material=air,
substrate_material=BK7,
reference_wl_um=0.6,
reference_AOI_deg=45.0,
)
for i in range(10): # 10 pairs = 20 layers
dichroic_stack.add_layer_qwot(material=TiO2, qwot_thickness=1.0, name=f"$TiO_2$")
dichroic_stack.add_layer_qwot(material=SiO2, qwot_thickness=1.0, name=f"$SiO_2$")
optimizer = ThinFilmOptimizer(dichroic_stack)
# Add all thicknesses as optimization variables
for i in range(len(dichroic_stack.layers)):
optimizer.add_variable(
layer_index=i,
min_nm=30,
max_nm=300
)
# we want to maximize the polarization contrast (Rs-Rp) at 600 nm and 45° AOI, so we
# minimize the negative contrast, averaged over a wavelength range to make it more
# robust to fabrication variations. The contrast is normalized to be between 0 and 1,
# where 1 corresponds to Rs=1 and Rp=0
wl_nm = be.linspace(595, 605, 11)
# Register and add a custom operand through add_operand
def polarization_contrast(
stack: ThinFilmStack, wavelength_nm: be.ndarray, aoi_deg: float
):
"""Calculate the polarization contrast (Rs-Rp) averaged over a wavelength range.
The contrast is normalized to be between 0 and 1,
where 1 corresponds to Rs=1 and Rp=0."""
rs = stack.reflectance_nm_deg(wavelength_nm, aoi_deg, "s")
rp = stack.reflectance_nm_deg(wavelength_nm, aoi_deg, "p")
return (1 + be.mean(rs - rp))/2
ThinFilmOptimizer.register_operand(
"polarization_contrast", polarization_contrast, overwrite=True
)
optimizer.add_operand(
property="polarization_contrast",
min_val=0.99,
input_data={"wavelength_nm": wl_nm, "aoi_deg": 45.0},
label="Rs-Rp @ 595-605nm, 45deg",
)
# Display optimization information
optimizer.info()
contrast_initial=optimizer.rss()
print(f"Initial RSS: {contrast_initial:.6e}")
# Launch optimization
result = optimizer.optimize(
method="L-BFGS-B",
max_iterations=1000,
)
print(
f"Merit: {result['initial_merit']:.15f} -> "
f"{result['final_merit']:.15f} in {result['iterations']} iterations"
)
print(f"Improvement: {result['improvement']:.15f}")
from optiland.thin_film.optimization import ThinFilmReport
report = ThinFilmReport(optimizer, result)
report.summary_table()
import matplotlib.pyplot as plt
analyzer = SpectralAnalyzer(dichroic_stack)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
wl_range = be.linspace(500, 700, 201) # Around 600 nm
# Calculate before optimization (reset and recalculate)
optimizer.reset()
Rs_before = dichroic_stack.reflectance_nm_deg(wl_range, 45, 's')
Rp_before = dichroic_stack.reflectance_nm_deg(wl_range, 45, 'p')
# Restore optimized state
for i, var_info in enumerate(optimizer.variables):
final_thickness = result['thickness_changes'][i]['final_nm'] / 1000 # nm → μm
dichroic_stack.layers[i].update_thickness(final_thickness)
Rs_after = dichroic_stack.reflectance_nm_deg(wl_range, 45, 's')
Rp_after = dichroic_stack.reflectance_nm_deg(wl_range, 45, 'p')
axes[0].plot(wl_range, Rs_before, 'b:', label='$R_s$ before', alpha=0.7)
axes[0].plot(wl_range, Rp_before, 'g:', label='$P_p$ before', alpha=0.7)
axes[0].plot(wl_range, Rs_after, 'b-', label='$R_s$ after', linewidth=2)
axes[0].plot(wl_range, Rp_after, 'g-', label='$P_p$ after', linewidth=2)
axes[0].set_xlabel('$\lambda$ (nm)')
axes[0].set_ylabel('Reflectance')
axes[0].set_title('Spectral Performance (AOI=45°)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].fill_betweenx([0, 1], 595, 605,
color='gray', alpha=0.2, label='Optimization Range')
# 2. Polarization contrast (Rs - Rp)
contrast_before = Rs_before - Rp_before
contrast_after = Rs_after - Rp_after
axes[1].plot(wl_range, contrast_before, 'g--', label='Before', alpha=0.7)
axes[1].plot(wl_range, contrast_after, 'g-', label='After', linewidth=2)
axes[1].set_xlabel('$\lambda$ (nm)')
axes[1].set_ylabel('Contrast ($R_s - R_p$)')
axes[1].set_title('Polarization Contrast')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# 3. Optimized stack structure
dichroic_stack.plot_structure_thickness(axes[2])
axes[2].set_title('Optimized Stack Structure')
fig.tight_layout()Conclusions
- A 20-layer TiO2/SiO2 quarter-wave stack on N-BK7 at 45° AOI was optimized with L-BFGS-B to maximize polarization contrast (Rs − Rp) near 600 nm, demonstrating how constrained gradient-based methods handle many free variables efficiently.
- The operand-centric API of
ThinFilmOptimizerlets you mix built-in spectral operands with fully custom Python functions registered viaregister_operand, without changing any other optimizer code. - Layer thicknesses were bounded between 30 nm and 300 nm during optimization, enforcing realistic manufacturing constraints while still achieving near-unity polarization contrast.
ThinFilmReportprovides a structured summary table of per-layer thickness changes, making it straightforward to compare initial and final designs and verify that all manufacturing bounds were respected.- The before/after spectral plots confirm that the optimized stack strongly separates s and p reflectances in the 595–605 nm window, validating that the custom merit function successfully encoded the physical design intent.
Next tutorials
NextColor Analysis for Thin-FilmsPerceive the visual appearance of coatings using colorimetry tools.RelatedMultilayer StackFoundational techniques for building complex thin-film hierarchies.
Original notebook: Tutorial_6d_Thin_Film_Optimization.ipynb on GitHub · ReadTheDocs