Skip to content

Conversation

@dbochkov-flexcompute
Copy link
Contributor

@dbochkov-flexcompute dbochkov-flexcompute commented Oct 23, 2025

Addresses #2530.

Notebooks re-run examples:

Still not sure about making it default in WavePorts

Greptile Overview

Updated On: 2025-10-23 01:43:36 UTC

Greptile Summary

This PR implements frequency downsampling/interpolation for mode monitors and mode solvers to reduce computational cost in broadband simulations. A new ModeInterpSpec class is introduced with three interpolation methods (linear, cubic, Chebyshev), allowing modes to be computed at fewer strategically-chosen frequencies and interpolated to the full frequency set. The feature is integrated throughout the mode computation pipeline: from WavePort through ModeMonitor and ModeSolver to ModeSolverData. The implementation requires mode tracking to be enabled (mode_spec.sort_spec.track_freq) to maintain consistent mode ordering across frequencies. Default behavior changes for wave ports now use 21-point Chebyshev interpolation unless explicitly disabled. The implementation includes comprehensive validation, extrapolation warnings, and extensive test coverage.

Important Files Changed

Changed Files
Filename Score Overview
tidy3d/init.py 5/5 Exports new ModeInterpSpec class in public API with proper alphabetical ordering
tidy3d/components/mode_spec.py 4/5 Introduces ModeInterpSpec class with three interpolation methods and validation logic
tidy3d/components/monitor.py 4/5 Adds interp_spec field to AbstractModeMonitor with tracking requirement validation
tidy3d/components/mode/mode_solver.py 2.5/5 Implements interpolation in ModeSolver but contains debug print statements requiring removal
tidy3d/components/data/monitor_data.py 4/5 Adds comprehensive interp() method to ModeSolverData supporting all three interpolation methods
tidy3d/plugins/smatrix/ports/wave.py 3/5 Enables interpolation for wave ports with default 21-point Chebyshev spec, changing existing behavior
tests/test_components/test_mode_interp.py 5/5 Comprehensive test coverage for all interpolation functionality with965lines of well-structured tests

Confidence score: 3/5

  • This PR introduces useful functionality but has issues requiring attention before merge, particularly debug print statements and potential backward compatibility impacts.
  • Score reflects: (1) debug print statements left in production code (lines 612, 616, 621, 626-628 in mode_solver.py), (2) default behavior change for wave ports that automatically enables interpolation, potentially affecting existing simulations, (3) no validation preventing num_points from exceeding total frequencies in ModeInterpSpec, and (4) lack of explicit handling for single-frequency edge case in sampling_points() method.
  • Pay close attention to tidy3d/components/mode/mode_solver.py (remove debug prints), tidy3d/plugins/smatrix/ports/wave.py (default behavior change may need migration guidance), and tidy3d/components/mode_spec.py (add upper bound validation for num_points).

Sequence Diagram

sequenceDiagram
    participant User
    participant ModeSolver
    participant ModeInterpSpec
    participant ModeSolverData
    participant compute_modes

    User->>ModeSolver: Create ModeSolver with interp_spec
    ModeSolver->>ModeInterpSpec: Validate num_points >= method requirements
    ModeInterpSpec-->>ModeSolver: Validation complete
    
    User->>ModeSolver: Call data_raw property
    ModeSolver->>ModeSolver: Check if interp_spec is provided
    
    alt interp_spec is None
        ModeSolver->>ModeSolver: Call _data_on_yee_grid()
        ModeSolver->>compute_modes: Solve at all freqs
        compute_modes-->>ModeSolver: Return n_complex, fields, eps_spec
        ModeSolver-->>User: Return ModeSolverData
    else interp_spec provided
        ModeSolver->>ModeSolver: Call _get_data_with_interp()
        ModeSolver->>ModeInterpSpec: sampling_points(freqs)
        
        alt method is "linear" or "cubic"
            ModeInterpSpec->>ModeInterpSpec: Generate uniformly spaced points
        else method is "cheb"
            ModeInterpSpec->>ModeInterpSpec: Generate Chebyshev nodes
        end
        
        ModeInterpSpec-->>ModeSolver: Return freqs_reduced
        
        ModeSolver->>ModeSolver: Create mode_solver_reduced with freqs_reduced
        ModeSolver->>ModeSolver: Call data_raw on mode_solver_reduced
        ModeSolver->>compute_modes: Solve at reduced freqs
        compute_modes-->>ModeSolver: Return data_reduced
        
        ModeSolver->>ModeSolverData: interp(freqs, method)
        ModeSolverData->>ModeSolverData: _interp_dataarray for each field
        
        alt method is "cheb"
            ModeSolverData->>ModeSolverData: Validate Chebyshev nodes
            ModeSolverData->>ModeSolverData: Use barycentric interpolation
        else method is "linear" or "cubic"
            ModeSolverData->>ModeSolverData: Use xarray interpolation
        end
        
        ModeSolverData-->>ModeSolver: Return interpolated data
        ModeSolver-->>User: Return ModeSolverData with all freqs
    end
Loading

Context used:

  • Rule from dashboard - Remove temporary debugging code (print() calls), commented-out code, and other workarounds before fi... (source)

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 files reviewed, 9 comments

Edit Code Review Agent Settings | Greptile

@github-actions
Copy link
Contributor

github-actions bot commented Oct 23, 2025

Diff Coverage

Diff: origin/develop...HEAD, staged and unstaged changes

  • tidy3d/init.py (100%)
  • tidy3d/components/base.py (100%)
  • tidy3d/components/data/dataset.py (60.0%): Missing lines 169-179,181-183
  • tidy3d/components/data/monitor_data.py (97.3%): Missing lines 2488,2739
  • tidy3d/components/microwave/data/dataset.py (100%)
  • tidy3d/components/microwave/data/monitor_data.py (46.7%): Missing lines 319,322,459-463,466
  • tidy3d/components/mode/mode_solver.py (94.3%): Missing lines 548,676
  • tidy3d/components/mode/simulation.py (100%)
  • tidy3d/components/mode_spec.py (95.2%): Missing lines 198,445
  • tidy3d/components/monitor.py (100%)
  • tidy3d/components/validators.py (84.2%): Missing lines 531-532,535

Summary

  • Total: 233 lines
  • Missing: 31 lines
  • Coverage: 86%

tidy3d/components/data/dataset.py

Lines 165-187

  165         sort_inds_2d : np.ndarray
  166             Array of shape (num_freqs, num_modes) where each row is the
  167             permutation to apply to the mode_index for that frequency.
  168         """
! 169         num_freqs, num_modes = sort_inds_2d.shape
! 170         modify_data = {}
! 171         for key, data in self.data_arrs.items():
! 172             if "mode_index" not in data.dims or "f" not in data.dims:
! 173                 continue
! 174             dims_orig = data.dims
! 175             f_coord = data.coords["f"]
! 176             slices = []
! 177             for ifreq in range(num_freqs):
! 178                 sl = data.isel(f=ifreq, mode_index=sort_inds_2d[ifreq])
! 179                 slices.append(sl.assign_coords(mode_index=np.arange(num_modes)))
  180             # Concatenate along the 'f' dimension name and then restore original frequency coordinates
! 181             data = xr.concat(slices, dim="f").assign_coords(f=f_coord).transpose(*dims_orig)
! 182             modify_data[key] = data
! 183         return self.updated_copy(**modify_data)
  184 
  185 
  186 class AbstractFieldDataset(Dataset, ABC):
  187     """Collection of scalar fields with some symmetry properties."""

tidy3d/components/data/monitor_data.py

Lines 2484-2492

  2484             mnt = values["monitor"]
  2485             if (mnt.reduce_data and len(val) != mnt.mode_spec.interp_spec.num_points) or (
  2486                 not mnt.reduce_data and len(val) != len(mnt.freqs)
  2487             ):
! 2488                 raise ValidationError(
  2489                     "eps_spec must be provided at the same frequencies as mode solver data."
  2490                 )
  2491         return val

Lines 2735-2743

  2735     @property
  2736     def interpolated_copy(self) -> ModeSolverData:
  2737         """Return a copy of the data with interpolated fields."""
  2738         if self.monitor.mode_spec.interp_spec is None or not self.monitor.reduce_data:
! 2739             return self
  2740         interpolated_data = self.interp_in_freq(
  2741             freqs=self.monitor.freqs,
  2742             method=self.monitor.mode_spec.interp_spec.method,
  2743             renormalize=True,

tidy3d/components/microwave/data/monitor_data.py

Lines 315-326

  315             permutation to apply to the mode_index for that frequency.
  316         """
  317         main_data_reordered = super()._apply_mode_reorder(sort_inds_2d)
  318         if self.transmission_line_data is not None:
! 319             transmission_line_data_reordered = self.transmission_line_data._apply_mode_reorder(
  320                 sort_inds_2d
  321             )
! 322             main_data_reordered = main_data_reordered.updated_copy(
  323                 transmission_line_data=transmission_line_data_reordered
  324             )
  325         return main_data_reordered

Lines 455-467

  455         >>> # Interpolate to 50 frequencies
  456         >>> freqs_dense = np.linspace(1e14, 2e14, 50)
  457         >>> # mode_data_interp = mode_data.interp(freqs=freqs_dense, method='linear')
  458         """
! 459         main_data_interp = super().interp_in_freq(freqs, method, renormalize)
! 460         if self.transmission_line_data is not None:
! 461             update_dict = self.transmission_line_data._interp_in_freq_update_dict(freqs, method)
! 462             transmission_line_data_interp = self.transmission_line_data.updated_copy(**update_dict)
! 463             main_data_interp = main_data_interp.updated_copy(
  464                 transmission_line_data=transmission_line_data_interp
  465             )
! 466         return main_data_interp

tidy3d/components/mode/mode_solver.py

Lines 544-552

  544             )
  545         )
  546 
  547         if self.reduce_data:
! 548             return data_reduced
  549 
  550         return data_reduced.interpolated_copy
  551 
  552     @cached_property

Lines 672-680

  672         eps_spec = []
  673         for _ in self.freqs:
  674             eps_spec.append("tensorial_complex")
  675         # finite grid corrections
! 676         grid_factors, relative_grid_distances = solver._grid_correction(
  677             simulation=solver.simulation,
  678             plane=solver.plane,
  679             mode_spec=solver.mode_spec,
  680             n_complex=n_complex,

tidy3d/components/mode_spec.py

Lines 194-202

  194             nodes_normalized = np.cos(k * np.pi / (self.num_points - 1))
  195             # Map from [-1, 1] to [f_min, f_max]
  196             return 0.5 * (f_min + f_max) + 0.5 * (f_max - f_min) * nodes_normalized
  197         else:
! 198             raise ValueError(f"Unknown interpolation method: {self.method}")
  199 
  200 
  201 class AbstractModeSpec(Tidy3dBaseModel, ABC):
  202     """

Lines 441-449

  441         if track_freq is not None:
  442             return track_freq
  443         if sort_spec is not None:
  444             return sort_spec.track_freq
! 445         return None
  446 
  447     @pd.validator("interp_spec", always=True)
  448     @skip_if_fields_missing(["sort_spec", "track_freq"])
  449     def _interp_spec_needs_tracking(cls, val, values):

tidy3d/components/validators.py

Lines 527-536

  527 
  528     @pydantic.validator("reduce_data", always=True, allow_reuse=True)
  529     def _validate_reduce_data(cls, val, values):
  530         """Ensure that `reduce_data` is compatible with `mode_spec.interp_spec`."""
! 531         if val and values["mode_spec"].interp_spec is None:
! 532             raise ValidationError(
  533                 "`reduce_data` is only valid when `mode_spec.interp_spec` is defined."
  534             )
! 535         return val

@dbochkov-flexcompute dbochkov-flexcompute changed the title feature: downsample frequencies in mode monitors (FXC-3351) feat: downsample frequencies in mode monitors (FXC-3351) Oct 23, 2025
@dbochkov-flexcompute dbochkov-flexcompute force-pushed the daniil/FXC-3351-Downsample-frequencies-in-mode-monitors branch 2 times, most recently from c070488 to 19df1ca Compare October 23, 2025 06:56
Copy link
Collaborator

@momchil-flex momchil-flex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I didn't know this was coming!

I think we might want to enable it by default for ModeMonitor-s, or at least for all ports. But we could also sit on it for a bit, maybe let @lucas-flexcompute test this out as a default in photonforge first?

"ModeAmpsDataArray",
"ModeData",
"ModeIndexDataArray",
"ModeInterpSpec",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to also add it in "all" later on in the file

freqs,
"nearest",
).data
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my sort_spec modification I introduced a better way to iterate all relevant arrays without the risk of forgetting something (or forgetting to add it if added in the future):

for key, data in self.data_arrs.items():
if "mode_index" not in data.dims or "f" not in data.dims:
continue
dims_orig = data.dims
f_coord = data.coords["f"]
slices = []
for ifreq in range(num_freqs):
sl = data.isel(f=ifreq, mode_index=sort_inds_2d[ifreq])
slices.append(sl.assign_coords(mode_index=np.arange(num_modes)))
# Concatenate along the 'f' dimension name and then restore original frequency coordinates
data = xr.concat(slices, dim="f").assign_coords(f=f_coord).transpose(*dims_orig)
modify_data[key] = data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, regarding grid correction factors, do you think it's ok to interpolate or better recalculate after interpolation?

Monitor that can use this specification to reduce mode computation cost.
:class:`ModeMonitor`:
Monitor that can use this specification to reduce mode computation cost.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first thought was that the interp spec belongs in ModeSpec.

However, reading through I think I see why you took this route. We might want to set different default values in different objects, which could get quite awkward if we only have a single shared place to set the default value. Furthermore, ModeSource-s should have this off (either error or ignore it if set) as they already have their num_freqs for Chebyshev interpolation.

On the other hand, I'm not a huge fan of having it outside of ModeSpec either. I think because of that, you've missed adding the parameter in at least two places: ModalPort (commented below) and ModeSimulation.

All things considered though it might be best to be separate (just be careful about adding it everywhere...)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also prefer this in ModeSpec. We can still set the defaults separately in different places, like WavePort.mode_spec defaults to ModeSpec(interp_spec=DEFAULT_WAVE_PORT_INTERP_SPEC)

Copy link
Collaborator

@momchil-flex momchil-flex Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I'm anticipating is when you want to set a non-default one. Say default ModeSpec doesn't interpolate but default ModeMonitor.mode_spec does:

ms = td.ModeSpec(angle_phi=np.pi / 4)
monitor = td.ModeMonitor(mode_spec=ms) # now this won't interpolate anymore

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I see. Well, we already have ModeSpecType = Union[ModeSpec, MicrowaveModeSpec]. Maybe we should introduce more subclasses like WavePortModeSpec, if we want different default mode specification behavior? I still think everything related to the mode solver settings should be in the shared class ModeSpec

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we actually have EMEModeSpec too already

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, no strong preference here from me, just unsure about having this information in mode_spec when some objects, ModeSource and ModeABCBoundary, will not use it. Another option to make it a bit cleaner while not putting ModeInterpSpec into ModeSpec is to create a mixin ModeInterpolationModel and make all revelant classes (ModeMonitor, ModeSolverMonitor, WavePort, ModalPort, ModeSimulation) to inherit from it

Copy link
Collaborator

@momchil-flex momchil-flex Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I see. Well, we already have ModeSpecType = Union[ModeSpec, MicrowaveModeSpec]. Maybe we should introduce more subclasses like WavePortModeSpec, if we want different default mode specification behavior? I still think everything related to the mode solver settings should be in the shared class ModeSpec

Just trying to go with this to see how it feels:

  • Say that we ignore interpolation spec in ModeSource (this in itself is a bit confusing but potentially OK). Then I can still use regular ModeSpec in ModeSource, ModeMonitor, ModeSolverMonitor, ModeSolver, ModeSimulation, ModalPort.
  • For all microwave objects like MicrowaveModeMonitor, WavePort, we use MicrowaveModeSpec. By the way @dmarek-flex I notice that WavePort expects a ModeSpec rather than a MicrowaveModeSpec, is that a mistake?

we actually have EMEModeSpec too already

  • In EME, we've been talking about a different setting to only simulate at the central frequency. But I guess separately from that it could benefit from this frequency interpolation as an approach that would be more accurate than central frequency only but not as computationally heavy as all freqs. So potentially the interpolation is still used in EMEModeSpec, with even different defaults if needed.

I guess overall it doesn't sound too bad, especially if the user is already forced to use MicrowaveModeSpec or EMEModeSpec in the corresponding objects, which I think should be the case?

Then for Daniil's suggestion:

Another option to make it a bit cleaner while not putting ModeInterpSpec into ModeSpec is to create a mixin ModeInterpolationModel and make all revelant classes (ModeMonitor, ModeSolverMonitor, WavePort, ModalPort, ModeSimulation) to inherit from it

Not opposed to that either, but apart from inheriting the field, there could be various places where we have to be careful that it's correctly passed. At least when it's in a ModeSpec, it should be passed around by default in most places.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is part of these bigger changes:
#2846

description="Extrudes structures that intersect the wave port plane by a few grid cells when ``True``, improving mode injection accuracy.",
)

interp_spec: Optional[ModeInterpSpec] = pd.Field(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to have this as an option in ModalPort-s too even if the default is still None.

Copy link
Contributor

@caseyflex caseyflex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome @dbochkov-flexcompute !

return self._get_data_with_group_index()

if self.interp_spec is not None and self.interp_spec.num_points < len(self.freqs):
return self._get_data_with_interp()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the interaction with group_index_step. Maybe if both are set, we should use the interpolated data to obtain the group index?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to improve on it indeed. What currently happens, if group_index_step is not False and interp_spec is not None:

  • _get_data_with_group_index intercepts original mode solver and creates a new mode solver with additional frequency points
  • _get_data_with_interp intercepts this modified mode solver and interpolates modes to all frequency points (including the additional aones)
  • _get_data_with_group_index uses interpolated values to calculate group index and dispersion

I'm thinking of modifying the behavior in the following way:

  • group_index_step is not False and interp_spec is linear: error, because linear interpolation is not enough for computing second order derivatives for dispersion
  • if group_index_step=True and interp_spec is not None: obtain group index and dispersion by differentiating underlying interpolators directly, without first interpolating to additional points
  • if group_index_step is a specific number and interp_spec is not None: obtain group index and dispersion as it's currently done, that is, by using interpolated values

Maybe @lucas-flexcompute would also have some opinion on that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I don't really see the benefit of your proposed approach. In the current approach, the group index will be computed relatively correctly at the sampling frequencies, or at least equally correctly to the way we're computing it now. Then we just interpolate that. The group index is usually smooth. The GVD might have stronger dependence but even that shouldn't be too bad.

In your proposal, this would be the default way people do the computation.

if group_index_step=True and interp_spec is not None: obtain group index and dispersion by differentiating underlying interpolators directly, without first interpolating to additional points

Are you suggesting it for higher accuracy or for increased speed compared to the current approach? I think we could consider it, but only if the accuracy of the GVD is higher.

Copy link
Collaborator

@momchil-flex momchil-flex Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I just reread your description of what the current setup does, and I see the issue. The reason I posted the comment above was because in my mind something else was happening. This is what I thought:

  • Interpolation frequencies at which modes will be computed are selected
  • If group index is enabled, the additional frequencies around these are added
  • The group index and GVD are computed with the same accuracy as it is now at those frequencies
  • They are then interpolated to the other solver frequencies just as n_complex, etc.

Does that make sense as the workflow to implement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I see, I think this is the matter of simply swapping the order of _get_data_with_group_index and _get_data_with_interp. The only concerns is that we are tripling number of mode solves, so being able to, say, use the underlying chebyshev interpolator could be nice as an advanced option

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe @lucas-flexcompute would also have some opinion on that?

Not strongly… My understanding is that the interpolation error should probably already be less than the error we get from the effective index due to discretization already. If interpolating over more points and getting the derivatives from the interpolation helps, solving for 3× the number of frequencies is not a big deal from the user perspective.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbochkov-flexcompute I think we should go with the approach of getting the "equivalent accuracy" group index at the sampling points (even if it triples the number of solves, as it has until now) just because it matches legacy usage and just overall keeps the usage straightforward. I'm not a huge fan for example of not allowing group_index_step=True with interpolation=linear. Maybe just in the future we could experiment with using the interpolators to compute the derivatives if group_index_step=True and interpolation is not linear.

Monitor that can use this specification to reduce mode computation cost.
"""

num_points: int = pd.Field(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a use case which is not quite covered by the current API. I want to know the mode profiles at a single frequency, say the central frequency; but I don't care about how they change wrt frequency. I do care about how the dispersion relation changes wrt frequency, and ideally I would compute that with interpolation.

Currently, I would need to do two separate mode solves; one at the central frequency recording the fields, and one over the full frequency range just storing the n_complex. This is a bit inefficient and perhaps cumbersome.

I wonder if we can add a flag to mode_spec like store_fields_at_all_freqs. If False, we just store the fields at the central frequency, while storing the index at all frequencies? This introduces some misalignment between the field and n_complex data arrays, but it can be handled reasonably by broadcasting where needed?

Also I think it would have been better if fields and conjugated_dot_product were part of mode_spec too.

@momchil-flex what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or assume_constant_fields. Basically that could be the API for interpolating the n_complex but only storing one copy of the mode fields

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a useful suggestion. As another example I think in photonforge @lucas-flexcompute has to get the fields, but can probably do all that's needed using the central frequency fields only. This could drastically reduce the amount of data that's downloaded. I mean in principle there's a workaround to this already: you could place a ModeSolverMonitor with a single frequency at the same position as the ModeMonitor. That was kind of the original purpose of the ModeSolverMonitor but we eventually kind of unified the two.

It would be nice to have a more native support yeah, although it definitely increases the complexity. So in any case I think that's something that should be done in a separet PR.

Also I think it would have been better if fields and conjugated_dot_product were part of mode_spec too

I guess this, as the current interp spec, has to do with the fact that they don't take effect in a ModeSource. Actually, conjugated_dot_product is only really used in a ModeMonitor. It seems that it is an attribute to the ModeSolver just so that it can be passed in to_monitor. But I feel like it should instead be passed as an argument to that method, i.e. add a kwargs as in to_source? Looping @dmarek-flex here too. It's not yet released officially so we could remove it from the ModeSolver.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, conjugated_dot_product is only really used in a ModeMonitor. It seems that it is an attribute to the ModeSolver just so that it can be passed in to_monitor. But I feel like it should instead be passed as an argument to that method, i.e. add a kwargs as in to_source? Looping @dmarek-flex here too. It's not yet released officially so we could remove it from the ModeSolver.

I might have jumped the gun adding conjugated_dot_product to the ModeSolver and ModeSimulation, but I will need to add some Microwave-specific features for post-processing modes that I think is best done in the backend mode solver, so I do think it might be convenient to have that field around in the future. On the other hand, maybe internally I don't need to consider the conjugated version for my uses.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a useful suggestion. As another example I think in photonforge @lucas-flexcompute has to get the fields, but can probably do all that's needed using the central frequency fields only.

PF needs the fields at a single frequency indeed only for computing mode number remapping (if the user uses symmetry or filter_pol) so that we can connect to the correct modes in other parts of the circuit where those might not have been used. But we build this as a projection operation (not index search) because there's no guarantee that the "same" modes will be found with and without symmetry due to degenerate mode mixing.
In the end, I think we might just keep using the full frequency data (at least in the short term) because it is easy to implement.

Copy link
Collaborator

@weiliangjin2021 weiliangjin2021 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very pleasant PR to review: both feature-rich, but not so many lines of code change!

As a future step, maybe it's useful to support adaptive sampling, as the mode profile probably changes more rapidly in high-frequency compared to low-frequency range. Or perhaps expose an API for custom sampling frequencies for now?

if self.method in ("linear", "cubic"):
# Uniformly spaced points
return np.linspace(f_min, f_max, self.num_points)
elif self.method == "cheb":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pylint would complain no else is needed


# Check relative error
freq_range = np.abs(expected_freqs[-1] - expected_freqs[0])
max_error = np.max(np.abs(freqs_sorted - expected_sorted)) / freq_range
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will np.allclose with custom atol and rtol be more robust here?

# Interpolate field components if present
for field_name, field_data in self.field_components.items():
if field_data is not None:
update_dict[field_name] = self._interp_dataarray(field_data, freqs, method)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs the risk of being very slow, did you benchmark it at all?

mode_solver_reduced = self.copy(update={"freqs": freqs_reduced, "interp_spec": None})

# Get data at reduced frequencies
data_reduced = mode_solver_reduced.data_raw
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use _data_on_yee_grid here instead, and then do the processing later in data_raw?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbochkov-flexcompute I still can't use this directly in EME unless you use _data_on_yee_grid instead of data_raw here. It might take reworking the group index handling a bit too, but I think it is worth it

@momchil-flex
Copy link
Collaborator

Or perhaps expose an API for custom sampling frequencies for now?

Yes I was thinking about suggesting the same thing. Another application of custom sampling frequencies could be when you have a mode crossing, you could put more frequencies around it so that the tracking works.

Copy link
Collaborator

@lucas-flexcompute lucas-flexcompute left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if this is a dumb question, but I need to understand it first: is the ModeInterpSpec a superset of what ModeSource.num_freqs already does? How do they combine?

@dbochkov-flexcompute
Copy link
Contributor Author

Sorry if this is a dumb question, but I need to understand it first: is the ModeInterpSpec a superset of what ModeSource.num_freqs already does? How do they combine?

There is definitely a big overlap between them, but ModeInterpSpec was not intended as a replacement for num_freqs. More like ModeInterpSpec is analogue of broadband source's num_freqs for monitors with more options. I think we could consider using ModeInterpSpec in sources as well and limit it there to only method="cheb" (and also maybe rename it to FreqInterpSpec as it would also apply to gaussian beam sources).

@momchil-flex @caseyflex this could actually make the decision whether to put ModeInterpSpec into ModeSpec or not easier. Because, if sources use ModeInterpSpec too, then no issue with them ignoring it in ModeSpec. However, for gaussian beams we still need to pass it separately. Btw, I don't think there is any benefit in using profile interpolation in incoming gaussian monitors, right?

@momchil-flex
Copy link
Collaborator

@momchil-flex @caseyflex this could actually make the decision whether to put ModeInterpSpec into ModeSpec or not easier. Because, if sources use ModeInterpSpec too, then no issue with them ignoring it in ModeSpec. However, for gaussian beams we still need to pass it separately.

So are you driving towards separating it also because of this argument? And I guess deprecating ModeSource.num_freqs for a FreqInterpSpec?

Btw, I don't think there is any benefit in using profile interpolation in incoming gaussian monitors, right?

I don't think so as the field computation is just analytical. Maybe a tiny benefit to interpolating.

@caseyflex
Copy link
Contributor

@dbochkov-flexcompute I think it makes sense to incorporate it into ModeSpec and use it in mode sources as well

@dbochkov-flexcompute
Copy link
Contributor Author

I think I will move ModeInterpSpec into ModeSpec, but not switch broadband sources to use it. This is because we have broadband gaussian beams and even plane wave, and it would also be weird to require method="cheb" only

@dbochkov-flexcompute dbochkov-flexcompute force-pushed the daniil/FXC-3351-Downsample-frequencies-in-mode-monitors branch 2 times, most recently from eb1a1f8 to 18f3fcd Compare October 29, 2025 03:26
@dbochkov-flexcompute
Copy link
Contributor Author

Did a revision of this (turned out a bit more than expected):

  • moved interp spec into mode spec
  • added renormalization after interpolation
  • slight reorg of basic dataset classes so that we can easily reorder and interpolate both main mode data and additional RF data attached to it (there is an option just to recalculate RF priorities though)
  • swapped order of interpretors in data_raw so that group index and dispersion are calculated as previously

Still not sure whether we need to worry much about interpolating grid correction factor or better to recompute them.

Another question is whether we want to implement any sort of automatic fall back strategy "cheb" -> "cubic" -> "linear" -> "nearest" of we detect a mode discontinuity

@caseyflex
Copy link
Contributor

I think it's better to recompute the grid correction factor, interpolating the complex exponential can lead to bad behavior (abs < 1)

I'm not sure about a fallback, but I am going to add method="poly" where the sampling points are closely concentrated around the central frequency (for more reliable tracking), and polynomial extrapolation is used

@dbochkov-flexcompute dbochkov-flexcompute added the rc3 3rd pre-release label Nov 3, 2025
@momchil-flex
Copy link
Collaborator

One thing I remembered, you should add the ModeInterpSpec to the api docs. And while your at it could you also add the ModeSortSpec which I forgot in my PR?

@dbochkov-flexcompute dbochkov-flexcompute force-pushed the daniil/FXC-3351-Downsample-frequencies-in-mode-monitors branch from 3124b4d to d58af9b Compare November 4, 2025 17:14
@dbochkov-flexcompute dbochkov-flexcompute force-pushed the daniil/FXC-3351-Downsample-frequencies-in-mode-monitors branch from d58af9b to 07415ba Compare November 4, 2025 17:23
@dbochkov-flexcompute
Copy link
Contributor Author

I think it's better to recompute the grid correction factor, interpolating the complex exponential can lead to bad behavior (abs < 1)

Agree. I did have to add some new fields (grid_distances_primal and grid_distances_dual) into ModeSolverData though to enable that.

ModeSolverData.interpolated_data is working now too and I followed the following logic:

mode_solver_full_mnt = td.ModeSolverMonitor(..., reduce_data=False, name="full")
mode_solver_reduced_mnt = td.ModeSolverMonitor(..., reduce_data=True, name="reduced")
...
assert sim_data["full"] == sim_data["reduced"].interpolated_copy

@momchil-flex
Copy link
Collaborator

I think it's better to recompute the grid correction factor, interpolating the complex exponential can lead to bad behavior (abs < 1)

Agree. I did have to add some new fields (grid_distances_primal and grid_distances_dual) into ModeSolverData though to enable that.

Curious about this - the grid should be the same at all frequencies, no?

@dbochkov-flexcompute
Copy link
Contributor Author

I think it's better to recompute the grid correction factor, interpolating the complex exponential can lead to bad behavior (abs < 1)

Agree. I did have to add some new fields (grid_distances_primal and grid_distances_dual) into ModeSolverData though to enable that.

Curious about this - the grid should be the same at all frequencies, no?

yeah, just after n_complex is interpolated onto new set of frequencies, we need to recompute grid_factor = |d2| / (|d1| + |d2|) * exp(1j * k * n * d1) + |d1| / (|d1| + |d2|) * exp(1j * k * n * d2) for each new frequency. Currently, grid factors are computed in ModeSolver where distances in normal direction d1 and d2 are known from the simulation. To allow this recalculation to happen in ModeSolverData.interp_in_freq() -> ModeSolverData we need to keep d1 and d2 in ModeSolverData as well


return mode_solver.data_raw._group_index_post_process(self.mode_spec.group_index_step)

def _get_data_with_interp(self) -> ModeSolverData:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbochkov-flexcompute Maybe it would be better to directly incorporate this method into _data_on_yee_grid. That is, define a property ModeSolver.sampling_points which is either freqs or sampling_points(freqs); then _data_on_yee_grid always computes at sampling_points. Then interpolated_data can be called, depending on reduce_data, either there or in data_raw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think this should be doable now with .interpolated_copy property now, because we do need to wait until mode tracking is done and until group_index/dispersion are calculated to do interpolation

return _LazyProxy


class InterpolatableMixin(Tidy3dBaseModel):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this has to be a mixin, instead of an extra field in ModeInterpSpec

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also you may need a unit test for reduce_data, not sure it works currently

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ModeSpec/ModeInterpSpec are used in so many places I think it might get a bit confusing for the user to remember what options in those specs apply to which objects. With including interp_spec into ModeSpec it is not so bad as distinction there is simple: it applies only to monitors. But distinction for reduce_data is more fine grained: it applies to ModeSolverMonitor and ModeSolver (ModeSimulation and EMEModeSolverMonitor in future?), but not ModeMonitor. It also avoids accidentally turning on this feature where we do not want/anticipate it.

)
return val

_warn_interp_num_points = validate_interp_num_points()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we put this warning in data_raw instead?

@caseyflex caseyflex self-requested a review November 6, 2025 20:26
Copy link
Contributor

@caseyflex caseyflex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbochkov-flexcompute I left a few more comments, but I'd like to get this in soon, so I can clean up my EME PR. I guess the important thing is to get the API stabilized -- maybe we can do that and merge, and then I can change the internals while working on EME. To that end, I'd just consider moving reduce_data into ModeInterpSpec as the primary API change, then see if we can get this merged

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rc3 3rd pre-release RF

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants