Additive Backend¶
The additive backend can be used to calculate path lengths, group delays, and other additive quantities. Unlike the default multiplicative backend (which multiplies S-parameters along paths), the additive backend sums values. This is useful for computing optical path lengths, propagation delays, or any linear quantity that accumulates along the signal path.
from functools import partial
import jax.numpy as jnp
from sax.models import straight
import matplotlib.pyplot as plt
import sax
How the Additive Backend Works¶
In standard S-parameter simulations, we multiply transmission coefficients along signal paths. For example, if light passes through two waveguides with transmissions \(T_1\) and \(T_2\), the total transmission is \(T_1 \times T_2\).
With the additive backend, values are summed instead of multiplied. This is useful for quantities like: - Path length: Total physical length = \(L_1 + L_2\) - Group delay: Total delay = \(\tau_1 + \tau_2\) - Phase (unwrapped): Total phase = \(\phi_1 + \phi_2\) - Loss (in dB): Total loss = \(\text{Loss}_1 + \text{Loss}_2\)
The additive backend uses the same netlist structure, but interprets the "S-parameter" values as additive quantities.
Path Length Models¶
Let's define models that return path lengths instead of transmission coefficients. The coupler has a physical length that light traverses regardless of which port it exits from:
def coupler(length=50.0) -> sax.SDict:
"""Coupler model returning path lengths (in μm)."""
sdict = {
("in0", "out0"): length,
("in0", "out1"): length,
("in1", "out0"): length,
("in1", "out1"): length,
}
return sax.reciprocal(sdict)
def waveguide(length=50.0) -> sax.SDict:
"""Coupler model returning path lengths (in μm)."""
sdict = {
("in0", "out0"): length,
}
return sax.reciprocal(sdict)
def waveguide(length=100.0) -> sax.SDict: """Waveguide model returning path lengths (in μm).""" sdict = { ("in0", "out0"): length, } return sax.reciprocal(sdict)
MZI with Additive Backend¶
Let's create an MZI (Mach-Zehnder Interferometer) with different arm lengths and use the additive backend to calculate the total path length through each arm:
┌─────────────────────────┐
┌─────┤ top (500 μm) ├─────┐
│ └─────────────────────────┘ │
in ──┤ lft rgt├── out
│ ┌─────────────────────────┐ │
└─────┤ btm (100 μm) ├─────┘
└─────────────────────────┘
mzi, _ = sax.circuit(
netlist={
"instances": {
"lft": coupler,
"top": partial(waveguide, length=500),
"btm": partial(waveguide, length=100),
"rgt": coupler,
},
"connections": {
"lft,out0": "btm,in0",
"btm,out0": "rgt,in0",
"lft,out1": "top,in0",
"top,out0": "rgt,in1",
},
"ports": {
"in0": "lft,in0",
"in1": "lft,in1",
"out0": "rgt,out0",
"out1": "rgt,out1",
},
},
backend="additive",
)
result = mzi()
result
{('in0', 'in0'): [Array([0.], dtype=float64)],
('in0', 'out0'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('in0', 'out1'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('in1', 'in1'): [Array([0.], dtype=float64)],
('in1', 'out0'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('in1', 'out1'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('out0', 'in0'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('out0', 'in1'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('out0', 'out0'): [Array([0.], dtype=float64)],
('out1', 'in0'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('out1', 'in1'): [Array([200.], dtype=float64), Array([600.], dtype=float64)],
('out1', 'out1'): [Array([0.], dtype=float64)]}
Interpreting the Results¶
The additive backend returns the total path length for each input-output port combination. Let's analyze the results:
# Path through bottom arm: lft (50) + btm (100) + rgt (50) = 200 μm
print(f"in0 -> out0 (bottom arm): {result['in0', 'out0'][0][0]:.1f} μm")
# Path through top arm: lft (50) + top (500) + rgt (50) = 600 μm
print(f"in0 -> out1 (top arm): {result['in0', 'out1'][0][0]:.1f} μm")
# Path length difference (important for MZI interference)
delta_L = result["in0", "out1"][0][0] - result["in0", "out0"][0][0]
print(f"\nPath length difference (ΔL): {delta_L:.1f} μm")
in0 -> out0 (bottom arm): 200.0 μm
in0 -> out1 (top arm): 200.0 μm
Path length difference (ΔL): 0.0 μm
Example: Group Delay Calculation¶
The additive backend is also useful for computing group delays. Let's define models that return propagation time instead of length:
# Speed of light in vacuum
c = 3e8 # m/s
# Effective group index for silicon waveguide at 1550nm
ng = 4.2
def coupler_delay(length=50.0) -> sax.SDict:
"""Coupler model returning group delay (in ps)."""
# Convert length from μm to m, compute delay
delay_ps = (length * 1e-6) / (c / ng) * 1e12
sdict = {
("in0", "out0"): delay_ps,
("in0", "out1"): delay_ps,
("in1", "out0"): delay_ps,
("in1", "out1"): delay_ps,
}
return sax.reciprocal(sdict)
def waveguide_delay(length=100.0) -> sax.SDict:
"""Waveguide model returning group delay (in ps)."""
delay_ps = (length * 1e-6) / (c / ng) * 1e12
sdict = {
("in0", "out0"): delay_ps,
}
return sax.reciprocal(sdict)
mzi_delay, _ = sax.circuit(
netlist={
"instances": {
"lft": coupler_delay,
"top": partial(waveguide_delay, length=500),
"btm": partial(waveguide_delay, length=100),
"rgt": coupler_delay,
},
"connections": {
"lft,out0": "btm,in0",
"btm,out0": "rgt,in0",
"lft,out1": "top,in0",
"top,out0": "rgt,in1",
},
"ports": {
"in0": "lft,in0",
"in1": "lft,in1",
"out0": "rgt,out0",
"out1": "rgt,out1",
},
},
backend="additive",
)
delay_result = mzi_delay()
print(f"Group delay through bottom arm: {delay_result['in0', 'out0'][0][0]:.3f} ps")
print(f"Group delay through top arm: {delay_result['in0', 'out1'][0][0]:.3f} ps")
print(
f"Differential group delay: {delay_result['in0', 'out1'][0][0] - delay_result['in0', 'out0'][0][0]:.3f} ps"
)
Group delay through bottom arm: 2.800 ps
Group delay through top arm: 2.800 ps
Differential group delay: 0.000 ps
Visualization: Comparing Path Lengths¶
Let's visualize the path lengths for different MZI arm configurations:
top_lengths = jnp.linspace(100, 1000, 10)
bottom_lengths = jnp.array([100.0]) # Fixed bottom arm
path_diff = []
for top_len in top_lengths:
mzi_test, _ = sax.circuit(
netlist={
"instances": {
"lft": coupler,
"top": partial(waveguide, length=float(top_len)),
"btm": partial(waveguide, length=100),
"rgt": coupler,
},
"connections": {
"lft,out0": "btm,in0",
"btm,out0": "rgt,in0",
"lft,out1": "top,in0",
"top,out0": "rgt,in1",
},
"ports": {
"in0": "lft,in0",
"in1": "lft,in1",
"out0": "rgt,out0",
"out1": "rgt,out1",
},
},
backend="additive",
)
result = mzi_test()
path_diff.append(result["in0", "out1"][0][0] - result["in0", "out0"][0][0])
plt.figure(figsize=(8, 4))
plt.plot(top_lengths, path_diff, "o-", linewidth=2, markersize=8)
plt.xlabel("Top Arm Length (μm)")
plt.ylabel("Path Length Difference ΔL (μm)")
plt.title("MZI Path Length Difference vs Top Arm Length")
plt.grid(True, alpha=0.3)
plt.axhline(y=0, color="k", linestyle="--", alpha=0.5)
plt.show()

When to Use the Additive Backend¶
The additive backend is useful when you need to:
- Calculate optical path lengths - Important for interferometer design, determining FSR (Free Spectral Range)
- Compute group delays - Essential for high-speed communication systems and pulse propagation
- Sum losses in dB - Convenient for loss budgeting when models return loss in dB
- Analyze phase accumulation - When you want to track unwrapped phase
The additive backend uses the same circuit analysis infrastructure as the standard backend, so it handles complex circuits with multiple paths correctly. When multiple paths exist between ports, each path's contribution is computed separately.