API Reference#

End-to-end differentiable pipeline: signed design field theta -> objective.

Composes density filter -> doping mapping -> ChargeTransport.jl (0V, -5V) -> Soref-Bennett coupling -> gyptis -> delta_n_eff into a single JAX-differentiable function, optionally minus a weighted first-order modal free-carrier loss.

class prismo.pipeline.BiasPoint(bias_v, delta_neff, modal_loss_db_cm, vpi_lpi_v_cm, fom_v_db)[source]#

Bases: NamedTuple

One operating point of the reported figures of merit.

delta_neff is measured against the same 0 V reference as the objective; modal_loss_db_cm is the modal free-carrier loss of the device at this bias (depletion removes carriers, so it falls as the bias deepens, unlike the fixed 0 V loss the objective reports). vpi_lpi_v_cm and fom_v_db are inf where Δneff is zero, which the 0 V point always is.

Parameters:
bias_v: float#

Alias for field number 0

delta_neff: float#

Alias for field number 1

fom_v_db: float#

Alias for field number 4

modal_loss_db_cm: float#

Alias for field number 2

vpi_lpi_v_cm: float#

Alias for field number 3

class prismo.pipeline.DesignNodes(indices: ndarray, n_mesh_nodes: int)[source]#

Bases: object

Which shared-mesh nodes carry a design variable, and how to place them back.

The design field is defined on the silicon nodes (slab + rib_silicon) rather than on every node of the shared mesh. Only silicon nodes have physics attached to them: ChargeTransport gathers doping on the silicon subgrid and scatters carriers back from it, and every gyptis design cell is a rib triangle whose three vertices are silicon nodes. A variable on an oxide, substrate, clad or PML node therefore has an exactly-zero gradient unless the density filter happens to reach a silicon node, in which case it dopes silicon from outside the device – a degree of freedom with no physical referent either way.

Restricting the design set drops those variables from the MMA problem and shrinks the dense filter matrix quadratically (it is (n_design, n_design)). Downstream contracts are unchanged: scatter() places the filtered field back into full gmsh node order before the doping map, so mesh_ref’s node ordering and the (n_design_cells, n_nodes) transfer still see a full-length nodal field. Non-design nodes scatter to theta = 0, i.e. net-intrinsic, which is what the oxide already meant.

Variables:
  • indices – (n_design,) node indices into full gmsh node order.

  • n_mesh_nodes – Number of nodes in the full shared mesh.

Parameters:
  • indices (ndarray)

  • n_mesh_nodes (int)

classmethod all_nodes(n_mesh_nodes: int)[source]#

Every mesh node is a design node (the no-silicon-groups fallback).

Parameters:

n_mesh_nodes (int)

indices: ndarray#
n_mesh_nodes: int#
scatter(design_field: Array)[source]#

Place a design-node field into full node order, zero elsewhere.

Parameters:

design_field (Array)

scatter_numpy(design_field: ndarray)[source]#

scatter() for plotting and other non-traced callers.

Parameters:

design_field (ndarray)

class prismo.pipeline.PipelineComponents(chargetransport: Callable[[...], Any], gyptis: Callable[[...], Any], gyptis_background: Callable[[...], Any], design_cell_centroids: Callable[[], ndarray] | None = None, design_cell_vertices: Callable[[], ndarray] | None = None, write_mesh: Callable[[str | Path], ndarray] | None = None, mode_field: Callable[[ndarray, float], tuple[ndarray, ndarray]] | None = None, reset_chargetransport: Callable[[], None] | None = None, closers: tuple[Callable[[], None], ...] = ())[source]#

Bases: object

The differentiable components one pipeline() call composes.

Carries all backend state – container handles or local api modules and the per-lifecycle background eigenmode cache – so pipeline() reads no module globals. The container lifecycle builds one on startup and releases it via close().

design_cell_centroids is the static geometry query bound to the same gyptis backend as the solve components: a zero-arg reader returning the (n_design, 2) centroids in design_epsilon order, so the pipeline setup can build the mesh-transfer operator through the seam without reaching for a raw container handle. mode_field is the matching read-only query for the tracked mode’s |E| profile, used by the headline mode figure. Both are None when no gyptis backend is bound.

Parameters:
chargetransport: Callable[[...], Any]#
close()[source]#

Release owned resources (containers, worker processes).

closers: tuple[Callable[[], None], ...] = ()#
design_cell_centroids: Callable[[], ndarray] | None = None#
design_cell_vertices: Callable[[], ndarray] | None = None#
gyptis: Callable[[...], Any]#
gyptis_background: Callable[[...], Any]#
mode_field: Callable[[ndarray, float], tuple[ndarray, ndarray]] | None = None#
reset_chargetransport: Callable[[], None] | None = None#
write_mesh: Callable[[str | Path], ndarray] | None = None#
class prismo.pipeline.PipelineTerms(delta_neff, modal_loss_db_cm)[source]#

Bases: NamedTuple

The two physical terms one pipeline evaluation yields.

delta_neff is the signed effective-index shift the optimizer has always maximized; modal_loss_db_cm is the first-order modal free-carrier loss of the unbiased device (nan when no mode-overlap weights were given). Both are JAX scalars and differentiable; pipeline() combines them.

Parameters:
  • delta_neff (Array)

  • modal_loss_db_cm (Array)

delta_neff: Array#

Alias for field number 0

modal_loss_db_cm: Array#

Alias for field number 1

prismo.pipeline.bias_sweep(theta: Array, bias_voltages: Sequence[float], H: Array | None = None, H_sum: Array | None = None, mesh_ref: MeshRef | None = None, background_epsilon: float | None = None, design_transfer: Array | None = None, design_nodes: DesignNodes | None = None, components: PipelineComponents | None = None, mode_overlap: Array | ndarray | None = None)[source]#

The reported figures of merit of one design over a range of biases.

A post-hoc characterization, not part of the objective: the optimizer works at the single operating point REVERSE_BIAS_V, and this reruns the same physics – one ChargeTransport solve and one eigensolve per bias, against the shared 0 V equilibrium – to show how Δneff, the modal loss and VπLπ·alpha develop as the reverse bias deepens. Not differentiated.

The 0 V equilibrium is solved once and reused as both the Soref-Bennett reference and, where 0.0 is in bias_voltages, that point’s own carrier state: at zero bias Δneff is exactly zero and VπLπ is infinite, which is the honest reading of “no bias, no phase shift”.

bias_voltages are the biases to characterize [V], e.g. 0 to -5, and mode_overlap the frozen weights from read_mode_overlap() (without them the loss and the figure of merit read nan). The remaining arguments match pipeline(). Returns one BiasPoint per bias, in the order given.

Parameters:
  • theta (Array)

  • bias_voltages (Sequence[float])

  • H (Array | None, default: None)

  • H_sum (Array | None, default: None)

  • mesh_ref (MeshRef | None, default: None)

  • background_epsilon (float | None, default: None)

  • design_transfer (Array | None, default: None)

  • design_nodes (DesignNodes | None, default: None)

  • components (PipelineComponents | None, default: None)

  • mode_overlap (Array | ndarray | None, default: None)

prismo.pipeline.build_chargetransport_component(tesseract: Tesseract | None = None, *, rewrite_mesh_path: bool = False)[source]#

Build the ChargeTransport component bound to a served Tesseract.

tesseract is a served handle – a container image or an in-process tesseract_api served via Tesseract.from_tesseract_api(). tesseract_jax.apply_tesseract() composes its apply / vector_jacobian_product endpoints into the JAX program, so the returned callable differentiates straight through the drift-diffusion solve. With no handle, calling it raises – there is no physics-free identity fallback.

rewrite_mesh_path rewrites a host mesh_ref path to the container’s read-only mount (see _CT_MESH_MOUNT); the in-process handle reads the host path directly, so it leaves the path alone.

Parameters:
  • tesseract (Tesseract | None, default: None)

  • rewrite_mesh_path (bool, default: False)

prismo.pipeline.build_default_components(mode_index: int = 0)[source]#

Build the default in-process components from the local tesseract apis.

Serves each component’s tesseract_api in-process via Tesseract.from_tesseract_api() when its module imports. There is no physics-free stub: a component whose tesseract_api has no live solver (no Julia, no gyptis/FEniCS) raises when called rather than fabricating carriers or an effective index. Used when pipeline() is called without an explicit bundle (no containers running). mode_index is the guided mode the gyptis solves target (see build_gyptis_components()); the shared module-level bundle is built for the fundamental.

Parameters:

mode_index (int, default: 0)

prismo.pipeline.build_design_transfer(components: PipelineComponents, node_coords: ndarray, design_cell_vertices: ndarray | None = None)[source]#

Assemble the dense mesh-transfer matrix for the container gyptis path.

Both solvers share one gmsh geometry, so the transfer is an exact local restriction: each gyptis design cell is a triangle of the shared mesh whose three vertices are shared-mesh nodes. Reads the design-cell vertices from the gyptis backend (in design_epsilon order) and matches them to node_coords (the same shared-mesh nodes the ChargeTransport solve and the density filter use), returning the (n_design_cells, n_nodes) matrix that feeds pipeline(design_transfer=...) directly.

Parameters:
prismo.pipeline.build_gyptis_components(tesseract: Tesseract | None = None, mode_index: int = 0)[source]#

Build the perturbed and background gyptis components for a served handle.

tesseract is a served handle – a container image or an in-process tesseract_api served via Tesseract.from_tesseract_api(). The perturbed component composes its apply / vector_jacobian_product endpoints through tesseract_jax.apply_tesseract(), so neff_sq differentiates straight through the eigensolve. With no handle, calling either component raises – there is no physics-free fallback.

The background solve is rho-independent (its field is a uniform background of the design length), so it must contribute an exact zero design gradient and run only once per geometry. It therefore runs forward-only behind a pure_callback guarded by stop_gradient and a per-lifecycle cache, rather than through apply_tesseract – no eigen-adjoint is ever assembled for it.

mode_index selects the guided mode every solve of this bundle targets: 0 the fundamental (largest neff in the guided window), k the k-th guided mode in descending neff. The component ranks the modes on the first solve of a geometry and tracks the chosen branch by nearest eigenvalue thereafter, so Δneff is the shift of that mode throughout an optimization. The index is baked into the bundle rather than threaded through pipeline(): one run optimizes one mode.

Parameters:
  • tesseract (Tesseract | None, default: None)

  • mode_index (int, default: 0)

prismo.pipeline.carrier_fields(theta: Array, H: Array | None = None, H_sum: Array | None = None, mesh_ref: MeshRef | None = None, design_nodes: DesignNodes | None = None, components: PipelineComponents | None = None)[source]#

The carrier densities behind one design: (n0, p0, n1, p1) [cm^-3].

Filter -> doping -> ChargeTransport at 0 V and at the reverse bias, in full node order, for the headline depletion figure (swept carriers under the mode). Not differentiated; the same two solves the pipeline runs.

Parameters:
prismo.pipeline.default_components()[source]#

The shared in-process components pipeline() uses without a bundle.

Built on first use, not at import: the gyptis tesseract_api reads its geometry knobs (PRISMO_GYPTIS_*, see gyptis_author_env()) when it loads, so the CLI can set them for an in-process run before the bundle exists.

prismo.pipeline.design_epsilon_from_theta(theta: Array, H: Array | None = None, H_sum: Array | None = None, mesh_ref: MeshRef | None = None, background_epsilon: float | None = None, design_transfer: Array | None = None, design_nodes: DesignNodes | None = None, components: PipelineComponents | None = None)[source]#

Everything pipeline() does up to the eigensolves.

Runs filter -> signed doping -> both ChargeTransport solves -> Soref-Bennett -> mesh transfer, and returns the (epsilon_bg, epsilon_pert) design-cell permittivity fields the two gyptis solves consume. Split out of pipeline() so the same permittivity the objective was evaluated on can be handed to the mode-field query for the headline figure, rather than reconstructing the chain a second way.

Arguments match pipeline().

Parameters:
  • theta (Array)

  • H (Array | None, default: None)

  • H_sum (Array | None, default: None)

  • mesh_ref (MeshRef | None, default: None)

  • background_epsilon (float | None, default: None)

  • design_transfer (Array | None, default: None)

  • design_nodes (DesignNodes | None, default: None)

  • components (PipelineComponents | None, default: None)

prismo.pipeline.dev_mounts_requested()[source]#

PRISMO_DEV_MOUNTS is set to a truthy value (1/true/yes).

prismo.pipeline.free_carrier_absorption_cm(electrons_cm3: Array, holes_cm3: Array, coeffs: SorefBennettCoefficients | None = None)[source]#

Absolute Soref-Bennett free-carrier absorption per node [cm^-1].

alpha = C_e * N_e^D_e + C_h * N_h^D_h from the absolute carrier densities (cm^-3), not the equilibrium-subtracted perturbation the permittivity uses: the insertion loss of a doped waveguide is set by every carrier the mode sees. At 1e19 cm^-3 this is ~85 cm^-1 (electrons) and ~60 cm^-1 (holes), i.e. hundreds of dB/cm.

Parameters:
  • electrons_cm3 (Array)

  • holes_cm3 (Array)

  • coeffs (SorefBennettCoefficients | None, default: None)

prismo.pipeline.gyptis_author_env(mesh_size: float | None = None, contact_offset: float | None = None, domain_width: float | None = None)[source]#

Environment knobs the gyptis mesh author reads, for the values given.

The gyptis tesseract_api sizes the shared mesh from PRISMO_GYPTIS_MESH_SIZE / PRISMO_GYPTIS_CONTACT_OFFSET / PRISMO_GYPTIS_WIDTH when it loads. The container path passes them to the image; the in-process path exports them before the module is first imported. None keeps the author’s default.

Parameters:
prismo.pipeline.init_tesseract_containers(mesh_dir: str | Path | None = None, mesh_size: float | None = None, mode_index: int = 0, contact_offset: float | None = None, domain_width: float | None = None)[source]#

Start tesseract Docker containers and bundle the live components.

Returns a PipelineComponents the caller owns and passes to pipeline(). Containers stay running until the bundle’s close() is called (see teardown_containers). Startup is all-or-nothing so a container run can never silently use local component stubs.

Parameters:
  • mesh_dir (str | Path | None, default: None) – Host directory holding the shared .msh file. It is bind-mounted read-only into the ChargeTransport container at _CT_MESH_MOUNT so its Julia solver can load the real 2D grid instead of the 1D fallback. The bind mount is live, so a mesh written after the container starts is still visible.

  • mesh_size (float | None, default: None) – Characteristic element size of the silicon (rib + slab) in micrometres, passed to the gyptis container as PRISMO_GYPTIS_MESH_SIZE. gyptis authors the shared mesh, so this one knob sets the resolution both solvers see. None keeps the component’s own default (0.04 µm).

  • mode_index (int, default: 0) – Guided mode the gyptis solves target and track – 0 the fundamental, k the k-th guided mode in descending neff (see build_gyptis_components()).

  • contact_offset (float | None, default: None) – Gap from the rib edge to the near contact edge [µm], passed to the gyptis mesh author as PRISMO_GYPTIS_CONTACT_OFFSET. None keeps the component’s default (0.2 µm).

  • domain_width (float | None, default: None) – Physical box width [µm] (the slab spans it; the PML lies outside), passed as PRISMO_GYPTIS_WIDTH. None keeps the default (2.0 µm).

prismo.pipeline.loss_figure_of_merit_v_db(delta_neff: float | Array, modal_loss_db_cm_value: float | Array, bias_v: float = -5.0)[source]#

VπLπ x alpha [V·dB]: the literature’s efficiency-loss figure of merit.

Smaller is better; good depletion modulators reach ~10-30 V·dB. Like VπLπ it is reported, not optimized, and inherits VπLπ’s sign.

Parameters:
  • delta_neff (float | Array)

  • modal_loss_db_cm_value (float | Array)

  • bias_v (float, default: -5.0)

prismo.pipeline.modal_loss_db_cm(alpha_cells_cm: Array, mode_overlap: Array, neff_background: Array | float, background_index: float = 3.4757)[source]#

Modal free-carrier loss [dB/cm] from the per-cell absorption, first order.

With w_cell = d(neff^2)/d(eps_cell) the mode’s sensitivity to the design-cell permittivity (the Hellmann-Feynman adjoint gyptis already computes), an imaginary permittivity Im(eps) = n_si*alpha*lambda/(2*pi) in each cell shifts Im(neff^2) by sum(w*Im(eps)), and the modal power loss 2*k0*Im(neff) is (n_si/neff) * sum(w_cell * alpha_cell); the wavelength cancels. For a uniform core this is the textbook confinement-weighted loss Gamma * alpha * n_si/neff. The weights are those of the unperturbed (background) mode, frozen: the carrier-induced permittivity shift is ~1e-3 and does not reshape the mode.

Parameters:
  • alpha_cells_cm (Array)

  • mode_overlap (Array)

  • neff_background (Array | float)

  • background_index (float, default: 3.4757)

prismo.pipeline.pipeline(theta: Array, H: Array | None = None, H_sum: Array | None = None, mesh_ref: MeshRef | None = None, background_epsilon: float | None = None, design_transfer: Array | None = None, design_nodes: DesignNodes | None = None, components: PipelineComponents | None = None, loss_weight: float = 0.0, mode_overlap: Array | ndarray | None = None)[source]#

Signed design field theta -> objective (Δneff, optionally loss-penalized).

Parameters:
  • theta (Array) – Signed design field per design node in [-1, 1], shape (n_design,) – the silicon nodes when design_nodes is given, otherwise every mesh node. Its sign is the free P/N polarity (junction = zero-crossing).

  • H (Array | None, default: None) – Dense filter matrix, shape (n_design, n_design). Skip filter if None.

  • H_sum (Array | None, default: None) – Pre-computed row sums of H.

  • mesh_ref (MeshRef | None, default: None) – MeshRef forwarded to ChargeTransport calls.

  • background_epsilon (float | None, default: None) – Background Si relative permittivity (default: n_si^2 = 3.4757^2).

  • design_transfer (Array | None, default: None) – Dense (n_design_cells, n_nodes) mesh-transfer matrix carrying the nodal perturbation onto the gyptis design cells. None maps the perturbation node-for-node (identity).

  • design_nodes (DesignNodes | None, default: None) – Which shared-mesh nodes theta addresses. The filtered field is scattered back to full node order before the doping map, so everything downstream still sees (n_nodes,). None means theta already spans every mesh node.

  • components (PipelineComponents | None, default: None) – Live differentiable components to compose. Defaults to the in-process components built from the local tesseract apis.

  • loss_weight (float, default: 0.0) – Weight w of the modal free-carrier loss in the objective Δneff - w * alpha_mode [neff per dB/cm]. 0 (default) optimizes Δneff alone.

  • mode_overlap (Array | ndarray | None, default: None) – (n_design_cells,) mode-overlap weights from read_mode_overlap(). Required when loss_weight > 0; without them the loss is not evaluated.

Returns:

Signed effective-index shift Δneff = Re[neff(-5V)] - Re[neff(0)], minus loss_weight times the modal loss in dB/cm. Smooth and differentiable through zero; depletion (the physical bias response) makes Δneff positive. Report VπLπ from it via vpi_lpi_v_cm() and the two terms via pipeline_with_terms().

Return type:

Array

prismo.pipeline.pipeline_with_terms(theta: Array, H: Array | None = None, H_sum: Array | None = None, mesh_ref: MeshRef | None = None, background_epsilon: float | None = None, design_transfer: Array | None = None, design_nodes: DesignNodes | None = None, components: PipelineComponents | None = None, loss_weight: float = 0.0, mode_overlap: Array | ndarray | None = None)[source]#

pipeline() returning (objective, terms) for has_aux callers.

The objective is delta_neff - loss_weight * modal_loss_db_cm; with loss_weight == 0 it is exactly delta_neff and the loss is only reported (nan when no mode_overlap is bound). See pipeline() for the arguments.

Parameters:
  • theta (Array)

  • H (Array | None, default: None)

  • H_sum (Array | None, default: None)

  • mesh_ref (MeshRef | None, default: None)

  • background_epsilon (float | None, default: None)

  • design_transfer (Array | None, default: None)

  • design_nodes (DesignNodes | None, default: None)

  • components (PipelineComponents | None, default: None)

  • loss_weight (float, default: 0.0)

  • mode_overlap (Array | ndarray | None, default: None)

prismo.pipeline.read_gyptis_design_cell_centroids(*, tesseract: Tesseract | None = None)[source]#

Read gyptis design-cell centroids in design_epsilon field order.

Parameters:

tesseract (Tesseract | None, default: None)

prismo.pipeline.read_gyptis_design_cell_vertices(*, tesseract: Tesseract | None = None)[source]#

Read the (n_design, 3, 2) design-cell vertex coordinates.

Each gyptis design cell is a triangle of the shared unified mesh; its three vertices are shared-mesh nodes. The host matches these coordinates to node indices to assemble the exact node->DG0-cell restriction operator. Exposed through the write_mesh operation.

Parameters:

tesseract (Tesseract | None, default: None)

prismo.pipeline.read_gyptis_mode_field(design_epsilon: ndarray, core_epsilon: float = 12.080490489999999, *, tesseract: Tesseract | None = None, mode_index: int = 0)[source]#

Read the tracked mode’s |E| profile at the gyptis mesh vertices.

Solves once at design_epsilon and returns (abs_e, coords) – the peak-normalized magnitude per vertex and the matching (n_vertices, 2) vertex coordinates in micrometres. This is the headline optical-mode figure’s data source; it is a read-only query, so it does not advance the component’s tracked mode branch.

core_epsilon must be the same background the solve components were given: it is part of the geometry key the component tracks the mode branch by, so a mismatched value would both solve a different device and miss the tracked branch, falling back to the untracked mode selection. Likewise mode_index must match the solve components’ target: it is part of the same branch key, so the figure shows the mode that was optimized.

Parameters:
  • design_epsilon (ndarray)

  • core_epsilon (float, default: 12.080490489999999)

  • tesseract (Tesseract | None, default: None)

  • mode_index (int, default: 0)

prismo.pipeline.read_mode_overlap(components: PipelineComponents, n_design_cells: int, background_epsilon: float = 12.080490489999999)[source]#

The mode-overlap weights d(neff^2)/d(eps_cell) at the uniform background.

One eigensolve plus one eigen-adjoint of the bound gyptis component on the rho-independent background permittivity. The result feeds pipeline(mode_overlap=...) as a constant: the loss term is first order in the carrier perturbation, so the weights are frozen at the unperturbed mode rather than re-derived (which would need second-order eigen- derivatives the adjoint does not provide).

Parameters:
prismo.pipeline.reset_chargetransport_worker(*, tesseract: Tesseract | None = None)[source]#

Drop the ChargeTransport worker’s warm solutions.

The next solve is then a function of the doping alone – the cold continuation from near-intrinsic equilibrium and the bias ramp – rather than of the Newton starting points the previous designs left behind. Carried as the reset operation of the component’s apply endpoint.

Parameters:

tesseract (Tesseract | None, default: None)

prismo.pipeline.seed_design_field(coords: ndarray, kind: str = 'lateral')[source]#

Seed one of SEED_KINDS on the design nodes at |theta| = 0.3.

lateral: seed_signed_junction() – n-type left of the median x, p-type right of it. vertical: in the rib, n-type below the rib’s mid-height and p-type above it; a p column along the rib’s right wall joins the p top to the right slab, and the slab itself is lateral so both regions reach their contact. u: n-type wraps under (lower third of the rib) and beside (left wall) a p core that, with the right wall and the right slab, is p-type. All three are reverse-biased with the cathode on the right.

Parameters:
  • coords (ndarray)

  • kind (str, default: 'lateral')

prismo.pipeline.seed_signed_junction(coords: ndarray)[source]#

Seed a signed lateral P/N junction across the mesh in every run path.

Nodes left of the median x seed n-type (+0.3), nodes to the right seed p-type (-0.3). With the cathode on the right at -5 V, this is the reverse-bias orientation. sign(theta) remains free, so the optimizer can move, dissolve, or reverse this junction.

Parameters:

coords (ndarray)

prismo.pipeline.teardown_containers(components: PipelineComponents)[source]#

Stop and remove the running tesseract containers owned by components.

Parameters:

components (PipelineComponents)

prismo.pipeline.vpi_lpi_v_cm(delta_neff: float | Array, bias_v: float = -5.0)[source]#

Half-wave voltage-length product VπLπ [V·cm] from signed Δneff.

A carrier-depletion phase modulator accrues φ = (2π/λ)·Δneff·L, so the length for a π shift at the fixed reverse bias is Lπ = λ/(2·Δneff) and VπLπ = |V_bias|·λ/(2·Δneff). This is the field-standard modulation- efficiency headline (smaller |VπLπ| is better); it is reported, not optimized – signed Δneff is the optimized proxy. VπLπ carries Δneff’s sign (a wrong-polarity optimum reads negative) and diverges as Δneff → 0.

bias_v is the bias Δneff was measured at; it defaults to the operating point the objective uses, and the bias sweep passes its own.

Parameters:
  • delta_neff (float | Array)

  • bias_v (float, default: -5.0)

prismo.pipeline.write_gyptis_mesh(mesh_path: str | Path, *, tesseract: Tesseract | None = None)[source]#

Persist gyptis’ unified mesh on host and return its design-cell vertices.

Parameters:
  • mesh_path (str | Path)

  • tesseract (Tesseract | None, default: None)

Move-limited MMA optimization loop for the PRISMO pipeline.

Wraps the JAX-differentiable pipeline in NLopt’s MMA, one fresh MMA subproblem per outer step inside a move-limit box. Design variables: the signed design field theta in [-1, 1], one entry per design node (the silicon nodes of the shared mesh; see pipeline.DesignNodes). Objective: maximize signed delta_n_eff, optionally minus a weighted modal free-carrier loss.

exception prismo.optimizer.OptimizationCancelled[source]#

Bases: Exception

Raised when the user interrupts the optimization loop.

prismo.optimizer.optimize_doping(initial_rho: ndarray | None = None, n_nodes: int | None = None, H: Array | None = None, H_sum: Array | None = None, mesh_coords: ndarray | None = None, mesh_path: str | Path | None = None, r_min: float = 0.05, *, max_iter: int = 200, ftol_rel: float = 0.001, move_limit: float = 0.05, max_move_halvings: int = 8, checkpoint_path: str | Path | None = None, use_jit: bool = True, on_iteration: Callable[[int, ndarray], None] | None = None, design_transfer: Array | None = None, design_nodes: DesignNodes | None = None, mesh_ref: MeshRef | None = None, components: PipelineComponents | None = None, loss_weight: float = 0.0, mode_overlap: ndarray | Array | None = None)[source]#

Run the move-limited MMA optimization loop.

Parameters:
  • initial_rho (ndarray | None, default: None) – Starting signed design field (n_design,) in [-1, 1] – one entry per design node, which on a real mesh is the silicon nodes rather than every mesh node. Defaults to a uniform 0.25 fallback; the real run seeds a signed junction (main.py). The name is retained for call-site compatibility.

  • n_nodes (int | None, default: None) – Number of design variables (derived from initial_rho if given, else required when H / mesh_coords / mesh_path is provided).

  • H (Array | None, default: None) – Dense filter matrix (n_design, n_design). Built from mesh_coords or mesh_path if omitted.

  • H_sum (Array | None, default: None) – Pre-computed row sums of H.

  • mesh_coords (ndarray | None, default: None) – (n_design, 2) design-node coordinates for building the filter matrix.

  • mesh_path (str | Path | None, default: None) – Path to a .msh file for building the filter matrix (requires gmsh).

  • r_min (float, default: 0.05) – Filter radius in micrometres – the unit the shared mesh’s coordinates are authored in (default 0.05, i.e. 50 nm).

  • max_iter (int, default: 200) – Maximum number of physics evaluations (optimizer iterations).

  • ftol_rel (float, default: 0.001) – Relative tolerance on the objective: the loop stops once an accepted full-move-limit step improves the objective by less than this fraction.

  • move_limit (float, default: 0.05) – Per-iteration move limit on theta: no design variable moves more than this in one iteration.

  • max_move_halvings (int, default: 8) – Consecutive halvings of the move limit (after failed or non-improving steps) before the iterate is declared stalled.

  • checkpoint_path (str | Path | None, default: None) – Where to write {"rho_opt", "history", ...} after every evaluation, so a killed run still yields a figure. None writes no checkpoint.

  • use_jit (bool, default: True) – JIT-compile combined objective and gradient computation.

  • on_iteration (Callable[[int, ndarray], None] | None, default: None) – Optional callback receiving an iteration number and its candidate design field immediately before its solver evaluation.

  • design_transfer (Array | None, default: None) – Dense (n_design_cells, n_nodes) mesh-transfer matrix carrying the nodal perturbation onto the gyptis design cells. None maps the perturbation node-for-node (identity).

  • design_nodes (DesignNodes | None, default: None) – Which shared-mesh nodes the design vector addresses (the silicon nodes on a real mesh). Forwarded to pipeline, which scatters the filtered field back to full node order. None means one variable per mesh node.

  • mesh_ref (MeshRef | None, default: None) – Shared-mesh reference forwarded to the ChargeTransport solves so they run on the real 2D grid. None leaves CT on its 1D fallback device.

  • components (PipelineComponents | None, default: None) – Live pipeline components to compose. Defaults to the in-process components (see pipeline).

  • loss_weight (float, default: 0.0) – Weight of the modal free-carrier loss in the objective delta_neff - loss_weight * loss_db_cm. 0 keeps the pure Δneff objective; the loss is still recorded when mode_overlap is given.

  • mode_overlap (ndarray | Array | None, default: None) – (n_design_cells,) mode-overlap weights from pipeline.read_mode_overlap; required for a positive loss_weight.

Returns:

(rho_opt, history) where rho_opt is the best design whose physics solved and history is a list of per-evaluation records: objective (what MMA maximized), delta_n_eff, modal_loss_db_cm (None when not evaluated), step and timing fields.

Raises:
Return type:

tuple[ndarray, list[dict[str, Any]]]

prismo.density_filter.apply_filter(rho: ndarray, H: csr_matrix)[source]#

Apply density filter: rho_tilde = H @ rho / H_sum.

Parameters:
  • rho (ndarray)

  • H (csr_matrix)

prismo.density_filter.assemble_filter_matrix(mesh_coords: ndarray, r_min: float = 0.05)[source]#

Build sparse convolution filter matrix from node coordinates.

H_ij = max(0, r_min - dist(i,j)) Returns csr_matrix.

r_min is a distance in whatever length unit mesh_coords is authored in – the two must agree. The PRISMO run paths author both in micrometres (see prismo.waveguide_mesh), which is what the 0.05 default (a 50 nm radius) is expressed in; a radius in the wrong unit silently turns the filter into either an all-pairs average or the identity rather than failing.

Parameters:
  • mesh_coords (ndarray)

  • r_min (float, default: 0.05)

prismo.density_filter.vjp_filter(cotangent: ndarray, H: csr_matrix)[source]#

Adjoint of density filter: dJ/drho = H @ (dJ/drho_tilde / H_sum).

The cotangent is normalized by H_sum before the matmul: the forward pass normalizes each output row by H_sum_i, so the adjoint divides the incoming cotangent by H_sum_i before propagating through H.

Parameters:
  • cotangent (ndarray)

  • H (csr_matrix)

Carrier-field -> gyptis-design-cell mesh-transfer operator.

Both solvers now share one gmsh geometry, so the transfer is an exact local restriction rather than a cross-mesh interpolation: every gyptis design cell is a triangle of the shared mesh whose three vertices are shared-mesh nodes. The design-cell value is the mean of its three vertex nodal values – the value a linear nodal field takes at the cell centroid – so the operator has weight 1/3 on each vertex node and nothing else.

The result is a static, sparse operator T of shape (n_design_cells, n_nodes). Two properties hold by construction:

  • Silicon-only support. A design cell is a silicon rib cell, so only silicon nodes carry weight; oxide-only nodes have zero columns.

  • Partition of unity. Each row sums to one (three weights of 1/3), so a uniform nodal field maps to the same uniform field on the design cells – the invariant the pipeline relies on to keep the background solve rho-independent.

class prismo.mesh_transfer.MeshTransferOperator(matrix: csr_matrix)[source]#

Bases: object

A static nodal-field -> design-cell restriction, as a sparse matrix.

Variables:

matrix – Sparse (n_design_cells, n_nodes) operator. Calling the instance applies it to a nodal field.

Parameters:

matrix (csr_matrix)

dense()[source]#

The operator as a dense (n_design_cells, n_nodes) array.

The pipeline needs a dense, JAX-traceable matrix to apply the transfer inside an autodiff pass; pass operator.dense() as design_transfer.

matrix: csr_matrix#
property shape: tuple[int, int]#

(n_design_cells, n_nodes).

prismo.mesh_transfer.build_mesh_transfer_operator(node_coords: ndarray, design_cell_vertices: ndarray, tol: float = 1e-06)[source]#

Build the exact nodal-field -> design-cell restriction operator.

Parameters:
  • node_coords (ndarray) – (n_nodes, 2) shared-mesh node coordinates, in the same frame and units as design_cell_vertices.

  • design_cell_vertices (ndarray) – (n_design_cells, 3, 2) vertex coordinates of each gyptis design cell (from write_mesh / gyptis.tesseract_api.design_cell_vertices); the operator’s row order follows this array. Each vertex is a shared-mesh node.

  • tol (float, default: 1e-06) – Maximum distance for matching a design-cell vertex to a shared-mesh node. The two are the same mesh, so a match is essentially exact; the tolerance only absorbs gmsh/dolfin float round-trip.

Returns:

A MeshTransferOperator wrapping the sparse (n_design_cells, n_nodes) matrix with weight 1/3 on each of a cell’s three vertices.

Return type:

MeshTransferOperator

Shared waveguide mesh generation for the PRISMO pipeline.

Generates a 2D SOI rib waveguide cross-section mesh as a Gmsh .msh file. Both the ChargeTransport.jl and gyptis Tesseracts consume the same mesh file at runtime via MeshRef.path. Physical-group naming is defined here so both integrators agree on which subdomains are contacts, silicon, and oxide.

Units and vocabulary follow the shared-mesh contract, not SI. A container run authors the shared mesh with gyptis, an in-process run authors it here, and ChargeTransport reads whichever one was written: ct_common.jl scales the grid it loads by MICROMETRES_TO_METRES and collects its silicon cells from the slab and rib_silicon physical groups. Both authors therefore emit micrometre coordinates under gyptis’ group names – and so do the geometry dimensions below, the --r-min filter radius, and the node coordinates the plots consume. Authoring this mesh in metres instead made the µm-scaled default filter radius an all-pairs filter over a 3 µm device (which collapses the design field to its global mean) and left the Julia silicon lookup with no group it recognised.

Geometry (cross-section, not to scale):

┌───────────────────────────────────────┐  ← SiO₂ cladding
│                                       │
│         ┌─────────────┐               │  ← Si rib (220 nm x 500 nm)
│         │             │               │
├─────────┤             ├─────────┬─────┤  ← Si slab (100 nm)
│         │             │         │     │  ← Al contacts on slab shoulders
│         └─────────────┘         │     │
│                                       │
├───────────────────────────────────────┤  ← SiO₂ substrate
│                                       │
└───────────────────────────────────────┘

Physical groups exported to the .msh file: slab, rib_silicon, oxide, contact_anode, contact_cathode.

class prismo.waveguide_mesh.RibWaveguideGeometry(*, rib_thickness: float = 0.22, rib_width: float = 0.5, slab_thickness: float = 0.1, substrate_thickness: float = 0.5, cladding_thickness: float = 0.5, box_width: float = 3.0, contact_width: float = 0.05, contact_offset: float = 0.2, mesh_res_junction: float = 0.01, mesh_res_core: float = 0.02, mesh_res_bulk: float = 0.05, silicon_index: float = 3.4757, oxide_index: float = 1.444, wavelength: float = 1.55)[source]#

Bases: object

SOI rib waveguide cross-section dimensions and material properties.

Every length is in micrometres, matching the shared-mesh contract this module documents; the refractive indices are dimensionless.

Parameters:
  • rib_thickness (float, default: 0.22)

  • rib_width (float, default: 0.5)

  • slab_thickness (float, default: 0.1)

  • substrate_thickness (float, default: 0.5)

  • cladding_thickness (float, default: 0.5)

  • box_width (float, default: 3.0)

  • contact_width (float, default: 0.05)

  • contact_offset (float, default: 0.2)

  • mesh_res_junction (float, default: 0.01)

  • mesh_res_core (float, default: 0.02)

  • mesh_res_bulk (float, default: 0.05)

  • silicon_index (float, default: 3.4757)

  • oxide_index (float, default: 1.444)

  • wavelength (float, default: 1.55)

box_width: float#
cladding_thickness: float#
contact_offset: float#
contact_width: float#
property half_width: float#

Half the total domain width.

mesh_res_bulk: float#
mesh_res_core: float#
mesh_res_junction: float#
oxide_index: float#
property rib_left: float#

Left edge x-coordinate of the rib (centered at x=0).

property rib_right: float#

Right edge x-coordinate of the rib (centered at x=0).

rib_thickness: float#
property rib_top: float#

Top y-coordinate of the rib (slab top + rib thickness).

rib_width: float#
silicon_index: float#
slab_thickness: float#
property slab_top: float#

Top y-coordinate of the slab layer.

substrate_thickness: float#
property total_height: float#

substrate + slab + rib + cladding.

Type:

Total domain height

wavelength: float#
prismo.waveguide_mesh.build_rib_waveguide_mesh(mesh_path: str | Path | None = None, geometry: RibWaveguideGeometry | None = None)[source]#

Build the SOI rib waveguide mesh and write a .msh file.

Parameters:
  • mesh_path (str | Path | None, default: None) – Destination path for the .msh file. Defaults to outputs/waveguide.msh.

  • geometry (RibWaveguideGeometry | None, default: None) – Geometry parameters. Defaults to RibWaveguideGeometry().

Returns:

Absolute path to the generated mesh file.

Return type:

str

prismo.waveguide_mesh.build_rib_waveguide_mesh_via_gmsh(mesh_path: str, geometry: RibWaveguideGeometry, *, view: bool = False)[source]#

Low-level mesh builder using gmsh Python API directly.

Exported so tests can call it after gmsh.initialize().

Parameters:
prismo.waveguide_mesh.read_mesh_node_coordinates(mesh_path: str | Path)[source]#

Extract 2D node coordinates from a Gmsh .msh v4 file.

Returns a (n_nodes, 2) float64 array of (x, y) positions, suitable for feeding into prismo.density_filter.assemble_filter_matrix().

Requires gmsh to be importable.

Parameters:

mesh_path (str | Path)

prismo.waveguide_mesh.read_mesh_silicon_triangulation(mesh_path: str | Path)[source]#

Extract zero-based silicon triangle node indices from a Gmsh mesh.

The returned indices address the coordinate rows from read_mesh_node_coordinates(). Only dim-2, three-node triangle elements in the SILICON_GROUP_NAMES physical groups (slab and rib_silicon, the same pair ct_common.jl collects) are included, so the reader spans the whole silicon domain of either mesh author. As with the node coordinate reader, an environment without Gmsh returns an empty array so no-backend code paths remain importable.

Parameters:

mesh_path (str | Path)

Soref-Bennett free-carrier plasma dispersion coupling layer.

Converts carrier-density perturbations from the charge-transport solver into optical permittivity and absorption perturbations for gyptis via the Soref-Bennett model (Soref & Bennett, IEEE JQE 23(1), 1987).

The charge-transport solver reports carrier densities in m^-3 while the Soref-Bennett coefficients are calibrated for cm^-3, so densities are converted before the model is applied (1 m^-3 = 1e-6 cm^-3).

Delta_n = -(A_e * Delta_N_e^B_e + A_h * Delta_N_h^B_h) Delta_alpha = C_e * Delta_N_e^D_e + C_h * Delta_N_h^D_h Delta_epsilon = 2 * n_bg * Delta_n (first-order expansion)

Pure, element-wise and vectorized — no Python loops. Operates on lists, NumPy arrays, or JAX arrays (converted via np.asarray).

prismo.soref_bennett.soref_bennett(carriers: CarrierDensityField, coeffs: SorefBennettCoefficients | None = None)[source]#

Compute Soref-Bennett permittivity and absorption perturbations.

Parameters:
  • carriers (CarrierDensityField) – CarrierDensityField with electrons, holes and equilibrium densities (all [m^-3]).

  • coeffs (SorefBennettCoefficients | None, default: None) – SorefBennettCoefficients; defaults to the lambda = 1.55 um silicon values if omitted.

Returns:

SorefBennettResult with delta_permittivity and delta_absorption (both element-wise lists).

Raises:

ValueError – if equilibrium carrier densities are not supplied.

Return type:

SorefBennettResult

Paper-ready visualization outputs for the PRISMO pipeline.

The headline figure set. generate_outputs emits four: the MMA convergence curve carrying both the optimized signed Δneff and the reported VπLπ, the adjoint-vs-finite-difference gradient validation, the initial-vs-optimized signed θ field, and the optical mode |E| on the rib cross-section. Panels that serve none of those were dropped. A loss-aware run adds the loss history, the Δneff-vs-loss trade-off plane, and an animation of the doping field over the optimizer’s evaluations. A run that swept the bias adds the initial-vs-optimized figures of merit against reverse bias.

class prismo.outputs.ColdReevaluation(warm_delta_neff: float, cold_delta_neff: float, tolerance: float = 0.0001)[source]#

Bases: object

Warm-vs-cold Δneff of the reported design.

warm_delta_neff is the value the optimizer saw (solved from the neighbouring designs’ Newton starting points); cold_delta_neff is the same design solved after the ChargeTransport worker dropped every warm solution. The headline (and VπLπ) is the cold value.

Parameters:
  • warm_delta_neff (float)

  • cold_delta_neff (float)

  • tolerance (float, default: 0.0001)

cold_delta_neff: float#
property passed: bool#

Whether the discrepancy is within the tolerance.

property rel_discrepancy: float#

|cold - warm| / max(|cold|, |warm|) (0 when both are zero).

tolerance: float = 0.0001#
warm_delta_neff: float#
class prismo.outputs.GradientValidationResult(n_directions: int, best_rel_errors: list[float], worst_rel_error: float, tolerance: float, passed: bool, figure_path: Path)[source]#

Bases: object

Outcome of an adjoint-vs-finite-difference gradient check.

best_rel_errors[i] is the smallest relative error over the feasible finite-difference steps for direction i – the point where central FD is closest to the adjoint’s directional derivative before rounding or the CT readout’s own FD floor takes over. worst_rel_error is the largest of those per-direction minima; the gradient passes when it stays at or below tolerance.

Parameters:
best_rel_errors: list[float]#
figure_path: Path#
n_directions: int#
passed: bool#
tolerance: float#
worst_rel_error: float#
class prismo.outputs.ModeField(abs_e: ndarray, coords_um: ndarray, rib_bounds: tuple[float, float, float, float] | None = None, mode_index: int = 0)[source]#

Bases: object

The tracked optical mode’s |E| profile on the shared mesh.

abs_e is the peak-normalized magnitude at each mesh vertex and coords_um the matching (n_vertices, 2) vertex coordinates. Unlike the doping-field plots – which take SI node coordinates and scale them – these come straight off the gyptis mesh and are already in micrometres, so the plot uses them unscaled. rib_bounds is the optional (x_min, x_max, y_min, y_max) rectangle of the silicon rib interior, in the same micrometre frame, drawn as an outline so the mode can be read against the guiding core. mode_index is the guided-mode order the solve targeted (0 = fundamental), shown in the figure title.

Parameters:
abs_e: ndarray#
coords_um: ndarray#
mode_index: int = 0#
rib_bounds: tuple[float, float, float, float] | None = None#
class prismo.outputs.ObjectiveLineScan(offsets: ndarray, values: ndarray, fit_coefficients: ndarray, residuals: ndarray, rms_rel_residual: float, max_rel_residual: float, noise_estimate: float, adjoint_slope: float, fitted_slope: float, figure_path: Path | None, json_path: Path | None)[source]#

Bases: object

f(rho + t*d) sampled along one direction at uniform spacing.

The smoothness probe: the optimizer’s trials around an earlier optimum sat ~2e-3 relative below the iterate regardless of how small the box became, and this scan separates a genuine kink (the residual of a quadratic fit is structured and shrinks with the spacing) from an evaluation noise floor (the residual is white and does not). All relative quantities are relative to |f(rho)| at the centre sample.

noise_estimate is the white-noise amplitude implied by the second differences of the samples, std(diff(values, 2)) / sqrt(6), relative to |f(rho)|: second differences of a smooth function at fine spacing are ~0, so whatever is left is the per-evaluation scatter.

Parameters:
  • offsets (ndarray)

  • values (ndarray)

  • fit_coefficients (ndarray)

  • residuals (ndarray)

  • rms_rel_residual (float)

  • max_rel_residual (float)

  • noise_estimate (float)

  • adjoint_slope (float)

  • fitted_slope (float)

  • figure_path (Path | None)

  • json_path (Path | None)

adjoint_slope: float#
figure_path: Path | None#
fit_coefficients: ndarray#
fitted_slope: float#
json_path: Path | None#
max_rel_residual: float#
noise_estimate: float#
offsets: ndarray#
residuals: ndarray#
rms_rel_residual: float#
values: ndarray#
class prismo.outputs.OverlayGeometry(rib_left: float, rib_right: float, slab_top: float, rib_top: float, substrate_top: float, half_width: float, contact_offset: float, contact_width: float)[source]#

Bases: object

The overlay frame of _overlay_geometry(), as plain values.

RibWaveguideGeometry satisfies the same duck-typed contract but can only describe the mesh the local author builds (y from 0 at the substrate bottom, 0.5 µm substrate). The gyptis author centres its layer stack on y = 0 with a 0.35 µm substrate, so drawing the local frame on container figures put the rib outline off the device entirely. The container path builds one of these from the gyptis mesh itself instead.

substrate_top is the y of the substrate/slab interface – the value the local geometry exposes as substrate_thickness, which only equals it in a frame whose substrate starts at y = 0.

Parameters:
contact_offset: float#
contact_width: float#
half_width: float#
rib_left: float#
rib_right: float#
rib_top: float#
slab_top: float#
property substrate_thickness: float#

Alias for the duck-typed attribute _overlay_geometry reads.

substrate_top: float#
prismo.outputs.animate_doping_evolution(doping_frames: Sequence[ndarray], history: list[dict], mesh_coords: ndarray, geometry: object | None = None, output_dir: str | Path | None = None, triangles: ndarray | None = None, fps: int = 6, formats: Sequence[str] = ('gif', 'mp4'), name: str = 'doping_evolution')[source]#

Animate the net doping the solvers saw at every optimizer evaluation.

One frame per evaluated design, on a colour scale fixed across the whole run (symlog, like the live frames), titled with the iteration, its Δneff and – when evaluated – its modal loss; a trial the optimizer rejected (objective below the running best) is labelled as such, so the move-limit halvings read as what they are rather than as progress.

Parameters:
  • doping_frames (Sequence[ndarray]) – Full-node net doping (n_nodes,) [cm^-3] per evaluation, in history order – the filtered, scattered, mapped field, i.e. what ChargeTransport solved on.

  • history (list[dict]) – The matching per-evaluation records (same length).

  • mesh_coords (ndarray) – (n_nodes, 2) node coordinates in micrometres.

  • geometry (object | None, default: None) – Overlay frame (see _overlay_geometry()); optional.

  • output_dir (str | Path | None, default: None) – Directory for <name>.gif / <name>.mp4.

  • triangles (ndarray | None, default: None) – Silicon triangulation, as in plot_doping_field().

  • fps (int, default: 6) – Frames per second.

  • formats (Sequence[str], default: ('gif', 'mp4')) – Which of gif (Pillow) and mp4 (ffmpeg) to write; a format whose encoder is unavailable is skipped with a note.

  • name (str, default: 'doping_evolution') – Output file stem.

Returns:

Paths written (empty when there are no frames or no usable writer).

Return type:

list[Path]

prismo.outputs.feasible_offsets(rho: Array, direction: Array, offsets: ndarray)[source]#

The subset of signed offsets t with rho + t*direction in [-1, 1].

Unlike _feasible_steps() (which bounds a symmetric central stencil) each side is bounded on its own, so a design pinned at +1 in the direction’s sense still keeps its whole negative half-line.

Parameters:
  • rho (Array)

  • direction (Array)

  • offsets (ndarray)

prismo.outputs.generate_outputs(rho_initial: ndarray, rho_opt: ndarray, history: list[dict], mesh_coords: ndarray, geometry: object | None = None, pipeline_fn: Callable[[...], Array] | None = None, ftol_rel: float | None = None, gradient_validation_directions: int = 3, gradient_validation_steps: ndarray | None = None, gradient_validation_rho: ndarray | None = None, mode_field: ModeField | None = None, output_dir: str | Path | None = None, mesh_triangles: ndarray | None = None, cold_reevaluation: ColdReevaluation | None = None, design_history: Sequence[ndarray] | None = None, animation_history: list[dict] | None = None, animation_formats: Sequence[str] = ('gif', 'mp4'), swept_carriers: ndarray | None = None, bias_sweep_initial: Sequence[Any] | None = None, bias_sweep_optimized: Sequence[Any] | None = None)[source]#

Generate the headline output figures.

Parameters:
  • rho_initial (ndarray) – Initial design vector.

  • rho_opt (ndarray) – Optimized design vector.

  • history (list[dict]) – Optimization history from optimize_doping.

  • mesh_coords (ndarray) – Node coordinates (n_nodes, 2) in micrometres.

  • mesh_triangles (ndarray | None, default: None) – (n_triangles, 3) silicon triangulation of the shared mesh, so the doping figure draws the device’s own elements rather than a Delaunay fill of the whole domain.

  • geometry (object | None, default: None) – RibWaveguideGeometry instance.

  • pipeline_fn (Callable[..., Array] | None, default: None) – Differentiable pipeline function for gradient validation. Skipped if None.

  • ftol_rel (float | None, default: None) – MMA relative tolerance for the convergence marker.

  • gradient_validation_directions (int, default: 3) – Number of finite-difference directions.

  • gradient_validation_steps (ndarray | None, default: None) – Central-difference steps for gradient validation.

  • gradient_validation_rho (ndarray | None, default: None) – Reference design for gradient validation. Defaults to rho_opt.

  • mode_field (ModeField | None, default: None) – Tracked optical mode |E| from the gyptis backend. Skipped if None (no gyptis backend bound).

  • output_dir (str | Path | None, default: None) – Output directory (default: outputs/).

  • cold_reevaluation (ColdReevaluation | None, default: None) – Warm/cold Δneff of the reported design, annotated on the convergence figure. None draws nothing.

  • design_history (Sequence[ndarray] | None, default: None) – Full-node net doping [cm^-3] per evaluated design, in history order; animated into doping_evolution.{gif,mp4} (see animate_doping_evolution()). None skips the animation.

  • animation_history (list[dict] | None, default: None) – The records matching design_history one-to-one; defaults to history (use it when only some records carry a design).

  • animation_formats (Sequence[str], default: ('gif', 'mp4')) – Encoders to try for the animation.

  • swept_carriers (ndarray | None, default: None) – (n + p)(V_bias) - (n + p)(0 V) per node at the optimized design, for plot_depletion_field(); None skips that figure.

  • bias_sweep_initial (Sequence[Any] | None, default: None) – BiasPoint sequence from bias_sweep() for the seed design, for plot_bias_sweep().

  • bias_sweep_optimized (Sequence[Any] | None, default: None) – The same for the reported design. The figure is skipped when both are empty.

Returns:

List of paths to the generated plot files.

Return type:

list[Path]

prismo.outputs.history_loss_series(history: list[dict])[source]#

(iterations, modal_loss_db_cm) for the entries that evaluated a loss.

The optimizer records modal_loss_db_cm as None when no mode-overlap weights were bound; a history with no finite loss yields two empty lists and the convergence figure draws no loss panel.

Parameters:

history (list[dict])

prismo.outputs.iso_fom_delta_neff(loss_db_cm: ndarray, fom_v_db: float)[source]#

Δneff along the curve VπLπ·alpha = fom: |V|·λ·alpha / (2·fom).

Parameters:
  • loss_db_cm (ndarray)

  • fom_v_db (float)

prismo.outputs.plot_bias_sweep(initial: Sequence[Any] | None, optimized: Sequence[Any] | None, output_dir: str | Path | None = None)[source]#

The three reported figures of merit vs reverse bias, initial vs optimized.

Three panels sharing the bias axis – Δneff, the modal free-carrier loss alpha, and the VπLπ·alpha efficiency-loss figure of merit – each carrying one curve per design, so what the optimizer bought is visible across the whole operating range rather than only at the single bias it optimized. The bias axis runs left to right from 0 V into reverse bias.

Δneff is zero at 0 V by construction (it is measured against that equilibrium), so VπLπ·alpha is infinite there and that point is dropped from the third panel rather than drawn off-scale.

Parameters:
  • initial (Sequence[Any] | None) – BiasPoint sequence for the seed design; None or empty draws no initial curve.

  • optimized (Sequence[Any] | None) – The same for the reported design.

  • output_dir (str | Path | None, default: None) – Directory to write bias_sweep.pdf.

Returns:

Path to the saved figure, or None when neither design swept.

Return type:

Path | None

prismo.outputs.plot_convergence(history: list[dict], output_dir: str | Path | None = None, ftol_rel: float | None = None, cold_reevaluation: ColdReevaluation | None = None)[source]#

Convergence plot: signed delta_n_eff and reported VpiLpi vs iteration.

The loss axis of a loss-aware run is its own figure, plot_loss_convergence().

Parameters:
  • history (list[dict]) – Per-iteration records from optimize_doping.

  • output_dir (str | Path | None, default: None) – Directory to write convergence.pdf.

  • ftol_rel (float | None, default: None) – MMA relative tolerance for the marker annotation.

  • cold_reevaluation (ColdReevaluation | None, default: None) – Warm/cold Δneff of the reported design; drawn as a marker at the last iteration, with a warning annotation when the discrepancy exceeds its tolerance.

Returns:

Path to the saved figure.

Return type:

Path

prismo.outputs.plot_depletion_field(swept_carriers: ndarray, mesh_coords: ndarray, mode_field: ModeField | None = None, geometry: object | None = None, output_dir: str | Path | None = None, triangles: ndarray | None = None)[source]#

Where the modulation happens: swept carriers under the optical mode.

swept_carriers is (n + p)(V_bias) - (n + p)(0 V) per node [cm^-3] – negative where reverse bias depletes the junction, which is the only place the design changes the index. Drawn on the silicon elements with the tracked mode’s |E| contours on top, so the figure shows at a glance how much of the depleted region the mode actually sees (and how much doping sits in the mode for nothing but loss).

Parameters:
  • swept_carriers (ndarray) – (n_nodes,) change in total carrier density.

  • mesh_coords (ndarray) – (n_nodes, 2) node coordinates in micrometres.

  • mode_field (ModeField | None, default: None) – The tracked mode for the contour overlay; None draws the carriers alone.

  • geometry (object | None, default: None) – Overlay frame (see _overlay_geometry()); optional.

  • output_dir (str | Path | None, default: None) – Directory to write depletion_field.pdf.

  • triangles (ndarray | None, default: None) – Silicon triangulation, as in plot_doping_field().

Returns:

Path to the saved figure.

Return type:

Path

prismo.outputs.plot_doping_field(rho_initial: ndarray, rho_opt: ndarray, mesh_coords: ndarray, geometry: object | None = None, output_dir: str | Path | None = None, triangles: ndarray | None = None)[source]#

Side-by-side doping field: initial vs optimized.

Parameters:
  • rho_initial (ndarray) – Initial design vector (n_nodes,).

  • rho_opt (ndarray) – Optimized design vector (n_nodes,).

  • mesh_coords (ndarray) – Node coordinates (n_nodes, 2) in micrometres – the unit both mesh authors emit, and the unit the axes are labelled in, so no conversion happens here.

  • geometry (object | None, default: None) – RibWaveguideGeometry for overlay (optional).

  • output_dir (str | Path | None, default: None) – Directory to write doping_field.pdf.

  • triangles (ndarray | None, default: None) – (n_triangles, 3) silicon triangulation of the shared mesh. With it the field is drawn on the device’s own elements and the oxide stays blank; without it the whole point set is drawn on a Delaunay triangulation.

Returns:

Path to the saved figure.

Return type:

Path

prismo.outputs.plot_gradient_validation(pipeline_fn: Callable[[...], Array], rho: Array, directions: list[Array] | None = None, n_directions: int = 3, step_sizes: ndarray | None = None, output_dir: str | Path | None = None, tolerance: float | None = None)[source]#

Gradient validation: relative error vs FD step size.

Parameters:
  • pipeline_fn (Callable[..., Array]) – Callable (rho) -> scalar differentiable by JAX.

  • rho (Array) – Reference design vector.

  • directions (list[Array] | None, default: None) – Random unit perturbation directions. Generated if None.

  • n_directions (int, default: 3) – Number of random directions (ignored if directions given).

  • step_sizes (ndarray | None, default: None) – Central-difference steps. Defaults to 20 logarithmic steps from 1e-6 to 1e-1.

  • output_dir (str | Path | None, default: None) – Directory to write gradient_validation.pdf.

  • tolerance (float | None, default: None) – If given, draw the acceptance bar as a horizontal line on the figure. Use validate_gradient() for the pass/fail result.

Returns:

Path to the saved figure.

Return type:

Path

prismo.outputs.plot_live_doping_field(doping: ndarray, mesh_coords: ndarray, iteration: int, geometry: object | None = None, output_dir: str | Path | None = None, name: str | None = None, triangles: ndarray | None = None)[source]#

Write the optimizer’s current signed doping-field image.

One image per optimizer callback, named by name; callers pass the iteration number so a long run leaves a snapshot per iteration rather than one file overwritten in place. mesh_coords is in micrometres, as everywhere else in the app. triangles is the silicon triangulation, as in plot_doping_field().

Parameters:
  • doping (ndarray)

  • mesh_coords (ndarray)

  • iteration (int)

  • geometry (object | None, default: None)

  • output_dir (str | Path | None, default: None)

  • name (str | None, default: None)

  • triangles (ndarray | None, default: None)

prismo.outputs.plot_loss_convergence(history: list[dict], output_dir: str | Path | None = None)[source]#

Modal loss [dB/cm] and the VπLπ·alpha figure of merit [V·dB] vs iteration.

The companion of plot_convergence() for the loss axis: the free-carrier loss of the unbiased device at every evaluation, and the literature’s efficiency-loss figure of merit on a twin axis. Returns None (writes nothing) when the history carries no evaluated loss.

Parameters:
prismo.outputs.plot_mode_field(mode: ModeField, output_dir: str | Path | None = None)[source]#

Optical mode |E| on the rib cross-section.

The eigensolver’s mode is what turns the doping design into a phase shift, so the headline set shows it directly: whichever mode the solve tracked, on the same cross-section as the doping figure.

Parameters:
  • mode (ModeField) – The tracked mode’s normalized |E| and vertex coordinates.

  • output_dir (str | Path | None, default: None) – Directory to write mode_field.pdf.

Returns:

Path to the saved figure.

Return type:

Path

prismo.outputs.plot_tradeoff(history: list[dict], output_dir: str | Path | None = None)[source]#

The optimizer’s path in the (modal loss, Δneff) plane.

Every evaluated design is a point, coloured by iteration and joined in order, against iso-VπLπ·alpha hyperbolae: a design moving up-left trades loss for efficiency, and crossing below the 30 V·dB curve puts it in the literature’s band. Returns None when no loss was evaluated.

Parameters:
prismo.outputs.probe_direction(pipeline_fn: Callable[[...], Array], rho: Array, kind: str, before_evaluation: Callable[[], None] | None = None)[source]#

Unit direction for a line scan through rho, plus the gradient used.

"gradient" is the normalized adjoint gradient at rho (the direction the optimizer would step); "random" is the first seeded unit vector validate_gradient() also samples. Either way the direction is projected off rail-pinned variables so the scan can move both ways inside the [-1, 1] box. Returns (direction, gradient) – the gradient is None for a random direction – so scan_objective_line() can take its adjoint slope from the same adjoint solve instead of running a second one. Raises ValueError for an unknown kind and RuntimeError when there is nothing to scan (zero gradient, or every weighted variable pinned).

Parameters:
prismo.outputs.scan_objective_line(pipeline_fn: Callable[[...], Array], rho: Array, direction: Array, offsets: ndarray, output_dir: str | Path | None = None, before_evaluation: Callable[[], None] | None = None, gradient: Array | None = None)[source]#

Sample pipeline_fn along direction and fit a quadratic.

Parameters:
  • pipeline_fn (Callable[..., Array]) – Callable (rho) -> scalar differentiable by JAX.

  • rho (Array) – Centre design.

  • direction (Array) – Unit direction d.

  • offsets (ndarray) – Signed steps t; every rho + t*d is evaluated as given (filter with feasible_offsets() first to respect the box).

  • output_dir (str | Path | None, default: None) – When given, write objective_line_scan.pdf and objective_line_scan.json there.

  • before_evaluation (Callable[[], None] | None, default: None) – Hook run before the gradient and before every sample.

  • gradient (Array | None, default: None) – The adjoint gradient at rho if the caller already has it (e.g. from probe_direction()); computed here when None.

Returns:

An ObjectiveLineScan.

Return type:

ObjectiveLineScan

prismo.outputs.validate_gradient(pipeline_fn: Callable[[...], Array], rho: Array, directions: list[Array] | None = None, n_directions: int = 3, step_sizes: ndarray | None = None, tolerance: float = 0.01, output_dir: str | Path | None = None, before_evaluation: Callable[[], None] | None = None)[source]#

Check the adjoint against central FD and gate on a stated tolerance.

The proof behind the headline: the composed rho -> Delta n_eff gradient is exercised through whatever pipeline_fn closes over – pass a pipeline bound to the live ChargeTransport and gyptis components to prove the cross-boundary adjoint (CT included), not a stub. The figure is always written; passed records whether the worst per-direction best error stayed at or below tolerance (it does not raise, so a failing check still produces the diagnostic figure).

Parameters:
  • pipeline_fn (Callable[..., Array]) – Callable (rho) -> scalar differentiable by JAX.

  • rho (Array) – Reference design vector to validate the gradient at.

  • directions (list[Array] | None, default: None) – Perturbation directions. Random unit vectors if None.

  • n_directions (int, default: 3) – Number of random directions (ignored if directions).

  • step_sizes (ndarray | None, default: None) – Central-difference steps. Defaults to 20 logarithmic steps from 1e-6 to 1e-1.

  • tolerance (float, default: 0.01) – Acceptance bar on the worst per-direction best relative error.

  • output_dir (str | Path | None, default: None) – Directory to write gradient_validation.pdf.

  • before_evaluation (Callable[[], None] | None, default: None) – Hook run before every pipeline evaluation (the cold-start option: reset the ChargeTransport worker so the FD reference is not polluted by warm-start path dependence).

Returns:

A GradientValidationResult with the per-direction best errors, the worst of them, the pass/fail verdict, and the figure path.

Return type:

GradientValidationResult

prismo.outputs.vpi_axis_is_symlog(magnitudes: list[float])[source]#

Whether the convergence figure’s VpiLpi axis should be symlog, not linear.

symlog exists to compress a wide span. VpiLpi goes as 1/delta_n_eff, so a run that starts near zero spans many decades and a linear axis flattens every iteration after the first. A run that starts already converged spans almost nothing, and there symlog’s locator places no ticks at all, leaving a labelled axis with no numbers on it. Switch at two decades.

Parameters:

magnitudes (list[float])