In this notebook, we will use inverse design and Tidy3D to create a 7x7 diffractive beam splitter using topology optimization.
A similar approach was presented in the work of Dong Cheon Kim, Andreas Hermerschmidt, Pavel Dyachenko, and Toralf Scharf, "Adjoint method and inverse design for diffractive beam splitters", Proceedings of SPIE 11261, Components and Packaging for Laser Systems VI, (2020).DOI: https://doi.org/10.1117/12.2543367, where the authors used the adjoint method to optimize the design with RCWA, starting from a pre-optimized structure obtained using the iterative Fourier transform algorithm.
In this example, we will achieve similar results using FDTD, starting from a random distribution. The final structure is a grating that splits the power of an incident plane wave equally into the first seven diffraction orders along the x and y directions.
import autograd.numpy as anpimport matplotlib.pyplot as pltimport numpy as npimport tidy3d as tdfrom autograd.tracer import getvalfrom IPython.display import clear_output, displayfrom tidy3d import webfrom tidy3d.plugins.autograd import ( make_erosion_dilation_penalty, make_filter_and_project, rescale, value_and_grad,)td.config.local_cache.enabled =Truedef _to_float(x):"""Unwrap traced/xarray value to plain Python float. Order matters: unwrap xarray DataArray first, then numpy scalar, then autograd ArrayBox, then convert to float. """ifhasattr(x, "values") andhasattr(x, "dims"): x = x.valuesifhasattr(x, "item"): x = x.item() x = getval(x)returnfloat(x)np.random.seed(111)
Simulation Setup
First we will define some global parameters.
# Wavelength and frequencywavelength =0.94freq0 = td.C_0 / wavelengthfwidth =0.1* freq0run_time = td.RunTimeSpec(quality_factor=20)# Material propertiespermittivity =1.4512**2# Etch depth and pixel sizethickness =1.18pixel_size =0.01# Unit cell sizelength =5# Distances between PML and source / monitorbuffer=1.5* wavelength# Distances between source / monitor and the maskdist_src =1.5* wavelengthdist_mnt =1.1* wavelength# Resolutionmin_steps_per_wvl =15# Target diffraction ordersTARGET_ORDER =3NUM_TARGET_ORDERS = (2* TARGET_ORDER +1) **2# Fabrication penalty weightFAB_WEIGHT =0.2
# Total z size and center variablesLz =buffer+ dist_src + thickness + dist_mnt +bufferz_center_slab =-Lz /2+buffer+ dist_src + thickness /2.0
Next, we determine the resolution of the design region, as well as the number of pixels.
# Number of pixel cells in the design region (in x and y)nx = ny =int(length / pixel_size)dl_design_region = pixel_size
Define Simulation Components
Next, we will define the static structures, PlaneWave source, and monitors.
Next, we will define auxiliary functions to create the optimization volume, and filters to address fabrication constraints.
The structure consists of nx by ny pixels, representing etched areas on the substrate.
To ensure minimum feature sizes, we will use the auxiliary function make_filter_and_project to create a FilterAndProject object, which applies convolution and binarization filters to enforce binarization and minimum feature sizes.
For more information on fabrication constraints, please refer to this lecture.
# Creating filtersradius =0.1filter_project = make_filter_and_project(radius, dl_design_region)erosion_dilation_penalty = make_erosion_dilation_penalty(radius, dl_design_region, beta=10)def get_eps(params: anp.ndarray, beta: float) -> anp.ndarray:"""Get the permittivity values (1, permittivity) array as a function of the parameters (0, 1)""" density = filter_project(params, beta) eps = rescale(density, 1, permittivity)return eps.reshape((nx, ny, 1))def make_slab(params: anp.ndarray, beta: float) -> td.Structure:"""Make the optimization design region""" box = td.Box(center=(0, 0, z_center_slab), size=(2* length, 2* length, thickness)) eps_data = get_eps(params, beta)return td.Structure.from_permittivity_array(geometry=box, eps_data=eps_data)
Finally, we will define an auxiliary function that returns the simulation object as a function of the optimization parameters and the binarization control variable, beta.
def make_sim(params: anp.ndarray, beta: float) -> td.Simulation:"""The simulation as a function of the design parameters.""" slab = make_slab(params, beta)# Mesh override structure to ensure uniform dl across the slab design_region_mesh = td.MeshOverrideStructure( geometry=slab.geometry, dl=[dl_design_region] *3, enforce=False, )return td.Simulation( size=(length, length, Lz), grid_spec=td.GridSpec.auto( min_steps_per_wvl=min_steps_per_wvl, override_structures=[design_region_mesh], ), boundary_spec=td.BoundarySpec( x=td.Boundary( plus=td.Periodic(), minus=td.Periodic(), ), y=td.Boundary( plus=td.Periodic(), minus=td.Periodic(), ), z=td.Boundary( plus=td.PML(), minus=td.PML(), ), ), structures=[substrate, slab], monitors=[diffractionmonitor], sources=[src], run_time=run_time, )
Now, we will create a simulation with random parameters to test and visualize the setup.
# Make symmetric, random starting parametersparams0 = np.random.random((nx, ny))params0 += np.fliplr(params0)params0 += np.flipud(params0)params0 /=4.0beta0 =1.0sim = make_sim(params=params0, beta=beta0)
We will also define an auxiliary function to post process and visualize the results.
def post_process(sim_data):"""Post-process simulation data: compute efficiency, RMSE, and plot diffraction results.""" order = TARGET_ORDER number_of_orders = NUM_TARGET_ORDERS# Extract data plot_data = sim_data["diffractionmonitor"] intensity_measured = plot_data.power theta, phi = plot_data.angles theta = theta.isel(f=0) phi = phi.isel(f=0) power_values = plot_data.power.isel(f=0) total_power =0# Calculate diffraction orders order1, power1, desiredPower1 = [], [], []for xorder in intensity_measured.orders_x:for yorder in intensity_measured.orders_y: val = ( sim_data["diffractionmonitor"] .power.isel(f=0) .sel(orders_x=xorder, orders_y=yorder) .values ) total_power += valif (abs(xorder) <= order) and (abs(yorder) <= order): order1.append((int(xorder), int(yorder))) power1.append(val) desiredPower1.append(1/ number_of_orders) rmse = np.sqrt( (1/ number_of_orders) * np.sum((np.array(power1) - np.sum(power1) / number_of_orders) **2) ) rmse *=100# to get percentage labels1 = [f"({x},{y})"for x, y in order1]# Create a figure with two subplots side by side fig = plt.figure(figsize=(10, 4)) ax_polar = fig.add_subplot(1, 2, 1, projection="polar") ax_bar = fig.add_subplot(1, 2, 2)# --- Polar plot --- sc = ax_polar.scatter(phi, theta, c=power_values, cmap="hot_r") fig.colorbar(sc, ax=ax_polar, orientation="vertical", pad=0.1) ax_polar.set_title("Far-Field Diffraction Pattern", va="bottom")# --- Bar plot --- total_power =float(np.sum(power_values)) ax_bar.bar(range(len(power1)),100* np.array(power1) / total_power, color="tab:blue", label="Measured (nonzero)", ) ax_bar.bar(range(len(desiredPower1)), np.array(desiredPower1) *100, color="cyan", alpha=0.3, label="Desired", ) ax_bar.legend() ax_bar.set_xticks(range(len(power1))) ax_bar.set_xticklabels(labels1, rotation=90, fontsize=8) ax_bar.set_xlabel("Diffraction Order (x,y)") ax_bar.set_title("Diffraction Monitor Power by Order") plt.tight_layout() plt.show() efficiency =sum(power1) / total_powerprint(f"Efficiency: {efficiency:.2f}")print(f"RMSE: {rmse:.2f}")return efficiency, rmse
Normalization Simulation
We run a simulation with the flat substrate (no grating) to measure the total available transmitted power, power0. This serves as our fixed reference for computing efficiency, so the optimization target does not shift as the design changes.
Next, we will define a function to analyze the DiffractionMonitor data and evaluate the total power inside the desired diffraction orders, along with a penalty to ensure equal distribution of the intensities.
def intensity_diff_fn(sim_data, weight_outside=0.1):"""Returns a measure of the difference between desired and target intensity patterns.""" power = sim_data["diffractionmonitor"].power# Total power at desired orders total_power =0.0for ordersx in power.orders_x:for ordersy in power.orders_y: power_xy = power.sel(orders_x=ordersx, orders_y=ordersy)ifabs(ordersx) <= TARGET_ORDER andabs(ordersy) <= TARGET_ORDER: total_power = total_power + power_xy# Adding the penalty for uneven distribution of the power, and also power at undesired orders cost =0.0for ordersx in power.orders_x:for ordersy in power.orders_y: power_xy = power.sel(orders_x=ordersx, orders_y=ordersy)ifabs(ordersx) <= TARGET_ORDER andabs(ordersy) <= TARGET_ORDER: cost = cost + (total_power / NUM_TARGET_ORDERS - power_xy) **2else: cost = cost + weight_outside * anp.abs(power_xy) **2return cost
Loss Function
Finally, we can create our loss function, which takes as input the parameter list and beta, creates and runs the simulation object, processes the data, and returns the loss, including fabrication constraint penalties. This is the function that will be differentiated using autograd.
It extracts the diffraction orders from the DiffractionMonitor, and first calculates the total power as the sum of the intensities of all desired diffraction orders. Next, the function adds to the cost function a penalty for the difference of each order with respect to the mean power, to enforce homogeneity. Finally, the power outside the desired orders is accounted for as a penalty to enforce high efficiency.
def loss_fn(params, beta, verbose=False):"""Loss function for the design, the difference in intensity + the feature size penalty.""" sim = make_sim(params, beta=beta) sim_data = web.run(sim, task_name="diffractive_beam_splitter", verbose=verbose) cost = intensity_diff_fn(sim_data) density = filter_project(params, beta) fab_penalty = erosion_dilation_penalty(density) res = cost**-1- FAB_WEIGHT * fab_penalty# Extract per-order powers for monitoring (detached from autograd graph). power_da = sim_data["diffractionmonitor"].power.isel(f=0) pv = power_da.values orders_x = power_da.orders_x.values orders_y = power_da.orders_y.values order_powers = [] order_labels = [] target_sum =0.0 total_power =0.0for i, ox inenumerate(orders_x):for j, oy inenumerate(orders_y): v = _to_float(pv[i, j]) total_power += vifabs(ox) <= TARGET_ORDER andabs(oy) <= TARGET_ORDER: order_powers.append(v) order_labels.append((int(ox), int(oy))) target_sum += v aux_data =dict( objective=_to_float(cost), fab_penalty=_to_float(fab_penalty), efficiency=target_sum / total_power if total_power >0else0.0, order_powers=np.array(order_powers), order_labels=order_labels, )return res, aux_dataloss_fn_val_grad = value_and_grad(loss_fn, has_aux=True)
Before running the optimization, we first check that everything is working correctly.
(val, grad), aux_data = loss_fn_val_grad(params0, beta0, verbose=True)print(f"Loss value (maximize): {val:.4f}")print(f" Cost (minimize): {aux_data['objective']:.4e}")print(f" Fab penalty: {aux_data['fab_penalty']:.4e}")print(f" Efficiency: {aux_data['efficiency']:.4f}")
As in the other tutorials, we use Tidy3D’s Adam optimizer. We negate the gradient at each step since we are maximizing the objective (which is cost^(-1)).
The binarization strength beta is gradually increased over the course of the optimization, encouraging the design to converge toward a fabricable binary structure.
19:38:56 CEST Estimated FlexCredit cost: 0.781. Minimum cost depends on task
execution details. Use 'web.real_cost(task_id)' to get the billed
FlexCredit cost after a simulation run.
19:38:57 CEST status = queued
To cancel the simulation, use 'web.abort(task_id)' or
'web.delete(task_id)' or abort/delete the task in the web UI.
Terminating the Python script will not stop the job running on the
cloud.
19:40:18 CEST Loading results from simulation_data.hdf5
As we can see, although starting with a random distribution, we can achieve good figures of merit when compared with the reference paper.
Although the performance is good, the minimum feature sizes might be too small for some fabrication systems. In that case, it is possible to increase the radius parameter to help enforce larger feature sizes.
We use necessary cookies to run this website. With your permission, we
also use analytics cookies to understand site usage and marketing
cookies for advertising, retargeting, and HubSpot tracking.
Learn more about our cookie policy.
Privacy choices
Choose which optional cookies Flexcompute may use. Necessary cookies
are always on because they support core website behavior, security, and
saving your consent record.
Your browser is sending a Global Privacy Control signal, so marketing
cookies are disabled.
Subscribe
Thanks for subscribing
Publish Your Notebook
Thank you for publishingA confirmation email has been sent to your inbox. Your notebook will be available within the next 48 hours.
Community Library
TERMS & CONDITIONS
EFFECTIVE DATE: January 1, 2025
Terms and Conditions for User-Submitted Content
By submitting content to Flexcompute, you agree to the following terms:
1. Ownership and Copyright
All content, including but not limited to text, data, images, and other materials submitted by users,
remains the sole property of the original creator. Flexcompute does not claim ownership of the submitted
content or any intellectual property rights associated with it.
Users affirm that they own the copyright or have obtained all necessary permissions for the submitted
content and are fully responsible for ensuring that their submissions do not infringe on any third-party
rights.
2. Responsibility for Content
Users are solely responsible for the content they submit. Flexcompute does not endorse, guarantee,
or verify the accuracy, legality, or appropriateness of any submitted content. Users are responsible
for ensuring that their content complies with all applicable laws and regulations.
Users declare that they have obtained authorization from all co-authors and collaborators associated
with the submitted content, granting them the right to submit and sign on behalf of all contributors.
Users agree not to submit content that is illegal, defamatory, obscene, or violates the rights
of others, including privacy and intellectual property rights.
3. Modification and Presentation
Flexcompute reserves the right to review, edit, or modify submitted content to improve clarity,
formatting, and presentation while maintaining the original intent and message.
These modifications are made to enhance the overall quality and readability of the published material.
Users acknowledge that Flexcompute may format or present the content in a way
that aligns with our editorial and visual standards.
4. Liability Disclaimer
Flexcompute shall not be held liable for any disputes arising from user-submitted content, including
but not limited to copyright claims, inaccuracies, or damages resulting from the publication of user content.
Users agree to indemnify Flexcompute against any claims or legal actions resulting from their submitted content.
5. Content Use Rights
By submitting content, users grant Flexcompute a non-exclusive, royalty-free, worldwide license to publish, distribute,
and promote the content for the purposes of showcasing user contributions and marketing our services.
This license does not transfer ownership of the content or any copyright to Flexcompute.
Users retain the right to withdraw their submissions at any time.
Upon request, Flexcompute will remove the content from its platform.
6. Privacy and Confidentiality
Users acknowledge that submitted content may be publicly accessible and therefore waive any
rights to confidentiality or privacy regarding the published material.
Flexcompute will not share personal information of users without
their consent, in accordance with our privacy policy.
Community Library
How the process works:
You submit your notebook.
You will get a permanent URL to promote your work.
Cite the URL as a reference for others in your upcoming papers to enhance the impact.
When someone clicks your URL, your notebook will be displayed.
Enter your email address below to receive the presentation slides. In the future, we’ll share you very few emails when we have new tutorial release, development updates, valuable toolkits and technical guidance . You can unsubscribe at any time by clicking the link at the bottom of every email. We’ll never share your information.