Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modal group index calculation without finite differences #2010

Open
gvnwst opened this issue Oct 9, 2024 · 14 comments
Open

Modal group index calculation without finite differences #2010

gvnwst opened this issue Oct 9, 2024 · 14 comments

Comments

@gvnwst
Copy link

gvnwst commented Oct 9, 2024

Problem

Modal group index calculations take a while, and cost a lot. That's because they use finite differences.

Aside from the cost, it becomes quite problematic to finite difference when simulating GVD. Not only are you propagating even more numerical error (and are susceptible to choice of step size, though this is a lesser issue), frequently photonics designers are searching for places where GVD is flat or zero -- exactly where numerical noise will be the most visible!

Solution I'd like

It's possible to analytically calculate the modal group index based off the effective index, modal field distribution, and permittivity distribution. This was referenced in a comment on #1169, but seems to have been lost in the ether when that issue closed.

References for equations to implement

The referenced paper here has a rigorous treatment of this (Eqs. 14 and 15). There are additional simplifications which can be made, particularly considering the case of Z-invariant (X-invariant, whatever) propagation, but this is probably easy enough to implement as-is.

Po-Ru and Steven's derivation does not include the effects of anisotropy. It's not a difficult extension -- I've derived it in the past for the case of negligible loss (maintaining a hermetian operator) with fully-anisotropic and dispersive materials. If there's interest in pushing this forward I can dig up those derivations. A super brief version exists in this paper, Section 6 of the supplement, leaving it in a general form and then the solution in the plane-wave basis.

A problem with this would be that material dispersion can strongly affect the EM energy density and must be accounted for. In general you already have dispersive materials so it's juts a question of dielectric smoothing. While dielectric smoothing is underway, it'd be useful to get these calculations going with the restriction that it only works for un-smoothed models (much like fully anisotropic materials are at the moment).

@momchil-flex
Copy link
Collaborator

Yeah this would be great to have. By the way one of the reasons for keeping our frontend open (including the local mode solver!) is hoping the community can also contribute with things like these that we may not find time for right now. It sounds like you have quite a lot of expertise on the matter so if you want to give it an initial try, we'd certainly be happy to support as much as possible.

@gvnwst
Copy link
Author

gvnwst commented Oct 10, 2024

I've poked around at what it would take -- still pretty new to the tidy3D interface so I'm hesitant to write something crappy and submit it 😅

There are some other things I'm going to try my hand at first, in particular dielectric smoothing for generally-anisotropic materials. That'd be a much bigger improvement for the projects I'm working on in the forseeable future (and would make the solution to this problem better!). Might rope @doddgray into it.
Upcoming Issue on this, most likely...

@doddgray
Copy link

I would be glad to help with this. Calculating group index from a single frequency solution should be faster, more accurate and straightforward. It's the ratio of the integrated energy density to the integrated poynting flux dotted into the plane. If you want it to be accurate you need to add the dispersive terms to the energy density.

I tried doing this by hand with tidy3D mode data a year or so ago and got confused about whether the field and dielectric tensor elements corresponded to the same spatial grids or whether they were on the Yee lattice. Maybe this is documented now. What is the best/expected way to calculate Poynting flux and (ED + HB) with Tidy3D mode data? That's all there is to it.

@momchil-flex
Copy link
Collaborator

Thanks! So the fields in Tidy3D can be either colocated to grid boundaries or returned on the Yee grid, depending on the colocate setting of monitors and the ModeSolver (default is True). We have an in-built method to compute the flux which basically takes care of colocating the fields if they were not to begin with, as well as applying a small correction related to the finite grid size in the normal direction. Unfortunately computing ED + HB will be a bit more complicated. I'll focus on D because usually media should be non-magnetic, although some of our advanced subpixel schemes for metals do introduce permeability different from 1. Basically, I think the most accurate way to get D is to compute E and eps with colocate=False, multiply those (and also multiply by E one more time?) and only then colocate to the grid boundaries (so that the final ED lives on the same coordinates as the colocated HB). I mean, this all sounds like it could be incorporated in our code, because we always compute the data on the yee grid and only then collocate if needed. It just would require a way to store D as well, at least temporarily. Happy to discuss further if you want to take a crack at it.

@doddgray
Copy link

Thanks, that sounds similar to what I tried previously so good to know I wasn't too far off. IIRC I ended up with something that seemed nearly correct but I was worried that I was performing unnecessary or slightly incorrect field & permittivity interpolations (mu also 1/I for me). If calculated consistently with the field data grid locations I think this should result in modal group index values that are accurate to smth comparable to the eigenvalue tolerance used for mode solving. I think i was doing some inconsistent interpolations to colocate the fields and this was noticeably degrading that accuracy. I'll dig up the code today and share here. I bet you could fix it in seconds.

This reminds me- Do field and tensor elements always/usually live on the Yee lattice in frequency domain solvers (like the mode solver)?. I thought the Yee lattice was needed for stable discretized time domain integration and would have naively thought to use colocalized fields and tensors for the mode solver.

@doddgray
Copy link

doddgray commented Oct 30, 2024

Oh right IIRC out-of-plane field elements are needed for accurate (ED+HB) and i was confused how to do this for a mode solver defined in a 2D plane when the fields are on the Yee grid.

@momchil-flex
Copy link
Collaborator

This reminds me- Do field and tensor elements always/usually live on the Yee lattice in frequency domain solvers (like the mode solver)?. I thought the Yee lattice was needed for stable discretized time domain integration and would have naively thought to use colocalized fields and tensors for the mode solver.

The Yee grid is actually more about accuracy than about stability, although if a different grid is used in the time domain, a stability analysis would certainly need to be done. But yeah it's just a way to directly do centered derivatives on a grid that feels like twice higher resolution than a collocated one, with the same amount of work and memory cost. For this reason it is ubiquitously used in frequency-domain methods too, like FDFD. (Not sure about specific references, I'm sure there are many, e.g. https://www.youtube.com/watch?v=hv5lIx4u8mY&ab_channel=EMPossible) The mode solver is something like an FDFD solver but with a slightly different matrix and obviously doing an eigen solve rather than a linear solve.

You can for sure write a mode solver on a collocated grid too. But apart from the convenience / potentially higher accuracy of the Yee grid, when the mode solver is strongly coupled to FDTD simulations as is typically the case, it's even more crucial for accuracy reasons to solve on the same grid that the FDTD uses. This is both to avoid interpolation errors if you need to interpolate the mode fields to the FDTD grid, and in order to use the same subpixel averaging in both solvers, which again is grid-dependent.

Oh right IIRC out-of-plane field elements are needed for accurate (ED+HB) and i was confused how to do this for a mode solver defined in a 2D plane when the fields are on the Yee grid.

So along the normal direction the fields are actually always collocated to the mode plane. I'm not sure I understand this point fully but if you do need them anywhere out of that plane, you just need to apply the exp(1j * n_complex * z * omega / c) factor.

@doddgray
Copy link

Thanks for those explanations. Here's what I tried before, which seemed at least slightly wrong at the time. Any corrections or suggested more efficient/accurate ways to do this would be greatly appreciated.

# .... create a ModeSolver `mode_solver` ...
# ... solve for modes, generating `mode_data` ....
# ... choose mode `mode_index` to analyze ...

### Modal Energy Density `U` & Integral `W` ###
grid    =   mode_solver.grid_snapped # or `mode_data.grid_expanded`? or one of these grids'  `.centers`?

Ex = mode_data.Ex.sel(mode_index=mode_index)
Ey = mode_data.Ey.sel(mode_index=mode_index)
Ez = mode_data.Ez.sel(mode_index=mode_index)

eps_xx  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ex").interp_like(Ex)
eps_yy  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ey").interp_like(Ey)
eps_zz  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ez").interp_like(Ez)

Dx = eps_xx * Ex
Dy = eps_yy * Ey
Dz = eps_zz * Ez

U   =   (Ex.conj() * Dx + Ey.conj() * Dy + Ez.conj() * Dz).real          # Modal Energy density [J/cm^3]
W   =   float(U.integrate(coord=["x", "z"]).real)                        # Modal Energy density per unit length, dU/dy [J/cm] 
print(f"Modal Energy Density Per Length `W`:\t{W}")

### Poynting Flux `S` and Integral `P` ###
norm_factor = 1 # not used right now, needed for unitful flux and power quantities
Ex = mode_data.Ex.sel(mode_index=mode_index).squeeze(drop=True) * norm_factor
Ez = mode_data.Ez.interp_like(Ex).sel(mode_index=mode_index).squeeze(drop=True) * norm_factor
Hx = mode_data.Hx.interp_like(Ex).sel(mode_index=mode_index).squeeze(drop=True) * norm_factor
Hz = mode_data.Hz.interp_like(Ex).sel(mode_index=mode_index).squeeze(drop=True) * norm_factor

S = ( (Ez.conj() * Hx - Ex.conj() * Hz) - (Hz.conj() * Ex - Hx.conj() * Ez) ) / 4   # Modal Poynting Flux Density [W/cm^2]
P = float(S.integrate(coord=["x", "z"]).real)                                       # Modal Power [W]
print(f"Modal Power Integral `P`:\t{P}")

### Modal Group Index ###
n_g = W / P          # possibly missing factor of td.C_0, if so this is inverse modal group velocity
print(f"Modal Group Index `n_g`:\t{n_g}")

@momchil-flex
Copy link
Collaborator

Thanks!

So this was giving NaNs at first, I believe something to do with mismatch of various grids on which things are computed. However I fixed it in a way that seems to produce meaningful results like this:

# Get mode_data from a mode solver; normal direction is assumed y
# Note : set colocate = False in the mode solver
mode_index = 0
freq_index = 0

### Modal Energy Density `U` & Integral `W` ###
grid    =   mode_solver.grid_snapped # or `mode_data.grid_expanded`? or one of these grids'  `.centers`?

# First compute everything on the Yee grid
Ex = mode_data.Ex.sel(mode_index=mode_index).isel(f=freq_index)
Ey = mode_data.Ey.sel(mode_index=mode_index).isel(f=freq_index)
Ez = mode_data.Ez.sel(mode_index=mode_index).isel(f=freq_index)

eps_xx  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ex")
eps_yy  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ey")
eps_zz  =   mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ez")

Dx = eps_xx * Ex
Dy = eps_yy * Ey
Dz = eps_zz * Ez

# Now we colocate and only after taking the product
Ux = (Ex.conj() * Dx).interp(x=grid.boundaries.x, z=grid.boundaries.z, kwargs={"fill_value": "extrapolate"})
Uy = (Ey.conj() * Dy).interp(x=grid.boundaries.x, z=grid.boundaries.z, kwargs={"fill_value": "extrapolate"})
Uz = (Ez.conj() * Dz).interp(x=grid.boundaries.x, z=grid.boundaries.z, kwargs={"fill_value": "extrapolate"})
U   =   (Ux + Uy + Uz).real          # Modal Energy density [J/cm^3]
W   =   float(U.integrate(coord=["x", "z"]).real)                        # Modal Energy density per unit length, dU/dy [J/cm] 
print(f"Modal Energy Density Per Length `W`:\t{W}")

### Poynting Flux `S` and Integral `P` ###
P = mode_data.flux # the modes are already normalized to flux = 1 though so this should not be needed

### Modal Group Index ###
n_g = W / P.isel(mode_index=mode_index, f=freq_index)          # possibly missing factor of td.C_0, if so this is inverse modal group velocity
print(f"Modal Group Index `n_g`:\t{n_g}")

The normalization seem off as the values are of the order of 1600-1800 for the three modes I tried for the mode solver in our notebook example. The difference doesn't seem to be (only) a td.C_0 factor. However, the ratio of the values is pretty close to the one computed using the finite difference derivative:

Ratios analytic:    1.0625 1.0438908659549229
Ratios finite diff: 1.0646835975261968 1.044559102392067

Would be great if we can figure the final touches out!

That said it's clear this will involve some effort to incorporate natively, to handle and test all edge cases, dispersive materials, etc. But figuring out this simple example should be a good start!

@doddgray
Copy link

doddgray commented Oct 31, 2024

Nice those numbers look reasonably close! Thank you for looking at this.

Using the native modal .flux is much cleaner than my previous attempt. I'm not sure that attribute existed when I tried this. How is that calculated? It's good news that the mode is already flux-normalized, one less quantity to calculate. As is, this would already save meaningful compute time for expensive mode solves on big grids.

Maybe worth looking at this analytic vs. finite difference group index comparison for a more confined mode (n_g = ~1.06 here? or did I miss a factor of 2...) as a function of grid size and resolution, and the frequency step size used in the finite difference case. I bet that fitting the slope of n_eff from more than two frequency points (5?) would make the finite difference value more accurate, but also annoyingly slow.

In terms of final touches to make the analytic group index calculation generally correct and accurate, a few thoughts:

  • What are the natural/correct grid positions on which to evaluate (E*D + H*B)/2? I just guessed when I wrote the code above. I have no guess when there are off-diagonal permittivity or permeability tensor elements, but I definitely need that in some cases.
  • Do the terms dependent on out-of-plane field components need to be corrected as you alluded to earlier (phase advanced consistent with the modal effective index)?
  • I think that in theory (E*D + H*B)/2 = E*D when averaged over time for modal fields, I assumed this above, but it might be more accurate to actually use (E*D + H*B)/2.
  • To incorporate material dispersion we would need (d\epsilon/d\omega) tensor data at the same grid positions as epsilon. Calculating the material (d\epsilon/d\omega) tensors and smoothing those at the same time as \epsilon would be ideal. If you can differentiate the material index models and smoothing with JAX, that's another way. Finite difference-ing the (smoothed) dielectric tensor calculation would also work and should be cheaper and more accurate than finite difference-ing the mode solve itself.

I assume the dielectric function is not re-calculated and smoothed at different frequencies used in the finite difference group index calculation currently used? That would be necessary to check the group index calculation including material dispersion. This may sound insignificant but it is very important for those of us who care about modal GVDs and dispersion engineering for pulses and nonlinear optics. Without material dispersion the modal group index is strictly wrong, typically at the few-percent level in cases I've focused on and the GVD is very wrong.

@momchil-flex
Copy link
Collaborator

Using the native modal .flux is much cleaner than my previous attempt. I'm not sure that attribute existed when I tried this. How is that calculated? It's good news that the mode is already flux-normalized, one less quantity to calculate. As is, this would already save meaningful compute time for expensive mode solves on big grids.

The code is here. There are some intricacies to make sure that the flux computed this way matched exactly what the remote solver would also return (e.g. for a perfectly injected mode propagating in the FDTD grid), so the finite step size in the z direction is actually incorporated. This is the only point that I am not sure about whether it would require a re-computation of the flux, or whether it should be incorporated in the U integration. Maybe if we really want accuracy down to numerical precision we'll have to look into that.

Maybe worth looking at this analytic vs. finite difference group index comparison for a more confined mode (n_g = ~1.06 here? or did I miss a factor of 2...) as a function of grid size and resolution, and the frequency step size used in the finite difference case. I bet that fitting the slope of n_eff from more than two frequency points (5?) would make the finite difference value more accurate, but also annoyingly slow.

Sorry, to clarify the normalization of the group index computed by the code above seems off, so what I am showing in the end of my message is the ratios of group indexes between the second and the first mode, and between the third and the second mode. The actual values are higher than 2 and the modes are well confined. So there is definitely still the issue of getting the normalization right to do the exact comparison.

In terms of final touches to make the analytic group index calculation generally correct and accurate, a few thoughts:

  • What are the natural/correct grid positions on which to evaluate (E*D + H*B)/2? I just guessed when I wrote the code above. I have no guess when there are off-diagonal permittivity or permeability tensor elements, but I definitely need that in some cases.

Basically, these live at different locations, so any choice would require interpolation. Taking the grid boundaries is what we normally do for integrations. Some time ago we were actually using grid centers but switched to boundaries for some reasons that I don't need to get into, but the integration results would generally change very little.

Regarding off-diagonal permittivity, do you mean this is needed when the underlying materials are fully anisotropic? Or are there other cases?

  • Do the terms dependent on out-of-plane field components need to be corrected as you alluded to earlier (phase advanced consistent with the modal effective index)?

Yeah so like I mentioned above re flux, we may need to look into that, but I would leave it for last.

  • I think that in theory (E*D + H*B)/2 = E*D when averaged over time for modal fields, I assumed this above, but it might be more accurate to actually use (E*D + H*B)/2.

Yeah probably the averaging only works for lossless modes.

  • To incorporate material dispersion we would need (d\epsilon/d\omega) tensor data at the same grid positions as epsilon. Calculating the material (d\epsilon/d\omega) tensors and smoothing those at the same time as \epsilon would be ideal. If you can differentiate the material index models and smoothing with JAX, that's another way. Finite difference-ing the (smoothed) dielectric tensor calculation would also work and should be cheaper and more accurate than finite difference-ing the mode solve itself.

I see, yeah, we'd have to finite difference it, but it will be cheaper. But again, for starters we could just set this up to only work for non-dispersive cases and then expand later.

I assume the dielectric function is not re-calculated and smoothed at different frequencies used in the finite difference group index calculation currently used? That would be necessary to check the group index calculation including material dispersion. This may sound insignificant but it is very important for those of us who care about modal GVDs and dispersion engineering for pulses and nonlinear optics. Without material dispersion the modal group index is strictly wrong, typically at the few-percent level in cases I've focused on and the GVD is very wrong.

We run completely independent mode solves so independent structure parsing and permittivity smoothening is applied at every separate freuquency, including the small offset freqs used for the numerical derivatives.

@doddgray
Copy link

doddgray commented Nov 1, 2024

Thanks that all makes sense. I will check/fix the normalization today so the fraction of integrals is actually the group index. I know I figured it out in the past. I'm using tidy3D a lot so definitely excited to get this working.

What if anything would be helpful for us to do? Do you want a user submitted PR or should we leave it to the pros?

I have some more responses to yours, particularly about fully anisotropic tensor data but will type those up when I get to a computer.

@momchil-flex
Copy link
Collaborator

Thanks that all makes sense. I will check/fix the normalization today so the fraction of integrals is actually the group index. I know I figured it out in the past. I'm using tidy3D a lot so definitely excited to get this working.

That would be great!

What if anything would be helpful for us to do? Do you want a user submitted PR or should we leave it to the pros?

You can certainly attempt to PR it yourself if you wish, we're always happy to get external contributions. If left up to us, it may take some time to get to it. We are trying to hire a few more people though and this could be a good first project if we get some of them soon.

@doddgray
Copy link

doddgray commented Jan 8, 2025

I think I finally figured this out, or at least this group index calculation based on individual mode solutions seems much closer to correct than my earlier attempt. The main issues with my previous snippet were that I forgot the vacuum permittivity EPSILON_0 factor in the energy density U and the speed of light factor the group index.

In this version I've fixed those and included both the E*D and H*B terms in the energy density. I prototyped this with some existing mode_data generated from a mode_solver with mode_solver.colocate = True, but I intend to start calculating D=epsilon * E on the Yee grid before colocating as you explained above.

Beyond that, I think I still need to

  • update this to be agnostic about the ModeSolver plane/orientation. Is there documentation about the "right" way to do this? I've seen some examples in the tidy3d code but can't remember if I've seen docs.
  • add the option of including the material dispersion term ( E * ( d epsilon / d omega ) * E ) in the energy density for more accurate group index values
  • (optionally) grab the smoothed epsilon data from a remote dummy FDTD simulation to improve the accuracy, since this is the dielectric distribution used to calculate the mode fields in the first place.
import tidy3d as td
import xarray as xr
from tidy3d.constants import ETA_0, EPSILON_0, MU_0, C_0

def calc_ng(
    mode_data,
    mode_solver,
    grid = None,
    check_density_balance:bool = False,
    squeeze:bool = True,
):
    """ Calculate the modal group index as
            n_g = td.C_0 * W / P 
        where `W` is the modal energy density per unit length in [J/μm]
        and `P` is the modal power in [W] 
        `P` = `mode_data.flux` and is typically normalized to 1W."""
    if grid is None:
        grid    =   mode_data.grid_expanded # mode_solver.grid_expanded
    
    interp_kwargs = dict(
        y=grid.centers.y,
        z=grid.centers.z,
        kwargs={"fill_value": "extrapolate"},
    )

    # First compute everything on the Yee grid
    Ex = mode_data.Ex.interp(**interp_kwargs)
    Ey = mode_data.Ey.interp(**interp_kwargs)
    Ez = mode_data.Ez.interp(**interp_kwargs)

    Hx = mode_data.Hx.interp(**interp_kwargs)
    Hy = mode_data.Hy.interp(**interp_kwargs)
    Hz = mode_data.Hz.interp(**interp_kwargs)

    # Relative Dielectric Tensor Elements
    eps_xx = xr.concat(
        [mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ex", freq=freq).expand_dims(dim={'f':[freq,]}) for freq in mode_solver.freqs],
        dim='f',
    ).interp(**interp_kwargs)
    eps_yy = xr.concat(
        [mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ey", freq=freq).expand_dims(dim={'f':[freq,]}) for freq in mode_solver.freqs],
        dim='f',
    ).interp(**interp_kwargs)
    eps_zz = xr.concat(
        [mode_solver.simulation.epsilon_on_grid(grid=grid, coord_key="Ez", freq=freq).expand_dims(dim={'f':[freq,]}) for freq in mode_solver.freqs],
        dim='f',
    ).interp(**interp_kwargs)

    # Modal E-field Energy density [J/μm³] scaled by C_0 ( C_0 * EPSILON_0 * n² * |E|² = (1/Z_0) * n² * |E|² ) 
    U_E     =   (                                       
        (eps_xx * Ex.abs**2).interp(**interp_kwargs)
        + (eps_yy * Ey.abs**2 ).interp(**interp_kwargs)
        + (eps_zz * Ez.abs**2 ).interp(**interp_kwargs)
    ) / td.ETA_0

    # Modal H-field Energy density [J/μm³] scaled by C_0 ( C_0 * MU_0 * |H|² = Z_0 * |H|² ) 
    U_H     =   (                                       
        ( Hx.abs**2).interp(**interp_kwargs)
        + ( Hy.abs**2 ).interp(**interp_kwargs)
        + ( Hz.abs**2 ).interp(**interp_kwargs)
    ) * td.ETA_0
    if check_density_balance:
        W_E  =  U_E.integrate(coord=["y", "z"]).real    # Modal E-field Energy density per unit length, dU/dy [J/μm]
        print(f"\tW_E:\t{W_E}")
        W_H  =  U_H.integrate(coord=["y", "z"]).real    # Modal H-field Energy density per unit length, dU/dy [J/μm]
        print(f"\tW_H:\t{W_H}")
        W = (W_E + W_H) / 4                             # TODO: Where should this factor of 4 come in?
    else:
        W  =  ( U_E + U_H ).integrate(coord=["y", "z"]).real / 4   # Modal Energy density per unit length, dU/dy [J/μm]

    if squeeze:
        W = W.squeeze(drop=True)                        # Drop singleton spatial dimension coordinate

    # n_g = W / P * C_0, but we have already scaled W by C_0 and P = 1 [W] for Tidy3D's normalized mode fields 
    # so here, n_g = W. So we just return W

    return W

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants