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¶
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_angle0 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.