flexcompute / tidy3d

Fast electromagnetic solver (FDTD) at scale.
https://docs.flexcompute.com/projects/tidy3d/en/latest/
GNU Lesser General Public License v2.1
181 stars 41 forks source link

FieldProjectionAngleMonitor weirdness #1280

Closed yaugenst closed 10 months ago

yaugenst commented 10 months ago

We have come across some potential issues with the td.FieldProjectionAngleMonitor. In particular, we seem to be unable to reproduce far field radiation patterns when compared to either Lumerical or our own implementation of the Fraunhofer approximation, which both match each other (courtesy of @bhnzhang). Maybe we are not using the monitor correctly? But what is especially weird is that the monitor does not seem to converge with higher resolutions. Is there documentation on what exactly the td.FieldProjectionAngleMonitor implements? Since there is a proj_distance parameter, I'm guessing it's a different calculation.

This is what our comparison looks like: gc_convergence

It is strange that the two plots (very roughly) match only for the lowest resolution. If we zoom in a little on the higher resolutions the difference becomes even more pronounced: gc_convergence

This is the simulation setup: screenshot_20231201_105453

Here is the code to reproduce these results. Note that we originally came across this issue in a different setting (grating coupler simulations), but I reduced it to the MWE below:

#!/usr/bin/env python3

import matplotlib.pyplot as plt
import numpy as np
import tidy3d as td
import xrft
from tidy3d.web import Batch, BatchData

def near2far(field, angles=(-15, 5), pad_mult=5, downsample=1):
    nx = int(np.floor(len(field.x) / downsample))
    x_interp = np.linspace(field.x[0], field.x[-1], nx)

    field_interp = field.interp(x=x_interp)

    npad = pad_mult * len(x_interp)
    field_pad = xrft.padding.pad(field_interp, {"x": npad})
    field_k = xrft.fft(field_pad)

    lambda0 = np.squeeze(td.C_0 / field.f).values
    deg = np.rad2deg(field_k.freq_x * lambda0)
    field_k = field_k.assign_coords(thetax=deg)
    return field_k.where(
        (field_k.thetax >= angles[0]) & (field_k.thetax <= angles[1]), drop=True
    )

def make_gc_sim(resolution, angles=(-15, 5)):
    rng = np.random.default_rng(123456)

    sim_size = (40, 0, 2.0)
    lcen = 1.0
    fcen = td.C_0 / lcen

    grid_spec = td.GridSpec.auto(wavelength=lcen, min_steps_per_wvl=resolution)
    boundary_spec = td.BoundarySpec(
        x=td.Boundary.pml(),
        y=td.Boundary.periodic(),
        z=td.Boundary.pml(),
    )

    structures = [
        td.Structure(
            geometry=td.Box(
                center=(cx, 0, 0), size=(rng.uniform(0.2, 0.8, 1), td.inf, 0.5)
            ),
            medium=td.Medium(name="test", permittivity=8),
        )
        for cx in np.linspace(-10, 10, 20)
    ]

    nf_monitor = td.FieldMonitor(
        center=(0, 0, -0.5),
        size=(td.inf, td.inf, 0),
        freqs=[fcen],
        colocate=True,
        name="near_fields",
    )

    ff_monitor = td.FieldProjectionAngleMonitor(
        center=nf_monitor.center,
        size=nf_monitor.size,
        freqs=nf_monitor.freqs,
        normal_dir="-",
        phi=[0.0],
        theta=np.linspace(*map(np.deg2rad, angles), 501),
        colocate=True,
        name="far_fields",
    )

    source = td.GaussianBeam(
        center=(0, 0, 0.5),
        size=(td.inf, td.inf, 0),
        source_time=td.GaussianPulse(freq0=fcen, fwidth=fcen / 10),
        direction="-",
        waist_radius=20,
        pol_angle=np.pi / 2,
        angle_theta=np.deg2rad(5),
    )

    sim = td.Simulation(
        size=sim_size,
        sources=[source],
        structures=structures,
        monitors=[nf_monitor, ff_monitor],
        grid_spec=grid_spec,
        boundary_spec=boundary_spec,
        shutoff=1e-6,
        run_time=5e-12,
    )

    return sim

def main():
    run_dir = "ff_test"

    batch = Batch(simulations={res: make_gc_sim(res) for res in (10, 20, 30, 40, 50)})
    batch.run(path_dir=run_dir)

    batch_data = BatchData.load(run_dir)

    fig, ax = plt.subplots(2, 1, sharex=True, tight_layout=True, dpi=100)

    for resolution in batch_data.task_ids:
        sim_data = batch_data.load_sim_data(resolution)

        power = np.squeeze(sim_data["far_fields"].power)

        ek = near2far(np.squeeze(sim_data["near_fields"].Ey))
        abs2k = np.abs(ek) ** 2

        ax[0].plot(np.rad2deg(power.theta), power, label=resolution)
        ax[1].plot(abs2k.thetax, abs2k, label=resolution)

    ax[0].set_title("FieldProjectionAngleMonitor")
    ax[0].legend(title="Resolution")
    ax[1].set_title("Fraunhofer approximation")
    ax[1].set_xlabel("Angle (deg)")
    fig.savefig("gc_convergence.png", bbox_inches="tight", pad_inches=0.1, dpi=300)

if __name__ == "__main__":
    main()

The match does become better when the near field is not perturbed as much.

momchil-flex commented 10 months ago

Interesting! We have this example showcasing various filed pojrection results, as well as various tests vs analytic results (e.g. far field emission of a dipole, scattering from nanoparticles,...). We know that our projection works as expected in many cases, so it's important to figure out what is going on here. The dependence on resolution is indeed very weird. The projection distance should not matter when you have the far field approximation on, which is true by default (more precisely, it should just be an overall scaling factor).

@shashwat-sh could you take a look?

shashwat-sh commented 10 months ago

I'll take a closer look, but right off the bat my initial guess is that the fields are being projected backwards. I'll look into this more carefully though.

momchil-flex commented 10 months ago

Ahh so it's just some numerical-precision-level power? That makes sense.

shashwat-sh commented 10 months ago

Yeah. The definition of angles should be with respect to the global coordinate system. So here, since we are projecting "downwards" along -z, I think the angles (in degrees) should be (165, 185) rather than `(-15, 5).

shashwat-sh commented 10 months ago

Or rather (175, 195)

yaugenst commented 10 months ago

Ooooooh... well, that solves it then: screenshot_20231201_111034

Thanks a lot for the quick resolution.

shashwat-sh commented 10 months ago

I think this has come up before - maybe we should add a validator that warns when the requested angles are "behind" the monitor, to make sure the user is aware? @momchil-flex

yaugenst commented 10 months ago

I'm wondering if perhaps from a user standpoint it would make sense to flip the global coordinates depending on the normal_dir? Intuitively most people would define their coordinates like that I guess.

shashwat-sh commented 10 months ago

Yeah that's also a good point

shashwat-sh commented 10 months ago

I think the challenge was that in some cases, like scattering from a sphere, the projection monitor is a closed surface, and the normal_dir doesn't have much meaning unless you look surface-by-surface. In that case, it made sense that the angles are always defined in the global system. But maybe we can treat the open-surface and closed-surface cases differently.

yaugenst commented 10 months ago

Right that makes sense. Then a warning as you proposed is probably a better approach.

momchil-flex commented 10 months ago

Yeah we probably don't want to break backwards compatibility on this, even though in the 3D case we actually support giving the monitor a 3D geometry, and we handle the normal direction assignment internally for all surfaces. For a 2D monitor, what exactly does normal_dim affect, if not the global axis orientation? In this particular example what happens if I set the angles to (175, 195), but use normal_dim = "+"?

shashwat-sh commented 10 months ago

normal_dir affects how the equivalent surface currents are computed from the fields, i.e. it is the n in n x E, n x H. So its definition actually does not relate to the projection space at all, but rather the source current definition

shashwat-sh commented 10 months ago

Well, implicitly it does relate to the projection space, but yeah, in your example, it would again be as though you're projecting "backwards" if you set normal_dir to +

momchil-flex commented 10 months ago

Changing it just adds a global - sign to the projected fields then? I think that makes sense, but yeah not super useful (or natural) if it doesn't flip the coordinate axis. But yeah I think historically it was mostly for accounting surfaces of 3D boxes.

I think a warning is fine yeah, if a 2D monitor with normal_dim = "-" but theta < 90.

shashwat-sh commented 10 months ago

Ok sounds good. I guess in future major releases when backwards compatibility is being broken anyway, we could try to think of a better design, maybe treating the 3D and 2D cases differently

bhnzhang commented 10 months ago

Curious why +/- propagation direction matters here when propagating to infinity? In free space wouldn't the fields look the same whether you propagate to +infinity or -infinity?

momchil-flex commented 10 months ago

If a plane wave going up has (E, H) then the plane wave going down has (E, -H). The sign matters and is what makes the field projection directional. The quick and dirty near2far script in the code above takes only the E field into account and cannot make that distinction though. In our projection, both E and H contributions are included.

bhnzhang commented 10 months ago

Ah I see, yes including H is more accurate. Thanks!