Inverse Design Waveguide Bend

Complete file at examples folder

We do inverse design of a compact photonic waveguide bend to demonstrate workflow of FDTD adjoint optimization. First, we seed the design using 2d TE adjoint simulations which serve as fast approximations. Optionlly, we finetune the resulting design in full blown 3d adjoint simulations.


using UnPack, LinearAlgebra, Random, StatsBase, Dates
using Zygote, Flux, CUDA, GLMakie, Jello
using Flux: mae, Adam
using Zygote: withgradient, Buffer
using BSON: @save, @load
using AbbreviatedStackTraces
using Jello, Luminescent, LuminescentVisualization
Random.seed!(1)

# if running directly without module # hide
# include("$(pwd())/src/main.jl") # hide
# include("$(pwd())/../LuminescentVisualization.jl/src/main.jl") # hide

We skip 3d finetuning as it's 20x more compute and memory intensive than 2d adjoints. If wishing to do 3d finetuning, set iterations3d. In any case, 3d forward simulations (without adjoint) only take a few seconds.

name = "inverse_design_waveguide_bend"
iterations2d = 10
iterations3d = 0
record2d = true
record3d = false
F = Float32
ongpu = false
model_name = nothing # if load saved model

We load design layout which includes a 2d static_mask of static waveguide geometry as well as variables with locations of ports, sources, design regions and material properties.


@load "$(@__DIR__)/layout.json" static_mask sources ports designs λ dx ϵbase ϵclad ϵcore hbase hwg hclad
dx, = [dx,] / λ

We initialize a Jello.jl Blob object which will generate geometry of design region. Its parameters will get optimized during adjoint optimization. We initialize it with a straight slab connecting input to output port.


szd = Tuple(round.(Int, designs[1].L / λ / dx) .+ 1) # design region size
if isnothing(model_name)
    nbasis = 5 # complexity of design region
    contrast = 10 # edge sharpness 
    rmin = nothing
    init = [-1 -1 1 -1 -1; -1 1 1 -1 -1; 1 1 -1 -1 -1; -1 -1 -1 -1 -1; -1 -1 -1 -1 -1]
    # init = nothing # random 
    # init = 1 # uniform slab
    model = Blob(szd...; init, nbasis, contrast, rmin,)
else
    @load "$(@__DIR__)/$model_name" model
end
model0 = deepcopy(model)
heatmap(model())

We set key time intervals. The signal must first propagate to port 2 after which all port power fluxes will get monitored


deltas = zeros(2)
# deltas[1] = 1
deltas[1] = 2 + 1.6norm(sources[1].c - ports[2].c) / λ * sqrt(ϵcore) # simulation duration in [periods] for signal to reach output ports
deltas[2] = 2 # duration to record power at output ports
T = cumsum(deltas)

We set boundary conditions, sources , and monitor. The modal source profile is obtained from external mode solver , in our case VectorModesolver.jl . Please refer to guide section of docs website for details . To get an approximate line source for use in 2d from the cross section profile , we sum and collapse it along its height axis


boundaries = [] # unspecified boundaries default to PML
monitors = [
    # (center, lower bound, upper bound; normal)
    Monitor(p.c / λ, p.lb / λ, p.ub / λ; normal=p.n)
    for p = ports
]

# modal source
@unpack Ex, Ey, Ez, Hx, Hy, Hz = sources[1].modes[1]
Jy, Jx, Mz = map([Ex, Ez, Hy]) do a
    transpose(sum(a, dims=2))
end
Jy, Jx = [Jy, Jx] / maximum(maximum.(abs, [Jy, Jx]))
c = sources[1].c / λ
lb_ = [0, sources[1].lb[1]] / λ
ub_ = [0, sources[1].ub[1]] / λ
sources = [Source(t -> cispi(2t), c, lb_, ub_; Jx, Jy,)]

ϵmin = ϵclad
static_mask = F.(static_mask)
ϵbase, ϵcore, ϵclad = F.((ϵbase, ϵcore, ϵclad))
sz = size(static_mask)

prob = setup(boundaries, sources, monitors, dx, sz; F, ϵmin)
@unpack dx, dt, sz, geometry_padvals, field_lims, field_boundvals, source_instances, monitor_instances, u0, = prob

# n = (size(Jy) .- size(monitor_instances[1])) .÷ 2
# power_profile = F.(abs.(Jy[range.(1 .+ n, size(Jy) .- n)...]))
power_profile = F.(real.(Jy .* conj.(Mz)))
power_profile /= norm(power_profile)

if ongpu
    using Flux
    # using CUDA
    # @assert CUDA.functional()
    u0, model, static_mask, μ, σ, m, field_boundvals, source_instances =
        gpu.((u0, model, static_mask, μ, σ, m, field_boundvals, source_instances))
    merge!(prob, (; u0, field_boundvals, source_instances))
end

We define a geometry update function that'll be called each adjoint iteration. It calls geometry generator model to generate design region which gets placed onto mask of static features.

function make_geometry(model, static_mask, prob)#; make3d=false)
    @unpack sz, geometry_padvals, field_lims = prob
    μ = ones(F, sz)
    σ = zeros(F, sz)
    m = zeros(F, sz)
    # μ = 1
    # σ = m = 0

    mask_ = Zygote.Buffer(static_mask)
    mask_[:, :] = static_mask
    # place!(mask_, σ.(model), round.(Int, designs[1].o / λ / dx) .+ 1)
    place!(mask_, model(), round.(Int, designs[1].o / λ / dx) .+ 1)
    mask = copy(mask_)
    ϵ = mask * ϵcore + (1 .- mask) * ϵclad

    if length(sz) == 3
        ϵ = sandwich(ϵ, round.(Int, [hbase, hwg, hclad] / λ / dx)..., ϵbase, ϵclad)
    end

    p = apply(geometry_padvals; ϵ, μ, σ, m)
    p = apply(field_lims, p)
end

Optimal design will maximize powers into port 1 and out of port 2. Monitor normals were set so both are positive. metrics function compute these figures of merit (FOM) quantities by a differentiable FDTD simulation . loss is then defined accordingly


function metrics(model, prob; autodiff=true, history=nothing)
    p = make_geometry(model, static_mask, prob;)
    if !isnothing(history)
        ignore_derivatives() do
            push!(history, p[:ϵ])
        end
    end
    @unpack u0, field_boundvals, source_instances, monitor_instances = prob
    # run simulation
    _step = if autodiff
        update
    else
        update!
    end
    u = reduce((u, t) -> _step(u, p, t, dx, dt, field_boundvals, source_instances;), 0:dt:T[1], init=deepcopy(u0))
    port_fluxes = reduce(T[1]+dt:dt:T[2], init=(u, 0)) do (u, port_fluxes), t
        _step(u, p, t, dx, dt, field_boundvals, source_instances),
        port_fluxes + dt * flux.((u,), monitor_instances[1:2],)
    end[2] / deltas[2]

    A = area.(monitor_instances)
    port_mode_powers = [mean(vec(a) .* vec(power_profile)) * A for (a, A) = zip(port_fluxes, A)]
    port_powers = mean.(port_fluxes) .* A
    # @info "" port_powers port_mode_powers
    @show port_powers, port_mode_powers
    # println("metrics $port_fluxes")
    abs.(port_mode_powers)
end
# @show const tp = metrics(model, T[1]=1, T[2]=2, autodiff=false)[1] # total power
# error()

function score(v)
    sum(-v)
end

# p0 = make_geometry(model0, static_mask, μ, σ, m)
history = []
loss = model -> score(metrics(model, prob; history))

We now do adjoint optimization. The first few iterations may show very little change but will pick up momentum


opt = RADAM(0.1)
opt_state = Flux.setup(opt, model)
# iterations2d = 66
# iterations2d = 400
for i = 1:iterations2d
    println("$i")
    @time l, (dldm,) = withgradient(loss, model)
    Flux.update!(opt_state, model, dldm)
    println(" $l\n")
end
@save "$(@__DIR__)/2d_model_$(time()).json" model
# error()

We do a simulation movie using optimized geometry


# @show metrics(model)
function runsave(model, prob; kw...)
    p = make_geometry(model, static_mask, prob)
    @unpack u0, dx, dt, field_boundvals, source_instances, monitor_instances = prob
    @showtime global u = accumulate((u, t) ->
            update!(deepcopy(u), p, t, dx, dt, field_boundvals, source_instances),
        0:dt:T[2], init=u0)

    # move to cpu for plotting
    if ongpu
        u, p, source_instances = cpu.((u, p, source_instances))
    end
    Hz = field.(u, :Hz)
    ϵEy = field(p, :ϵEy)
    dir = @__DIR__
    d = ndims(Hz[1])
    _name = "$(d)d_$name"
    # error()
    recordsim("$dir/$(_name).mp4", Hz, ;
        dt,
        field=:Hz,
        monitor_instances,
        source_instances,
        geometry=ϵEy,
        rel_lims=0.5,
        playback=1,
        axis1=(; title="$(replace( _name,"_"=>" ")|>titlecase)"),
        axis2=(; title="monitor powers"),
        kw...
    )

end

record = model -> runsave(model, prob)
record2d && record(model)

We now finetune our design in 3d by starting off with optimized model from 2d. We make 3d geometry simply by sandwiching thickened 2d mask between lower substrate and upper clad layers.


ϵdummy = sandwich(static_mask, round.(Int, [hbase, hwg, hclad] / λ / dx)..., ϵbase, ϵclad)
sz = size(ϵdummy)
model2d = deepcopy(model)


# "monitors"
δ = 0.1 # margin
monitors = [Monitor([p.c / λ..., hbase / λ], [p.lb / λ..., -δ / λ], [p.ub / λ..., hwg / λ + δ / λ]; normal=[p.n..., 0]) for p = ports]

# modal source
@unpack Ex, Ey, Ez, = sources[1].modes[1]
Jy, Jz, Jx = map([Ex, Ey, Ez] / maximum(maximum.(abs, [Ex, Ey, Ez]))) do a
    reshape(a, 1, size(a)...)
end
c = [sources[1].c / λ..., hbase / λ]
lb = [0, sources[1].lb...] / λ
ub = [0, sources[1].ub...] / λ
sources = [Source(t -> cispi(2t), c, lb, ub; Jx, Jy, Jz)]
# sources = [Source(t -> cispi(2t), c, lb, ub; Jx=1)]

prob = setup(boundaries, sources, monitors, dx, sz; F, ϵmin, Courant=0.3)
if ongpu
    u0, model, static_mask, μ, σ, m, field_boundvals, source_instances =
        gpu.((u0, model, static_mask, μ, σ, m, field_boundvals, source_instances))
    merge!(prob, (; u0, field_boundvals, source_instances))
end


loss = model -> score(metrics(model, prob;))
opt = RADAM(0.1)
opt_state = Flux.setup(opt, model)
for i = 1:iterations3d
    @time l, (dldm,) = withgradient(loss, model)
    Flux.update!(opt_state, model, dldm)
    println("$i $l")
end
@save "$(@__DIR__)/3d_model_$(time()).json" model


record = model -> runsave(model, prob; elevation=70°, azimuth=110°)
record3d && record(model)