Skip to content

From a gdsfactory layout to compact models

photonhub.integrations.gdsfactory takes a gdsfactory Component and a layer stack and gives you PhotonHub structures and ports, a preview, and an S-matrix study. The S-matrix exports to Touchstone, to a JSON model and to a callable model that a SAX circuit accepts.

Models inherit the simulation's accuracy

An exported model is as good as the run behind it. Its S-parameters carry the convergence of that run and the mesh you chose. The passivity and reciprocity checks on every export test consistency, not accuracy. Refine the mesh and compare before you rely on a value.

Install

python -m pip install "photonhub[gdsfactory]"

The extra installs gdsfactory 9.x. The rest of the SDK does not need it, and photonhub.integrations.gdsfactory imports without it; a call that needs gdsfactory then says which extra to install.

Import a component

import gdsfactory as gf
import photonhub as ph
from photonhub.integrations import gdsfactory as phgf

gf.gpdk.PDK.activate()
mmi = gf.components.mmi1x2()

device = phgf.import_component(mmi, wlen_um=1.55)
device.levels          # the drawn levels, in paint order
device.omitted_levels  # levels that drew nothing in this component, and why
device.ports           # one ph.Port per optical port

With no layer_stack, the active PDK's stack is used. Each level that draws shapes in the component becomes extruded Polygon structures:

gdsfactory PhotonHub
zmin, thickness the slab the polygon spans along z
sidewall_angle (degrees) Polygon.sidewall_angle (radians, positive narrows upward)
width_to_z 0, 0.5, 1 reference_plane bottom, middle, top
mesh_order (lower wins) paint order (painted last wins)
material a medium from materials=, else the material library at wlen_um

Materials from the library are evaluated at wlen_um as non-dispersive media. Use the centre of the band you will simulate. Override any name, or supply one the library lacks, with materials=:

device = phgf.import_component(
    mmi, wlen_um=1.55,
    materials={"si": ph.Medium(permittivity=3.48 ** 2), "sio2": 1.444},
)

A value is a ph.Medium, a ph.materials.Material or a refractive index. phgf.MATERIAL_ALIASES lists the names that map to the library. Metals are not in it; a drawn metal level needs materials=.

The scene has one background medium, the cladding. It comes from background=, else the stack's clad level, else its box level. Wafer-wide levels such as the substrate and the buried oxide are not drawn. When one of them has another medium and sits against the device, the import warns.

Instead of a gdsfactory LayerStack, you can pass a list of ph.GDSLayer, each (layer, datatype) extruded with its own medium. Then pass background= as well.

Ports

Each optical port becomes a ph.Port at the gdsfactory port centre, at the mid-height of the level its layer belongs to. Its direction follows the orientation: 0, 90, 180 and 270 degrees face +x, +y, -x and -y. The mode is "TE0" unless you pass mode= (one mode for all ports, or a mapping by port name). The mode window follows the same rule as any other port.

The S-matrix is referenced at the gdsfactory port centres. That is what lets models compose port to port in a circuit. Each port's guide is extended outward by extension_um (default three wavelengths) with its own cross section, so it runs through the absorbing layers.

Preview

import numpy as np

WLENS = tuple(np.linspace(1.5, 1.6, 41))   # see "Sample densely" below

device.plot()                          # a cut at the first port's height
sim = device.simulation(wlens_um=WLENS, mesh=ph.Mesh(cells_per_wlen=12))
sim.plot_3d()                          # needs the viz extra

simulation() fits the domain around the device and declares the ports. It sets no source, and it has no default mesh: the resolution is your choice.

Plan, quote and run

plan = phgf.plan_component_smatrix(
    mmi, wlens_um=WLENS, mesh=ph.Mesh(cells_per_wlen=12),
)
plan.quote()          # per-port and total cost estimate
result = plan.run()   # one driven run per port on the local solver

A study fits the domain so that every port's launch, which sits a short distance outside its port plane, keeps the usual margin from the absorbing layers. A symmetry plane passed through simulation_kwargs must contain every port: a study drives one port at a time, so a plane that mirrors a port away is refused. The plane's sign must also match the parity of each port mode's tangential electric field on that plane: -1 is the odd (electric) mirror and +1 the even (magnetic) mirror. A TE0 strip cut lengthwise by a vertical plane, for example, needs -1. With the wrong sign the run reads another mode, and the S-parameters look plausible but are wrong.

run_component_smatrix does both in one call. With runner="cloud" the runs go through ph.cloud, which quotes each job and holds it to your spending ceiling. result.S has dims (port_out, port_in, f), labelled with the gdsfactory port names.

Export compact models

result.to_touchstone("mmi1x2", version=2)   # Touchstone 2.0, mmi1x2.ts
result.to_touchstone("mmi1x2")              # Touchstone 1.0, mmi1x2.s3p
result.to_json("mmi1x2.json")               # wavelength-interpolated JSON model
model = result.to_model()                   # a callable model

model(wl=...) takes wavelengths in microns and returns {(port_from, port_to): array}, the key order SAX uses. The default is linear magnitude and unwrapped phase (polar) interpolation in wavelength. At model construction, each interval is checked at 15 interior points, at fractions 1/16 through 15/16. An interval switches to linear real-and-imaginary interpolation of the whole matrix only when polar interpolation gains more than passivity_atol above the largest gain at any sample in the model, plus a floating-point roundoff margin. The sample reference is at least zero. Noisy or uniformly gained samples keep polar interpolation unless polar interpolation between them still exceeds that threshold, as it can near a zero. Gain below the threshold is kept, not repaired, and is reported by the sample and interior checks, which retain their original tolerance. Because the reference is the largest gain at any sample, one bad sample raises the threshold for every interval of the model: interior gain up to that sample's gain plus the tolerance is then kept and reported. This switch rule does not certify passivity for noisy samples. A convex combination of passive endpoint matrices stays passive and preserves reciprocity. The selected interpolant is checked again; choices are stored in the model and its JSON, so calls do not repeat the decision. The model refuses wavelengths outside the sampled range.

Passivity is not accuracy. Complex interpolation can introduce loss. Any switch always warns once per model, naming the number of switched intervals, the worst interval's index and wavelength bounds, and the worst midpoint amplitude drop in dB relative to polar interpolation on a significant entry. Entries below 10% of their row or column maximum at the polar midpoint are not used to judge sag. There is no silent loss allowance. An interval that remains non-passive also warns, or raises in strict mode. These diagnostics cannot recover a resonance missing from the input samples.

Sample densely to resolve rapid phase evolution. The model records the largest phase step and warns above pi/2 for entries whose magnitudes at both endpoints exceed 1e-6, including near-zero crossings. Switching never suppresses this warning, so sharp rings retain the same coarse-sampling warning as polar-only models. Denser sampling reduces phase and amplitude interpolation errors, but does not remove the phase jump at a zero; the complex fallback handles that jump. No sample-based check can detect whole phase turns lost between samples. More wavelengths cost little because they come from the same simulation runs.

The model needs no SAX import to build. In a SAX circuit:

import numpy as np
import sax

mzi, _ = sax.circuit(
    netlist={
        "instances": {"split": "mmi", "combine": "mmi"},
        "connections": {"split,o2": "combine,o2", "split,o3": "combine,o3"},
        "ports": {"in": "split,o1", "out": "combine,o1"},
    },
    models={"mmi": model},
)
s = mzi(wl=np.linspace(1.5, 1.6, 201))

Call the circuit directly; the model is plain NumPy, so a circuit that uses it cannot be compiled with jax.jit.

Load a saved model with ph.analysis.SMatrixModel.from_json("mmi1x2.json"), and a Touchstone file of any version with ph.analysis.read_touchstone.

Checks on every export

Every writer checks the sampled matrices and records:

  • passivity violation: the largest eigenvalue of S^H S minus one;
  • reciprocity error: the largest difference between S_ij and S_ji.

New callable and JSON models check the selected interpolant at the 15 interior points of every wavelength interval as well as the original samples. Their checks combine the worst sample and interior errors. Metadata under photonhub.compact_model records separate sample_checks and interpolant_checks, including probe fractions, switched intervals, and each worst interior error with its wavelength and interval. If a caller already owns that metadata key, .generated is appended until the key is unused. Rebuilding from loaded metadata replaces stale generated diagnostics and refreshes the SDK version and provenance note. These finite probes can miss polar peaks between probes: they provide no continuous-interval passivity bound or accuracy certificate. In our sweeps with tolerance 1e-3, the probes missed excess of about 1.1e-3: 1.06e-3 without a warning for an optimized symmetric unitary four-port pair (1.000/1.001 um samples, 4000x evaluation grid), and 1.18e-3 with warnings for analytic MZIs (60 um first arm, arm differences 15-400 um, arm amplitudes 0.3-0.95, six output coupler angles, 0.5-4 nm steps, four offsets, 400x grid; neff=2.35 and ng=4 at 1.55 um, 1.50-1.60 um band).

Each error is compared with passivity_atol or reciprocity_atol, both 1e-3 by default. A failed check warns and leaves the model available for inspection, with the failure recorded. With strict=True a failed check raises before export; an interpolant failure names the interval and error. An S-matrix with missing entries is always refused. Denser sampling can reduce interpolation error, but it does not guarantee passivity for independently interpolated magnitudes and phases. Check the model at the wavelengths your circuit needs as well.

New JSON uses format version 2 to store interval choices; older SDK readers reject it rather than silently discarding the choices. Loading version 2 preserves its stored choices, thresholds and checks without rerunning them. Version 1 JSON retains the original polar interpolation everywhere. Its metadata explicitly records that legacy policy and the absence of interior checks on load. Rebuild from the original S-matrix to apply the new checks and fallback.

Limits

  • Ports must face x or y, and sit on the face of the device they point through.
  • A polygon with holes is split into hole-free pieces on a vertical-wall level. On a sloped-wall level it is refused, so a closed ring on a stack with sloped core walls needs sidewall_angle 0 for that level.
  • One mode per port. Library materials are non-dispersive at wlen_um.
  • The scene has one background medium; wafer-wide levels are not drawn.