Electrostatics

DC Laplace/Poisson solves and capacitance-matrix extraction with terminals, charge sources, boundary specs, and typed results.

This page covers the electrostatic solver in witwin.maxwell. It is a separate cell-centred finite-volume solver for the DC potential, not a low-frequency FDTD run. Use it to find the static potential and field around fixed-voltage or charged conductors, to extract an N-terminal capacitance matrix, or to seed an FDTD run with a DC field. All names on this page are exported from the top-level package (import witwin.maxwell as mw).

Note

The electrostatic solver is experimental. Its API and limits can change between releases.

A solve follows the usual Scene, Simulation, Result flow:

  1. Build a Scene with a Domain, a GridSpec, and boundary=mw.BoundarySpec.none(). The electrostatic solver sets its own outer boundary through ElectrostaticBoundarySpec, and any other scene boundary raises NotImplementedError.
  2. Add dielectrics as ordinary Structure objects with non-dispersive materials. Add conductors with scene.add_electrostatic_terminal(...) and free charge with scene.add_charge_density(...). You can also pass lists to Scene(electrostatic_terminals=..., charge_densities=...).
  3. Run mw.Simulation.electrostatic(scene, ...) or mw.Simulation.capacitance(scene, ...).
  4. Read result.electrostatic (an ElectrostaticResultData) or result.capacitance (a CapacitanceData).

Units are SI: metres, volts, coulombs, farads, joules.

import witwin.maxwell as mw
 
scene = mw.Scene(
    domain=mw.Domain(bounds=((0.0, 0.5), (0.0, 0.5), (0.0, 0.5))),
    grid=mw.GridSpec.uniform(0.05),
    boundary=mw.BoundarySpec.none(),
)
boundary = mw.ElectrostaticBoundarySpec(
    default="neumann", z_low=("dirichlet", 1.0), z_high=("dirichlet", 0.0)
)
result = mw.Simulation.electrostatic(scene, boundary=boundary).run()
dc = result.electrostatic
print(dc.Ez.mean(), dc.energy)   # uniform field of 1 V / 0.5 m

Warning

The problem needs at least one Dirichlet face or one terminal; without either, run() raises NotImplementedError. If the only terminals are floating (charge=) and no face is Dirichlet, the potential level is fixed by setting its mean to zero. Material regions (Scene.material_regions), PEC structures, dispersive materials, and complex permittivity are rejected; model conductors with ElectrostaticTerminal.

Simulation.electrostatic#

mw.Simulation.electrostatic(scene, *, boundary=None, solver=None) -> ElectrostaticSimulation

Builds an electrostatic (Laplace/Poisson) run. Call .run() on the returned runner to get a Result with method="electrostatic".

sceneScenerequired

The scene. Its boundary must be BoundarySpec.none(). See Scene.

boundaryElectrostaticBoundarySpec | Nonedefault None

Outer boundary conditions. None means ElectrostaticBoundarySpec.grounded_box() (0 V on all six faces).

solverElectrostaticSolverConfig | Nonedefault None

Linear-solver settings. None means ElectrostaticSolverConfig().

Returns an ElectrostaticSimulation runner with two methods: prepare() compiles the scene and returns the compiled problem, and run() solves it and returns a Result. result.electrostatic is the ElectrostaticResultData. result.solver_stats holds iterations, residual, residual_abs, and gauss_error.

Raises TypeError if boundary or solver has the wrong type.

Simulation.capacitance#

mw.Simulation.capacitance(scene, *, terminals=None, reference=None, boundary=None,
                          solver=None, truncation_estimate=None) -> CapacitanceSimulation

Builds an N-terminal Maxwell capacitance-matrix extraction. For each active terminal it runs one solve with that terminal at 1 V and every other conductor at 0 V, and reads the induced charge on each terminal. Free charge from ChargeDensity sources is ignored. The terminals' own potential and charge settings do not affect the result.

sceneScenerequired

The scene with its electrostatic terminals.

terminalsSequence[str] | Nonedefault None

Terminal names to include, in matrix order. None means all terminals in declaration order. Names must exist and be distinct.

referencestr | Nonedefault None

Name of the grounded return conductor. It is held at 0 V and left out of the matrix. Without a reference, the boundary must have a Dirichlet face so the field has somewhere to end.

boundaryElectrostaticBoundarySpec | Nonedefault None

Outer boundary. None means ElectrostaticBoundarySpec.grounded_box().

solverElectrostaticSolverConfig | Nonedefault None

Linear-solver settings. None means ElectrostaticSolverConfig().

truncation_estimateTruncationEstimate | Nonedefault None

Opt-in domain-truncation check. When set, run() performs one extra solve on an enlarged grounded box and stores a TruncationReport in result.capacitance.truncation_estimate. No extra solve runs without it.

Returns a CapacitanceSimulation runner with prepare() and run(). run() returns a Result with method="capacitance"; result.capacitance is the CapacitanceData. result.solver_stats holds reciprocity_error, row_sum_error, terminal_order, reference, and, with a truncation estimate, truncation_max_relative_delta and truncation_richardson_max_relative_shift.

Raises ValueError if the scene has no terminals, a name is unknown, no non-reference terminal remains, or there is no reference and no Dirichlet face.

scene = mw.Scene(domain=mw.Domain(bounds=((-1.0, 1.0),) * 3),
                 grid=mw.GridSpec.uniform(0.05), boundary=mw.BoundarySpec.none())
scene.add_electrostatic_terminal(
    mw.ElectrostaticTerminal(name="a", geometry=mw.Sphere(position=(-0.5, 0, 0), radius=0.18), potential=1.0))
scene.add_electrostatic_terminal(
    mw.ElectrostaticTerminal(name="b", geometry=mw.Sphere(position=(0.5, 0, 0), radius=0.18), potential=0.0))
cap = mw.Simulation.capacitance(scene).run().capacitance
print(cap.matrix, cap.mutual_capacitance("a", "b"))

ElectrostaticTerminal#

class ElectrostaticTerminal(name, geometry=None, structure=None, potential=None, charge=None, grounded=False)

Frozen equipotential conductor. It pins the potential of every cell whose centre lies inside its geometry (signed distance at most zero). Terminals live in Scene.electrostatic_terminals, never in the RF Scene.ports set. Terminals must not overlap, and each must cover at least one cell.

namestrrequired

Non-empty name, unique within the scene.

geometrygeometry | Nonedefault None

A witwin.core geometry primitive (anything with signed_distance(x, y, z)). Give exactly one of geometry and structure. See Geometry.

structureStructure | Nonedefault None

A witwin.core.Structure; only its geometry is used. Do not also add a PEC copy of it to scene.structures, because the electrostatic compiler rejects PEC structures.

potentialfloat | Nonedefault None

Fixed potential in volts.

chargefloat | Nonedefault None

Total charge in coulombs for a floating conductor. Its potential is solved for. Mutually exclusive with potential.

groundedbooldefault False

Shorthand for potential=0.0. A grounded terminal cannot set charge or a non-zero potential.

Raises ValueError if none of potential, charge, grounded=True is given, if both potential and charge are given, or if both or neither of geometry and structure are given. Raises TypeError for a geometry without signed_distance.

Property is_floating is True when the terminal has a prescribed charge and no potential.

Note

Floating conductors are solved by linear superposition. Gradients through a solve with floating conductors are not implemented and raise NotImplementedError when the permittivity or free charge requires grad.

mw.ElectrostaticTerminal(name="shield", geometry=mw.Box(position=(0, 0, 0), size=(0.2, 0.2, 0.2)), charge=0.0)

ChargeDensity#

class ChargeDensity(geometry, density)

Frozen volumetric free-charge source for the Poisson solve. Add it with scene.add_charge_density(...).

geometrygeometryrequired

A witwin.core geometry with signed_distance(x, y, z).

densityfloat | torch.Tensorrequired

Charge density in C/m^3. A non-tensor value is converted to float. A tensor keeps its autograd graph, so the solve can be differentiated with respect to it.

The compiler multiplies the density by each cell's occupancy and volume, so the per-cell free charge is in coulombs.

scene.add_charge_density(mw.ChargeDensity(geometry=mw.Box(position=(0, 0, 0), size=(0.5, 0.5, 0.5)), density=1.0e-6))

ElectrostaticBoundarySpec#

class ElectrostaticBoundarySpec(default='neumann', value=0.0, x_low=None, x_high=None,
                                y_low=None, y_high=None, z_low=None, z_high=None)

Frozen per-face outer boundary for the electrostatic domain. It is independent of the full-wave Scene.boundary.

KindMeaning
'dirichlet'Fixed potential, in volts.
'neumann'Zero normal displacement (insulating).
'symmetry'Even mirror plane, realised as a homogeneous Neumann condition.

'open' raises NotImplementedError. To model an isolated object, enlarge a grounded box until the result converges, and use TruncationEstimate to measure the remaining error.

defaultstrdefault 'neumann'

Kind for every face without an override.

valuefloatdefault 0.0

Potential in volts for a Dirichlet default. Must be 0.0 for other kinds.

x_lowstr | tuple[str, float] | Nonedefault None

Override for one face: a kind string, or a (kind, value) pair. Only Dirichlet faces may carry a non-zero value. x_high, y_low, y_high, z_low, and z_high work the same way.

Raises ValueError for an unknown kind or a non-zero value on a non-Dirichlet face.

Class methods#

MethodResult
grounded_box()Dirichlet 0 V on all six faces.
dirichlet(value=0.0, **faces)Dirichlet default at value, with optional face overrides.
neumann(**faces)Neumann default, with optional face overrides.
symmetry(**faces)Symmetry default, with optional face overrides.

ElectrostaticBoundarySpec.face#

ElectrostaticBoundarySpec.face(axis, side) -> tuple[str, float]

Returns the resolved (kind, value) for one face, for example face("z", "low"). Raises ValueError for an unknown face.

Property has_dirichlet is True when any face is Dirichlet.

spec = mw.ElectrostaticBoundarySpec(default="neumann", z_low=("dirichlet", 2.0))
assert spec.face("z", "low") == ("dirichlet", 2.0)
assert spec.face("z", "high") == ("neumann", 0.0)

ElectrostaticSolverConfig#

class ElectrostaticSolverConfig(tolerance=1e-10, max_iterations=20000, dtype=torch.float64)

Frozen settings for the Jacobi-preconditioned conjugate-gradient solve.

tolerancefloatdefault 1e-10

Target relative residual norm(A x - b) / norm(b). Must be greater than zero.

max_iterationsintdefault 20000

Iteration limit. Must be greater than zero.

dtypetorch.dtypedefault torch.float64

Working precision: torch.float32 or torch.float64. float64 is recommended so the residual can be trusted.

The solver stops at max_iterations without raising. Check ElectrostaticResultData.residual against your tolerance.

ElectrostaticResultData#

Typed output of Simulation.electrostatic(...).run(), read as result.electrostatic. All fields live on the cell-centred grid with shape (nx, ny, nz).

FieldMeaning
potentialPotential at cell centres, V.
Ex, Ey, EzElectric field at cell centres, V/m (central differences, one-sided at the domain edges).
Dx, Dy, DzDisplacement field at cell centres, C/m^2.
epsilon_rRelative permittivity per cell.
free_chargeFree charge per cell, C.
cell_volumeCell volume, m^3.
xc, yc, zcCell-centre coordinates, m.
energyField energy, J (scalar tensor).
residualFinal relative solver residual.
residual_absFinal absolute solver residual.
iterationsSolver iterations used.
gauss_errorLargest per-cell discrete Gauss-law residual over free (non-conductor) cells.
boundary_chargeTotal charge on Dirichlet boundary faces, C, or None without Dirichlet faces.
conductor_maskBoolean mask of cells pinned by terminals.
epsilon_tensorFull permittivity tensor field for anisotropic scenes, else None.
Property or methodReturns
EEx, Ey, Ez stacked on the last axis, shape (nx, ny, nz, 3).
DDx, Dy, Dz stacked on the last axis.
terminal_namesTuple of terminal names.
terminal_charge(name)Charge on that terminal in C; raises KeyError for an unknown name.

The fixed-potential solve is differentiable in the cell permittivity and in the free charge. Anisotropic (tensor) permittivity runs forward only and raises NotImplementedError when anything requires grad.

dc = result.electrostatic
print(dc.terminal_charge("inner"), dc.residual, dc.gauss_error)

CapacitanceData#

Typed output of Simulation.capacitance(...).run(), read as result.capacitance. matrix[i, j] is the charge in coulombs on active terminal i when terminal j is at 1 V and all other conductors are at 0 V, so it is also the Maxwell capacitance in farads. The matrix is reported raw, without symmetrization.

FieldMeaning
matrixCapacitance matrix, shape (n, n), F.
terminal_orderNames labelling the rows and columns (active terminals, without the reference).
referenceReference terminal name, or None.
chargesThe same raw charge measurements as matrix, C.
energyField energy of each unit excitation, J, shape (n,).
reciprocity_errormax abs(C - C^T) / max abs(C).
row_sum_errorLargest charge on an active terminal, relative to max abs(C), when all conductors are at 1 V. It should vanish for an insulating boundary with no Dirichlet face.
truncation_estimateTruncationReport, or None when not requested.

Matrix entries are differentiable in the cell permittivity.

CapacitanceData.capacitance#

CapacitanceData.capacitance(a, b) -> torch.Tensor

Returns the raw matrix entry C[a, b] in farads. Raises KeyError for a name not in terminal_order.

CapacitanceData.mutual_capacitance#

CapacitanceData.mutual_capacitance(a, b) -> torch.Tensor

Returns the coupling capacitance -C[a, b] in farads. Raises ValueError if a == b.

CapacitanceData.capacitance_to_reference#

CapacitanceData.capacitance_to_reference(a) -> torch.Tensor

Returns the row sum of C for terminal a: its capacitance to the reference, in farads.

CapacitanceData.two_terminal_capacitance#

CapacitanceData.two_terminal_capacitance(a=None, b=None) -> torch.Tensor

Returns the capacitance seen between terminals a and b with all other active terminals held at the reference: det / (Caa + Cbb + Cab + Cba) from the 2x2 submatrix. With no arguments it uses the two terminals of a 2x2 matrix.

Raises ValueError if the matrix is larger than 2x2 and you omit the names, or if a and b are equal.

TruncationEstimate#

class TruncationEstimate(padding_cells=8)

Frozen opt-in request for a domain-truncation estimate. Pass it to Simulation.capacitance(truncation_estimate=...).

padding_cellsintdefault 8

Whole cells added on every side of every axis for the enlarged solve. Must be greater than zero.

The estimate needs a uniform GridSpec, a boundary with at least one Dirichlet face, and no dielectric structure touching the domain boundary. Otherwise run() raises ValueError.

result = mw.Simulation.capacitance(
    scene, terminals=("c",), boundary=mw.ElectrostaticBoundarySpec.grounded_box(),
    truncation_estimate=mw.TruncationEstimate(padding_cells=8),
).run()
report = result.capacitance.truncation_estimate
print(report.max_relative_delta, report.richardson_matrix)

TruncationReport#

Result of a truncation estimate, stored in CapacitanceData.truncation_estimate.

FieldMeaning
padding_cellsCells added per side.
base_matrixCapacitance matrix on the original domain, F.
enlarged_matrixCapacitance matrix on the enlarged domain, F.
deltaenlarged_matrix - base_matrix.
max_relative_deltamax abs(delta) / max abs(base_matrix). Small values mean the enclosure is far enough away.
base_sizeSmallest domain extent of the original domain, m.
enlarged_sizeSmallest domain extent of the enlarged domain, m.
richardson_matrix1/L Richardson extrapolation to an infinite domain: C_enl + (C_enl - C_base) / (enlarged_size / base_size - 1). An estimate, not a bound.
richardson_max_relative_shiftmax abs(richardson_matrix - enlarged_matrix) / max abs(base_matrix).

ElectrostaticInitialCondition#

ElectrostaticInitialCondition.from_result(dc_result, *, tolerance=1e-3) -> ElectrostaticInitialCondition   # classmethod

Wraps an electrostatic result as an initial field for an FDTD run. Pass it to mw.Simulation.fdtd(..., initial_condition=ic). At prepare time, the FDTD solver sets its electric field to E = -grad(phi) on the Yee grid and leaves the magnetic field at zero. See Simulation.

dc_resultResultrequired

A result from Simulation.electrostatic(...).run(). Other methods raise ValueError.

tolerancefloatdefault 1e-3

Largest accepted relative discrete Gauss-law residual of the mapped field. Must be zero or more. Interface-heavy scenes may need a looser value.

Returns an ElectrostaticInitialCondition holding detached copies of phi, xc, yc, zc, epsilon_r, free_charge, cell_volume, conductor_mask, the field energy, the tolerance, and a provenance dict with the DC solve diagnostics. After the FDTD solver applies it, gauss_residual and gauss_residual_abs hold the measured residual, and result.electrostatic_prebias on the FDTD result reports the provenance.

The electrostatic cell centres must match the FDTD grid on every axis. Use the same Domain and GridSpec for both runs, and give the FDTD scene a boundary that does not extend the grid (for example BoundarySpec.periodic()); a PML boundary is rejected.

Warning

The pre-bias is not supported for multi-GPU FDTD, FDTD runs with trainable parameters, or Bloch-periodic boundaries. These raise NotImplementedError. If the Gauss-law residual exceeds tolerance, prepare raises ValueError.

dc = mw.Simulation.electrostatic(dc_scene, boundary=boundary).run()
ic = mw.ElectrostaticInitialCondition.from_result(dc)
fdtd_scene = mw.Scene(domain=dc_scene.domain, grid=dc_scene.grid, boundary=mw.BoundarySpec.periodic())
result = mw.Simulation.fdtd(fdtd_scene, frequencies=[1e9], initial_condition=ic).run()
print(result.electrostatic_prebias["gauss_residual"])

Two lower-level methods are public: map_to_yee(prepared, *, dtype=torch.float64) returns the (Ex, Ey, Ez) Yee-edge fields for a prepared scene, and apply_to_solver(solver) writes them into an FDTD solver's field buffers. The FDTD runner calls them for you.