Skip to content

Conversation

@weiliangjin2021
Copy link
Collaborator

@weiliangjin2021 weiliangjin2021 commented Feb 5, 2026

Summary

  • Add TerminalWavePort for terminal-based S-parameter extraction with automatic transmission line mode computation
  • Implement TerminalComponentModeler for multi-port S-parameter extraction with differential pair support
  • Migrate performance-critical computations (terminal transforms, snap_box_to_grid) to C++ via tidy3d_extras
  • Add visualization utilities for terminal and differential pair annotations

Test plan

  • Unit tests for terminal detection and labeling
  • Unit tests for differential pair transformation (Q-matrix)
  • Integration tests for TerminalComponentModeler
  • Visualization label packing tests

🤖 Generated with Claude Code


Note

Medium Risk
Touches mode-solver postprocessing and modal overlap math and expands public schemas/types, which could affect RF results/serialization if edge cases slip through, but most changes are additive with backwards-compatible defaults.

Overview
Enables terminal-based RF excitation and S-parameter extraction by introducing TerminalWavePort/MicrowaveTerminalSource and a new MicrowaveTerminalModeSpec flow that computes per-terminal voltage/current integrals, differential-pair transforms, and terminal impedance matrices.

Extends the mode-solver/microwave data model with terminal-indexed datasets and data arrays (TransmissionLineTerminalDataset, terminal field datasets, terminal-index dims), adds Z0_matrix helpers, and updates overlap integrals (dot/outer_dot) to support both symmetric and asymmetric forms used by transmission-line analysis.

Updates JSON schemas and docs to expose the new terminal types (including num_modes="auto" for microwave mode specs) and adds tests for impedance-matrix handling, WavePort multimode absorber warnings, and automated label packing for terminal plotting; also hardens geometry merging for zero-area Shapely types and improves HDF5 unicode coord serialization.

Written by Cursor Bugbot for commit ed24da2. This will update automatically on new commits. Configure here.

@weiliangjin2021
Copy link
Collaborator Author

TerminalWavePort: Comprehensive Workflow Documentation

Overview

TerminalWavePort is a terminal-driven wave port for microwave S-parameter extraction in Tidy3D. Unlike the modal-based WavePort, it excites specific terminals (physical conductor regions) rather than modes, enabling support for differential pairs and coupled transmission lines.

High-Level Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                        TerminalWavePort S-Parameter Flow                     │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌────────────────┐    ┌───────────────────┐    ┌──────────────────────┐
  │   Definition   │───►│   Mode Solving    │───►│   FDTD Simulation    │
  │  (Port Setup)  │    │  (Terminal Basis) │    │  (Field Excitation)  │
  └────────────────┘    └───────────────────┘    └──────────────────────┘
                                                          │
                                                          ▼
  ┌────────────────┐    ┌───────────────────┐    ┌──────────────────────┐
  │   S-Matrix     │◄───│   V/I Transform   │◄───│   Mode Decomposition │
  │   (Output)     │    │  (Terminal Space) │    │   (Monitor Data)     │
  └────────────────┘    └───────────────────┘    └──────────────────────┘

1. TerminalWavePort Definition

Class Hierarchy

                  AbstractTerminalPort
                         │
                         ▼
                  AbstractWavePort (+ Box)
                   /            \
                  /              \
           WavePort         TerminalWavePort
        (mode-based)       (terminal-based)

Key Fields

Field Type Description
terminal_specs AutoImpedanceSpec or tuple[CustomImpedanceSpec] Defines how V/I are computed for each terminal
differential_pairs tuple[tuple[str, str], ...] Pairs of terminals forming differential lines
reference_impedance "Z0", scalar, or DataArray Reference Z for S-parameter normalization

Terminal Labeling Convention

Single-ended terminals:   T0, T1, T2, ...
Differential pairs:       Diff0@comm, Diff0@diff, Diff1@comm, Diff1@diff, ...

Example Definition

port = TerminalWavePort(
    center=(0, 0, 0),
    size=(10, 10, 0),
    direction="+",
    terminal_specs=AutoImpedanceSpec(),      # Auto-detect conductors
    differential_pairs=(("T0", "T1"),),      # T0+/T1- form differential pair
    reference_impedance=50,
)

2. Terminal Detection & Mode Spec Resolution

Auto-Detection Flow

┌──────────────────────────────────────────────────────────────────────┐
│              Terminal Detection Pipeline                              │
└──────────────────────────────────────────────────────────────────────┘

  Simulation Structures
          │
          ▼
  ┌─────────────────────────┐
  │  ModePlaneAnalyzer      │  (path_integrals/mode_plane_analyzer.py)
  │  - Extract conductor    │
  │    cross-sections on    │
  │    port plane           │
  └───────────┬─────────────┘
              │
              ▼
  ┌─────────────────────────┐
  │  get_conductor_         │
  │  bounding_boxes()       │
  │  - Group isolated       │
  │    floating conductors  │
  │  - Return shapes + bbox │
  └───────────┬─────────────┘
              │
              ▼
  ┌─────────────────────────┐
  │  Label terminals        │
  │  T0, T1, T2, ...        │
  │  (left→right, bottom→top)│
  └───────────┬─────────────┘
              │
              ▼
  ┌─────────────────────────┐
  │  Create                 │
  │  MicrowaveTerminalMode  │
  │  Spec                   │
  └─────────────────────────┘

Mode Spec Structure

MicrowaveTerminalModeSpec(
    num_modes=2,                           # Number of quasi-TEM modes
    impedance_specs={                      # V/I path definitions per terminal
        "T0": CustomImpedanceSpec(...),
        "T1": CustomImpedanceSpec(...),
    },
    terminals_mapping={                    # Terminal → single-ended mapping
        "T0": "T0",                        # Single-ended: identity
        "Diff0@comm": ("T0", "T1"),        # Differential: pair of terminals
        "Diff0@diff": ("T0", "T1"),
    }
)

3. Terminals Mapping & Differential Pairs

Two-Stage Transformation

The transformation from mode space to final terminal space involves two stages:

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Mode → Terminal Transformation Pipeline                   │
└─────────────────────────────────────────────────────────────────────────────┘

   Mode Space              Single-Ended Terminals         Final Terminals
 ┌─────────────┐          ┌─────────────────────┐       ┌─────────────────┐
 │  [mode 0]   │          │       [T0]          │       │  [T2] (active)  │
 │  [mode 1]   │  ──T_V─► │       [T1]          │ ──Q─► │  [Diff0@comm]   │
 │  [mode 2]   │          │       [T2]          │       │  [Diff0@diff]   │
 └─────────────┘          └─────────────────────┘       └─────────────────┘

   T_V = voltage_transform (from mode solver)
   Q   = differential pair transformation matrix

Q-Matrix Transformation

The Q-matrix transforms from single-ended terminals to final terminals (which may include differential pairs):

Differential Pair Q-matrix (for pair T0+, T1-):

              T0    T1
    comm  [ 0.5   0.5 ]    V_comm = (V_T0 + V_T1) / 2
    diff  [ 0.5  -0.5 ]    V_diff = (V_T0 - V_T1) / 2

For a system with 3 single-ended terminals (T0, T1, T2) where T0 and T1 form a differential pair:

                    T0    T1    T2
    T2 (active)  [  0     0     1  ]     # Pass-through for active single-ended
    Diff0@comm   [ 0.5   0.5    0  ]     # Common mode
    Diff0@diff   [ 0.5  -0.5    0  ]     # Differential mode

Terminal Ordering

┌─────────────────────────────────────────────────────────┐
│  Final Terminal Order (for S-matrix indexing)           │
├─────────────────────────────────────────────────────────┤
│  1. Active single-ended terminals                       │
│     (not used in any differential pair)                 │
│  2. Differential pair comm/diff modes                   │
│     (Diff0@comm, Diff0@diff, Diff1@comm, ...)           │
└─────────────────────────────────────────────────────────┘

4. Source Generation: MicrowaveTerminalSource

Source Creation Flow

TerminalWavePort.to_source()
        │
        ▼
┌─────────────────────────────────┐
│  MicrowaveTerminalSource        │  (microwave/source.py:12)
│  - mode_spec: MicrowaveTerminal │
│    ModeSpec                     │
│  - terminal_index: str          │
│    (e.g., "T0" or "Diff0@diff") │
└─────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────┐
│  Mode Solver computes:          │
│  1. Eigenmodes (quasi-TEM)      │
│  2. Voltage transform matrix    │
│  3. Current transform matrix    │
│  4. Terminal-space fields       │
└─────────────────────────────────┘

Terminal Mode Excitation

                    Mode Solver
                         │
      ┌──────────────────┼──────────────────┐
      │                  │                  │
      ▼                  ▼                  ▼
   Mode 0            Mode 1            Mode 2
  (quasi-TEM)       (quasi-TEM)       (quasi-TEM)
      │                  │                  │
      └──────────────────┼──────────────────┘
                         │
                         ▼
              ┌──────────────────────┐
              │  Terminal Transform  │
              │  V_terminal = Q · V  │
              │  I_terminal = Q · I  │
              └──────────────────────┘
                         │
         ┌───────────────┼───────────────┐
         │               │               │
         ▼               ▼               ▼
       T0              T1          Diff0@diff
    (single)        (single)     (differential)

5. FDTD Simulation & Monitoring

Simulation Setup

┌───────────────────────────────────────────────────────────────────┐
│              TerminalComponentModeler.sim_dict                     │
└───────────────────────────────────────────────────────────────────┘

  For each terminal_index in port._terminal_indices:

  ┌──────────────────────────────────────────────────────────────┐
  │  Simulation: "port_name@terminal_index"                       │
  │                                                               │
  │  Sources:    MicrowaveTerminalSource (excites 1 terminal)     │
  │  Monitors:   MicrowaveModeMonitor (measures all modes)        │
  │  Absorbers:  InternalAbsorber (optional mode ABC)             │
  └──────────────────────────────────────────────────────────────┘

Monitor Data Structure

MicrowaveModeData
├── amps: DataArray[f, mode_index, direction]       # Mode amplitudes
├── n_complex: DataArray[f, mode_index]             # Effective indices
└── transmission_line_terminal_data:
    ├── Z0: ImpedanceFreqTerminalTerminal           # Z0 matrix [f, t_out, t_in]
    ├── voltage_transform: [f, terminal, mode]      # V_term = T_V · V_mode
    └── current_transform: [f, terminal, mode]      # I_term = T_I · I_mode

6. Voltage/Current Computation

From Mode Amplitudes to Terminal V/I

┌────────────────────────────────────────────────────────────────────┐
│            V/I Extraction from Simulation Data                      │
└────────────────────────────────────────────────────────────────────┘

  MicrowaveModeData.amps
  ├── a+ (forward amplitude per mode)
  └── a- (backward amplitude per mode)
          │
          ▼
  ┌─────────────────────────────────────┐
  │  Mode-level V/I:                    │
  │  V_mode = (a+ + a-)                 │  (wave.py:946-948)
  │  I_mode = sign * (a+ - a-)          │  (wave.py:960-962)
  └─────────────────────────────────────┘
          │
          ▼
  ┌─────────────────────────────────────┐
  │  Transform to terminal space:       │
  │  V_term = voltage_transform · V     │
  │  I_term = current_transform · I     │
  └─────────────────────────────────────┘
          │
          ▼
  ┌─────────────────────────────────────┐
  │  Output: VoltageFreqTerminalDataArray│
  │          CurrentFreqTerminalDataArray│
  └─────────────────────────────────────┘

7. Wave Amplitude & S-Parameter Computation

Wave Amplitude Formulas

┌────────────────────────────────────────────────────────────────────┐
│   S-Parameter Wave Definitions (analysis/terminal.py)              │
└────────────────────────────────────────────────────────────────────┘

  "pseudo" waves (Marks & Williams):
  ┌─────────────────────────────────────────────────────────────────┐
  │  F = sqrt(Re(Z)) / (2|Z|)                                       │
  │  a = F · (V + Z·I)                                              │
  │  b = F · (V - Z·I)                                              │
  └─────────────────────────────────────────────────────────────────┘

  "power" waves (Kurokawa/Pozar):
  ┌─────────────────────────────────────────────────────────────────┐
  │  F = 1 / (2·sqrt(Re(Z)))                                        │
  │  a = F · (V + Z·I)                                              │
  │  b = F · (V - Z*·I)     (* = conjugate)                         │
  └─────────────────────────────────────────────────────────────────┘

  "symmetric_pseudo" waves:
  ┌─────────────────────────────────────────────────────────────────┐
  │  F = 1 / (2·sqrt(Z))    (complex sqrt)                          │
  │  a = F · (V + Z·I)                                              │
  │  b = F · (V - Z·I)                                              │
  └─────────────────────────────────────────────────────────────────┘

S-Matrix Construction

┌───────────────────────────────────────────────────────────────────────┐
│                    S-Matrix Construction                               │
└───────────────────────────────────────────────────────────────────────┘

  For N terminals, run N simulations (one per terminal excitation):

           Excite T0         Excite T1         Excite Diff@diff
               │                 │                    │
               ▼                 ▼                    ▼
         ┌─────────┐       ┌─────────┐          ┌─────────┐
         │ Sim 0   │       │ Sim 1   │   ...    │ Sim N-1 │
         └────┬────┘       └────┬────┘          └────┬────┘
              │                 │                    │
              ▼                 ▼                    ▼
         [a0, b0]          [a1, b1]            [aN-1, bN-1]
              │                 │                    │
              └─────────────────┼────────────────────┘
                                │
                                ▼
                    ┌───────────────────────┐
                    │  Build matrices:      │
                    │  A = [a0 a1 ... aN-1] │  (columns)
                    │  B = [b0 b1 ... bN-1] │
                    └───────────┬───────────┘
                                │
                                ▼
                    ┌───────────────────────┐
                    │  S = B · A⁻¹          │
                    │  (or S_ij = b_i/a_jj  │
                    │   if ideal excitation)│
                    └───────────────────────┘

8. Reference Impedance Handling

Impedance Matrix Structure

For TerminalWavePort, the reference impedance can be a full matrix (not just diagonal) to handle coupled terminals:

                     Z0_matrix [f, terminal_out, terminal_in]

               T0      T1      Diff@comm   Diff@diff
          ┌─────────────────────────────────────────┐
    T0    │  Z00     Z01        ...         ...     │
    T1    │  Z10     Z11        ...         ...     │
  Diff@c  │  ...     ...        Z_cc        Z_cd    │
  Diff@d  │  ...     ...        Z_dc        Z_dd    │
          └─────────────────────────────────────────┘

9. Complete Data Flow Diagram

┌──────────────────────────────────────────────────────────────────────────────┐
│                     COMPLETE TERMINALWAVEPORT WORKFLOW                        │
└──────────────────────────────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────────────────────┐
  │ PHASE 1: SETUP (TerminalComponentModeler.__init__)                       │
  └─────────────────────────────────────────────────────────────────────────┘
                                     │
      ┌──────────────────────────────┼───────────────────────────────┐
      │                              │                               │
      ▼                              ▼                               ▼
  Detect floating          Resolve mode_spec          Validate differential
  conductors at port       (auto → explicit)          pair terminal labels
      │                              │                               │
      └──────────────────────────────┼───────────────────────────────┘
                                     │
                                     ▼
                        _resolved_mode_specs: dict

  ┌─────────────────────────────────────────────────────────────────────────┐
  │ PHASE 2: SIMULATION GENERATION (sim_dict)                                │
  └─────────────────────────────────────────────────────────────────────────┘
                                     │
      ┌──────────────────────────────┼───────────────────────────────┐
      │                              │                               │
      ▼                              ▼                               ▼
  Create base_sim            For each terminal:           Add monitors
  (grid, absorbers,          Create Microwave             (MicrowaveModeMonitor)
   mesh overrides)           TerminalSource
                                     │
                                     ▼
                          SimulationMap (one sim per terminal)

  ┌─────────────────────────────────────────────────────────────────────────┐
  │ PHASE 3: RUN SIMULATIONS (via web.run_async)                             │
  └─────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
                    ┌────────────────────────────────┐
                    │  FDTD Server Execution         │
                    │  - Mode solve for source       │
                    │  - Time-domain propagation     │
                    │  - Mode decomposition          │
                    └────────────────────────────────┘
                                     │
                                     ▼
                    TerminalComponentModelerData.data
                    (dict of SimulationData per task)

  ┌─────────────────────────────────────────────────────────────────────────┐
  │ PHASE 4: POST-PROCESSING (smatrix())                                     │
  └─────────────────────────────────────────────────────────────────────────┘
                                     │
      ┌──────────────────────────────┼───────────────────────────────┐
      │                              │                               │
      ▼                              ▼                               ▼
  port_reference          port_voltage_current        port_wave_amplitude
  _impedances()           _matrices()                 _matrices()
      │                              │                               │
      │                              │                               │
      │      ┌───────────────────────┘                               │
      │      │                                                       │
      │      ▼                                                       │
      │  compute_voltage()  ─────►  voltage_transform · (a+ + a-)    │
      │  compute_current()  ─────►  current_transform · (a+ - a-)    │
      │      │                                                       │
      │      └───────────────────────┬───────────────────────────────┘
      │                              │
      │                              ▼
      │                   _compute_wave_amplitudes_from_VI()
      │                   a = F·(V + Z·I),  b = F·(V - Z·I)
      │                              │
      └──────────────────────────────┤
                                     │
                                     ▼
                        terminal_construct_smatrix()
                        S = B · A⁻¹  (or diagonal approx)
                                     │
                                     ▼
                        MicrowaveSMatrixData
                        (S-matrix + reference impedances)

10. C++ Binding Integration

Performance-critical computations have been migrated to C++ via tidy3d_extras:

Function Location Description
compute_terminal_transforms tidy3d_extras Voltage/current transformation matrices (mode → terminal)
snap_box_to_grid tidy3d_extras Grid-aligned box snapping for geometry utilities

ImpedanceDefinition is now an IntEnum for C++ binding compatibility.


11. Visualization

The viz.py module provides plotting utilities for terminal annotations:

┌─────────────────────────────────────────────────────────────────────┐
│  Visualization Elements                                              │
├─────────────────────────────────────────────────────────────────────┤
│  Terminal boxes      : Orange dashed rectangles around conductors   │
│  Differential pairs  : Blue dashed rectangles grouping pairs        │
│  Labels              : Terminal names (T0, T1, Diff0@diff, etc.)    │
│  Padding regions     : Hatched gray areas for absorber padding      │
└─────────────────────────────────────────────────────────────────────┘

Usage via TerminalComponentModeler:

  • plot_port() - Plot port geometry with terminal annotations
  • plot_sim() - Plot simulation with sources and monitors

@weiliangjin2021 weiliangjin2021 added the awaiting backend not to be merged as backend is not finalized label Feb 5, 2026
@weiliangjin2021 weiliangjin2021 force-pushed the FXC-4678-terminal-wave-port branch from 17a0592 to 3afcdd8 Compare February 5, 2026 04:30
Copy link
Collaborator

@yaugenst-flex yaugenst-flex left a comment

Choose a reason for hiding this comment

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

RF snapping now requires extras, is this by design? Would this mean extras is no longer optional?

@weiliangjin2021
Copy link
Collaborator Author

RF snapping now requires extras, is this by design? Would this mean extras is no longer optional?

Yes, extras would be required for many RF simulations now. However, in this particular case, I plan to move snapping back, as so many frontend tests depend on it. So in this PR, only voltage/current transformation matrix in mode solver depends on extras.

@weiliangjin2021 weiliangjin2021 force-pushed the FXC-4678-terminal-wave-port branch from 3baef04 to 30d934e Compare February 11, 2026 17:39
@weiliangjin2021 weiliangjin2021 force-pushed the FXC-4678-terminal-wave-port branch 2 times, most recently from 96ddea3 to 8fa7dbe Compare February 11, 2026 18:24
@github-actions
Copy link
Contributor

github-actions bot commented Feb 11, 2026

Diff Coverage

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

  • tidy3d/components/data/data_array.py (100%)
  • tidy3d/components/data/dataset.py (100%)
  • tidy3d/components/data/monitor_data.py (79.2%): Missing lines 901,922,941,1059,1122
  • tidy3d/components/geometry/utils.py (100%)
  • tidy3d/components/microwave/data/dataset.py (92.6%): Missing lines 121,132
  • tidy3d/components/microwave/data/monitor_data.py (40.0%): Missing lines 280-281,285-289,291-292,294,513,516
  • tidy3d/components/microwave/impedance_calculator.py (100%)
  • tidy3d/components/microwave/mode_spec.py (49.1%): Missing lines 143-145,262-268,271-272,279-282,288,290,293-295,299,304,309-311,316
  • tidy3d/components/microwave/monitor.py (100%)
  • tidy3d/components/microwave/path_integrals/factory.py (5.9%): Missing lines 182,185,187,189-190,192-201,207
  • tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py (50.0%): Missing lines 211-212,214-215,217,221-222,228
  • tidy3d/components/microwave/path_integrals/specs/current.py (100%)
  • tidy3d/components/microwave/path_integrals/specs/impedance.py (84.6%): Missing lines 139-142,144,182
  • tidy3d/components/microwave/path_integrals/specs/voltage.py (88.9%): Missing lines 144,217
  • tidy3d/components/microwave/source.py (71.4%): Missing lines 70-72,77
  • tidy3d/components/mode/mode_solver.py (29.4%): Missing lines 234,651,1480-1481,1484,1557-1558,1560-1561,1564-1565,1568-1569,1572,1574,1605-1606,1609,1612-1614,1617-1618,1620-1621,1624-1626,1628-1635,1638,1642,1665-1666,1668,1672-1673,1676-1679,1682,1686-1687,1690,1693,1699-1701,1704,1707,1712-1714,1722,1725,1730,1742,1745-1746,1749,1762,1775,1787,1802-1803
  • tidy3d/components/monitor.py (100%)
  • tidy3d/components/source/field.py (48.0%): Missing lines 375,380,385-389,394-399
  • tidy3d/components/source/utils.py (100%)
  • tidy3d/components/types/mode_spec.py (100%)
  • tidy3d/plugins/smatrix/init.py (100%)
  • tidy3d/plugins/smatrix/analysis/terminal.py (96.6%): Missing lines 158,356
  • tidy3d/plugins/smatrix/component_modelers/base.py (85.7%): Missing lines 248-250
  • tidy3d/plugins/smatrix/component_modelers/terminal.py (39.0%): Missing lines 102,115,134,138,146-153,155-158,442,509-513,515,519-524,527,529-530,533,536-537,539,554-557,579,582-584,587-590,594,600,603,609,612,618,621,627,630-631,656-657,659,662-664,666-668,671-674,677-679,682,684,693,696,699,701,704-707,710,721,755,759-760,762-763,765-766,768-774,777,779-781,784-787,789,791-792,802,829,831-832,834-835,837,839-841,844,847,851,853-854,856-859,862-865,868-872,874,877,883,885-886,896,924-925,927-929,931-934,936-938,940,967-969,972,977-979,981,991,996-998,1000,1009,1051-1053,1083-1084,1477,1611-1612,1615-1617,1634-1635,1637-1639,1659,1661-1663,1722-1725,1728,1740-1741,1780
  • tidy3d/plugins/smatrix/component_modelers/viz.py (100%)
  • tidy3d/plugins/smatrix/data/terminal.py (87.5%): Missing lines 505
  • tidy3d/plugins/smatrix/ports/base_lumped.py (100%)
  • tidy3d/plugins/smatrix/ports/base_terminal.py (100%)
  • tidy3d/plugins/smatrix/ports/coaxial_lumped.py (100%)
  • tidy3d/plugins/smatrix/ports/types.py (100%)
  • tidy3d/plugins/smatrix/ports/wave.py (56.2%): Missing lines 137,144-147,151,167,169-171,173-174,176,178-179,182,188,190,197,202,204,244,275,278,280,288,295,440,515-517,519,525,575,723,725,729,797-800,803,814,816-817,820-821,828-829,831-833,836-837,843-844,849,856-857,861,869-870,872-876,881,889-890,894-895,910-911,921-922,924-925,928-929,931,947-953,959,981,985-986,1009-1011,1013-1016,1031-1032,1036-1040,1043,1047-1051,1054-1057
  • tidy3d/plugins/smatrix/utils.py (76.1%): Missing lines 177,236,238,240,242-244,247,253,257-258
  • tidy3d/rf.py (100%)

Summary

  • Total: 1218 lines
  • Missing: 500 lines
  • Coverage: 58%

tidy3d/components/data/monitor_data.py

Lines 897-905

  897         if conjugate:
  898             if use_symmetric_form:
  899                 fields_self = {key: field.conj() for key, field in fields_self.items()}
  900             else:
! 901                 fields_other = {key: field.conj() for key, field in fields_other.items()}
  902         dim1, dim2 = self._tangential_dims
  903         d_area = self._diff_area
  904 
  905         # After interpolation, the tangential coordinates should match. However, the two arrays

Lines 918-926

  918                 integrand = xr.DataArray(
  919                     e_self_x_h_other - h_self_x_e_other, coords=fields_self["E" + dim1].coords
  920                 )
  921             else:
! 922                 integrand = xr.DataArray(e_self_x_h_other, coords=fields_self["E" + dim1].coords)
  923 
  924             integrand *= d_area
  925         else:
  926             # Broadcasting is needed, which may be complicated depending on the dimensions order.

Lines 937-945

  937                 h_self_x_e_other = fields_self["H" + dim1] * fields_other["E" + dim2]
  938                 h_self_x_e_other -= fields_self["H" + dim2] * fields_other["E" + dim1]
  939                 integrand = (e_self_x_h_other - h_self_x_e_other) * d_area
  940             else:
! 941                 integrand = e_self_x_h_other * d_area
  942 
  943         # Integrate over plane
  944         coefficient = 0.25 if use_symmetric_form else 0.5
  945         return ModeAmpsDataArray(coefficient * integrand.sum(dim=d_area.dims))

Lines 1055-1063

  1055         if conjugate:
  1056             if use_symmetric_form:
  1057                 fields_self = {key: field.conj() for key, field in fields_self.items()}
  1058             else:
! 1059                 fields_other = {key: field.conj() for key, field in fields_other.items()}
  1060 
  1061         # Tangential field component names
  1062         dim1, dim2 = tan_dims
  1063         e_1 = "E" + dim1

Lines 1118-1126

  1118             if use_symmetric_form:
  1119                 h_self_x_e_other = h_self_1 * e_other_2 - h_self_2 * e_other_1
  1120                 summand = 0.25 * (e_self_x_h_other - h_self_x_e_other) * d_area
  1121             else:
! 1122                 summand = 0.5 * e_self_x_h_other * d_area
  1123             return summand
  1124 
  1125         result = self._outer_fn_summation(
  1126             fields_1=fields_self,

tidy3d/components/microwave/data/dataset.py

Lines 117-125

  117 
  118     @cached_property
  119     def Z0_matrix(self) -> ImpedanceFreqTerminalTerminalDataArray:
  120         """The characteristic impedance matrix (diagonal matrix here)."""
! 121         return self.Z0
  122 
  123     @cached_property
  124     def voltage_transform_inv(self) -> VoltageFreqTerminalModeDataArray:
  125         """Inverse of the voltage transformation matrix.

Lines 128-136

  128         -------
  129         VoltageFreqTerminalModeDataArray
  130             Inverse of the voltage transform matrix that maps modes to terminals.
  131         """
! 132         return xr.apply_ufunc(
  133             np.linalg.inv,
  134             self.voltage_transform,
  135             input_core_dims=[["terminal_index", "mode_index"]],
  136             output_core_dims=[["terminal_index", "mode_index"]],

tidy3d/components/microwave/data/monitor_data.py

Lines 276-298

  276         Optional[TerminalFieldDataset]
  277             Dataset containing Ex, Ey, Ez, Hx, Hy, Hz field components indexed by terminal_index,
  278             or None if transmission_line_terminal_data is not set.
  279         """
! 280         if self.transmission_line_terminal_data is None:
! 281             return None
  282 
  283         # Transform each field component: field_terminal = voltage_transform^-1 @ field_mode
  284         # Use the cached inverse of the voltage transform matrix
! 285         voltage_transform_inv = self.transmission_line_terminal_data.voltage_transform_inv
! 286         field_dict = {}
! 287         for field_name in ["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]:
! 288             mode_field = getattr(self, field_name, None)
! 289             if mode_field is not None:
  290                 # Use xarray.dot for the matrix multiplication over mode_index
! 291                 terminal_field = xr.dot(voltage_transform_inv, mode_field, dims="mode_index")
! 292                 field_dict[field_name] = ScalarTerminalFieldDataArray(terminal_field)
  293 
! 294         return TerminalFieldDataset(**field_dict)
  295 
  296     @property
  297     def modes_info(self) -> xr.Dataset:
  298         """Dataset collecting various properties of the stored modes."""

Lines 509-520

  509             )
  510 
  511         # Add transmission line terminal data handling if present
  512         if self.transmission_line_terminal_data is not None:
! 513             transmission_line_terminal_data_reordered = (
  514                 self.transmission_line_terminal_data._apply_mode_reorder(sort_inds_2d)
  515             )
! 516             main_data_reordered = main_data_reordered.updated_copy(
  517                 transmission_line_terminal_data=transmission_line_terminal_data_reordered
  518             )
  519         return main_data_reordered

tidy3d/components/microwave/mode_spec.py

Lines 139-149

  139         val = self.impedance_specs
  140         num_modes = self.num_modes
  141 
  142         if num_modes == "auto":
! 143             if not isinstance(val, (tuple, list)):
! 144                 if not isinstance(val, AutoImpedanceSpec):
! 145                     raise SetupError(
  146                         "num_modes='auto' with a single non-AutoImpedanceSpec cannot determine "
  147                         "the number of modes. Provide a tuple of specs, "
  148                         "or use AutoImpedanceSpec for automatic conductor detection."
  149                     )

Lines 258-276

  258     @model_validator(mode="after")
  259     def _validate_terminal_mode_spec(self) -> Self:
  260         """Validate impedance definitions consistency, num_modes match, and terminals mapping."""
  261         # Validate consistent impedance definitions
! 262         val = self.impedance_specs
! 263         if len(val) > 0:
! 264             specs = list(val.values())
! 265             first_definition = specs[0].impedance_definition
! 266             for impedance_spec in specs[1:]:
! 267                 if impedance_spec.impedance_definition != first_definition:
! 268                     raise SetupError("Inconsistent impedance definitions across terminals.")
  269 
  270         # Check impedance specs consistent with num_modes
! 271         if len(val) != self.num_modes:
! 272             raise SetupError(
  273                 f"Given {len(val)} impedance specifications in the 'MicrowaveTerminalModeSpec', "
  274                 f"but the number of modes requested is {self.num_modes}. Please ensure that the "
  275                 "number of impedance specifications is equal to the number of modes."
  276             )

Lines 275-286

  275                 "number of impedance specifications is equal to the number of modes."
  276             )
  277 
  278         # Check terminals mapping consistency with impedance specs
! 279         terminals_mapping = self.terminals_mapping
! 280         if terminals_mapping is not None:
! 281             if len(terminals_mapping) != len(val):
! 282                 raise SetupError(
  283                     f"Given {len(terminals_mapping)} terminals mapping in the 'MicrowaveTerminalModeSpec', "
  284                     f"but the number of impedance specifications is {len(val)}. Please ensure that the "
  285                     "number of terminals mapping is equal to the number of impedance specifications."
  286                 )

Lines 284-320

  284                     f"but the number of impedance specifications is {len(val)}. Please ensure that the "
  285                     "number of terminals mapping is equal to the number of impedance specifications."
  286                 )
  287 
! 288             for terminal_label in terminals_mapping.values():
  289                 # Handle both single terminal (str) and differential pair (tuple[str, str])
! 290                 labels_to_check = (
  291                     (terminal_label,) if isinstance(terminal_label, str) else terminal_label
  292                 )
! 293                 for label in labels_to_check:
! 294                     if label not in val.keys():
! 295                         raise SetupError(
  296                             f"Terminal label '{label}' is not present in the impedance specifications."
  297                         )
  298 
! 299         return self
  300 
  301     @cached_property
  302     def impedance_definition(self) -> ImpedanceDefinition:
  303         """Impedance definition (consistent across all terminals)."""
! 304         return next(iter(self.impedance_specs.values())).impedance_definition
  305 
  306     @cached_property
  307     def _terminal_indices(self) -> list[str]:
  308         """List of terminal indices."""
! 309         if self.terminals_mapping is None:
! 310             return list(self.impedance_specs.keys())
! 311         return list(self.terminals_mapping.keys())
  312 
  313     @cached_property
  314     def _impedance_specs_as_tuple(self) -> tuple[Optional[ImpedanceSpecType]]:
  315         """Gets the impedance_specs field converted to a tuple."""
! 316         return tuple(self.impedance_specs.values())
  317 
  318 
  319 MicrowaveModeSpecType = Union[MicrowaveModeSpec, MicrowaveTerminalModeSpec]

tidy3d/components/microwave/path_integrals/factory.py

Lines 178-205

  178     SetupError
  179         If path integrals cannot be constructed from the impedance specifications.
  180     """
  181 
! 182     integrals_dict = {}
  183 
  184     # impedance_specs is a dict mapping terminal labels to CustomImpedanceSpec
! 185     impedance_specs = microwave_terminal_mode_spec.impedance_specs
  186 
! 187     for terminal_label, impedance_spec in impedance_specs.items():
  188         # Get voltage and current specs from CustomImpedanceSpec
! 189         v_spec = impedance_spec.voltage_spec
! 190         i_spec = impedance_spec.current_spec
  191 
! 192         try:
! 193             v_integral = None
! 194             i_integral = None
! 195             if v_spec is not None:
! 196                 v_integral = make_voltage_integral(v_spec)
! 197             if i_spec is not None:
! 198                 i_integral = make_current_integral(i_spec)
! 199             integrals_dict[terminal_label] = (v_integral, i_integral)
! 200         except Exception as e:
! 201             raise SetupError(
  202                 f"Failed to construct path integrals for terminal '{terminal_label}' "
  203                 "from the impedance specification. "
  204                 "Please create a github issue so that the problem can be investigated."
  205             ) from e

Lines 203-208

  203                 "from the impedance specification. "
  204                 "Please create a github issue so that the problem can be investigated."
  205             ) from e
  206 
! 207     return integrals_dict

tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py

Lines 207-226

  207         ------
  208         SetupError
  209             If no valid isolated conductors are found in the mode plane.
  210         """
! 211         mode_symmetry_3d = self._get_mode_symmetry(sim_box, symmetry)
! 212         min_b_3d, max_b_3d = self._get_mode_limits(grid, mode_symmetry_3d)
  213 
! 214         intersection_plane = Box.from_bounds(min_b_3d, max_b_3d)
! 215         conductor_shapely = self._get_isolated_conductors_as_shapely(intersection_plane, structures)
  216 
! 217         conductor_shapely = self._filter_conductors_touching_sim_bounds(
  218             (min_b_3d, max_b_3d), mode_symmetry_3d, conductor_shapely
  219         )
  220 
! 221         if len(conductor_shapely) < 1:
! 222             raise SetupError(
  223                 "No valid isolated conductors were found in the mode plane. Please ensure that a 'Structure' "
  224                 "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching "
  225                 "the boundaries of the mode plane."
  226             )

Lines 224-232

  224                 "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching "
  225                 "the boundaries of the mode plane."
  226             )
  227 
! 228         return conductor_shapely
  229 
  230     def get_conductor_bounding_boxes(
  231         self,
  232         structures: list[Structure],

tidy3d/components/microwave/path_integrals/specs/impedance.py

Lines 135-148

  135             - VI: Both voltage and current specs provided.
  136             - PI: Only current spec provided.
  137             - PV: Only voltage spec provided.
  138         """
! 139         if self.voltage_spec is not None and self.current_spec is not None:
! 140             return ImpedanceDefinition.VI
! 141         elif self.current_spec is not None:
! 142             return ImpedanceDefinition.PI
  143         else:
! 144             return ImpedanceDefinition.PV
  145 
  146     def _check_path_integrals_within_box(self, box: Box) -> None:
  147         """Raise SetupError if a path specification is defined outside a candidate box."""
  148         for spec, spec_type in [

Lines 178-186

  178     def from_bounding_box(
  179         cls, bounding_box: Box, current_sign: Direction = "+"
  180     ) -> CustomImpedanceSpec:
  181         """Create a custom impedance specification from a bounding box."""
! 182         return cls(
  183             current_spec=AxisAlignedCurrentIntegralSpec(
  184                 center=bounding_box.center,
  185                 size=bounding_box.size,
  186                 sign=current_sign,

tidy3d/components/microwave/path_integrals/specs/voltage.py

Lines 140-148

  140         if plot_markers:
  141             ax.plot(xs, ys, markevery=[0, -1], **plot_kwargs)
  142         else:
  143             # Plot without markers
! 144             ax.plot(xs, ys, **{**plot_kwargs, "marker": ""})
  145 
  146         # Plot special end points
  147         if plot_markers:
  148             end_kwargs = plot_params_voltage_plus.include_kwargs(**path_kwargs).to_kwargs()

Lines 213-221

  213         if plot_markers:
  214             ax.plot(xs, ys, markevery=[0, -1], **plot_kwargs)
  215         else:
  216             # Plot without markers
! 217             ax.plot(xs, ys, **{**plot_kwargs, "marker": ""})
  218 
  219         # Plot special end points
  220         if plot_markers:
  221             end_kwargs = plot_params_voltage_plus.include_kwargs(**path_kwargs).to_kwargs()

tidy3d/components/microwave/source.py

Lines 66-78

  66 
  67     @model_validator(mode="after")
  68     def _validate_terminal_index(self) -> Self:
  69         """Validate that terminal_index exists in mode_spec terminal indices."""
! 70         terminal_indices = self.mode_spec._terminal_indices
! 71         if self.terminal_index not in terminal_indices:
! 72             raise ValueError(
  73                 f"terminal_index '{self.terminal_index}' not found in mode_spec. "
  74                 f"Available terminals: {terminal_indices}"
  75             )
  76 
! 77         return self

tidy3d/components/mode/mode_solver.py

Lines 230-238

  230     @model_validator(mode="after")
  231     def _validate_mode_spec(self) -> Self:
  232         """Validate that num_modes is an integer."""
  233         if not isinstance(self.mode_spec.num_modes, int):
! 234             raise ValidationError("num_modes must be an integer.")
  235         return self
  236 
  237     @field_validator("simulation")
  238     @classmethod

Lines 647-655

  647         # Calculate and add the transmission line data
  648         if self._has_microwave_mode_spec:
  649             mode_solver_data = self._add_microwave_data(mode_solver_data)
  650         if self._has_microwave_terminal_mode_spec:
! 651             mode_solver_data = self._add_microwave_terminal_data(mode_solver_data)
  652         return mode_solver_data
  653 
  654     @cached_property
  655     def bend_axis_3d(self) -> Axis:

Lines 1476-1488

  1476     def _make_path_integrals_for_terminal(
  1477         self,
  1478     ) -> dict[str, tuple[Optional[VoltageIntegralType], Optional[CurrentIntegralType]]]:
  1479         """Wrapper for making path integrals for each terminal from the MicrowaveTerminalModeSpec."""
! 1480         if not self._has_microwave_terminal_mode_spec:
! 1481             raise ValueError(
  1482                 "Cannot make path integrals for when 'mode_spec' is not a 'MicrowaveTerminalModeSpec'."
  1483             )
! 1484         return make_path_integrals_for_terminal(self.mode_spec)
  1485 
  1486     def _generate_transmission_line_data(
  1487         self, mode_solver_data: MicrowaveModeSolverData
  1488     ) -> TransmissionLineDataset:

Lines 1553-1578

  1553             Transform matrix with dimensions (f, terminal_index, mode_index).
  1554         """
  1555         # Stack voltage/current data for each terminal across modes, then stack terminals
  1556         # Result: (f, terminal_index, mode_index)
! 1557         stacked_list = []
! 1558         for terminal_label in terminal_labels:
  1559             # Concat across modes for this terminal: list of FreqDataArray -> (f, mode_index)
! 1560             mode_data = xr.concat(input_list[terminal_label], dim="mode_index")
! 1561             stacked_list.append(mode_data)
  1562 
  1563         # Stack all terminals: (f, mode_index) per terminal -> (f, terminal_index, mode_index)
! 1564         result = xr.concat(stacked_list, dim="terminal_index")
! 1565         result = result.assign_coords(terminal_index=terminal_labels)
  1566 
  1567         # Assign explicit mode_index coordinates
! 1568         num_modes = len(input_list[terminal_labels[0]])
! 1569         result = result.assign_coords(mode_index=np.arange(num_modes))
  1570 
  1571         # Ensure dimension order is (f, terminal_index, mode_index)
! 1572         result = result.transpose("f", "terminal_index", "mode_index")
  1573 
! 1574         return result
  1575 
  1576     @staticmethod
  1577     def _construct_differential_pair_transform(
  1578         terminals_mapping: Optional[dict[str, Union[str, tuple[str, str]]]],

Lines 1601-1646

  1601         np.ndarray
  1602             Transformation matrix Q with shape (num_output_terminals, num_input_terminals).
  1603         """
  1604         # if terminals_mapping is None, return an identity matrix
! 1605         if terminals_mapping is None:
! 1606             return np.eye(len(terminal_labels))
  1607 
  1608         # Create mapping from terminal labels to indices for fast lookup
! 1609         label_to_idx = {label: idx for idx, label in enumerate(terminal_labels)}
  1610 
  1611         # Initialize Q matrix
! 1612         num_output = len(terminals_mapping)
! 1613         num_input = len(terminal_labels)
! 1614         Q = np.zeros((num_output, num_input))
  1615 
  1616         # Fill Q matrix based on terminals_mapping
! 1617         for output_idx, (output_label, input_mapping) in enumerate(terminals_mapping.items()):
! 1618             if isinstance(input_mapping, str):
  1619                 # Single-ended terminal: direct mapping
! 1620                 input_idx = label_to_idx[input_mapping]
! 1621                 Q[output_idx, input_idx] = 1.0
  1622             else:
  1623                 # Differential pair: tuple (label1, label2)
! 1624                 label1, label2 = input_mapping
! 1625                 idx1 = label_to_idx[label1]
! 1626                 idx2 = label_to_idx[label2]
  1627 
! 1628                 if output_label.endswith("@comm"):
! 1629                     factor = 0.5 if voltage_transform else 1.0
! 1630                     Q[output_idx, idx1] = factor
! 1631                     Q[output_idx, idx2] = factor
! 1632                 elif output_label.endswith("@diff"):
! 1633                     factor = 1 if voltage_transform else 0.5
! 1634                     Q[output_idx, idx1] = factor
! 1635                     Q[output_idx, idx2] = -factor
  1636                 else:
  1637                     # Should not happen based on the design, but handle gracefully
! 1638                     raise ValueError(
  1639                         f"Differential pair terminal '{output_label}' must end with '@comm' or '@diff'"
  1640                     )
  1641 
! 1642         return Q
  1643 
  1644     def _generate_transmission_line_terminal_data(
  1645         self, mode_solver_data: MicrowaveModeSolverData
  1646     ) -> TransmissionLineTerminalDataset:

Lines 1661-1697

  1661             Dataset containing Z0 matrix (terminal_index_out × terminal_index_in),
  1662             voltage transformation matrix (terminal_index × mode_index),
  1663             and current transformation matrix (terminal_index × mode_index).
  1664         """
! 1665         integrals_dict = self._make_path_integrals_for_terminal()
! 1666         impedance_definition = self.mode_spec.impedance_definition
  1667         # Need to operate on the full symmetry expanded fields
! 1668         mode_solver_data_expanded = mode_solver_data.symmetry_expanded_copy
  1669 
  1670         # Initialize dictionaries to collect voltage/current for each terminal across all modes
  1671         # Structure: {terminal_label: [voltage_mode0, voltage_mode1, ...]}
! 1672         V_list = {terminal_label: [] for terminal_label in integrals_dict.keys()}
! 1673         I_list = {terminal_label: [] for terminal_label in integrals_dict.keys()}
  1674 
  1675         # Loop over mode indices
! 1676         for mode_index in range(self.mode_spec.num_modes):
! 1677             single_mode_data = mode_solver_data_expanded._isel(mode_index=[mode_index])
! 1678             for terminal_label, (v_integral, i_integral) in integrals_dict.items():
! 1679                 impedance_calc = ImpedanceCalculator(
  1680                     voltage_integral=v_integral, current_integral=i_integral
  1681                 )
! 1682                 voltage, current = impedance_calc.compute_voltage_current(
  1683                     single_mode_data,
  1684                 )
  1685                 # Append to list for this terminal
! 1686                 V_list[terminal_label].append(voltage)
! 1687                 I_list[terminal_label].append(current)
  1688 
  1689         # Validate that terminal transforms feature is available
! 1690         check_tidy3d_extras_licensed_feature("terminal_transformation_matrix")
  1691 
  1692         # Get input terminal labels (before differential pair transformation)
! 1693         terminal_labels = list(integrals_dict.keys())
  1694 
  1695         # Determine which arrays are needed based on impedance definition
  1696         # VI (0): needs v_list, i_list
  1697         # PI (1): needs i_list, power_matrix

Lines 1695-1718

  1695         # Determine which arrays are needed based on impedance definition
  1696         # VI (0): needs v_list, i_list
  1697         # PI (1): needs i_list, power_matrix
  1698         # PV (2): needs v_list, power_matrix
! 1699         need_v = impedance_definition in (ImpedanceDefinition.VI, ImpedanceDefinition.PV)
! 1700         need_i = impedance_definition in (ImpedanceDefinition.VI, ImpedanceDefinition.PI)
! 1701         need_p = impedance_definition in (ImpedanceDefinition.PI, ImpedanceDefinition.PV)
  1702 
  1703         # Assemble V and I arrays as numpy arrays: (num_freqs, num_terminals, num_modes)
! 1704         v_array = (
  1705             self._assemble_transform_matrices(V_list, terminal_labels).values if need_v else None
  1706         )
! 1707         i_array = (
  1708             self._assemble_transform_matrices(I_list, terminal_labels).values if need_i else None
  1709         )
  1710 
  1711         # Compute power matrix if needed: (num_freqs, num_modes, num_modes)
! 1712         power_matrix = None
! 1713         if need_p:
! 1714             power_matrix = (
  1715                 2
  1716                 * mode_solver_data_expanded.outer_dot(
  1717                     mode_solver_data_expanded, conjugate=True, use_symmetric_form=False
  1718                 )

Lines 1718-1734

  1718                 )
  1719             ).values
  1720 
  1721         # Build Q matrices for differential pair transformation
! 1722         Q_voltage = self._construct_differential_pair_transform(
  1723             self.mode_spec.terminals_mapping, terminal_labels, voltage_transform=True
  1724         )
! 1725         Q_current = self._construct_differential_pair_transform(
  1726             self.mode_spec.terminals_mapping, terminal_labels, voltage_transform=False
  1727         )
  1728 
  1729         # Call C++ binding to compute transforms and Z0
! 1730         voltage_transform_arr, current_transform_arr, z0_arr = tidy3d_extras[
  1731             "mod"
  1732         ].extension._compute_terminal_transforms(
  1733             v_array,
  1734             i_array,

Lines 1738-1753

  1738             impedance_definition,
  1739         )
  1740 
  1741         # Get output terminal labels (after differential pair transformation)
! 1742         out_terminal_labels = list(self.mode_spec._terminal_indices)
  1743 
  1744         # Get coordinates from mode solver data
! 1745         freqs = mode_solver_data.n_eff.coords["f"].values
! 1746         mode_indices = mode_solver_data.n_eff.coords["mode_index"].values
  1747 
  1748         # Wrap voltage_transform: (num_freqs, num_terminals, num_modes)
! 1749         voltage_transform = VoltageFreqTerminalModeDataArray(
  1750             xr.DataArray(
  1751                 voltage_transform_arr,
  1752                 coords={
  1753                     "f": freqs,

Lines 1758-1766

  1758             )
  1759         )
  1760 
  1761         # Wrap current_transform: (num_freqs, num_terminals, num_modes)
! 1762         current_transform = CurrentFreqTerminalModeDataArray(
  1763             xr.DataArray(
  1764                 current_transform_arr,
  1765                 coords={
  1766                     "f": freqs,

Lines 1771-1779

  1771             )
  1772         )
  1773 
  1774         # Wrap Z0: (num_freqs, num_terminals, num_terminals)
! 1775         Z0 = ImpedanceFreqTerminalTerminalDataArray(
  1776             xr.DataArray(
  1777                 z0_arr,
  1778                 coords={
  1779                     "f": freqs,

Lines 1783-1791

  1783                 dims=["f", "terminal_index_out", "terminal_index_in"],
  1784             )
  1785         )
  1786 
! 1787         return TransmissionLineTerminalDataset(
  1788             Z0=Z0, voltage_transform=voltage_transform, current_transform=current_transform
  1789         )
  1790 
  1791     def _add_microwave_data(

Lines 1798-1807

  1798     def _add_microwave_terminal_data(
  1799         self, mode_solver_data: MicrowaveModeSolverData
  1800     ) -> MicrowaveModeSolverData:
  1801         """Calculate and add microwave terminal data to ``mode_solver_data`` which uses the path specifications."""
! 1802         mw_data = self._generate_transmission_line_terminal_data(mode_solver_data)
! 1803         return mode_solver_data.updated_copy(transmission_line_terminal_data=mw_data)
  1804 
  1805     @cached_property
  1806     def data(self) -> ModeSolverDataType:
  1807         """:class:`.ModeSolverData` containing the field and effective index data.

tidy3d/components/source/field.py

Lines 371-403

  371 
  372     @cached_property
  373     def angle_theta(self) -> float:
  374         """Polar angle of propagation."""
! 375         return self.mode_spec.angle_theta
  376 
  377     @cached_property
  378     def angle_phi(self) -> float:
  379         """Azimuth angle of propagation."""
! 380         return self.mode_spec.angle_phi
  381 
  382     @cached_property
  383     def _dir_vector(self) -> tuple[float, float, float]:
  384         """Source direction normal vector in cartesian coordinates."""
! 385         radius = 1.0 if self.direction == "+" else -1.0
! 386         dx = radius * np.cos(self.angle_phi) * np.sin(self.angle_theta)
! 387         dy = radius * np.sin(self.angle_phi) * np.sin(self.angle_theta)
! 388         dz = radius * np.cos(self.angle_theta)
! 389         return self.unpop_axis(dz, (dx, dy), axis=self._injection_axis)
  390 
  391     @cached_property
  392     def _bend_axis(self) -> Axis:
  393         """Bend axis for curved sources."""
! 394         if self.mode_spec.bend_radius is None:
! 395             return None
! 396         in_plane = [0, 0]
! 397         in_plane[self.mode_spec.bend_axis] = 1
! 398         direction = self.unpop_axis(0, in_plane, axis=self.injection_axis)
! 399         return direction.index(1)
  400 
  401 
  402 class ModeSource(AbstractModeSource):
  403     """Injects current source to excite modal profile on finite extent plane.

tidy3d/plugins/smatrix/analysis/terminal.py

Lines 154-162

  154     # so here we just choose the first one.
  155     first_sim_index = modeler_data.modeler.matrix_indices_run_sim[0]
  156     port, selection_index = modeler_data.modeler.network_dict[first_sim_index]
  157     if isinstance(port, TerminalWavePort):
! 158         task_name = modeler_data.modeler.get_task_name(port=port, terminal_index=selection_index)
  159     elif isinstance(port, WavePort):
  160         task_name = modeler_data.modeler.get_task_name(port=port, mode_index=selection_index)
  161     else:
  162         task_name = modeler_data.modeler.get_task_name(port=port)

Lines 352-360

  352     # Apply sign to Z: Z --> sign @ Z (flip rows for negative diagonal)
  353     if np.any(sign_vec == -1):
  354         # sign_vec has shape (f, port), Z_numpy has shape (f, port_out, port_in)
  355         # Apply: Z_new[f,i,j] = sign[f,i] * Z[f,i,j]
! 356         Z_numpy = sign_vec[:, :, np.newaxis] * Z_numpy
  357 
  358     # Compute F
  359     F_numpy = compute_F(Z_numpy, s_param_def)

tidy3d/plugins/smatrix/component_modelers/base.py

Lines 244-254

  244                 return f"{port.name}@{mode_index}"
  245             return f"{port.name}@{port._mode_indices()[0]}"
  246         elif isinstance(port, TerminalWavePort):
  247             # TerminalWavePorts has no default
! 248             if terminal_index is None:
! 249                 raise ValueError("'terminal_index' must be specified for a terminal port.")
! 250             return f"{port.name}@{terminal_index}"
  251         else:
  252             # Modal ports default to 0
  253             if mode_index is not None:
  254                 return f"{port.name}@{mode_index}"

tidy3d/plugins/smatrix/component_modelers/terminal.py

Lines 98-106

   98     np.ndarray
   99         Packed label centers (pixels), same shape as ``anchor_centers_px``.
  100     """
  101     if anchor_centers_px.size == 0:
! 102         return anchor_centers_px
  103 
  104     centers = np.asarray(anchor_centers_px, dtype=float).copy()
  105     widths = np.asarray(widths_px, dtype=float)
  106     order = np.argsort(centers)

Lines 111-119

  111         half = widths[idx] / 2
  112         left = centers[idx] - half
  113         if prev_right is None:
  114             if left < x_min_px:
! 115                 centers[idx] += x_min_px - left
  116         else:
  117             min_left = prev_right + pad_px
  118             if left < min_left:
  119                 centers[idx] += min_left - left

Lines 130-142

  130             half = widths[idx] / 2
  131             right = centers[idx] + half
  132             if next_left is None:
  133                 if right > x_max_px:
! 134                     centers[idx] -= right - x_max_px
  135             else:
  136                 max_right = next_left - pad_px
  137                 if right > max_right:
! 138                     centers[idx] -= right - max_right
  139             next_left = centers[idx] - half
  140 
  141         # Ensure the first label doesn't underflow the left bound; if it does,
  142         # shift right and do one final forward stabilization pass.

Lines 142-162

  142         # shift right and do one final forward stabilization pass.
  143         first = order[0]
  144         underflow = x_min_px - (centers[first] - widths[first] / 2)
  145         if underflow > 0:
! 146             centers[order] += underflow
! 147             prev_right = None
! 148             for idx in order:
! 149                 half = widths[idx] / 2
! 150                 left = centers[idx] - half
! 151                 if prev_right is None:
! 152                     if left < x_min_px:
! 153                         centers[idx] += x_min_px - left
  154                 else:
! 155                     min_left = prev_right + pad_px
! 156                     if left < min_left:
! 157                         centers[idx] += min_left - left
! 158                 prev_right = centers[idx] + half
  159 
  160     return centers
  161 

Lines 438-446

  438         -------
  439         matplotlib.axes.Axes
  440             The axes with the plot.
  441         """
! 442         return self._sim_with_sources.plot_grid(x=x, y=y, z=z, ax=ax, **kwargs)
  443 
  444     @equal_aspect
  445     @add_ax_if_none
  446     def plot_sim_eps(

Lines 505-543

  505         -------
  506         matplotlib.axes.Axes
  507             The axes with the plot.
  508         """
! 509         if isinstance(port, TerminalPortType):
! 510             if port not in self.ports:
! 511                 raise ValueError(f"Port {port} not found in the modeler.")
! 512         elif isinstance(port, str):
! 513             port = self.get_port_by_name(port)
  514         else:
! 515             raise ValueError(
  516                 f"Invalid port type: {type(port)}. Must be a string or a TerminalPortType object."
  517             )
  518 
! 519         injection_axis = port.injection_axis
! 520         plot_kwargs = {"ax": ax, **kwargs}
! 521         plot_kwargs.setdefault("monitor_alpha", 0)
! 522         plot_kwargs.setdefault("source_alpha", 0)
! 523         plot_kwargs["xyz"[injection_axis]] = port.center[injection_axis]
! 524         ax = self._sim_with_sources.plot(**plot_kwargs)
  525 
  526         # Set default xlim and ylim based on port bounds in the transverse plane
! 527         from tidy3d.components.geometry.base import Box
  528 
! 529         _, (xmin, ymin) = Box.pop_axis(port.bounds[0], axis=injection_axis)
! 530         _, (xmax, ymax) = Box.pop_axis(port.bounds[1], axis=injection_axis)
  531 
  532         # Add padding with shaded regions and set axis limits
! 533         self._add_port_padding_shading(ax, xmin, xmax, ymin, ymax)
  534 
  535         # Plot bounding boxes and labels for TerminalWavePorts
! 536         if isinstance(port, TerminalWavePort):
! 537             ax = self._plot_terminal_wave_port(ax, port, label_font_size)
  538 
! 539         return ax
  540 
  541     def mode_solver_for_port(self, port_name: str) -> ModeSolver:
  542         """Get the mode solver object for a given port.

Lines 550-561

  550         -------
  551         ModeSolver
  552             The mode solver for the given port.
  553         """
! 554         port = self.get_port_by_name(port_name)
! 555         if not isinstance(port, WavePortType):
! 556             raise ValueError("Mode solver is only supported for TerminalWavePort and WavePort.")
! 557         return port.to_mode_solver(
  558             self.base_sim, self.freqs, mode_spec=self._resolved_mode_specs.get(port_name)
  559         )
  560 
  561     def _add_port_padding_shading(

Lines 575-598

  575             Minimum y-coordinate of the port.
  576         ymax : float
  577             Maximum y-coordinate of the port.
  578         """
! 579         from matplotlib.patches import Rectangle
  580 
  581         # Calculate padding based on port size
! 582         port_width = xmax - xmin
! 583         port_height = ymax - ymin
! 584         padding = TERMINAL_BOX_PADDING_FRACTION * max(port_width, port_height)
  585 
  586         # Padded bounds (outer region)
! 587         xmin_padded = xmin - padding
! 588         xmax_padded = xmax + padding
! 589         ymin_padded = ymin - padding
! 590         ymax_padded = ymax + padding
  591 
  592         # Add shaded rectangles for padding regions (top, bottom, left, right)
  593         # Bottom padding region
! 594         bottom_rect = Rectangle(
  595             (xmin_padded, ymin_padded),
  596             xmax_padded - xmin_padded,
  597             padding,
  598             **plot_params_padding_shade,

Lines 596-607

  596             xmax_padded - xmin_padded,
  597             padding,
  598             **plot_params_padding_shade,
  599         )
! 600         ax.add_patch(bottom_rect)
  601 
  602         # Top padding region
! 603         top_rect = Rectangle(
  604             (xmin_padded, ymax),
  605             xmax_padded - xmin_padded,
  606             padding,
  607             **plot_params_padding_shade,

Lines 605-616

  605             xmax_padded - xmin_padded,
  606             padding,
  607             **plot_params_padding_shade,
  608         )
! 609         ax.add_patch(top_rect)
  610 
  611         # Left padding region
! 612         left_rect = Rectangle(
  613             (xmin_padded, ymin),
  614             padding,
  615             port_height,
  616             **plot_params_padding_shade,

Lines 614-625

  614             padding,
  615             port_height,
  616             **plot_params_padding_shade,
  617         )
! 618         ax.add_patch(left_rect)
  619 
  620         # Right padding region
! 621         right_rect = Rectangle(
  622             (xmax, ymin),
  623             padding,
  624             port_height,
  625             **plot_params_padding_shade,

Lines 623-635

  623             padding,
  624             port_height,
  625             **plot_params_padding_shade,
  626         )
! 627         ax.add_patch(right_rect)
  628 
  629         # Set the expanded limits
! 630         ax.set_xlim(xmin_padded, xmax_padded)
! 631         ax.set_ylim(ymin_padded, ymax_padded)
  632 
  633     def _add_packed_label_lane(
  634         self,
  635         ax: Ax,

Lines 652-688

  652             Parameters for label styling.
  653         arrow_params : dict[str, Any]
  654             Parameters for arrow styling.
  655         """
! 656         if not items:
! 657             return
  658 
! 659         fig = ax.figure
  660 
  661         # Create temporary texts (single draw) to measure rendered extents.
! 662         temp_texts = [ax.text(0.0, 0.0, str(it["label"]), **label_params) for it in items]
! 663         fig.canvas.draw()
! 664         renderer = fig.canvas.get_renderer()
  665 
! 666         widths_px = np.array([t.get_window_extent(renderer=renderer).width for t in temp_texts])
! 667         for t in temp_texts:
! 668             t.remove()
  669 
  670         # Axis bounds in display coordinates.
! 671         ax_bbox = ax.get_window_extent(renderer=renderer)
! 672         pad_px = max(4.0, 0.25 * float(label_params.get("fontsize", 12)))
! 673         x_min_px = ax_bbox.x0 + pad_px
! 674         x_max_px = ax_bbox.x1 - pad_px
  675 
  676         # Desired anchor centers in display x.
! 677         anchors_x = np.array([float(it["x_anchor"]) for it in items])
! 678         lane_y_disp = ax.transData.transform((0.0, lane_y))[1]
! 679         anchors_disp = ax.transData.transform(
  680             np.column_stack([anchors_x, np.full_like(anchors_x, lane_y)])
  681         )
! 682         anchor_centers_px = anchors_disp[:, 0]
  683 
! 684         packed_centers_px = _pack_label_centers_1d(
  685             anchor_centers_px=anchor_centers_px,
  686             widths_px=widths_px,
  687             x_min_px=x_min_px,
  688             x_max_px=x_max_px,

Lines 689-714

  689             pad_px=pad_px,
  690         )
  691 
  692         # Convert packed centers back to data x at y=lane_y.
! 693         packed_points_data = ax.transData.inverted().transform(
  694             np.column_stack([packed_centers_px, np.full_like(packed_centers_px, lane_y_disp)])
  695         )
! 696         packed_x_data = packed_points_data[:, 0]
  697 
  698         # Use the lane y (data) to create a display-scale marker size.
! 699         marker_ms = max(3.0, 0.35 * float(label_params.get("fontsize", 12)))
  700 
! 701         for it, x_text in zip(items, packed_x_data):
  702             # Connect to a point on the box edge that is closest to the label x.
  703             # This makes the association clearer when adjacent boxes have similar centers.
! 704             x_min_box = float(it.get("x_min", it["x_anchor"]))
! 705             x_max_box = float(it.get("x_max", it["x_anchor"]))
! 706             x_conn = float(np.clip(x_text, x_min_box, x_max_box))
! 707             y_anchor = float(it["y_anchor"])
  708 
  709             # Small marker at the connection point improves readability without heavy clutter.
! 710             ax.plot(
  711                 [x_conn],
  712                 [y_anchor],
  713                 marker="o",
  714                 markersize=marker_ms,

Lines 717-725

  717                 zorder=float(label_params.get("zorder", 11)) + 0.1,
  718                 markeredgewidth=0.0,
  719             )
  720 
! 721             ax.annotate(
  722                 str(it["label"]),
  723                 xy=(x_conn, y_anchor),
  724                 xytext=(float(x_text), float(lane_y)),
  725                 arrowprops=arrow_params,

Lines 751-796

  751         -------
  752         tuple[list[dict[str, float | str]], list[float]]
  753             Terminal items for labeling and list of max y-coordinates.
  754         """
! 755         from tidy3d.components.microwave.path_integrals.specs.impedance import (
  756             CustomImpedanceSpec,
  757         )
  758 
! 759         plot_coord = {0: "x", 1: "y", 2: "z"}[injection_axis]
! 760         plot_kwargs = {plot_coord: port.center[injection_axis], "ax": ax}
  761 
! 762         terminal_items: list[dict[str, float | str]] = []
! 763         terminal_box_ymax: list[float] = []
  764 
! 765         for terminal_label, impedance_spec in impedance_specs.items():
! 766             if isinstance(impedance_spec, CustomImpedanceSpec):
  767                 # Determine which spec to use (current takes priority)
! 768                 path_spec = None
! 769                 if impedance_spec.current_spec is not None:
! 770                     path_spec = impedance_spec.current_spec
! 771                     path_spec.plot(**plot_kwargs, color=TERMINAL_BOX_COLOR, plot_arrow=False)
! 772                 elif impedance_spec.voltage_spec is not None:
! 773                     path_spec = impedance_spec.voltage_spec
! 774                     path_spec.plot(**plot_kwargs, color=TERMINAL_BOX_COLOR, plot_markers=False)
  775 
  776                 # Get bounding box from the path spec for labeling
! 777                 if path_spec is not None:
  778                     # Get bounds and convert to 2D coordinates based on injection axis
! 779                     bounds_3d = path_spec.bounds
! 780                     _, (xmin, ymin) = Box.pop_axis(bounds_3d[0], axis=injection_axis)
! 781                     _, (xmax, ymax) = Box.pop_axis(bounds_3d[1], axis=injection_axis)
  782 
  783                     # Apply padding for visualization
! 784                     padding = TERMINAL_BOX_PADDING_FRACTION * max(xmax - xmin, ymax - ymin)
! 785                     box_xmin = xmin - padding
! 786                     box_xmax = xmax + padding
! 787                     box_ymax = ymax + padding
  788 
! 789                     terminal_box_ymax.append(box_ymax)
  790 
! 791                     box_center_x = (box_xmin + box_xmax) / 2
! 792                     terminal_items.append(
  793                         {
  794                             "label": str(terminal_label),
  795                             "x_anchor": float(box_center_x),
  796                             "y_anchor": float(box_ymax),

Lines 798-806

  798                             "x_max": float(box_xmax),
  799                         }
  800                     )
  801 
! 802         return terminal_items, terminal_box_ymax
  803 
  804     def _plot_and_collect_diff_pair_info(
  805         self,
  806         ax: Ax,

Lines 825-881

  825         -------
  826         tuple[list[dict[str, float | str]], list[float]]
  827             Differential pair items for labeling and list of min y-coordinates.
  828         """
! 829         import matplotlib.patches as patches
  830 
! 831         diff_items: list[dict[str, float | str]] = []
! 832         diff_box_ymin: list[float] = []
  833 
! 834         if not port.differential_pairs:
! 835             return diff_items, diff_box_ymin
  836 
! 837         for pair_idx, (idx1, idx2) in enumerate(port.differential_pairs):
  838             # Get impedance specs for both terminals in the pair
! 839             if idx1 in impedance_specs and idx2 in impedance_specs:
! 840                 spec1 = impedance_specs[idx1]
! 841                 spec2 = impedance_specs[idx2]
  842 
  843                 # Get path specs (current takes priority)
! 844                 path_spec1 = (
  845                     spec1.current_spec if spec1.current_spec is not None else spec1.voltage_spec
  846                 )
! 847                 path_spec2 = (
  848                     spec2.current_spec if spec2.current_spec is not None else spec2.voltage_spec
  849                 )
  850 
! 851                 if path_spec1 is not None and path_spec2 is not None:
  852                     # Get bounds for both specs and compute combined bounding box
! 853                     bounds1_3d = path_spec1.bounds
! 854                     bounds2_3d = path_spec2.bounds
  855 
! 856                     _, (xmin1, ymin1) = Box.pop_axis(bounds1_3d[0], axis=injection_axis)
! 857                     _, (xmax1, ymax1) = Box.pop_axis(bounds1_3d[1], axis=injection_axis)
! 858                     _, (xmin2, ymin2) = Box.pop_axis(bounds2_3d[0], axis=injection_axis)
! 859                     _, (xmax2, ymax2) = Box.pop_axis(bounds2_3d[1], axis=injection_axis)
  860 
  861                     # Combined bounds
! 862                     xmin = min(xmin1, xmin2)
! 863                     xmax = max(xmax1, xmax2)
! 864                     ymin = min(ymin1, ymin2)
! 865                     ymax = max(ymax1, ymax2)
  866 
  867                     # Apply padding
! 868                     padding = TERMINAL_BOX_PADDING_FRACTION * max(xmax - xmin, ymax - ymin)
! 869                     box_xmin = xmin - padding
! 870                     box_xmax = xmax + padding
! 871                     box_ymin = ymin - padding
! 872                     box_ymax = ymax + padding
  873 
! 874                     diff_box_ymin.append(box_ymin)
  875 
  876                     # Create rectangle for differential pair with blue dashed border
! 877                     rect = patches.Rectangle(
  878                         (box_xmin, box_ymin),
  879                         box_xmax - box_xmin,
  880                         box_ymax - box_ymin,
  881                         **plot_params_diff_pair_box,

Lines 879-890

  879                         box_xmax - box_xmin,
  880                         box_ymax - box_ymin,
  881                         **plot_params_diff_pair_box,
  882                     )
! 883                     ax.add_patch(rect)
  884 
! 885                     box_center_x = (box_xmin + box_xmax) / 2
! 886                     diff_items.append(
  887                         {
  888                             "label": f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}: ({idx1}⁺, {idx2}⁻)",
  889                             "x_anchor": float(box_center_x),
  890                             "y_anchor": float(box_ymin),

Lines 892-900

  892                             "x_max": float(box_xmax),
  893                         }
  894                     )
  895 
! 896         return diff_items, diff_box_ymin
  897 
  898     def _place_labels_with_lane(
  899         self,
  900         ax: Ax,

Lines 920-944

  920             Parameters for arrow styling.
  921         placement : str
  922             Either "top" or "bottom" for label placement.
  923         """
! 924         if not box_y_coords:
! 925             return
  926 
! 927         ylim0, ylim1 = ax.get_ylim()
! 928         yrange = float(ylim1 - ylim0) if ylim1 != ylim0 else 1.0
! 929         lane_margin = 0.08 * yrange
  930 
! 931         if placement == "top":
! 932             lane_y = max(box_y_coords) + lane_margin
! 933             if lane_y > ylim1:
! 934                 ax.set_ylim(ylim0, lane_y + lane_margin)
  935         else:  # bottom
! 936             lane_y = min(box_y_coords) - lane_margin
! 937             if lane_y < ylim0:
! 938                 ax.set_ylim(lane_y - lane_margin, ylim1)
  939 
! 940         self._add_packed_label_lane(
  941             ax=ax,
  942             lane_y=lane_y,
  943             items=items,
  944             label_params=label_params,

Lines 963-985

  963         -------
  964         matplotlib.axes.Axes
  965             The axes with the plot.
  966         """
! 967         mode_spec = self._resolved_mode_specs[port.name]
! 968         impedance_specs = mode_spec.impedance_specs
! 969         injection_axis = port.injection_axis
  970 
  971         # Plot individual terminal paths and collect info for labeling
! 972         terminal_items, terminal_box_ymax = self._plot_and_collect_terminal_info(
  973             ax, port, impedance_specs, injection_axis
  974         )
  975 
  976         # Place terminal labels on top lane
! 977         label_params = plot_params_terminal_label.copy()
! 978         if label_font_size is not None:
! 979             label_params["fontsize"] = label_font_size
  980 
! 981         self._place_labels_with_lane(
  982             ax=ax,
  983             items=terminal_items,
  984             box_y_coords=terminal_box_ymax,
  985             label_params=label_params,

Lines 987-1004

   987             placement="top",
   988         )
   989 
   990         # Plot differential pair boxes and collect info for labeling
!  991         diff_items, diff_box_ymin = self._plot_and_collect_diff_pair_info(
   992             ax, port, impedance_specs, injection_axis
   993         )
   994 
   995         # Place differential pair labels on bottom lane
!  996         diff_label_params = plot_params_diff_pair_label.copy()
!  997         if label_font_size is not None:
!  998             diff_label_params["fontsize"] = label_font_size
   999 
! 1000         self._place_labels_with_lane(
  1001             ax=ax,
  1002             items=diff_items,
  1003             box_y_coords=diff_box_ymin,
  1004             label_params=diff_label_params,

Lines 1005-1013

  1005             arrow_params=plot_params_diff_pair_arrow,
  1006             placement="bottom",
  1007         )
  1008 
! 1009         return ax
  1010 
  1011     @staticmethod
  1012     def network_index(
  1013         port: TerminalPortType,

Lines 1047-1057

  1047                 ):
  1048                     key = self.network_index(port, mode_index=mode_index)
  1049                     network_dict[key] = (port, mode_index)
  1050             elif isinstance(port, TerminalWavePort):
! 1051                 for terminal_index in self._resolved_mode_specs[port.name]._terminal_indices:
! 1052                     key = self.network_index(port, terminal_index=terminal_index)
! 1053                     network_dict[key] = (port, terminal_index)
  1054             else:
  1055                 key = self.network_index(port, None)
  1056                 network_dict[key] = (port, None)
  1057         return network_dict

Lines 1079-1088

  1079                     mode_spec=self._resolved_mode_specs.get(port.name)
  1080                 ):
  1081                     matrix_indices.append(self.network_index(port, mode_index=mode_index))
  1082             elif isinstance(port, TerminalWavePort):
! 1083                 for terminal_index in self._resolved_mode_specs[port.name]._terminal_indices:
! 1084                     matrix_indices.append(self.network_index(port, terminal_index=terminal_index))
  1085             else:
  1086                 matrix_indices.append(self.network_index(port))
  1087         return tuple(matrix_indices)

Lines 1473-1481

  1473             mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port)
  1474             resolved_spec = self._resolved_mode_specs[port.name]
  1475             # use terminal_index if TerminalWavePort, otherwise use mode_index
  1476             if isinstance(port, TerminalWavePort):
! 1477                 index_kwargs["terminal_index"] = selection_index
  1478             else:
  1479                 index_kwargs["mode_index"] = selection_index
  1480             port_source = port.to_source(
  1481                 self._source_time,

Lines 1607-1621

  1607             if port.mode_spec.num_modes != "auto":
  1608                 continue
  1609 
  1610             # Get the resolved mode spec for this port
! 1611             resolved_mode_spec = self._resolved_mode_specs.get(port.name)
! 1612             num_modes = resolved_mode_spec.num_modes
  1613 
  1614             # Check that indices are within range of num_modes
! 1615             invalid_indices = [idx for idx in mode_selection if idx >= num_modes]
! 1616             if invalid_indices:
! 1617                 raise ValidationError(
  1618                     f"'mode_spec.mode_selection' contains indices {invalid_indices} that are >= "
  1619                     f"'mode_spec.num_modes' ({num_modes}) for port '{port.name}'. Valid range is 0 to {num_modes - 1}."
  1620                 )

Lines 1630-1643

  1630                 continue
  1631 
  1632             # Only validate if the original mode_spec.num_modes was 'auto'
  1633             # (if it was not 'auto', validation already happened in WavePort)
! 1634             if port.mode_spec.num_modes != "auto":
! 1635                 continue
  1636 
! 1637             num_modes = self._resolved_mode_specs[port.name].num_modes
! 1638             if mode_index >= num_modes:
! 1639                 raise ValidationError(
  1640                     f"'mode_index' is >= "
  1641                     f"'mode_spec.num_modes' ({num_modes}) for port '{port.name}'. Valid range is 0 to {num_modes - 1}."
  1642                 )

Lines 1655-1667

  1655             if isinstance(port, WavePort) and port.mode_spec.num_modes != "auto":
  1656                 continue
  1657 
  1658             # Get the resolved mode spec for this port
! 1659             resolved_mode_spec = self._resolved_mode_specs.get(port.name)
  1660 
! 1661             num_modes = resolved_mode_spec.num_modes
! 1662             if num_modes > 1:
! 1663                 log.warning(
  1664                     f"Port '{port.name}': Absorber is enabled with {num_modes} modes. "
  1665                     "Absorption is not properly implemented for multimode cases yet and will be "
  1666                     "added in a future release. For now, please extend the transmission line into the PML region."
  1667                 )

Lines 1718-1732

  1718         Queries both WavePort and TerminalWavePort.
  1719 
  1720         Each conductor (terminal) is identified by its label and maps to a tuple of (shape, bounding_box).
  1721         """
! 1722         conductors_dict = {}
! 1723         sim = self._base_sim_with_grid_and_lumped_elements
! 1724         for port in self._terminal_wave_ports + self._wave_ports:
! 1725             conductors_dict[port.name] = port.get_isolated_floating_conductors(
  1726                 sim.volumetric_structures, sim.grid, sim.symmetry, sim.simulation_geometry
  1727             )
! 1728         return conductors_dict
  1729 
  1730     @cached_property
  1731     def _resolved_mode_specs(self) -> dict[str, MicrowaveModeSpecType]:
  1732         """Returns a dict mapping port names to their mode specs."""

Lines 1736-1745

  1736             mode_spec = port._mode_spec
  1737             # handled unresolved mode spec here.
  1738             if mode_spec is None:
  1739                 # Get number of conductors
! 1740                 conductors = self._floating_isolated_conductors_at_waveport[port.name]
! 1741                 mode_spec = port._mode_spec_from_isolated_floating_conductors(conductors)
  1742 
  1743             mode_specs[port.name] = mode_spec
  1744 
  1745         return mode_specs

Lines 1776-1784

  1776     def task_name_from_index(self, source_index: NetworkIndex) -> str:
  1777         """Compute task name for a given network index without constructing simulations."""
  1778         port, selection_index = self.network_dict[source_index]
  1779         if isinstance(port, TerminalWavePort):
! 1780             return self.get_task_name(port=port, terminal_index=selection_index)
  1781         elif isinstance(port, WavePort):
  1782             return self.get_task_name(port=port, mode_index=selection_index)
  1783         else:
  1784             return self.get_task_name(port=port)

tidy3d/plugins/smatrix/data/terminal.py

Lines 501-509

  501         -------
  502         :class:`.MicrowaveSMatrixData`
  503             S-matrix renormalized to the new reference impedance.
  504         """
! 505         raise NotImplementedError("Renormalization algorithm to be implemented")
  506 
  507     @cached_property
  508     def port_voltage_current_matrices(self) -> tuple[TerminalPortDataArray, TerminalPortDataArray]:
  509         """Compute voltage and current matrices for all port combinations.

tidy3d/plugins/smatrix/ports/wave.py

Lines 133-141

  133 
  134         # Handle scalar complex values
  135         if isinstance(val, (int, float, complex)):
  136             if np.real(val) <= 0:
! 137                 raise ValidationError(
  138                     f"Reference impedance must have positive real part. Got {val} with real part {np.real(val)}."
  139                 )
  140             return val

Lines 140-155

  140             return val
  141 
  142         # Handle DataArray (ImpedanceModeDataArray or ImpedanceTerminalDataArray)
  143         # Check all values have positive real part
! 144         real_parts = np.real(val.values)
! 145         if np.any(real_parts <= 0):
! 146             min_real = np.min(real_parts)
! 147             raise ValidationError(
  148                 f"All reference impedance values must have positive real part. "
  149                 f"Found minimum real part: {min_real}."
  150             )
! 151         return val
  152 
  153     def get_reference_impedance_matrix(
  154         self, sim_mode_data: Union[SimulationData, MicrowaveModeData]
  155     ) -> Union[ImpedanceFreqModeModeDataArray, ImpedanceFreqTerminalTerminalDataArray]:

Lines 163-186

  163             return computed_Z0
  164 
  165         # User-specified reference impedance - build diagonal matrix
  166         # Detect whether computed_Z0 is mode-based or terminal-based
! 167         is_mode_based = "mode_index_out" in computed_Z0.dims
  168 
! 169         if is_mode_based:
! 170             dim_prefix = "mode"
! 171             result_cls = ImpedanceFreqModeModeDataArray
  172         else:
! 173             dim_prefix = "terminal"
! 174             result_cls = ImpedanceFreqTerminalTerminalDataArray
  175 
! 176         dim_1d, dim_out, dim_in = (f"{dim_prefix}_index{s}" for s in ("", "_out", "_in"))
  177 
! 178         indices = computed_Z0.coords[dim_out].values
! 179         num_indices = len(indices)
  180 
  181         # Create identity matrix as xarray DataArray for broadcasting
! 182         eye = xr.DataArray(
  183             np.eye(num_indices),
  184             coords={dim_out: indices, dim_in: indices},
  185         )

Lines 184-194

  184             coords={dim_out: indices, dim_in: indices},
  185         )
  186 
  187         # Handle scalar Complex vs DataArray reference impedance
! 188         if isinstance(self.reference_impedance, (int, float, complex)):
  189             # Scalar case: create uniform reference values for all indices
! 190             ref_values = xr.DataArray(
  191                 np.full(num_indices, self.reference_impedance),
  192                 coords={dim_1d: indices},
  193             )
  194         else:

Lines 193-208

  193             )
  194         else:
  195             # Data array: use values for each mode/terminal, fallback to default for missing ones
  196             # Reindex to match indices, filling missing with default value
! 197             ref_values = self.reference_impedance.reindex(
  198                 {dim_1d: indices}, fill_value=DEFAULT_REFERENCE_IMPEDANCE_VALUE
  199             )
  200 
  201         # Rename and broadcast to diagonal matrix
! 202         ref_diag = ref_values.rename({dim_1d: dim_out}) * eye
  203 
! 204         return result_cls(ref_diag)
  205 
  206     @abstractmethod
  207     def get_characteristic_impedance_matrix(
  208         self, sim_mode_data: Union[SimulationData, MicrowaveModeData]

Lines 240-248

  240 
  241     @cached_property
  242     def _mode_plane_analyzer(self) -> ModePlaneAnalyzer:
  243         """Mode plane analyzer for the port."""
! 244         return ModePlaneAnalyzer(
  245             center=self.center,
  246             size=self.size,
  247             field_data_colocated=MONITOR_COLOCATE,
  248         )

Lines 271-284

  271         -------
  272         dict[str, tuple[Shapely, Box]]:
  273             Mapping from terminal name to terminal shape and bounding box.
  274         """
! 275         bounding_boxes, shapes = self._mode_plane_analyzer.get_conductor_bounding_boxes(
  276             structures, grid, symmetry, sim_box
  277         )
! 278         labels = [f"{DEFAULT_TERMINAL_LABEL_PREFIX}{i}" for i in range(len(shapes))]
  279 
! 280         return {label: (shape, bbox) for label, shape, bbox in zip(labels, shapes, bounding_boxes)}
  281 
  282     def _validate_resolved_mode_spec(
  283         self, mode_spec: Optional[MicrowaveModeSpecType] = None
  284     ) -> MicrowaveModeSpecType:

Lines 284-292

  284     ) -> MicrowaveModeSpecType:
  285         """If the resolved mode_spec is not provided, validate that self._mode_spec not None."""
  286         if mode_spec is not None:
  287             if mode_spec.num_modes == "auto":
! 288                 raise SetupError(
  289                     "The supplied mode specification has num_modes='auto'. "
  290                     "Please pass a mode_spec with an explicit number of modes."
  291                 )
  292             return mode_spec

Lines 291-299

  291                 )
  292             return mode_spec
  293 
  294         if self._mode_spec is None:
! 295             raise SetupError(
  296                 "Mode specification cannot be resolved in WavePort alone. "
  297                 "Please pass a mode_spec with an explicit number of modes."
  298             )
  299         return self._mode_spec

Lines 436-444

  436     ) -> MicrowaveModeData:
  437         """Get the mode data from the simulation data or mode data directly."""
  438         if isinstance(sim_mode_data, SimulationData):
  439             return sim_mode_data[self._mode_monitor_name]
! 440         return sim_mode_data
  441 
  442     @property
  443     def _is_using_mesh_refinement(self) -> bool:
  444         """Check if this wave port is using mesh refinement options.

Lines 511-523

  511         # 1) num_modes already specified
  512         if num_modes != "auto":
  513             return self.mode_spec
  514         # 2) num_modes='auto', and can be refered from the size of impedance_specs
! 515         impedance_specs = self.mode_spec.impedance_specs
! 516         if isinstance(impedance_specs, (list, tuple)):
! 517             return self.mode_spec.updated_copy(num_modes=len(impedance_specs))
  518         # 3) num_modes='auto', and cannot be refered from the size of impedance_specs
! 519         return None
  520 
  521     def _mode_spec_from_isolated_floating_conductors(
  522         self, conductors: dict[str, tuple[Shapely, Box]]
  523     ) -> MicrowaveModeSpec:

Lines 521-529

  521     def _mode_spec_from_isolated_floating_conductors(
  522         self, conductors: dict[str, tuple[Shapely, Box]]
  523     ) -> MicrowaveModeSpec:
  524         """Update num_modes from the number of isolated floating conductors."""
! 525         return self.mode_spec.updated_copy(num_modes=len(conductors))
  526 
  527     @field_validator("mode_spec", mode="after")
  528     @classmethod
  529     def _validate_path_integrals_within_port(

Lines 571-579

  571         # Check that indices are within range of num_modes
  572         mode_spec = self.mode_spec
  573         num_modes = mode_spec.num_modes
  574         if num_modes == "auto":
! 575             return self
  576         invalid_indices = [idx for idx in indices if idx >= num_modes]
  577         if invalid_indices:
  578             raise ValidationError(
  579                 f"'mode_selection' contains indices {invalid_indices} that are >= "

Lines 719-733

  719         :class:`.FreqModeDataArray`
  720             Frequency-dependent characteristic impedance Z0 for the specified mode.
  721             The impedance is complex-valued and varies with frequency.
  722         """
! 723         reference_impedance_matrix = self.get_reference_impedance_matrix(sim_mode_data)
  724         # Select diagonal element and reshape to (f, mode_index) format
! 725         Z0_selected = reference_impedance_matrix.sel(
  726             mode_index_out=mode_index, mode_index_in=mode_index
  727         )
  728         # Expand dims to add mode_index dimension and cast to FreqModeDataArray
! 729         return FreqModeDataArray(Z0_selected.expand_dims(mode_index=[mode_index]))
  730 
  731 
  732 class TerminalWavePort(AbstractWavePort):
  733     """Class representing a single terminal-driven wave port.

Lines 793-807

  793         # Check for duplicates - flatten all terminal labels from all pairs
  794         terminals_present = set()
  795         terminals_duplicates = set()
  796         for pair in val:
! 797             for terminal_label in pair:
! 798                 if terminal_label in terminals_present:
! 799                     terminals_duplicates.add(terminal_label)
! 800                 terminals_present.add(terminal_label)
  801 
  802         if terminals_duplicates:
! 803             raise ValidationError(
  804                 f"Terminal labels {sorted(terminals_duplicates)} appear more than once in differential_pairs. "
  805                 "Each terminal can only be used in one differential pair."
  806             )
  807         return val

Lines 810-825

  810     def _validate_terminal_specs(self) -> Self:
  811         """Validate terminal_specs: if it's a list of CustomImpedanceSpec, validate that current and voltage specs
  812         are defined consistently: if one of them is None, it must be None for all CustomImpedanceSpec in the list.
  813         """
! 814         val = self.terminal_specs
  815         # Skip validation for AutoImpedanceSpec
! 816         if not isinstance(val, (tuple, list)):
! 817             return self
  818 
  819         # Check for empty tuple/list
! 820         if len(val) == 0:
! 821             raise ValidationError(
  822                 "Empty 'terminal_specs' tuple is not allowed. "
  823                 "Please provide at least one CustomImpedanceSpec, or use AutoImpedanceSpec "
  824                 "for automatic terminal detection."
  825             )

Lines 824-841

  824                 "for automatic terminal detection."
  825             )
  826 
  827         # Check consistency of voltage_spec and current_spec across all CustomImpedanceSpec
! 828         has_voltage_spec = []
! 829         has_current_spec = []
  830 
! 831         for spec in val:
! 832             has_voltage_spec.append(spec.voltage_spec is not None)
! 833             has_current_spec.append(spec.current_spec is not None)
  834 
  835         # Check if voltage_spec consistency: all None or all not None
! 836         if not all(has_voltage_spec) and any(has_voltage_spec):
! 837             raise ValidationError(
  838                 "Inconsistent voltage specifications in terminal_specs: "
  839                 "If voltage_spec is defined for one terminal, it must be defined for all terminals."
  840             )

Lines 839-853

  839                 "If voltage_spec is defined for one terminal, it must be defined for all terminals."
  840             )
  841 
  842         # Check if current_spec consistency: all None or all not None
! 843         if not all(has_current_spec) and any(has_current_spec):
! 844             raise ValidationError(
  845                 "Inconsistent current specifications in terminal_specs: "
  846                 "If current_spec is defined for one terminal, it must be defined for all terminals."
  847             )
  848 
! 849         return self
  850 
  851     @model_validator(mode="after")
  852     def _check_absorber_if_extruding_structures(self) -> Self:
  853         """Raise validation error when ``extrude_structures`` is set to ``True``

Lines 852-865

  852     def _check_absorber_if_extruding_structures(self) -> Self:
  853         """Raise validation error when ``extrude_structures`` is set to ``True``
  854         while ``absorber`` is set to ``False``."""
  855 
! 856         if self.extrude_structures and not self.absorber:
! 857             raise ValidationError(
  858                 "Structure extrusion for a waveport requires an internal absorber. Set `absorber=True` to enable it."
  859             )
  860 
! 861         return self
  862 
  863     @cached_property
  864     def _mode_spec(self) -> Optional[MicrowaveTerminalModeSpec]:
  865         """Mode specification for the port if it can be resolved in this module;

Lines 865-885

  865         """Mode specification for the port if it can be resolved in this module;
  866         otherwise, return None.
  867         """
  868         # 1) in auto mode, needs more information to be determined
! 869         if not isinstance(self.terminal_specs, (list, tuple)):
! 870             return None
  871         # 2) manual definition
! 872         num_modes = len(self.terminal_specs)
! 873         terminal_labels = [f"{DEFAULT_TERMINAL_LABEL_PREFIX}{i}" for i in range(num_modes)]
! 874         impedance_specs = dict(zip(terminal_labels, self.terminal_specs))
! 875         terminals_mapping = self._get_terminals_mapping(terminal_labels)
! 876         mode_spec = MicrowaveTerminalModeSpec(
  877             impedance_specs=impedance_specs,
  878             num_modes=num_modes,
  879             terminals_mapping=terminals_mapping,
  880         )
! 881         return mode_spec
  882 
  883     def _mode_spec_from_isolated_floating_conductors(
  884         self, conductors: dict[str, tuple[Shapely, Box]]
  885     ) -> MicrowaveTerminalModeSpec:

Lines 885-899

  885     ) -> MicrowaveTerminalModeSpec:
  886         """Construct mode specification from isolated floating conductors
  887         when terminal_specs is an AutoImpedanceSpec.
  888         """
! 889         terminal_labels = list(conductors)
! 890         impedance_specs = {
  891             label: CustomImpedanceSpec.from_bounding_box(box)
  892             for label, (_, box) in conductors.items()
  893         }
! 894         terminals_mapping = self._get_terminals_mapping(terminal_labels)
! 895         return MicrowaveTerminalModeSpec(
  896             impedance_specs=impedance_specs,
  897             num_modes=len(terminal_labels),
  898             terminals_mapping=terminals_mapping,
  899         )

Lines 906-915

  906         mode_spec : MicrowaveModeSpecType, optional
  907             Resolved mode specification with integer num_modes. If None,
  908             uses self._mode_spec but raises SetupError if num_modes='auto'.
  909         """
! 910         mode_spec = self._validate_resolved_mode_spec(mode_spec)
! 911         return tuple(range(mode_spec.num_modes))
  912 
  913     @cached_property
  914     def _differential_pair_mapping(self) -> dict[str, tuple[str, str]]:
  915         """Get a mapping from differential pairs to single-ended terminals.

Lines 917-935

  917         Returns
  918         -------
  919         dict[str, tuple[str, str]]: Mapping from differential pairs to single-ended terminals.
  920         """
! 921         mapping = {}
! 922         for pair_idx, (label1, label2) in enumerate(self.differential_pairs):
  923             # Create labels for common mode and differential mode
! 924             comm_label = f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}@comm"
! 925             diff_label = f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}@diff"
  926 
  927             # Map both modes to the same pair of single-ended terminals
! 928             mapping[comm_label] = (label1, label2)
! 929             mapping[diff_label] = (label1, label2)
  930 
! 931         return mapping
  932 
  933     def _get_active_single_ended_terminals(self, single_ended_labels: list[str]) -> list[str]:
  934         """Get the single-ended terminals for S-parameter computation (excluding the
  935         ones used in differential pairs).

Lines 943-957

  943         -------
  944         list[str]
  945             List of single-ended terminal labels not used in any differential pair.
  946         """
! 947         active_terminals = single_ended_labels.copy()
! 948         for pair_idx, pair in enumerate(self.differential_pairs):
! 949             for label in pair:
! 950                 try:
! 951                     active_terminals.remove(label)
! 952                 except ValueError:
! 953                     raise SetupError(
  954                         f"Port '{self.name}': Differential pair {pair_idx} references terminal "
  955                         f"label '{label}' that is not present in the available single-ended "
  956                         f"terminals: {single_ended_labels}. "
  957                         f"Please ensure all differential pair labels match detected terminals."

Lines 955-963

  955                         f"label '{label}' that is not present in the available single-ended "
  956                         f"terminals: {single_ended_labels}. "
  957                         f"Please ensure all differential pair labels match detected terminals."
  958                     ) from None
! 959         return active_terminals
  960 
  961     def _get_terminals_mapping(
  962         self, single_ended_labels: list[str]
  963     ) -> dict[str, Union[str, tuple[str, str]]]:

Lines 977-990

  977             Mapping from terminal (single-ended terminal or differential pair) to single-ended terminals.
  978             Keys are ordered with single-ended terminals first, followed by differential pairs.
  979         """
  980         # Start with single-ended terminals (these come first in the ordering)
! 981         mapping = {
  982             label: label for label in self._get_active_single_ended_terminals(single_ended_labels)
  983         }
  984         # Then add differential pairs (these come after single-ended terminals)
! 985         mapping.update(self._differential_pair_mapping)
! 986         return mapping
  987 
  988     def to_source(
  989         self,
  990         source_time: SourceTimeType,

Lines 1005-1020

  1005         mode_spec : MicrowaveModeSpecType, optional
  1006             Resolved mode specification with integer num_modes. If None,
  1007             uses self._mode_spec but raises SetupError if num_modes='auto'.
  1008         """
! 1009         center = list(self.center)
! 1010         if snap_center:
! 1011             center[self.injection_axis] = snap_center
  1012         # Use provided mode_spec if given, otherwise fall back to self._mode_spec
! 1013         mode_spec = self._validate_resolved_mode_spec(mode_spec)
! 1014         if terminal_index is None:
! 1015             terminal_index = mode_spec._terminal_indices[0]
! 1016         return MicrowaveTerminalSource(
  1017             center=center,
  1018             size=self.size,
  1019             source_time=source_time,
  1020             mode_spec=mode_spec,

Lines 1027-1058

  1027     def get_characteristic_impedance_matrix(
  1028         self, sim_mode_data: Union[SimulationData, MicrowaveModeData]
  1029     ) -> ImpedanceFreqTerminalTerminalDataArray:
  1030         """Retrieve the characteristic impedance matrix of the port."""
! 1031         mode_data = self._get_mode_data(sim_mode_data)
! 1032         return mode_data.transmission_line_terminal_data.Z0_matrix
  1033 
  1034     def compute_voltage(self, sim_data: SimulationData) -> VoltageFreqTerminalDataArray:
  1035         """Helper to compute voltage across the port."""
! 1036         mode_data: MicrowaveModeData = sim_data[self._mode_monitor_name]
! 1037         voltage_transform = mode_data.transmission_line_terminal_data.voltage_transform
! 1038         amps = mode_data.amps
! 1039         fwd_amps = amps.sel(direction="+").squeeze()
! 1040         bwd_amps = amps.sel(direction="-").squeeze()
  1041         # Matrix multiply: voltage_transform[f, terminal_index, mode_index] @ amps[f, mode_index]
  1042         # to get voltage[f, terminal_index]
! 1043         return voltage_transform.dot(fwd_amps + bwd_amps, dim="mode_index")
  1044 
  1045     def compute_current(self, sim_data: SimulationData) -> CurrentFreqTerminalDataArray:
  1046         """Helper to compute current flowing through the port."""
! 1047         mode_data: MicrowaveModeData = sim_data[self._mode_monitor_name]
! 1048         current_transform = mode_data.transmission_line_terminal_data.current_transform
! 1049         amps = mode_data.amps
! 1050         fwd_amps = amps.sel(direction="+").squeeze()
! 1051         bwd_amps = amps.sel(direction="-").squeeze()
  1052         # In ModeData, fwd_amps and bwd_amps are not relative to
  1053         # the direction fields are stored
! 1054         sign = 1.0
! 1055         if self.direction == "-":
! 1056             sign = -1.0
! 1057         return sign * current_transform.dot(fwd_amps - bwd_amps, dim="mode_index")

tidy3d/plugins/smatrix/utils.py

Lines 173-181

  173     # Automatically detect if Z is diagonal and use appropriate method
  174     if _is_diagonal_matrix(Z_numpy):
  175         return _compute_F_diagonal(Z_numpy, s_param_def)
  176     else:
! 177         return _compute_F_full(Z_numpy, s_param_def)
  178 
  179 
  180 def _compute_F_diagonal(Z_numpy: np.ndarray, s_param_def: SParamDef) -> np.ndarray:
  181     """Compute F matrix assuming Z is diagonal (fast element-wise operations).

Lines 232-251

  232     -------
  233     np.ndarray
  234         F matrix with shape (f, port_out, port_in).
  235     """
! 236     num_freqs = Z_numpy.shape[0]
  237 
! 238     if s_param_def == "power":
  239         # F = 0.5 * (Real[Z])^(-1/2)
! 240         Z_real = np.real(Z_numpy)
  241         # Vectorize matrix inverse square root over the frequency dimension
! 242         F = np.array([0.5 * linalg.inv(linalg.sqrtm(Z_real[f_idx])) for f_idx in range(num_freqs)])
! 243         return F
! 244     elif s_param_def == "pseudo":
  245         # F = 0.5 * (Real(Z^-1))^(1/2)
  246         # First compute Z^-1, then take real part, then matrix square root
! 247         F = np.array(
  248             [
  249                 0.5 * linalg.sqrtm(np.real(np.linalg.inv(Z_numpy[f_idx])))
  250                 for f_idx in range(num_freqs)
  251             ]

Lines 249-262

  249                 0.5 * linalg.sqrtm(np.real(np.linalg.inv(Z_numpy[f_idx])))
  250                 for f_idx in range(num_freqs)
  251             ]
  252         )
! 253         return F
  254     elif s_param_def == "symmetric_pseudo":
  255         # F = 0.5 * Z^(-1/2)
  256         # Matrix inverse square root of complex Z
! 257         F = np.array([0.5 * linalg.inv(linalg.sqrtm(Z_numpy[f_idx])) for f_idx in range(num_freqs)])
! 258         return F
  259     else:
  260         raise ValueError(
  261             f"Unsupported S-parameter definition '{s_param_def}'. "
  262             "Supported values are 'pseudo', 'symmetric_pseudo', and 'power'."

Add terminal mode excitation support to the microwave S-matrix
plugin, enabling accurate characterization of transmission line structures.

- Implement TerminalComponentModeler for multi-port S-parameter extraction
- Add wave port support with automatic mode computation
- Support terminal-based voltage/current path integral definitions
- Add visualization methods for port geometry and field plots
- Extend DataArray with terminal-specific coordinate handling
- Add terminal mode support to ModeSolver

fix(microwave): revision round 1

Fix _dims as bare strings in ModeDataArray and TerminalDataArray that
would break set()-based validation, pass resolved mode_spec in
de-embedding _mode_indices() call, inherit impedance specs from
AbstractImpedanceSpec, and remove commented-out development code.

fix(microwave): revision round 2

- Move xarray import out of TYPE_CHECKING to fix NameError in terminal_fields
- Use _terminal_indices instead of terminals_mapping.keys() to avoid AttributeError when None
- Add VoltageFreqTerminalDataArray to DATA_ARRAY_TYPES for HDF5 round-trip

fix(microwave): fix model_dump data loss and missing runtime imports in mode_solver

- Replace model_dump() with direct attribute access when converting ModeSolverData
  to MicrowaveModeSolverData, preventing silent loss of impedance_specs
- Move CurrentFreqTerminalModeDataArray and ImpedanceFreqTerminalTerminalDataArray
  from TYPE_CHECKING to regular imports (used at runtime)

fix(microwave): remove unused _apply_matrix_operation method

fix(microwave): address review feedback on terminal wave port PR

- Update outdated docstring in _generate_transmission_line_terminal_data
  that incorrectly described "dummy values" instead of actual computation
- Register FreqModeModeDataArray and ImpedanceFreqModeModeDataArray in
  DATA_ARRAY_TYPES for consistent serialization support
- Update docs to reference TerminalWavePort alongside WavePort

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

main_data_reordered = main_data_reordered.updated_copy(
transmission_line_data=transmission_line_data_reordered
)

Copy link

Choose a reason for hiding this comment

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

Missing terminal data handling in group index post-processing

Low Severity

The _group_index_post_process method filters frequency dimensions from transmission_line_data but does not handle the new transmission_line_terminal_data. The sibling method _apply_mode_reorder was updated to handle both datasets, creating an inconsistency. While the current mode solver execution order adds terminal data after group index processing, this asymmetry is fragile and would silently produce frequency dimension mismatches if the order ever changes.

Fix in Cursor Fix in Web

@weiliangjin2021 weiliangjin2021 force-pushed the FXC-4678-terminal-wave-port branch from e92a216 to ed24da2 Compare February 11, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting backend not to be merged as backend is not finalized

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants