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:Trueextra:forbidallow_inf_nan:False
Fields:
-
schema_version(str) -
size_um(Union[Tuple[PositiveUm, PositiveUm, PositiveUm], SkipJsonSchema[Domain]]) -
grid(Union[MeshType, SkipJsonSchema[Mesh]]) -
run(RunSpec) -
origin_um(SkipJsonSchema[Tuple[float, float, float]]) -
background(Union[Background, MaterialEntry]) -
pml_num_layers(int) -
pml_m(float) -
pml_kappa_max(float) -
pml_alpha_max(float) -
pml_sigma_max(float) -
absorber_num_layers(int) -
absorber_m(float) -
subpixel(bool) -
subpixel_method(SubpixelMethodName) -
field_precision(FieldPrecisionName) -
dft_precision(DftPrecisionName) -
structures(Tuple[Structure, ...]) -
boundaries(Boundaries) -
symmetry(Tuple[int, int, int]) -
bloch_k_per_um(Optional[Tuple[float, float, float]]) -
sources(Tuple[SourceType, ...]) -
monitors(Tuple[MonitorType, ...]) -
wlens_um(SkipJsonSchema[Optional[Union[float, Tuple[float, ...]]]]) -
wlen0_um(SkipJsonSchema[Optional[float]]) -
ports(SkipJsonSchema[Tuple[Any, ...]]) -
source(SkipJsonSchema[Optional[Any]])
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
size_um
pydantic-field
¶
size_um: Union[
Tuple[PositiveUm, PositiveUm, PositiveUm],
SkipJsonSchema[Domain],
]
origin_um
pydantic-field
¶
wlens_um
pydantic-field
¶
far_field_monitors
property
¶
{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 name: ModeMonitor} for a declarative simulation, else {}.
port_windows_um
property
¶
{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.
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 raisesValueErrornamingwith_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; aneps_of_mediumanchor 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 awith_*helper replaced stored coordinates of such a fit (saywith_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
¶
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 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
¶
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
¶
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=Truelifts this one rule, for boundary diagnostics only; it is not on the wire, so onlyrun_localcan 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
¶
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
¶
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
¶
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
¶
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_index
¶
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
¶
Interactive 3D geometry as a plotly Figure (requires the
photonhub[viz] extra). See photonhub.viz.plot_3d.
preview
¶
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.
monitor_name
property
¶
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.
in_direction
property
¶
The launch direction when this port is driven, into the device.
hv_center_um
¶
The transverse centre in the plane's natural (horizontal, vertical) order, the order the Yee mode solver takes.
with_out_direction
¶
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.
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
period_um
pydantic-field
¶
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
¶
The explicit interior extent for one axis (the length between two
given walls too), or None.
walls
¶
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
¶
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:
-
cells_per_wlen(Optional[float]) -
dl_um(Optional[Union[float, Tuple[Optional[float], Optional[float], Optional[float]]]]) -
refine(Tuple[MeshOverride, ...]) -
dl_min_um(Optional[float]) -
max_grading(float) -
uniform(bool)
Validators:
-
_one_resolution
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:
-
run_time_s(Optional[float]) -
transits(SkipJsonSchema[Optional[float]]) -
n_steps(Optional[int]) -
courant(float) -
shutoff(float) -
dft_shutoff(Optional[float])
Validators:
-
_exactly_one_duration
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:
Background
pydantic-model
¶
Bases: FrozenModel
Homogeneous, nonmagnetic background; scalar relative permittivity.
Fields:
-
permittivity(float)
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:
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:
-
type(Literal['graded']) -
dl_um(float) -
coords(GradedMeshAxis)
Validators:
-
_check_coords
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:
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
dl_um
pydantic-field
¶
axis_regions
¶
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
¶
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
¶
Speed of light in vacuum (m/s), exact SI (the solver's kC0).