Boolean clip operations are a way to form new geometry from set operations between simpler shapes: union, intersection, difference, and symmetric difference. In Tidy3D these operations are represented by ClipOperation and can be applied directly to geometry objects. This notebook uses that idea to optimize a finite GaN micro LED light extractor whose top surface is etched by the intersection of two slightly different hexagonal hole lattices. For more background, see the Geometry transformations notebook, especially the “Clip Operations” section.
The design parameters control the two lattice pitches, hole radii, and relative rotation. The relative lateral shift is fixed to zero to preserve the symmetry of the extractor.
Compared to a strictly periodic texture, this construction introduces a slowly varying superstructure and a broader set of spatial frequencies across the finite LED pixel. That extra geometric diversity can help scatter guided and high angle emission into upward radiation, while the pattern is still described by only a few differentiable parameters and physically motivated boolean operations.
The objective is normalized top light extraction efficiency (top LEE): the upward flux above the device divided by the emitted power through a small flux box around the dipole. Both flux monitors are adjoint-capable, so each optimization step uses one forward and one adjoint simulation to update all geometric parameters at once.
After optimization, we apply a fabrication-aware cleanup step using an opening followed by a closing operation with a selected process threshold. We then compute normalized top light extraction efficiency (top LEE) using flux monitors: the upward extracted power is divided by the power emitted through a small flux box around the dipole. We compare the raw and fabrication processed initial and optimized designs against a flat finite-pixel reference, and then check polarization averaging and dipole position averaging at twice the optimization mesh resolution.
Note: Running the complete notebook launches remote Tidy3D jobs. The optimization loop launches two simulations per iteration through the adjoint workflow, while the LEE and position averaging sections launch additional standard FDTD simulations.
We first import the libraries used for geometry construction, remote simulation, optimization, and visualization.
import autograd.numpy as anpimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport timeimport tidy3d as tdimport tidy3d.web as webfrom tidy3d.exceptions import WebErrorfrom tidy3d.plugins.autograd import adam, optimizetd.config.logging.level ="ERROR"
Next we define the fixed physical parameters. Lengths are in microns.
# Materials.air_index =1.0gan_index =2.4air_medium = td.Medium(permittivity=air_index**2)gan_medium = td.Medium(permittivity=gan_index**2)# Center wavelength and source bandwidth.center_wavelength =0.5bandwidth_wavelength =0.1# Physical lateral size of the finite GaN micro LED pixel.pixel_size_xy =3.5pixel_half_width = pixel_size_xy /2.0# Add one center wavelength of air padding on each side before the PML.domain_size_xy = pixel_size_xy +2.0* center_wavelength# Etched GaN layer. The air-hole pattern occupies this vertical interval.gan_z_max =0.5etch_z_min =0.3etch_z_max =0.5etch_depth = etch_z_max - etch_z_minetch_center_z =0.5* (etch_z_min + etch_z_max)# Vertical source, flux monitor, and boundary placement.bottom_source_spacing =0.5structure_monitor_spacing =0.4monitor_top_spacing =0.25field_monitor_z = gan_z_max + structure_monitor_spacingsource_z =-field_monitor_zdomain_z_min = source_z - bottom_source_spacingdomain_z_max = field_monitor_z + monitor_top_spacingdomain_size_z = domain_z_max - domain_z_mindomain_center_z =0.5* (domain_z_min + domain_z_max)domain_size = (domain_size_xy, domain_size_xy, domain_size_z)domain_center = (0.0, 0.0, domain_center_z)gan_z_min = domain_z_min# Extend the lower GaN substrate past the bottom PML so it does not terminate there.gan_substrate_z_min = domain_z_min -2.0* center_wavelengthsource_center = (0.0, 0.0, source_z)source_norm_box_size =0.4# Single center frequency sampled by each flux monitor.wavelength_min = center_wavelength - bandwidth_wavelength /2.0wavelength_max = center_wavelength + bandwidth_wavelength /2.0freq0 = td.C_0 / center_wavelengthfreq_min = td.C_0 / wavelength_maxfreq_max = td.C_0 / wavelength_minfreq_width = freq_max - freq_minmonitor_freqs = np.array([freq0])# Time window and base mesh density used by the optimization simulations.run_time =200.0/ freq_widthmin_steps_per_wvl =15
The helper below lets us set separate mesh densities for optimization and final evaluation. The final standard FDTD LEE evaluations use twice the optimization mesh resolution.
The design is described by five normalized parameters in [0, 1]. They map to two pitches, two radii, and one relative rotation angle. The lateral shift of the second lattice is fixed at zero to keep the pattern symmetric. We keep the number of lattice sites fixed during optimization so that the geometry changes smoothly as parameters move.
We now build two hexagonal lattices of cylindrical air holes. The second lattice can be rotated relative to the first while its lateral shift remains fixed at zero. The final etched region is the intersection of the two lattices clipped to the finite square pixel footprint.
The operation variable above is set to "intersection" for this notebook. Setting it to "symmetric_difference" switches the two lattice combination to a symmetric difference pattern.
One physical way to think about the intersection pattern is a double exposure lithography process. A first exposure writes one hexagonal mask, a second slightly rotated exposure writes another, and only the regions that receive enough combined dose open the resist for etching. The resulting top etch is represented directly in Tidy3D by intersecting the two hole lattices with a ClipOperation.
The optimization and LEE analysis simulations use flux monitors: a top monitor and a small closed flux box around the dipole for source power normalization. Both are adjoint-capable so the normalized top LEE ratio can be used directly as the objective.
def make_source(source_center=source_center, polarization="Ex"):"""Create a broadband point dipole source."""return td.PointDipole( center=source_center, polarization=polarization, source_time=td.GaussianPulse( freq0=freq0, fwidth=freq_width, amplitude=1.0, ), )def make_top_flux_monitor():"""Create the upward flux monitor with adjoint flux support enabled."""return td.FluxMonitor( center=(0.0, 0.0, field_monitor_z), size=(domain_size[0], domain_size[1], 0.0), freqs=tuple(monitor_freqs.tolist()), name="top_flux_monitor", normal_dir="+", enable_adjoint=True, )def make_norm_flux_monitor(source_center=source_center):"""Create the small adjoint-capable flux box for emitted dipole power."""return td.FluxMonitor( center=source_center, size=(source_norm_box_size, source_norm_box_size, source_norm_box_size), freqs=tuple(monitor_freqs.tolist()), name="norm_flux_monitor", enable_adjoint=True, )
def build_static_gan_structures():"""Create the lower GaN block and upper GaN cap.""" lower_gan = td.Structure( geometry=td.Box.from_bounds( rmin=(-pixel_half_width, -pixel_half_width, gan_substrate_z_min), rmax=(pixel_half_width, pixel_half_width, etch_z_min), ), medium=gan_medium, name="gan_lower", ) upper_gan_cap = td.Structure( geometry=td.Box.from_bounds( rmin=(-pixel_half_width, -pixel_half_width, etch_z_min), rmax=(pixel_half_width, pixel_half_width, etch_z_max), ), medium=gan_medium, name="gan_cap", )return lower_gan, upper_gan_capdef build_traced_etch_structure(etch_geometry):"""Wrap a raw or processed air etch geometry as a structure inside GaN."""return td.Structure( geometry=etch_geometry, medium=air_medium, background_medium=gan_medium, name="etch_holes", )def build_simulation( params, monitors, source_center, source_polarization, grid_spec, etch_geometry=None, include_etch=True,):"""Assemble a finite-pixel micro LED simulation.""" structures =list(build_static_gan_structures())if include_etch:if etch_geometry isNone: etch_geometry = build_etch_geometry(params, operation=operation) structures.append(build_traced_etch_structure(etch_geometry))return td.Simulation( center=domain_center, size=domain_size, medium=air_medium, structures=tuple(structures), sources=(make_source(source_center=source_center, polarization=source_polarization),), monitors=monitors, boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=True), grid_spec=grid_spec, run_time=run_time, )def build_optimization_simulation(params):"""Build the differentiable simulation used inside the adjoint objective."""return build_simulation( params, monitors=( make_top_flux_monitor(), make_norm_flux_monitor(), ), source_center=source_center, source_polarization="Ex", grid_spec=grid_spec, )def build_analysis_simulation( params, grid_spec, source_center=source_center, source_polarization="Ex", etch_geometry=None, include_etch=True,):"""Build a standard FDTD simulation for normalized top LEE evaluation.""" monitors = ( make_top_flux_monitor(), make_norm_flux_monitor(source_center=source_center), )return build_simulation( params, monitors=monitors, source_center=source_center, source_polarization=source_polarization, grid_spec=grid_spec, etch_geometry=etch_geometry, include_etch=include_etch, )def build_interface_reference_simulation( grid_spec, source_center=source_center, source_polarization="Ex",):"""Build the flat top finite-pixel reference simulation."""return td.Simulation( center=domain_center, size=domain_size, medium=air_medium, structures=( td.Structure( geometry=td.Box.from_bounds( rmin=(-pixel_half_width, -pixel_half_width, gan_substrate_z_min), rmax=(pixel_half_width, pixel_half_width, gan_z_max), ), medium=gan_medium, name="gan_interface_reference", ), ), sources=(make_source(source_center=source_center, polarization=source_polarization),), monitors=( make_top_flux_monitor(), make_norm_flux_monitor(source_center=source_center), ), boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=True), grid_spec=grid_spec, run_time=run_time, )
Initial Simulation Smoke Check
Before launching remote jobs, we build the initial optimization simulation and run local validate_pre_upload(). This catches geometry, source, monitor, and meshing setup issues without evaluating the objective or computing a gradient.
Our figure of merit is the center-wavelength top LEE for the center Ex dipole. It is computed from two FluxMonitor objects: the upward top flux divided by the emitted power through the small normalization box. The objective returns a scalar so the Tidy3D autograd optimizer can differentiate it with respect to the five design parameters. The first objective and gradient evaluation happens inside the optimization loop below.
The raw adjoint result may contain tiny etched islands, thin bridges, or narrow unetched gaps. Those features can be numerically meaningful in the boolean geometry, but they may not survive a real lithography and etch process. Here we apply a simple cleanup for both the initial and optimized designs: slice the etch geometry into Shapely polygons, apply an opening followed by a closing operation, then convert the cleaned polygons back into PolySlab geometry for final evaluation.
The cleanup first applies a morphological opening to remove narrow etched features, then a morphological closing to fill narrow unetched gaps. The threshold is set by minimum_fabrication_feature. The finite pixel mask is applied throughout the cleanup so morphology never expands etched regions outside the physical LED footprint.
This cleanup is not differentiable, so we evaluate it after optimization rather than including it directly in the adjoint loop. It models how the fabrication process can suppress very small features and gaps. By evaluating both raw designs and designs processed after optimization, we check that the optimized result does not depend on tiny features produced by the raw boolean geometry, and instead reflects the larger scale pattern found by the optimizer.
import shapelyminimum_fabrication_feature =0.05use_cleaned_design_for_analysis =Truepixel_mask_polygon = shapely.box(-pixel_half_width,-pixel_half_width, pixel_half_width, pixel_half_width,)def replace_infinite_coordinates(coords):"""Replace infinite Shapely coordinates by a finite box outside the pixel.""" coords = np.asarray(coords, dtype=float) finite_bound =10.0* domain_size_xyreturn np.where(np.isinf(coords), np.sign(coords) * finite_bound, coords)def finite_shapely_geometry(shapely_geometry):"""Return a Shapely geometry with infinite coordinates replaced.""" bounds = np.asarray(shapely_geometry.bounds, dtype=float)if shapely_geometry.is_empty ornot np.any(np.isinf(bounds)):return shapely_geometryreturn shapely.transform( shapely_geometry, replace_infinite_coordinates, include_z=False, )def polygon_list(shapely_geometry):"""Flatten a Shapely geometry into nonempty polygons.""" polygons = [] valid_geometry = shapely.make_valid(finite_shapely_geometry(shapely_geometry))for part in shapely.get_parts(valid_geometry):if part.is_empty:continueif part.geom_type =="Polygon"and part.area >0.0: polygons.append(part)elif part.geom_type in {"MultiPolygon", "GeometryCollection"}: polygons.extend(polygon_list(part))return polygonsdef as_multipolygon(shapely_geometry):"""Normalize a Shapely object into a valid `MultiPolygon`.""" polygons = polygon_list(shapely_geometry)return shapely.MultiPolygon(polygons)def constrain_to_pixel(shapely_geometry):"""Clip etch regions to the finite LED pixel footprint."""return as_multipolygon(shapely_geometry.intersection(pixel_mask_polygon))def etch_geometry_to_multipolygon(etch_geometry, cleanup=False, quad_segs=24):"""Slice a Tidy3D etch geometry into clipped 2D Shapely polygons.""" polygons = etch_geometry.intersections_plane( z=etch_center_z, cleanup=cleanup, quad_segs=quad_segs, )iflen(polygons) ==0:return shapely.MultiPolygon([]) section = shapely.union_all([finite_shapely_geometry(poly) for poly in polygons])return constrain_to_pixel(section)def filter_small_components(multipolygon, minimum_feature):"""Remove etched components below an equivalent diameter threshold.""" minimum_area = np.pi * (0.5* minimum_feature) **2 polygons = [ polygonfor polygon in polygon_list(multipolygon)if polygon.area >= minimum_area ]return shapely.MultiPolygon(polygons)def clean_etch_multipolygon(multipolygon, minimum_feature):"""Apply opening followed by closing to a 2D etch pattern.""" radius =0.5* minimum_feature cleaned = constrain_to_pixel(as_multipolygon(multipolygon)) cleaned = constrain_to_pixel(cleaned.buffer(-radius, quad_segs=8)) cleaned = constrain_to_pixel(cleaned.buffer(radius, quad_segs=8)) cleaned = constrain_to_pixel(cleaned.buffer(radius, quad_segs=8)) cleaned = constrain_to_pixel(cleaned.buffer(-radius, quad_segs=8)) cleaned = filter_small_components(as_multipolygon(cleaned), minimum_feature)return constrain_to_pixel(cleaned)def ring_vertices(ring):"""Convert a Shapely ring to vertices without the duplicated endpoint.""" x_coords, y_coords = ring.coords.xyreturnlist(zip(np.asarray(x_coords[:-1]), np.asarray(y_coords[:-1])))def polygon_to_polyslab(polygon):"""Convert one processed Shapely polygon into Tidy3D geometry.""" exterior = td.PolySlab( vertices=ring_vertices(polygon.exterior), slab_bounds=(etch_z_min, etch_z_max), axis=2, )iflen(polygon.interiors) ==0:return exterior interiors =tuple( td.PolySlab( vertices=ring_vertices(interior), slab_bounds=(etch_z_min, etch_z_max), axis=2, )for interior in polygon.interiorsiflen(interior.coords) >=4 )ifnot interiors:return exteriorreturn exterior - td.GeometryGroup(geometries=interiors)def multipolygon_to_etch_geometry(multipolygon):"""Convert processed Shapely etch regions back to Tidy3D geometry.""" polygons = [ polygonfor polygon in polygon_list(multipolygon)ifnot polygon.is_empty and polygon.area >0.0 ]iflen(polygons) ==0:returnNone polyslabs =tuple(polygon_to_polyslab(polygon) for polygon in polygons)iflen(polyslabs) ==1:return polyslabs[0]return td.GeometryGroup(geometries=polyslabs)def summarize_multipolygon(multipolygon, label):"""Summarize component count and equivalent feature sizes.""" polygons = polygon_list(multipolygon) areas = np.asarray([polygon.area for polygon in polygons], dtype=float) equivalent_diameters =2.0* np.sqrt(areas / np.pi) iflen(areas) else np.array([])return {"geometry": label,"num_components": len(polygons),"total_area_um2": float(np.sum(areas)) iflen(areas) else0.0,"min_equiv_diameter_um": float(np.min(equivalent_diameters)) iflen(areas) else np.nan,"median_equiv_diameter_um": float(np.median(equivalent_diameters)) iflen(areas) else np.nan, }raw_etch_multipolygons = {"initial": etch_geometry_to_multipolygon( build_etch_geometry(params0, operation=operation), cleanup=False, ),"optimized": etch_geometry_to_multipolygon( build_etch_geometry(params_final, operation=operation), cleanup=False, ),}cleaned_etch_multipolygons = { design: clean_etch_multipolygon(raw_etch, minimum_fabrication_feature)for design, raw_etch in raw_etch_multipolygons.items()}cleaned_etch_geometries = { design: multipolygon_to_etch_geometry(cleaned_etch)for design, cleaned_etch in cleaned_etch_multipolygons.items()}raw_initial_etch = raw_etch_multipolygons["initial"]raw_optimized_etch = raw_etch_multipolygons["optimized"]cleaned_initial_etch_multipolygon = cleaned_etch_multipolygons["initial"]cleaned_optimized_etch_multipolygon = cleaned_etch_multipolygons["optimized"]cleaned_initial_etch_geometry = cleaned_etch_geometries["initial"]cleaned_optimized_etch_geometry = cleaned_etch_geometries["optimized"]cleanup_summary_records = []for design, raw_etch in raw_etch_multipolygons.items():for label, etch in ( ("raw", raw_etch), ("processed", cleaned_etch_multipolygons[design]), ): record = summarize_multipolygon(etch, label) record["design"] = design cleanup_summary_records.append(record)cleanup_summary = pd.DataFrame(cleanup_summary_records)[ ["design","geometry","num_components","total_area_um2","min_equiv_diameter_um","median_equiv_diameter_um", ]]cleanup_summary.round(4)
The optimization objective already uses normalized top LEE for the center Ex dipole. For the final performance analysis, we reuse the same normalization and compare additional polarizations, source positions, and fabrication processed geometries.
We use only top LEE for the comparisons below. All final LEE simulations use analysis_grid_spec, which is set to twice the mesh resolution used during optimization. When use_cleaned_design_for_analysis is True, fabrication processed versions of both the initial and optimized designs are included as additional comparisons. The flat reference uses the same finite pixel footprint as the patterned designs so the comparison isolates the effect of the top texture.
def summarize_lee_result(sim_data, design, polarization, position_index=0, source_center=source_center):"""Package one LEE result into a dataframe row.""" emitted_flux = np.asarray(sim_data["norm_flux_monitor"].flux.values, dtype=float) extracted_flux = np.asarray(sim_data["top_flux_monitor"].flux.values, dtype=float) top_lee =float(np.squeeze(extracted_flux / emitted_flux))return {"design": design,"polarization": polarization,"position_index": position_index,"x": source_center[0],"y": source_center[1],"z": source_center[2],"top_lee": top_lee, }
Polarization Average
We first evaluate the center dipole for Ex and Ey polarizations for the raw and processed initial and optimized designs, plus a flat finite-pixel GaN reference.
Real LEDs emit from an active region rather than from one fixed dipole position. Here we sample a small lateral grid whose outer points are one center wavelength from the pixel center in the x and y directions, giving a two wavelength total span in each lateral direction. We then average top LEE over both position and in-plane polarization.
source_position_offset = center_wavelengthsource_position_coords = np.array([-source_position_offset, 0.0, source_position_offset])source_positions = [ (float(x_pos), float(y_pos), source_center[2])for y_pos in source_position_coordsfor x_pos in source_position_coords]
The first plot below keeps the lateral source-position dependence visible. At each sampled (x, y) location, the bar height is averaged over Ex and Ey polarization. We then reduce those polarization-averaged position samples to one position- and polarization-averaged top LEE value for each design.
This example used ClipOperation geometry to build a compact micro LED surface texture from two intersecting hexagonal hole lattices. The design parameters flowed through the boolean geometry, the Tidy3D remote adjoint solve, and the normalized top LEE objective into a Tidy3D Adam optimization loop.
The final analysis used independent flux monitors to normalize the upward extracted power by the emitted dipole power. It compared the raw and fabrication processed initial and optimized designs against a flat finite-pixel reference using top LEE, then averaged over in-plane dipole polarizations and lateral source positions. The final standard FDTD evaluations were run at twice the mesh resolution used during optimization after small feature cleanup was applied.
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.