Inverse design of a Y-branch using lumopt2#

An example of a parametric optimization for a Y-branch using lumopt2. This example demonstrates the use of a closed curve parametrization with a custom function to enforce symmetry. After the optimization, the final design is exported as a GDSII file.

Prerequisites:

  • Valid Lumerical FDTD license with Lumerical 2026 R1.2 release or newer.

b9193184063a46ce94ca4c53bd642133

Imports#

[ ]:
1from pathlib import Path
2
3from matplotlib import pyplot as plt
4
5plt.ion()
6import numpy as np
7
8import ansys.lumerical.core as lumapi
9import ansys.lumerical.core.lumopt2 as lmpt

Material, simulation, geometry#

Material and waveguide parameters#

[ ]:
10n_wg = np.sqrt(12.25)  # Silicon refractive index
11n_bg = np.sqrt(2.25)  # Silicon oxide background
12wg_width = 0.5e-6  # Waveguide width (500 nm)
13wg_height = 0.22e-6  # Waveguide height (220 nm)

Wavelength#

Set wavelength from 1300-1800nm

[ ]:
14wavelengths = np.linspace(1300e-9, 1800e-9, 11)

Design region parameters#

[ ]:
15splitter_span_x = 3.5e-6  # Length of the Y-branch splitter
16wg_y_offset = 1.0e-6  # Output arm offset in y
17port_width = 3 * wg_width  # Port width to capture the mode
18port_height = 2e-6  # Port height in z
19fdtd_span_x = splitter_span_x + 0.8e-6
20fdtd_span_y = 2 * wg_y_offset + 3 * wg_width
21fdtd_span_z = wg_height + 2e-6
22mesh_size = 25e-9
23offset = 4 * mesh_size  # Buffer between optimization region and FDTD edge

Base simulation#

[ ]:
24def generate_base_sim(fdtd):
25    """Build the FDTD region, the input/output ports, and the field monitor."""
26    fdtd.addfdtd(
27        {
28            "x": 0,
29            "x span": fdtd_span_x,
30            "y": 0,
31            "y span": fdtd_span_y,
32            "z": 0,
33            "z span": fdtd_span_z,
34            "index": n_bg,
35            "mesh accuracy": 3,
36            "mesh refinement": "precise volume average",
37        }
38    )
39    fdtd.addport({"name": "port_in"})
40    fdtd.set("injection axis", "x")
41    fdtd.set(
42        {
43            "direction": "Forward",
44            "x": -fdtd_span_x / 2 + 2e-7,
45            "y": 0,
46            "y span": port_width,
47            "z span": port_height,
48            "frequency dependent profile": False,
49        }
50    )
51
52    fdtd.addport({"name": "port_out1"})
53    fdtd.set("injection axis", "x")
54    fdtd.set(
55        {
56            "direction": "Backward",
57            "x": fdtd_span_x / 2 - 2e-7,
58            "y": wg_y_offset,
59            "y span": port_width,
60            "z span": port_height,
61            "frequency dependent profile": False,
62        }
63    )
64
65    # 2D field monitor through the device midplane (used by GraphicalVisualizer).
66    # Override the global monitor settings so this monitor only records the
67    # mid-O-band wavelength: that's all we need for live visualization and it
68    # keeps the monitor lightweight regardless of the optimization sweep.
69    center_wavelength = float(wavelengths[len(wavelengths) // 2])
70    fdtd.adddftmonitor(
71        {
72            "name": "field_monitor",
73            "x": 0,
74            "x span": fdtd_span_x,
75            "y": 0,
76            "y span": fdtd_span_y,
77            "z": 0,
78            "override global monitor settings": True,
79            "use source limits": False,
80            "frequency points": 1,
81            "wavelength center": center_wavelength,
82            "wavelength span": 0,
83        }
84    )
85
86    fdtd.setglobalsource("wavelength start", wavelengths[0])
87    fdtd.setglobalsource("wavelength stop", wavelengths[-1])
88    fdtd.setglobalmonitor("frequency points", len(wavelengths))
89    fdtd.setglobalmonitor("use wavelength spacing", True)
90    fdtd.setnamed("FDTD::ports", "override global monitor settings", False)

Optimization region#

The optimization region is kept inside the FDTD bounds with a small buffer

[ ]:
91optimization_region = lmpt.Box(
92    x_min=-splitter_span_x / 2,
93    x_max=splitter_span_x / 2,
94    y_min=-(wg_y_offset + wg_width),
95    y_max=wg_y_offset + wg_width,
96    z_min=-wg_height / 2.0,
97    z_max=wg_height / 2.0,
98    mesh_size=mesh_size,
99)

Closed curve geometry#

Closed-curve definition of the silicon Y-splitter. Walking the boundary counter-clockwise from the top-left, the four cubic segments (2, 6, 7, 11) form the parametric splitter region; everything else is fixed waveguide wall. Segments 2 and 11 control the outer walls of the upper / lower output arms (mirror partners across y=0); segments 6 and 7 control the inner V-shape between the two output arms (also mirror partners).

[ ]:
100path = [
101    lmpt.Segment([-fdtd_span_x / 2 - 200e-9, wg_width / 2], "linear"),  # Segment 1
102    lmpt.Segment([-splitter_span_x / 2, wg_width / 2], "cubic"),  # Segment 2  (upper outer wall, parametric)
103    lmpt.Segment([splitter_span_x / 2, wg_y_offset + wg_width / 2], "linear"),  # Segment 3
104    lmpt.Segment([fdtd_span_x / 2 + 200e-9, wg_y_offset + wg_width / 2], "linear"),  # Segment 4
105    lmpt.Segment([fdtd_span_x / 2 + 200e-9, wg_y_offset - wg_width / 2], "linear"),  # Segment 5
106    lmpt.Segment([splitter_span_x / 2, wg_y_offset - wg_width / 2], "cubic"),  # Segment 6  (upper inner wall, parametric)
107    lmpt.Segment([-splitter_span_x / 2 + 1500e-9, 0], "cubic"),  # Segment 7  (lower inner wall, parametric)
108    lmpt.Segment([splitter_span_x / 2, -(wg_y_offset - wg_width / 2)], "linear"),  # Segment 8
109    lmpt.Segment([fdtd_span_x / 2 + 200e-9, -(wg_y_offset - wg_width / 2)], "linear"),  # Segment 9
110    lmpt.Segment([fdtd_span_x / 2 + 200e-9, -(wg_y_offset + wg_width / 2)], "linear"),  # Segment 10
111    lmpt.Segment([splitter_span_x / 2, -(wg_y_offset + wg_width / 2)], "cubic"),  # Segment 11 (lower outer wall, parametric)
112    lmpt.Segment([-splitter_span_x / 2, -wg_width / 2], "linear"),  # Segment 12
113    lmpt.Segment([-fdtd_span_x / 2 - 200e-9, -wg_width / 2], "linear"),  # Segment 13 (closes loop)
114]
115
116y_branch_curve = lmpt.ClosedCurve(path, z_min=-wg_height / 2, z_max=wg_height / 2, index=n_wg, optimization_region=optimization_region)

Check the geometry of the curve. The code will continue to run after this if the whole script is ran.

[ ]:
117y_branch_curve.plot()
118plt.pause(0.1)

Parametrization#

Parametrize the y-branch geometry and enforce mirror symmetry across y=0.

Number of control vertices added on each parametric segment. Increasing these values gives the optimizer more shape freedom at the cost of more adjoint gradient evaluations per iteration.

[ ]:
119num_params_outer = 6
120num_params_inner = 5
121num_params = num_params_outer + num_params_inner + 1  # +1 for the V-tip x-offset

Subdivide the four cubic boundary segments. The mirror partners (11 mirrors 2, 7 mirrors 6) get the same number of vertices so the parametrization function below can pair them one-to-one.

[ ]:
122split_result = y_branch_curve.split_segments(
123    [
124        lmpt.EqualSplit(segment_index=2, num_added_vertices=num_params_outer),  # Upper outer wall
125        lmpt.EqualSplit(segment_index=6, num_added_vertices=num_params_inner),  # Upper inner wall
126        lmpt.EqualSplit(segment_index=7, num_added_vertices=num_params_inner),  # Lower inner wall (mirror of 6)
127        lmpt.EqualSplit(segment_index=11, num_added_vertices=num_params_outer),  # Lower outer wall (mirror of 2)
128    ]
129)
130seg2_vertices = split_result[2]
131seg6_vertices = split_result[6]
132seg7_vertices = split_result[7]
133seg11_vertices = split_result[11]
134center_vertex_idx = seg7_vertices[0] - 1  # V-tip vertex sits between segments 6 and 7

Defining the symmetric parametrization#

[ ]:
135def symmetric_parametrization(params):
136    """Enforce symmetric vertex displacements by mapping ``params`` to per-vertex deltas.
137
138    Enforces symmetric displacements in vertices by mapping vertex displacements
139    to the same parameter values in the ``params`` array. Uses the params
140    array as well as the vertex indices from earlier.
141
142    Parameters
143    ----------
144    params : array-like
145        Length ``num_params`` array.  Layout:
146
147        * ``params[0:num_params_outer]`` displace the outer-wall vertices
148          on segment 2 in +y; the mirror partners on segment 11 move in -y.
149        * ``params[num_params_outer:num_params_outer + num_params_inner]``
150          displace the inner-wall vertices on segment 6 in +y; the mirror
151          partners on segment 7 move in -y.
152        * ``params[-1]`` shifts the V-tip vertex in x.
153
154    Returns
155    -------
156    list of ParamVertex
157        Per-vertex displacement spec for every control vertex created by
158        ``split_segments`` above.
159    """
160    deltas = []
161
162    # Upper half
163    for i, idx in enumerate(seg2_vertices):
164        # Outer wall
165        deltas.append(lmpt.ParamVertex(idx=idx, delta_y=params[i]))
166    for i, idx in enumerate(seg6_vertices):
167        # Inner wall, starting at the end of the outer wall params
168        deltas.append(lmpt.ParamVertex(idx=idx, delta_y=params[num_params_outer + i]))
169
170    # V-tip vertex (x-only shift), this is the last parameter
171    deltas.append(lmpt.ParamVertex(idx=center_vertex_idx, delta_x=params[-1]))
172
173    # Lower half, traversed in reverse as we need the first vertex in segment 7 to pair with the last vertex in segment 6, etc.
174    # Symmetry enforced by making delta_y the negative of the same parameter
175    for i, idx in enumerate(seg11_vertices):
176        # Lower outer wall
177        mirror_i = num_params_outer - 1 - i
178        deltas.append(lmpt.ParamVertex(idx=idx, delta_y=-params[mirror_i]))
179    for i, idx in enumerate(seg7_vertices):
180        # Lower inner wall
181        mirror_i = num_params_inner - 1 - i
182        deltas.append(lmpt.ParamVertex(idx=idx, delta_y=-params[num_params_outer + mirror_i]))
183
184    return deltas

Bounds: the outer wall has more room to bow outward (positive y) than to bow inward (negative y). The inner wall and V-tip have asymmetric ranges tuned for the typical splitter optimization landscape.

[ ]:
185bounds = (
186    [(-250e-9, 500e-9)] * num_params_outer + [(-400e-9, 200e-9)] * num_params_inner + [(-400e-9, 100e-9)]  # V-tip x-offset
187)

Call the parametrization function to apply the symmetric parametrization

[ ]:
188y_branch_curve.set_parametrization_function(
189    func=symmetric_parametrization,
190    n_params=num_params,
191    bounds=bounds,
192)

Check the geometry again after applying parametrization. The code will continue to run after this if the whole script is ran.

[ ]:
193y_branch_curve.plot()
194plt.pause(0.1)

Figure of merit#

Define a broadband figure of merit: the target transmission to port_out1 is 0.5 (50% of the input power per output arm), averaged over the O-band sweep above. PNorm broadcasts the scalar target across all wavelengths automatically.

[ ]:
195port_out = lmpt.PortResults("port_out1", metric="transmission", wavelengths=wavelengths)
196y_branch_fom = lmpt.Fom(port_out, fct=lmpt.PNorm(p=2, target=0.5))

Optimization session setup#

FDTD session#

Create an FDTD session with visible UI

[ ]:
197fdtd_session = lmpt.FdtdSession(show_fdtd_cad=True)

Project#

Create the project (FOM is defined in y_branch_setup.py)

[ ]:
198project = lmpt.Project(setup=generate_base_sim, parametrization=y_branch_curve, fom=y_branch_fom, fdtd_session=fdtd_session)

Uncomment and run the code below to validate that the parameter and project setup was done correctly

[ ]:
199# params = project.parametrization.get_initial_params()
200# lmpt.validate_gradient(project=project, params=params, perturbation=1e-9)

Optimizer#

Use SciPy’s L-BFGS-B (gradient-based, supports bounds). The project’s adjoint gradient turns each iteration into one forward + one adjoint sim, which is the same per-iteration cost as a gradient-free method but with much better convergence per iteration in the smooth-FOM regime.

[ ]:
201optimizer = lmpt.ScipyOptimizer(method="L-BFGS-B", max_iter=30)

Visualizer#

Visualizer 1: FOM trace, gradient-norm trace, current geometry, and the real part of the Ey field so we can watch the splitter mode reshape during optimization.

[ ]:
202visualizer = lmpt.GraphicalVisualizer(
203    figsize=(12, 10),
204    layout=(2, 2),  # Arrange panels in 2x2 grid
205    panels=[
206        lmpt.FomPanel(),
207        lmpt.GradientNormPanel(),
208        lmpt.GeometryPanel(),
209        lmpt.MonitorPanel(
210            monitor_name="FDTD::ports::port_out1",
211            result_name="expansion for port monitor.T_out",
212            operation="abs",
213            title="|T_out| (matches FOM)",
214            # Clamp the transmission trace to the physical
215            # [0, 1] range so the plot stays comparable
216            # iteration-to-iteration even when the
217            # baseline transmission starts very low.
218            # ``axes_kwargs`` is forwarded verbatim to
219            # ``ax.set(**axes_kwargs)``.
220            axes_kwargs={"ylim": (0.0, 1.0)},
221        ),
222    ],
223)

Visualizer 2: Plot the y-direction electric field at the midplane monitor, for the midband wavelength.

[ ]:
224visualizer2 = lmpt.GraphicalVisualizer(
225    filename_prefix="field_plot_Ey",
226    panels=[
227        lmpt.MonitorPanel(monitor_name="field_monitor", result_name="E.Ey", operation="real", title="Ey field (real part)"),
228    ],
229)

Optimization session#

Put everything together into an optimization session

[ ]:
230optimization = lmpt.Optimization(
231    project=project,
232    optimizer=optimizer,
233    callbacks=[lmpt.FileLogger(), visualizer, visualizer2],
234)

Run optimization#

[ ]:
235result = optimization.run()

The final visualizer is shown below

182672365ad74f41bec094ef30380b33

The final electric field is shown below

c771182de0cb4dd4a852f301c394ec00

Save project#

[ ]:
236best_params, best_fom = result
237project.save_project("y_branch_final.fsp", params=best_params)

Export as gds#

[ ]:
238project_dir = Path(project.fom.config_map.project_folder).resolve()
[ ]:
239with lumapi.FDTD(project=str(project_dir / "y_branch_final.fsp"), hide=True) as fdtd:
240    f = fdtd.gdsopen(str(project_dir / "y_branch_final.gds"))
241    fdtd.gdsbegincell(f, "y_branch")
242    # Material is set to the index as it was created as an "<Object defined dielectric>"
243    fdtd.gdsaddstencil(f, "1:1", {"material": "3.5", "partialname": "optimization_polygon", "z": 0})
244    fdtd.gdsendcell(f)
245    fdtd.gdsclose(f)

The exported GDSII file is shown below

00ff47c213df49979a52af48eaa85e14