Skip to content

Simulation and grid

Simulation is the whole scene and the portable execution contract: domain, grid, run controls, sources, monitors, boundaries. Construction validates structure and cross-field consistency, so a malformed scene fails before a solver starts.

The scene

Simulation pydantic-model

Bases: FrozenModel

Complete simulation description. Serializes 1:1 to the JSON wire format consumed by phsolver (schemas/GOVERNANCE.md).

The cross-field validators here are best-effort early feedback mirroring the engine's checks where they are cheap and unambiguous; phsolver validate remains authoritative (notably for the plane-wave/PML intersection rule). One check is resolved rather than mirrored: DFT field-monitor box faces are AUTO-SNAPPED at construction to quarter-cell planes wherever the engine's per-component region snap would reject them or pass on float rounding luck (see ProfileMonitor); ingestion via from_wire_json/from_file never adjusts a document.

Config:

  • frozen: True
  • extra: forbid
  • allow_inf_nan: False

Fields:

Validators:

  • _bloch_pairing
  • _background_from_medium → background
  • _supported_major_version → schema_version
  • _unique_monitor_names → monitors
  • _symmetry_values → symmetry
  • _resolved_grid_resource_limits
  • _run_required_on_the_wire
  • _far_field_monitors_compiled
  • _resolve_plane_spans
  • _dft_regions_quarter_snap
  • _modal_port_rules
  • _symmetry_plane_rules
  • _periodic_graded_seam_spacings_match
  • _centers_inside_realized_domain
  • _plane_wave_transverse_axes_periodic
  • _resolve_subpixel_default
  • _ade_stability
  • _warn_bloch_with_undamped_poles
  • _warn_dispersive_media_in_pml
  • _auto_stabilize_dispersive_pml
  • _flux_planes_inside_domain
  • _monitor_freqs_in_source_band
  • _warn_non_optical_source
  • _full_tensor_subpixel_rules

schema_version pydantic-field

schema_version: str = SCHEMA_VERSION

size_um pydantic-field

size_um: Union[
    Tuple[PositiveUm, PositiveUm, PositiveUm],
    SkipJsonSchema[Domain],
]

grid pydantic-field

grid: Union[MeshType, SkipJsonSchema[Mesh]]

run pydantic-field

run: RunSpec

origin_um pydantic-field

origin_um: SkipJsonSchema[Tuple[float, float, float]] = (
    0.0,
    0.0,
    0.0,
)

background pydantic-field

background: Union[Background, MaterialEntry]

pml_num_layers pydantic-field

pml_num_layers: int = 12

pml_m pydantic-field

pml_m: float = 3.0

pml_kappa_max pydantic-field

pml_kappa_max: float = 3.0

pml_alpha_max pydantic-field

pml_alpha_max: float = 0.24

pml_sigma_max pydantic-field

pml_sigma_max: float = 1.5

absorber_num_layers pydantic-field

absorber_num_layers: int = 40

absorber_m pydantic-field

absorber_m: float = 3.0

subpixel pydantic-field

subpixel: bool = False

subpixel_method pydantic-field

subpixel_method: SubpixelMethodName = 'contour'

field_precision pydantic-field

field_precision: FieldPrecisionName = 'fp32'

dft_precision pydantic-field

dft_precision: DftPrecisionName = 'fp64'

structures pydantic-field

structures: Tuple[Structure, ...] = ()

boundaries pydantic-field

boundaries: Boundaries

symmetry pydantic-field

symmetry: Tuple[int, int, int] = (0, 0, 0)

bloch_k_per_um pydantic-field

bloch_k_per_um: Optional[Tuple[float, float, float]] = None

sources pydantic-field

sources: Tuple[SourceType, ...] = ()

monitors pydantic-field

monitors: Tuple[MonitorType, ...] = ()

wlens_um pydantic-field

wlens_um: SkipJsonSchema[
    Optional[Union[float, Tuple[float, ...]]]
] = None

wlen0_um pydantic-field

wlen0_um: SkipJsonSchema[Optional[float]] = None

ports pydantic-field

ports: SkipJsonSchema[Tuple[Any, ...]] = ()

source pydantic-field

source: SkipJsonSchema[Optional[Any]] = None

far_field_monitors property

far_field_monitors: dict

{name: faces} of the FarFieldMonitor surfaces this simulation records, each face as (monitor name, axis, outward sign): what far_field(data, name) projects.

port_monitors property

port_monitors

{port name: ModeMonitor} for a declarative simulation, else {}.

port_windows_um property

port_windows_um

{port name: (half_w_um, half_v_um)}: the mode window each port's mode was solved on (a port's window_um, or the default, clipped to the domain). Empty for a simulation without ports, and for one loaded from a file, whose modes are solved again on first use. A port the symmetry fold dropped reads through its mirror image and has no entry.

driven_port property

driven_port: Optional[str]

The name of the port source= drives, if any.

with_changes

with_changes(**fields) -> Simulation

A copy with fields replaced and everything re-validated: the supported edit of a simulation. A fitted or declarative simulation is rebuilt from the fields as the user gave them, in the user's frame, so new structures are fitted, folded and their ports re-solved like the originals; a hand-built one is the validated copy the with_* helpers use, built as the constructor builds it, so the construction-time resolution (the subpixel default, plane spans, the quarter-snap, the dispersive-PML profile and their warnings) is decided again for the edited scene from the fields as written. Either way the result is the simulation the constructor gives for the same fields. A simulation loaded with from_wire_json is edited as the document it is: nothing is resolved again, except that the monitors the edit adds (all of them when it changes the grid or the size) are placed on the cells as construction places them, and the declarations only the constructor resolves (a Domain, a Mesh, a run in transits, ports) are refused. domain= and mesh= are accepted as the constructor accepts them, for size_um and grid.

model_copy

model_copy(
    *, update=None, deep: bool = False
) -> Simulation

A copy with update applied. Prefer with_changes, the validated edit; this keeps pydantic's signature and what it means for each kind of simulation:

  • built by hand with a run, or loaded from the wire: pydantic's field copy, which re-validates nothing;
  • one the constructor resolved (domain=, mesh=, ports, or a run given in transits or not at all): with_changes, so the copy is the simulation the constructor builds from the same fields;
  • except on a domain= fit, where the stored structures, sources, monitors, size and grid are coordinates of the fitted box, whose origin is its low corner and not the user's: an update of one of them is ambiguous and raises ValueError naming with_changes, which takes them as you wrote them. An update that mixes one of them with other fields raises too. Renaming the structures (the same geometry and media) is allowed, and a later rebuild keeps the names; an eps_of_medium anchor on the guide still reaches its port extensions, also when the renamed structures carry copies of the media. A medium object the rename puts on several structures stands only for what all of them stand for, so an anchor on a Port's own medium never reaches the guide that way; a rename that puts the guide's own medium on a Port's extension leaves that anchor nothing to reach, and no warning says so. After a with_* helper replaced stored coordinates of such a fit (say with_auto_mesh), the other fields are copied as that helper copied them, in the stored coordinates.

On a resolved simulation the sources and monitors (as stored) and the subpixel, subpixel_method, field_precision and dft_precision switches are still a field copy: the path the SDK's own drivers (the S-matrix plan, the adjoint gradient) take to swap a resolved scene's launch and readout. Where the stored coordinates are yours (no domain= fit, no ports) a later with_changes or with_* helper keeps such a swap; on a domain= fit or a scene with ports, whose stored sources and monitors are not the ones you wrote, a later with_changes refuses instead of dropping it, while a with_* helper or a model_copy of other fields keeps the swap as stored. On a scene with ports or source= that edit refuses when it changes what the port solves read (the size, grid, structures, background, boundaries, symmetry, Bloch vector or layer counts): the swapped launch and readout were made for the old ones.

With deep=True the update's values are copied too (pydantic's own copy sets them in uncopied), so the copy shares no object with the original or the update.

model_post_init

model_post_init(__context) -> None

Fit the domain, mesh and run length, then resolve ports / source / wlens_um, once the fields have validated (pydantic runs this before the after-validators, which then check the resolved scene): the model at this point is the geometry-only simulation the mode solves need (final grid, structures, symmetry, the caller's sources and monitors). The launch and the readout planes are then set beside the caller's, and the merged wire-level content is validated once more so a plane that lands in the PML or a name clash fails here, at construction. Wire ingestion carries the wire_ingest context (as do the throwaway validations here and in _validated_copy) and is left alone; a fitted simulation refuses model_copy edits that would need this resolution again (see model_copy).

with_auto_mesh

with_auto_mesh(
    *,
    wlen_um: Optional[float] = None,
    cells_per_wlen: float = 20.0,
    **auto_mesh_kwargs,
) -> Simulation

Return a COPY of this simulation whose grid is replaced by an auto-meshed GradedMesh derived from this scene, its domain size_um, structures, background index, and (if wlen_um is omitted) the wavelength inferred from the first source. A convenience wrapper over photonhub.auto_mesh; extra keyword arguments (max_grading, axes, dl_min_um, refine_regions, ...) pass straight through.

Opt-in only: the default UniformMesh is unchanged, so no existing scene's wire output moves. Use this when you want per-medium per-medium refinement without hand-building coordinate arrays::

sim = sim.with_auto_mesh(cells_per_wlen=20)

Axes whose boundary is PERIODIC are passed to auto_mesh as periodic_axes (unless you override it explicitly), so a graded periodic axis is generated seam-symmetrically, equal first/last primary spacings, the closure requirement the engine hard-checks. Non-periodic scenes are byte-identical to before.

An axis whose refinement set is already mirror-symmetric about the domain centre gets a MIRROR-SYMMETRIC ladder, so the two halves of a symmetric device are on the same discretization and a quantity symmetry guarantees (a splitter's arm balance) is not thrown off by a grid artifact. Pass mirror_axes="" to switch that off, or explicit letters to force it; auto_mesh documents the detection, and photonhub.axis_mirror_mismatch measures the result.

with_mesh_overrides

with_mesh_overrides(
    *overrides,
    wlen_um: Optional[float] = None,
    cells_per_wlen: float = 20.0,
    **auto_mesh_kwargs,
) -> Simulation

Return a COPY whose grid is auto-meshed with one or more geometry-based photonhub.MeshOverride regions applied, the mesh is forced fine inside each override's geometry regardless of the local material, on top of the ordinary per-medium refinement.

A thin wrapper over with_auto_mesh that forwards the overrides as mesh_overrides=; all other auto-mesh knobs (max_grading, axes, dl_min_um, refine_pad_um, ...) pass straight through, including the periodic-boundary seam handling: axes whose boundary is periodic get seam-symmetric coordinates (equal first/last primary spacings, the closure requirement). Opt-in only, like the other with_* mesh helpers, no existing scene's wire output moves unless you call it::

from photonhub import MeshOverride, Box
sim = sim.with_mesh_overrides(
    MeshOverride(geometry=Box(center_um=(2, 1, 0.5),
                              size_um=(0.5, 0.5, 1.0)),
                 dl_um=(0.02, 0.02, None)),
    cells_per_wlen=20)

with_stabilized_pml

with_stabilized_pml(
    *,
    num_layers: int = _STABLE_PML_LAYERS,
    kappa_max: float = _STABLE_PML_KAPPA_MAX,
    alpha_scale: float = _STABLE_PML_ALPHA_SCALE,
) -> Simulation

Return a copy with the stabilized CPML profile.

Increase the layer count, real-stretch peak kappa_max, and complex frequency shift alpha_max. alpha_scale uses dimensionless 2*eps0/dt units, the same convention as pml_sigma_max. Convert it to pml_alpha_max in S/m at the returned simulation's timestep. A fitted domain may change the timestep, so the conversion is recalculated after fitting.

This helper keeps the timestep-based convention of the stabilized profile (40 layers, kappa_max 5, alpha_max 0.9). pml_sigma_max stays at its default (1.5 in 2*eps0/dt units). The automatic dispersive profile instead anchors alpha to the band; timestep-scaled alpha reflects more on finer grids.

Dispersive scenes receive alpha and kappa stabilization at construction. Use this helper to also increase their layer count, or to opt in for a non-dispersive scene::

sim = sim.with_stabilized_pml()
sim = sim.with_stabilized_pml(num_layers=60, alpha_scale=1.2)

with_oblique_plane_wave

with_oblique_plane_wave(
    *,
    axis: str,
    direction: str,
    position_um: float,
    polarization: str,
    source_time,
    angle_theta_rad: float,
    angle_phi_rad: float = 0.0,
    n: Optional[float] = None,
    amplitude: float = 1.0,
) -> Simulation

A copy with an OBLIQUE plane wave as the (only) source and the transverse axes configured as matching Bloch boundaries (schema 1.18, constant-k method; CPU solver only in this release).

The in-plane Bloch wavevector is derived at the PULSE CENTRE: k_t = 2 pi n f0 / c * sin(theta), split onto the two cyclic transverse axes by angle_phi_rad (measured from the first cyclic transverse axis (axis+1) % 3). n defaults to sqrt(background.permittivity), the index of the medium the wave is launched in. NOTE (constant-k): across a broadband pulse the physical angle varies with frequency; keep the band narrow when the angle matters (sin theta(f) = f0 sin(theta) / f). position_um is in your coordinates, as the rest of a domain= fit is written.

amplitude is the full field E0 of the wave, so a p-polarized wave's tangential E is E0 cos(theta). At the pulse centre f0 the wave carries E0^2 A n cos(theta) / (2 eta0) through a plane normal to axis, the same scale as a normal plane wave of that amplitude (PowerMonitor states the convention).

That closed form covers f0 only. Off it, the constant wavevector leaves a forward wave of field E0 (1 + r) / 2, with r = cos(theta) / cos(theta(f)), and a backward leak of E0 (1 - r) / 2, so an absolute reading off f0 does not follow it: at 30 degrees, 160 and 240 THz on a 200 THz pulse read 1.112 and 0.953 of it at theta(f), and at 45 degrees 1.577 (s-polarized) and 0.879. Keep the band clear of the cutoff f0 sin(theta): near it the reading at f0 itself swings with the run length, by about 1 % at 60 degrees with a 20 THz pulse on 200 THz, with a size and sign that depend on where the source and monitor sit.

with_absorber

with_absorber(
    *, num_layers: Optional[int] = None
) -> Simulation

Return a COPY with every face set to the adiabatic absorber instead of a PML. Use this when a structure crosses the domain boundary or a dispersive/gain medium touches the edge, the cases where a stretched-coordinate PML can diverge. The absorber trades reflection for robustness: its reflection depends on its thickness in wavelengths, not on the mesh. By default the slab is two wavelengths thick in the background at the lowest source or monitor frequency (freq0_hz - fwidth_hz of a pulse), and never thinner than 40 layers (at 1550 nm: 3.9 um per face in vacuum, 2.7 um in SiO2); at normal incidence, for a wave in a vacuum or SiO2 background, that reflects about -52 to -60 dB at the band's low edge and less above it (a PML: -68 dB and below). The count is recomputed on every call, so call it again after changing the mesh; num_layers sets it instead (one wavelength of the carrier reflects about -16 dB)::

sim = sim.with_absorber()                 # two wavelengths, 6 faces
sim = sim.with_absorber(num_layers=60)

A domain too small for the slabs fails check_runnable (and so run_local) with the layer count; enlarge it, or pass a smaller num_layers and accept the higher reflection. The count is one for all axes, set by the axis that needs the most cells (on a graded mesh, the one with the finest face cells). At normal incidence a plane wave in a medium of index n inside the slab is reflected at least about -139/n dB, however thick the slab: -40 dB in bulk silicon; a guided mode's bound depends on its group index and field and is usually lower.

with_auto_boundaries

with_auto_boundaries(
    *, absorber_num_layers: Optional[int] = None
) -> Simulation

Return a COPY whose OPEN (radiating) boundaries are chosen PER AXIS from the materials that reach the domain edge, following standard material-aware guidance that a stretched-coordinate PML wants a non-dispersive medium in its absorbing region:

  • an axis where a dispersive (Lorentz) medium crosses the boundary gets the adiabatic absorber (graded electric conductivity), the robust fallback for the regime where a PML can diverge (Oskooi & Johnson 2011);
  • every other open axis keeps the (thinner, lower-floor) PML.

periodic, pec, pmc and bloch axes are left untouched: those encode explicit physics, not an open boundary to auto-select. Opt-in, the default boundaries are unchanged, so no existing scene's wire output moves unless you call this::

sim = sim.with_auto_boundaries()   # PML, but absorber where dispersive

This is the programmatic form of the construction-time warning the same crossing raises; call it to act on that advice in one line.

The open-boundary decision has three cases: a well-behaved scene keeps the default PML; a scene that diverges without a dispersive edge (grazing / long run) wants with_stabilized_pml(); a dispersive medium at the wall wants the absorber this method selects.

The absorber is sized as in with_absorber: two wavelengths in the background at the lowest source or monitor frequency, at least 40 layers; absorber_num_layers gives the count instead (unused when no axis gets the absorber). Unlike with_absorber, a count the scene already sets is kept (a builder may have sized the domain around it), and it is a count of cells: after changing the mesh, pass absorber_num_layers or call with_absorber. A domain too small for the slabs fails check_runnable before a run.

point_sources_in_boundary_layers

point_sources_in_boundary_layers() -> list

Point dipoles whose centre lies inside the PML/absorber band.

The engine accepts such a source and runs it, but the boundary layers absorb it in place, so every recorded spectrum comes out physically meaningless and near zero with no diagnostic. Construction does NOT raise (geometry-only simulations legitimately carry a placeholder dipole that is never run, and the Workbench edits a scene through such states); check_runnable refuses the run, one error per logical source. The band is counted in realized cells, graded axes included. On a symmetry axis the band is one-sided: the min face is the mirror, not an absorber, so only the far face is tested there. Returns [(source_index, axis_name, band_um), ...] (empty when every point source is interior), band_um being the thickness of the band on the face the source is in.

sources_in_boundary_layers

sources_in_boundary_layers() -> list

One sentence per logical source that has point dipoles inside the PML/absorber band, naming the source, each face it reaches, how deep, and the smallest move that clears it. The samples of one mode launch, port or beam are one entry, not one per dipole (see point_sources_in_boundary_layers for the per-dipole list). Empty when every source is interior. check_runnable refuses a run with any; run_local(..., allow_sources_in_boundary=True) warns with them instead.

check_runnable

check_runnable(
    *, allow_sources_in_boundary: bool = False
) -> None

Raise ValueError naming every rule this simulation breaks that stops the solver from running it, each with its field and the fix. A scene may break these and still serve a plot or a mode solve, so construction allows them; photonhub.run_local, the cloud entry points and the Workbench preflight call this before the solver starts. The rules mirror the solver's own validation:

  • at least one source;
  • on a PML or absorber axis the slabs (two, one on a symmetry axis) leave cells between them: slabs * layers < cells;
  • a flux plane clears the boundary layers of its axis, L+1 <= kp <= n-L-1, as the flux reads E at plane kp and H at kp-1 and kp (uniform axes; the solver judges graded ones);
  • the run outlasts every cw source's ramp;
  • no point dipole lies inside the PML or absorber layers (the solver would run it, but the boundary absorbs the source in place and the recorded spectra are physically meaningless), reported once per logical source (sources_in_boundary_layers): the thousands of dipoles of one mode launch, port or beam are one error. A symmetry plane is a mirror, not a layer, and periodic or Bloch axes have none. allow_sources_in_boundary=True lifts this one rule, for boundary diagnostics only; it is not on the wire, so only run_local can pass it and the cloud refuses such a run.

Mode sources, plane waves and TF/SF boxes are placed on planes the solver's own validation keeps out of the layers.

from_wire_json classmethod

from_wire_json(text: Union[str, bytes]) -> Simulation

Strictly-typed ingestion of wire JSON, matching the engine's nlohmann typing exactly: JSON int -> float fields is accepted, string -> number and float -> int are rejected. Use this (not lax model_validate_json) when consuming sim.json files.

from_file classmethod

from_file(path: Union[str, Path]) -> Simulation

Load a canonical *.json simulation document from disk.

File ingestion keeps from_wire_json's strict JSON typing; it does not execute Python or apply the more permissive construction-time coercions. Missing files and filesystem read errors are surfaced unchanged so callers can distinguish them from schema validation.

to_wire_dict

to_wire_dict() -> dict

Canonical JSON-level dict: defaults materialized, unset optionals (the unused run_time_s/n_steps key, an unset pml_num_layers) omitted.

to_wire_json

to_wire_json(indent: int = 2) -> str

Serialize the resolved solver document as JSON with indent spacing.

Use the same aliases and exclusions as to_wire_dict. Client authoring state, including declared ports and wavelengths, is omitted. So is an explicit dft_shutoff=0, which a text reloaded with from_wire_json then reads as the default guard on a local CPU run; to_file keeps it. This is the solver contract, not a complete authoring-state round trip.

to_file

to_file(path: Union[str, Path]) -> Path

Atomically save this immutable model as canonical *.json.

The parent directory must already exist: a typo in a beta user's path must not silently create a new directory tree. The canonical wire document is written to a sibling temporary file, flushed to disk, and published with os.replace, so readers observe either the old complete document or the new complete document. The model itself is unchanged and the saved path is returned for convenient logging.

The document is to_wire_json with one addition: an explicit dft_shutoff=0 is written, so from_file gives the same stop rule back on a local CPU run. Runs, digests and the document sent to a GPU or the cloud leave it out, as to_wire_json does. The Workbench loses it even from a file that holds it: its Run dialog uses the default stop rule and its Save removes the value. A solver older than schema 1.21 refuses the key if handed the file directly.

plot

plot(
    x=None,
    y=None,
    z=None,
    *,
    ax=None,
    legend=True,
    grid=False,
    unfold=True,
    **kw,
)

2D analytic cross-section of the scene on a cut plane (exactly one of x/y/z, in microns). grid=True overlays the Yee mesh cell edges (the resolution sanity-check). unfold (the default) mirrors a symmetry-reduced half domain back into the whole device; pass unfold=False for the reduced domain the solver steps. Returns a matplotlib Axes. See photonhub.viz.plot.

plot_eps

plot_eps(*args, **kwargs)

Deprecated alias for plot_index (renamed 2026-09).

plot_index

plot_index(
    x=None,
    y=None,
    z=None,
    *,
    ax=None,
    cmap=None,
    grid=False,
    unfold=True,
    **kw,
)

Draw a heatmap of permittivity sampled on the mesh on a cut plane.

By default, follow Simulation.subpixel: use the volume-fraction average when smoothing is enabled and the hard point sample otherwise. grid=True overlays the cell edges. unfold (the default) mirrors a symmetry-reduced half domain back into the whole device; pass unfold=False for the reduced domain the solver steps. Returns a matplotlib Axes. See photonhub.viz.plot_index.

plot_3d

plot_3d(**kw)

Interactive 3D geometry as a plotly Figure (requires the photonhub[viz] extra). See photonhub.viz.plot_3d.

preview

preview(**kw)

Interactive Jupyter scrubber over the cut plane (slider + axis/grid/ε toggles). Requires the photonhub[viz] extra and a notebook. See photonhub.viz.interactive_preview.

cost_estimate

cost_estimate(
    *,
    rate_usd_per_tcell_step: float = ...,
    throughput_gcells_per_s: float = ...,
) -> CostEstimate

Pure-Python dollar / memory / output / wall-time estimate (the plan's "estimate in dollars before you press run"). See photonhub.cost.quote. Cell count, dt and step count match the engine's own, so the dollar figure tracks what phsolver will run; it is exact for a full-duration run (auto-shutoff can only make it cheaper).

Ports and declared sources

Port dataclass

Port(
    name: str,
    center_um: Tuple[float, float, float],
    axis: str,
    width_um: float,
    out_direction: Optional[str] = None,
    thickness_um: Optional[float] = None,
    thickness_axis: str = "z",
    mode: str = "TE0",
    medium: Optional[Medium] = None,
    window_um: Optional[Tuple[float, float]] = None,
    source_offset_um: Optional[float] = None,
    dl_um: Optional[float] = None,
    supersample: int = 8,
    num_modes: Optional[int] = None,
)

A waveguide port: the plane where a mode is launched or read out.

center_um is the port's centre in the user's frame; its component along axis is the readout plane and the other two are the waveguide's transverse centre. width_um and thickness_um size the default mode window, width/2 + pad by thickness/2 + pad: the pad reaches 20 % beyond the distance at which the mode's intensity is 30 dB below its peak, at the longest wavelength the port reads, from the mode's effective index and the highest cladding index at the core's faces (the rule of photonhub.mode_window_um, refined by the port's own mode solve). window_um gives the two half-extents explicitly. mode is a family and an index in one string ("TE0", "TM1"). out_direction is the side of the plane that faces the domain wall, "+" or "-"; None lets the simulation infer it from where the port sits relative to the device. source_offset_um is how far behind the plane, toward the wall, a launch sits when this port is driven. It is a LENGTH, defaulting to a third of a wavelength in the background: what the launch injects that is not the guide's discrete mode interferes with it at this port's plane, at a phase set by the distance in microns, so a standoff counted in cells would move that phase with the mesh and this port's reading would drift with it. medium is the port waveguide's material, used to extend the guide through the wall. dl_um, supersample and num_modes are the mode-solve frame controls.

sim.ports reads each port back as a HeldPort, a Port whose center_um is in that simulation's stored frame, from the fitted box's low corner. dataclasses.replace and a copy of the object keep it so. A new Port built from its fields, Port(held.name, held.center_um, ...) or Port(**dataclasses.asdict(held)), is a new port in the user's frame.

name instance-attribute

name: str

center_um instance-attribute

center_um: Tuple[float, float, float]

axis instance-attribute

axis: str

width_um instance-attribute

width_um: float

out_direction class-attribute instance-attribute

out_direction: Optional[str] = None

thickness_um class-attribute instance-attribute

thickness_um: Optional[float] = None

thickness_axis class-attribute instance-attribute

thickness_axis: str = 'z'

mode class-attribute instance-attribute

mode: str = 'TE0'

medium class-attribute instance-attribute

medium: Optional[Medium] = None

window_um class-attribute instance-attribute

window_um: Optional[Tuple[float, float]] = None

source_offset_um class-attribute instance-attribute

source_offset_um: Optional[float] = None

dl_um class-attribute instance-attribute

dl_um: Optional[float] = None

supersample class-attribute instance-attribute

supersample: int = 8

num_modes class-attribute instance-attribute

num_modes: Optional[int] = None

monitor_name property

monitor_name: str

The portable filename token that names this port's readout monitor: the name itself when it already is one, otherwise + spelled _plus and any other character outside letters, digits, _, -, . replaced by _ (a library crossing's "x+" reads out as "x_plus"). Results are looked up by the port name, not this.

plane_um property

plane_um: float

The readout plane: the centre's component along axis.

in_direction property

in_direction: Optional[str]

The launch direction when this port is driven, into the device.

family_index

family_index() -> Tuple[str, int]

("TE", 0) for mode="TE0".

hv_center_um

hv_center_um() -> Tuple[float, float]

The transverse centre in the plane's natural (horizontal, vertical) order, the order the Yee mode solver takes.

with_out_direction

with_out_direction(out_direction: str) -> 'Port'

A copy with out_direction set (the simulation infers it when None).

mode_window_um

mode_window_um(
    width_um: float,
    thickness_um: float,
    n_core,
    n_clad,
    wlen_um: float,
    mode: str = "TE0",
    n_clad_top=None,
    *,
    factor: float = MODE_WINDOW_FACTOR,
    n_eff: float | None = None,
) -> tuple[float, float]

The mode window of a port on a rectangular guide, as the two half-extents (half_width_um, half_thickness_um) that Port's window_um takes.

Each half-extent is the core's half-size plus a cladding pad of factor times the -30 dB bound, the distance at which the mode's intensity has fallen 30 dB below its peak: pad = factor * ln(1000) / (2 k0 sqrt(n_eff^2 - n_clad^2)), rounded up to the next 0.05 um. With a solved n_eff, the default factor=1.2 keeps the truncation error of a port reading under 0.001 dB; factor=1.0 is the bound itself (about 0.001 to 0.003 dB per port).

n_eff is, unless given, the effective-index estimate from two slab solves (the guide's thickness, then its width) in the polarization of mode; the mode's order counts across the width. Pass a solved n_eff when one is at hand: the estimate can overshoot (a silicon strip's TM0 by about 3 %), which shortens the pad by about 7 % and leaves about 0.002 dB per port. The same n_eff sizes both directions. A mode near cut-off has a pad of several wavelengths; that draws a warning.

wlen_um is the longest wavelength the port reads: the mode spreads as the wavelength grows, so the window that holds it there holds it across the band. n_core and n_clad are refractive indices or library materials (ph.materials.Si), read at wlen_um. n_clad_top is the cladding above the core when it differs from the one below (a strip in air on oxide). The window is symmetric about the guide, so each direction takes the larger pad of its sides, the one in the higher cladding index.

For a multimode port, pass the highest-order mode it reads. In a coupler, place the port where the guides are more than two pads apart, or give a window that encloses both guides.

mode_window_um(0.5, 0.22, 3.476, 1.444, 1.55) (0.8, 0.66)

GaussianBeam dataclass

GaussianBeam(
    axis: str,
    position_um: float,
    waist_um: Optional[object] = None,
    mfd_um: Optional[object] = None,
    direction: str = "+",
    power_watts: float = 1.0,
    center_um: Optional[Tuple[float, float, float]] = None,
    n: Optional[float] = None,
    polarization: Optional[str] = None,
    pol_angle_rad: Optional[float] = None,
    waist_distance_um: float = 0.0,
    angle_theta_rad: float = 0.0,
    angle_phi_rad: float = 0.0,
    half_w_um: Optional[float] = None,
    half_v_um: Optional[float] = None,
    window_sigmas: float = 3.0,
    amplitude_threshold: float = 1e-06,
    paraxial: bool = False,
)

A declared Gaussian-beam excitation on the plane axis = position_um.

Mirrors photonhub.analysis.gaussian_beam_source, which builds the beam's exact fields cell by cell on the plane when the beam joins a Simulation as source=: accurate for a tightly focused or tilted beam, and a pair of dipoles per cell of the window. The wavelength and the pulse come from the simulation's wlens_um. paraxial=True launches a beam at normal incidence, with its waist on the plane, as its Gaussian profile sampled over the plane: one source with the medium's index as its n_eff, right when the waist is many wavelengths wide (a fibre beam on a lens tens of microns across, where the cell-by-cell launch would be millions of dipoles). center_um is the beam axis in the user's frame (its component along axis is ignored); None is the domain centre, and on an axis with a symmetry plane the symmetry plane. power_watts is the beam power through its plane in watts, as a PowerMonitor reads it; with symmetry planes it is the whole device's power, like a port launch. Exactly one of waist_um (1/e field radius) or mfd_um (1/e² intensity diameter) sizes the spot, each a number or an (h, v) pair. Angles are in radians. amplitude_threshold, a finite fraction in [0, 1) (default 1e-6), compares each field with its own peak. At each sample, electric and magnetic currents are kept or dropped together, and power is normalized on the kept samples. It is ignored when paraxial=True.

axis instance-attribute

axis: str

position_um instance-attribute

position_um: float

waist_um class-attribute instance-attribute

waist_um: Optional[object] = None

mfd_um class-attribute instance-attribute

mfd_um: Optional[object] = None

direction class-attribute instance-attribute

direction: str = '+'

power_watts class-attribute instance-attribute

power_watts: float = 1.0

center_um class-attribute instance-attribute

center_um: Optional[Tuple[float, float, float]] = None

n class-attribute instance-attribute

n: Optional[float] = None

polarization class-attribute instance-attribute

polarization: Optional[str] = None

pol_angle_rad class-attribute instance-attribute

pol_angle_rad: Optional[float] = None

waist_distance_um class-attribute instance-attribute

waist_distance_um: float = 0.0

angle_theta_rad class-attribute instance-attribute

angle_theta_rad: float = 0.0

angle_phi_rad class-attribute instance-attribute

angle_phi_rad: float = 0.0

half_w_um class-attribute instance-attribute

half_w_um: Optional[float] = None

half_v_um class-attribute instance-attribute

half_v_um: Optional[float] = None

window_sigmas class-attribute instance-attribute

window_sigmas: float = 3.0

amplitude_threshold class-attribute instance-attribute

amplitude_threshold: float = 1e-06

paraxial class-attribute instance-attribute

paraxial: bool = False

hv_center_um

hv_center_um() -> Optional[Tuple[float, float]]

The beam axis in the plane's natural (horizontal, vertical) order, or None for the domain centre.

Domain and mesh by declaration

Domain pydantic-model

Bases: FrozenModel

Margins for a domain fitted around the device.

On an axis no port exits through, the PML inner face sits clearance_um beyond the structures' bounding box (a float, or a per-axis 3-tuple whose None entries take the default): two thirds of a wavelength in the background, wlen0_um / (1.5 * n_background), the same margin the port windows use. On an axis a port exits through, the face sits port_margin_um beyond the outermost port plane on that side (the launch plane on the driven side, the readout plane otherwise); default ten of the port's cells: on a graded mesh the cell of the port's medium (of the scene's highest index when the port names none), on a uniform mesh its one cell, with given spacings the finest of them. The boundary layers begin at that face, so the margin is the whole distance from the plane to the PML. A periodic or Bloch axis takes its extent from period_um (a 3-tuple with None for the other axes) or from the bounding box, with no margin and no PML. Its cell is a whole number of cells of the mesh, centred on the bounding box, so a shape drawn off centre moves the walls. A period that is not a whole number of cells is rounded to one with a warning that gives the size the cell has and a spacing that keeps the period (Mesh.dl_um takes one per axis), and a shape that crosses a wall, which cuts it and does not wrap it, also gets a warning. extent_um (a 3-tuple with None for the other axes) gives an axis its interior extent outright, centred on the structures, in place of the bounding box and the clearance: a membrane that runs past every wall, one period of a cavity array. An entry may also be a (low, high) pair, the two walls at those coordinates of the user's frame: a substrate that fills the domain below a device and air above it, each running into its own boundary layers. An entry on a periodic or Bloch axis is refused: give that axis period_um. The boundary layers are added outside every open face: pml_num_layers (or absorber_num_layers) cells of the mesh there. On a graded mesh those are the cells of the finest medium or refine region that crosses the face (a substrate, a cladding, a port's guide), else the background's, so the layers begin at the face: inside it by at most the rounding below and 3 % of their depth, which the fit checks on the built mesh, fitting a face again where it falls short (a warning names one it cannot fix). A medium or refine region that ends near the outer face is left outside the box or taken in by enough of its cells to keep the mesh's spacing-ratio check in nearly every case, at most 64 and never past its far end (a warning names one whose cells are within about 2 % of that check's limit, where 64 may not keep it and creating the simulation can raise a ValueError; coarser cells, or an end further from the face, avoid it), which can put more cells than the layers between the face and the outer face. The extent rounds to the nearest whole background cell (the one cell of a uniform mesh), which moves each outer face by up to a quarter of that cell (the far face of a symmetry fold by up to half), and the simulation records where the wire's corner landed in origin_um. A port whose guide stops short of its wall has the guide continued through the boundary layers in the port's width, thickness and medium.

Fields:

  • clearance_um (Optional[Union[float, Tuple[Optional[float], Optional[float], Optional[float]]]])
  • port_margin_um (Optional[float])
  • period_um (Optional[Tuple[Optional[float], Optional[float], Optional[float]]])
  • extent_um (Optional[Tuple[Optional[Union[float, Tuple[float, float]]], Optional[Union[float, Tuple[float, float]]], Optional[Union[float, Tuple[float, float]]]]])

Validators:

  • _positive

clearance_um pydantic-field

clearance_um: Optional[
    Union[
        float,
        Tuple[
            Optional[float],
            Optional[float],
            Optional[float],
        ],
    ]
] = None

port_margin_um pydantic-field

port_margin_um: Optional[float] = None

period_um pydantic-field

period_um: Optional[
    Tuple[Optional[float], Optional[float], Optional[float]]
] = None

extent_um pydantic-field

extent_um: Optional[
    Tuple[
        Optional[Union[float, Tuple[float, float]]],
        Optional[Union[float, Tuple[float, float]]],
        Optional[Union[float, Tuple[float, float]]],
    ]
] = None

extent

extent(axis_index: int) -> Optional[float]

The explicit interior extent for one axis (the length between two given walls too), or None.

walls

walls(axis_index: int) -> Optional[Tuple[float, float]]

The two walls given outright for one axis, in the user's frame, or None when the axis has none (a plain extent is centred on the structures instead).

clearance

clearance(axis_index: int) -> Optional[float]

The explicit clearance for one axis, or None for the default.

Mesh pydantic-model

Bases: FrozenModel

A mesh declared by resolution, resolved when it joins a Simulation.

Exactly one of cells_per_wlen (cells per wavelength in each medium, counted at the band centre wlen0_um) and dl_um (an explicit cell size, uniform), or both when dl_um is a per-axis 3-tuple: an axis with a number is uniform at that spacing (a lattice-commensurate mesh), an axis with None is graded by cells_per_wlen. The mesh has one base cell, the first spacing, and each other spacing is written as coordinates. An axis the solver cannot take that way needs the base cell, and two of them need the same spacing: a Bloch axis, an axis under four cells, or a periodic axis whose coordinates miss the seam, which rounding alone can do from about 2,500 cells, more often the longer the axis. uniform=True with cells_per_wlen gives a UniformMesh at wlen0_um / (n_max * cells_per_wlen), n_max the highest structure index; otherwise auto_mesh grades the mesh with refine as its overrides, dl_min_um as its floor and max_grading as its cell-to-cell ratio. A mesh by cells_per_wlen needs the simulation's wlens_um (or wlen0_um).

Fields:

Validators:

  • _one_resolution

cells_per_wlen pydantic-field

cells_per_wlen: Optional[float] = None

dl_um pydantic-field

dl_um: Optional[
    Union[
        float,
        Tuple[
            Optional[float],
            Optional[float],
            Optional[float],
        ],
    ]
] = None

refine pydantic-field

refine: Tuple[MeshOverride, ...] = ()

dl_min_um pydantic-field

dl_min_um: Optional[float] = None

max_grading pydantic-field

max_grading: float = 1.4

uniform pydantic-field

uniform: bool = False

spacing

spacing(axis_index: int) -> Optional[float]

The explicit spacing for one axis (None for a graded axis, or when the mesh is by cells_per_wlen alone).

Run controls

RunSpec pydantic-model

Bases: FrozenModel

Choose at most one duration: run_time_s, n_steps, or transits.

With none supplied, Simulation uses a default cap of 40 transits. One transit is max(size_um) * 1e-6 * n_max / c0 seconds: travel across the longest resolved domain extent at speed c0 / n_max. n_max is the maximum of the background index and the resolver's structure-index estimates at the reference wavelength (1.55 um when none is supplied). Simulation resolves transits to run_time_s before serialization. The resolved solver document requires exactly one of run_time_s and n_steps and never includes transits. Auto-shutoff may end a run before this duration cap.

On a uniform 3D grid, dt = courant * dl / (c0 * sqrt(3)). courant = 1.0 is on the stability limit and is rejected.

Config:

  • json_schema_extra: _RUN_ONE_OF

Fields:

Validators:

  • _exactly_one_duration

model_config class-attribute instance-attribute

model_config = ConfigDict(json_schema_extra=_RUN_ONE_OF)

run_time_s pydantic-field

run_time_s: Optional[float] = None

transits pydantic-field

transits: SkipJsonSchema[Optional[float]] = None

n_steps pydantic-field

n_steps: Optional[int] = None

courant pydantic-field

courant: float = 0.99

shutoff pydantic-field

shutoff: float = 1e-05

dft_shutoff pydantic-field

dft_shutoff: Optional[float] = None

Optional monitor-band decay estimate threshold for capable CPU runs.

Boundaries pydantic-model

Bases: FrozenModel

Per axis, both faces share one condition.

Default is pml on all three axes: an open (radiating) domain is the common case, so a Simulation that does not set boundaries is absorbing on every face. Set an axis to periodic for a transversely-infinite / Bloch problem (and for the transverse axes of a plane-wave source), pec for a hard mirror, or absorber for the adiabatic-absorber fallback when a structure crosses the boundary and the PML would diverge. bloch (schema 1.18) is the phase-shifted periodic wrap for oblique plane waves / band structure, pair it with Simulation.bloch_k_per_um (CPU solver only in this release).

Fields:

  • x (BoundaryKind)
  • y (BoundaryKind)
  • z (BoundaryKind)

x pydantic-field

x: BoundaryKind = 'pml'

y pydantic-field

y: BoundaryKind = 'pml'

z pydantic-field

z: BoundaryKind = 'pml'

Background pydantic-model

Bases: FrozenModel

Homogeneous, nonmagnetic background; scalar relative permittivity.

Fields:

permittivity pydantic-field

permittivity: float = 1.0

Grid specifications

UniformMesh pydantic-model

Bases: FrozenModel

Uniform Cartesian grid, single spacing for all axes. n_axis = max(n_min, round(L_axis / dl)), round half away from zero, with n_min = 1 on a plain periodic axis (quasi-2D reduction) and 4 otherwise.

Fields:

type pydantic-field

type: Literal['uniform'] = 'uniform'

dl_um pydantic-field

dl_um: float

GradedMesh pydantic-model

Bases: FrozenModel

Per-axis nonuniform (graded) grid. Each listed axis carries absolute primary-node coordinates in microns (strictly increasing, coords[0] == 0, >= 4 entries); the realized domain length and last cell width follow the replicate-last rule (the last cell repeats the final spacing). Any axis NOT listed in coords is uniform at dl_um (identical to UniformMesh). Users supply coordinates manually, or generate them from a physical target with the client-side auto_mesh resolver (which returns exactly this model, no new wire member).

Fields:

Validators:

  • _check_coords

type pydantic-field

type: Literal['graded'] = 'graded'

dl_um pydantic-field

dl_um: float

coords pydantic-field

GradedMeshAxis pydantic-model

Bases: FrozenModel

Per-axis primary-node coordinate arrays (microns) for the graded axes. An omitted axis is uniform at the parent dl_um. At least one must be present (otherwise use UniformMesh).

Fields:

  • x (Optional[Tuple[float, ...]])
  • y (Optional[Tuple[float, ...]])
  • z (Optional[Tuple[float, ...]])

x pydantic-field

x: Optional[Tuple[float, ...]] = None

y pydantic-field

y: Optional[Tuple[float, ...]] = None

z pydantic-field

z: Optional[Tuple[float, ...]] = None

MeshOverride pydantic-model

Bases: FrozenModel

A geometry-based mesh-refinement override. Inside geometry the auto-mesh is forced to the target spacing dl_um REGARDLESS of the local material: a coarse background region can be meshed fine, or a structure meshed finer than its refractive index alone would call for (a feature edge, a resonant gap, a field hotspot an adjoint run wants resolved). It acts through the geometry's per-axis bounding span, so a Box gives an exact axis-aligned region and a Sphere / Cylinder / Polygon act through their enclosing box (a safe OVER-refinement, never under).

Consumed by auto_mesh (mesh_overrides=) and photonhub.Simulation.with_mesh_overrides. Like every auto_mesh input it is a pure value, so identical overrides produce byte-identical coordinates, the mesh stays frozen across adjoint iterations (the mesh-freeze contract).

dl_um is either a single spacing applied on every axis, or a per-axis (dx, dy, dz) tuple in which a None entry leaves that axis ungoverned by this override (a per-component dl). For example dl_um=(0.02, 0.02, None) refines x and y tightly around a waveguide while leaving the propagation axis at the background spacing.

Fields:

  • geometry (GeometryType)
  • dl_um (Union[PositiveUm, Tuple[Optional[float], Optional[float], Optional[float]]])

Validators:

  • _check_dl

geometry pydantic-field

geometry: GeometryType

dl_um pydantic-field

dl_um: Union[
    PositiveUm,
    Tuple[
        Optional[float], Optional[float], Optional[float]
    ],
]

axis_regions

axis_regions() -> list[Tuple[str, float, float, float]]

Project this override onto each axis as an enforced-refinement region (axis_letter, lo_um, hi_um, dl_um), exactly the form auto_mesh consumes via refine_regions. An axis whose target dl is None, or one the geometry does not bound, yields nothing. The spans are unclamped (auto_mesh clamps them to the domain), so an override partly outside the domain still refines the in-domain part.

auto_mesh

auto_mesh(
    *,
    size_um: Tuple[float, float, float],
    wlen_um: Optional[float] = None,
    structures: Iterable[Structure] = (),
    background_index: float = 1.0,
    cells_per_wlen: float = 20.0,
    max_grading: float = _DEFAULT_MAX_GRADING,
    axes: str = "xyz",
    refine_pad_um: Optional[float] = None,
    min_nodes: int = 4,
    source=None,
    dl_min_um: Optional[float] = None,
    refine_regions: Iterable[
        Tuple[str, float, float, float]
    ] = (),
    mesh_overrides: Iterable[MeshOverride] = (),
    snap_interfaces: bool = True,
    periodic_axes: str = "",
    mirror_axes: Optional[str] = None,
    feature_ceil: bool = True,
    continuations: Iterable[Tuple[Structure, str]] = (),
) -> GradedMesh

Auto-mesh resolver (Track E): generate a VALID GradedMesh from a physical target, minimum steps-per-wavelength per medium, a maximum cell-to-cell grading ratio, the wavelength, the domain size, and the scene's structures (to find the high-index regions / material boundaries where the mesh must be fine). Refinement concentrates cells in and around high-index structures (local dl = lambda / (n * cells_per_wlen)) and coarsens smoothly outward within max_grading.

This is a PURE FUNCTION (the mesh-freeze contract): identical inputs produce BYTE-IDENTICAL coordinate arrays, with no dependence on optimizer/iteration state, so an adjoint loop that calls it every iteration sees a continuous, non-jittering mesh. No schema change, the result is the ordinary graded spec the engine already consumes.

Warning

ACCURACY CAVEAT, only structures whose in-band index EXCEEDS background_index drive the mesh. A structure at or BELOW the background index (an air trench or under-etched gap in oxide, a low-index filler) gets NO refinement and NO interface snapping: its material boundary can fall mid-cell at the coarse background spacing. This is by design (refinement follows the shorter in-medium wavelength, and it keeps "no refining structures" meshes exactly uniform), but if a low-index feature's interface is what you are resolving, a narrow trench, a slot waveguide's gap, cover it explicitly with refine_regions= / mesh_overrides=, which snap and refine regardless of index.

Parameters

size_um : domain extents (Lx, Ly, Lz), microns. wlen_um : free-space wavelength of interest (use the SHORTEST in a band so every frequency is resolved). Pass c / freq to drive from a frequency. If omitted, it is INFERRED from source (c / source.source_time.freq0_hz), automatic meshers drive their target frequency from the source the same way. Exactly one of wlen_um / source must be supplied. structures : the simulation's structures. Each is sampled per axis; its IN-BAND index sets the local cell size inside (and just around) it, n = sqrt(permittivity) for a non-dispersive medium, and for a dispersive (Lorentz) one n = sqrt(Re eps(omega)) evaluated at this wavelength via the pole model (meshing at eps_inf alone would under-refine; see _structure_index). Box / Sphere / Cylinder / Polygon are all supported (curved and extruded shapes are bounded by their enclosing box, refined, never under-refined). Geometries may extend beyond the domain, only the in-domain part drives the mesh. background_index : refractive index of the background medium (= sqrt(background.permittivity)); sets the coarse, far-from-structure cell size. cells_per_wlen : target cells per wavelength IN EACH MEDIUM (honoured inside structures; the background spacing can end up ~2 % coarser after interface snapping). A finer target (larger value) yields more cells / a smaller minimum spacing. max_grading : maximum cell-to-cell spacing growth ratio (the roadmap's 1.2-1.4). Must be > 1 and <= GRADED_RATIO_GUARD; the GLOBAL max/min ratio of the final spec is separately bounded by GRADED_RATIO_GUARD, which a small max_grading keeps well clear of. axes : which axes to grade (e.g. "xy" to leave z uniform). Axes omitted here are left out of coords and stay uniform at the returned dl_um (the background spacing). refine_pad_um : how far the fine mesh extends past a structure boundary (microns). Defaults to one background cell, enough to bracket the boundary and the near evanescent field. min_nodes : floor on primary nodes per graded axis (>= 4, the engine's minimum). source : optional source object to infer wlen_um from when it is not given (reads source.source_time.freq0_hz). dl_min_um : absolute LOWER bound (microns) on the minimum cell spacing, an absolute dl_min floor. No medium target or override may push a cell below this, so a very high-index inclusion (or an over-fine override) cannot explode the cell count. Must be > 0 if given. refine_regions : explicit enforced-refinement boxes, each (axis_letter, lo_um, hi_um, dl_um): force the mesh to dl_um over [lo, hi] on that axis regardless of the local material (the axis-interval form of a mesh override). The finest of {material target, override} wins at every point. dl_um is still clamped to dl_min_um. mesh_overrides : geometry-based MeshOverride objects (a per-structure override). Each is projected onto every axis it governs (its per-axis bounding span at the override's dl_um) and merged with refine_regions, the convenient front-end when the refinement region is a real scene geometry rather than hand-written axis intervals. snap_interfaces : when True (default), nudge the generated nodes so a primary grid line lands EXACTLY on each structure interface coordinate (every in-domain box face / curved-shape bbox edge / polyslab boundary) and each refine-region edge, grid-line snapping, so a material boundary never falls mid-cell. Snapping is a monotone piecewise-linear remap that PRESERVES the grading-ratio and dl_min invariants (a target that cannot be reconciled with max_grading / the floor is abandoned rather than emitting an out-of-spec mesh) and stays deterministic (mesh-freeze). Set False to recover the pre-snap node positions. periodic_axes : axes (e.g. "xy") whose simulation boundary is PERIODIC. A graded periodic axis must close with EQUAL first and last primary spacings, the engine's replicate dual-spacing closure is only correct at the seam when they match, and phsolver validate hard-rejects the unequal case, so each listed axis is generated seam-symmetrically: both walls grade down to the finer of the two seam cells (never coarsening a structure, at most a few extra cells) and the seam pair is kept exactly equal through snapping and the coordinate quantization. Axes listed here but not in axes stay uniform (trivially seam-equal). Simulation.with_auto_mesh / with_mesh_overrides fill this in from sim.boundaries automatically; only direct auto_mesh callers pass it by hand. mirror_axes : axes whose ladder is made MIRROR-SYMMETRIC about the domain centre. The marcher integrates from 0, so a perfectly mirror-symmetric target field still produces a ladder that is not its own mirror image; the two halves of a symmetric device then sit at different sub-cell offsets and a quantity symmetry GUARANTEES, the two arms of a splitter, the two ports of a coupler, comes out unequal by a pure grid artifact, typically an order of magnitude above the uniform-mesh noise. None (the default) AUTO-DETECTS: an axis is symmetrized exactly when its target field and its snap targets are already mirror images about the domain centre, which is free (both symmetrizing steps only ever SHRINK cells) and cannot be wrong, a symmetric driver set has no reason to want an asymmetric mesh. Pass "" to switch it off everywhere, or explicit letters ("y") to force those axes regardless of detection. Mirror symmetry implies the seam equality, so a mirrored axis is automatically periodic-legal. The mirror plane is the domain centre; for an off-centre device axis, measure the realized ladder with axis_mirror_mismatch and, if it matters, re-centre the domain on the device. feature_ceil : when True (default), quantize each structure's target cell so an INTEGER number of cells spans the feature at a size <= the requested lambda/(n*cells_per_wlen), eff = width / ceil(width/dl), as standard meshers do. This guarantees the realized in-material resolution is AT LEAST cells_per_wlen (never coarser), matching and removing the run-to-run variance of where the plain marcher lands a partial last cell. A feature that already holds a whole number of target cells (or a full-domain medium) is unchanged. False recovers the exact-target marcher (realized steps ~= requested, occasionally a hair under for a finite feature). Costs a few extra cells per graded axis; the accuracy is never lower. continuations : (structure, axis_letter) pairs, each a structure that CONTINUES another structure of the same medium along axis_letter: the port guide extensions a fitted Simulation draws from one cell inside each guide out through the boundary layers. A continuation refines the mesh like any structure, and its faces on the other two axes (the guide's sidewalls) are snap targets as usual; its two faces on axis_letter are not, because a butt joint between two structures of one medium is no material interface. Snapping the extension's inner face beside the guide's own end face at the port plane pins two nodes one background cell apart, and the irregular pair of cells between them reflects the guided mode (about 4e-4 of the power per port at 8 cells per wavelength).

Returns

GradedMesh whose listed axes carry the generated coordinate arrays and whose dl_um is the background spacing (used for any non-graded axis). The returned spec is run through GradedMesh validation here, so the GRADED_RATIO_GUARD and the graded-axis invariants (coords[0]=0, strictly increasing, >= 4 nodes) are guaranteed before it is handed back.

Raises

ValueError on invalid targets (no/non-positive wavelength, both/neither of wavelength & source, non-positive steps, grading <= 1, bad axis letters, non-positive dl_min_um, malformed refine_regions) or if a generated array somehow fails GradedMesh validation.

Grid diagnostics

graded_primary_spacings

graded_primary_spacings(
    coords_um: Tuple[float, ...],
) -> list[float]

Primary cell spacings of a graded axis: dq[i] = q[i+1]-q[i] for the interior, with the last cell replicating the final spacing (the replicate-last closing node). Length n.

axis_mirror_mismatch

axis_mirror_mismatch(
    coords_um: Sequence[float],
    mirror_um: float,
    *,
    closing_node_um: Optional[float] = None,
) -> float

How far a graded axis is from being MIRROR-SYMMETRIC about mirror_um, in microns: reflect every primary node through the plane and return the largest distance from a reflected node to the nearest real node.

0.0 means the ladder is exactly symmetric; a value comparable to (or above) the local cell size means the two sides of the plane are on genuinely different discretizations. That matters whenever the DEVICE is mirror-symmetric and you are reading a quantity symmetry guarantees, the two arms of a splitter, the two ports of a coupler: an asymmetric ladder puts each side at a different sub-cell offset, so the arms differ by a grid artifact that no amount of averaging removes. Compare the number against min(graded_primary_spacings(coords_um)) to judge it.

coords_um is a graded axis's stored primary-node array (spec.coords.y and friends). The closing node, implicit in the stored array, is included, since it is a real grid line: by default it is derived by the replicate-last rule, or pass closing_node_um (e.g. the domain length) to pin it. A uniform axis is symmetric by construction and needs no check.

Example, the y ladder of a Y-junction, against its finest cell::

q = spec.coords.y
mismatch = ph.axis_mirror_mismatch(q, size_um[1] / 2)
finest = min(ph.graded_primary_spacings(q))
print(f"{mismatch * 1e3:.1f} nm = {mismatch / finest:.2f} cells")

Physical constants

c0 module-attribute

c0: float = 299792458.0

Speed of light in vacuum (m/s), exact SI (the solver's kC0).

eps0 module-attribute

eps0: float = 1.0 / (mu0 * c0 * c0)

Vacuum permittivity (F/m), 1 / (mu0 * c0**2) (the solver's kEps0). Equal to the CODATA 2018 value 8.8541878128e-12 to its eleven stated digits.