Circuits
Linear MNA circuits coupled to FDTD ports, SPICE import, source waveforms, nonlinear device declarations, and ESD excitations.
This page covers circuit co-simulation in witwin.maxwell (import witwin.maxwell as mw): the Circuit graph and its devices, source waveforms, parse_spice, the CircuitData result, the nonlinear device declarations, and the ESD waveforms and excitations. Use a Circuit when a port load has internal nodes, controlled sources, switches, transient sources, or connects several EM ports.
A circuit binds existing LumpedPort or TerminalPort names (see Ports). During Simulation.fdtd(...), a GPU modified nodal analysis (MNA) solve runs in the same time step as the Yee update: each bound port contributes its Norton relation, the solve returns all node voltages and port currents, and the currents go back into the port edges before the step ends. Port voltage, current, and power conventions are those of the RF ports.
Current limits: one circuit per scene in a coupled run; a bound port cannot also take a PortExcitation or a PortSweep; the initial DC solution must draw zero current from the initially zero field (ramp active sources from zero).
import witwin.maxwell as mw
circuit = mw.Circuit("matching_network")
inp, mid, out = circuit.node("input"), circuit.node("middle"), circuit.node("output")
circuit.add(mw.Inductor("L1", inp, mid, 2.65e-9))
circuit.add(mw.Capacitor("C1", mid, circuit.ground, 2.12e-12))
circuit.add(mw.Inductor("L2", mid, out, 2.65e-9))
circuit.bind_port("feed", positive=inp, negative=circuit.ground)
circuit.bind_port("antenna", positive=out, negative=circuit.ground)
scene.add_circuit(circuit) # scene already declares ports "feed" and "antenna"
result = mw.Simulation.fdtd(scene, frequency=3e9, run_time=mw.TimeConfig(time_steps=1000)).run()
data = result.circuit("matching_network")
print(data.node_voltage("middle"), data.branch_current("L2"))Circuit#
class Circuit(name, *, parameters=None, config=None, metadata=None)Declarative linear circuit graph owned by a Scene. Add it with Scene(circuits=(...)) or scene.add_circuit(circuit); circuit names are unique in a scene, ignoring case.
Circuit name. Non-empty, no whitespace.
NoneNamed parameter values, stored in circuit.parameters.
NoneSolver settings. None uses MNAConfig().
NoneFree-form metadata, stored in circuit.metadata.
Attributes: name, ground (the node "0"), parameters, initial_conditions (node name to (value, constraint)), config, metadata. Properties: nodes, devices, and bindings, each a tuple in insertion order.
Raises TypeError if config is not an MNAConfig.
Circuit.node#
Circuit.node(name: str) -> CircuitNodeReturn the node with this name, creating it on first use. Lookup ignores case. "0" returns ground. Names must not contain whitespace or :.
Circuit.add#
Circuit.add(device) -> CircuitAdd a device and return the circuit. Accepted types: Resistor, Capacitor, Inductor (with CircuitNode terminals), VoltageSource, CurrentSource, the four controlled sources, MutualInductor, TimedSwitch, Diode, PiecewiseLinearIV, PolynomialIV, and VoltageDependentCapacitor. Device names are unique, ignoring case, and every terminal must be a node of this circuit.
Raises TypeError for an unsupported device, ValueError for a duplicate name or a foreign node.
Circuit.bind_port#
Circuit.bind_port(port_name: str, *, positive, negative) -> CircuitConnect scene port port_name between two circuit nodes. positive and negative are CircuitNode objects of this circuit or node names. Each port is bound at most once per circuit; the port must exist in the scene when the run is prepared.
Circuit.set_initial_condition#
Circuit.set_initial_condition(node, value, *, constraint=True) -> CircuitSet the initial voltage of node (a CircuitNode or name) in volts. constraint=False marks it as a guess, as in SPICE .ic ... guess. The ground node accepts only 0.
Circuit.compile#
Circuit.compile(*, available_ports=None)Validate the topology on the CPU and return the normalized circuit graph. Checks include at least one device, unknown count within config.dense_unknown_limit, controlled-source references, dependency cycles, voltage-source loops, a DC reference, and, when available_ports is given, that every bound port is in it.
Circuit.from_spice#
Circuit.from_spice(source, *, name=None, parameters=None, include_root=None) -> CircuitRead a netlist file. name defaults to the file stem. parameters overrides .param values. include_root is the sandbox folder for .include. See parse_spice for the supported subset.
circuit = mw.Circuit.from_spice("matching.cir", name="matching_network",
parameters={"Cmatch": 0.8e-12})
circuit.bind_port("feed", positive="input", negative="0")Circuit.to_spice#
Circuit.to_spice() -> strReturn a deterministic netlist with .param, device lines, .ic, and .end. Device names must start with their SPICE letter (R, C, L, V, I, E, G, F, H, K). Raises ValueError for a TimedSwitch or a misnamed device, TypeError for a nonlinear device.
parse_spice#
parse_spice(text, *, name='circuit', parameters=None, source_path=None,
include_root=None) -> CircuitParse netlist text into a Circuit. Expressions are evaluated safely; nothing executes.
Netlist text.
'circuit'Name of the returned circuit.
NoneParameter overrides. Names are unique ignoring case; values are real scalars and win over .param declarations.
NonePath of the netlist file. Required with include_root for .include.
NoneFolder that .include paths must stay inside.
Supported statements:
| Statement | Maps to |
|---|---|
R, C, L | Resistor, Capacitor, Inductor (two nodes, one value) |
V, I | VoltageSource, CurrentSource, with a DC value or PULSE(...), SIN(...), PWL(...) |
E, G | VoltageControlledVoltageSource, VoltageControlledCurrentSource (four nodes, one gain) |
F, H | CurrentControlledCurrentSource, CurrentControlledVoltageSource (two nodes, control source, gain) |
K | MutualInductor |
X with .subckt | Flattened subcircuit instance (nesting depth up to 32) |
.param, .ic V(node)=value [guess], .include, .end | Parameters, initial conditions, includes, end |
Values accept SPICE suffixes (t, g, meg, k, m, u, n, p, f, mil). Unknown devices or directives, statements after .end, unsafe include paths, and duplicate names raise ValueError. Nonlinear devices and TimedSwitch have no netlist statement.
circuit = mw.parse_spice("""
L1 input middle 2.65n
C1 middle 0 2.12p
.end
""", name="match")CircuitNode#
@dataclass(frozen=True)
class CircuitNode(name)Named circuit node. Create nodes with Circuit.node(...) so they belong to the circuit. The name must be non-empty without whitespace.
CircuitNode.is_ground#
CircuitNode.is_ground -> boolTrue when the name is "0".
PortBinding#
@dataclass(frozen=True)
class PortBinding(port_name, positive, negative)Record created by Circuit.bind_port: a scene port name and two distinct CircuitNode terminals.
MNAConfig#
@dataclass(frozen=True)
class MNAConfig(integration='trapezoidal', pivot_tolerance=1e-12, regularization='error',
initialization='dc', diagnostics='basic', dense_unknown_limit=256)Settings for the MNA solve.
'trapezoidal'Companion model: "trapezoidal" or "backward_euler". The runtime inserts local backward-Euler steps at source discontinuities either way.
1e-12Finite, positive pivot threshold for the factorization.
'error'Only "error" is supported: a singular system raises instead of being regularized.
'dc'"dc" or "zero" starting state.
'basic'"basic" or "full".
256Maximum number of unknowns (non-ground nodes plus branch currents) for the dense GPU solve. Positive.
VoltageSource#
class VoltageSource(name, positive, negative, voltage=0.0, *, waveform=None)Independent voltage source between two distinct CircuitNode terminals. It adds a branch current.
Device name, no whitespace.
Positive terminal.
Negative terminal.
0.0Real scalar value in volts. Used for every time sample when waveform is None.
NoneTime-domain waveform. When set, it supplies the time samples.
CurrentSource#
class CurrentSource(name, positive, negative, current=0.0, *, waveform=None)Independent current source. Arguments match VoltageSource, with current in amperes.
VoltageControlledVoltageSource#
class VoltageControlledVoltageSource(name, positive, negative, control_positive,
control_negative, gain)SPICE E element: output voltage gain * (V(control_positive) - V(control_negative)). Control nodes must be distinct CircuitNode objects. gain is a real scalar.
VoltageControlledCurrentSource#
class VoltageControlledCurrentSource(name, positive, negative, control_positive,
control_negative, transconductance)SPICE G element. transconductance is a real scalar in siemens.
CurrentControlledVoltageSource#
class CurrentControlledVoltageSource(name, positive, negative, control_source, transresistance)SPICE H element. control_source names a device that carries a branch current: an Inductor, VoltageSource, VoltageControlledVoltageSource, or CurrentControlledVoltageSource. transresistance is a real scalar in ohms. Dependency cycles raise ValueError at compile.
CurrentControlledCurrentSource#
class CurrentControlledCurrentSource(name, positive, negative, control_source, gain)SPICE F element. control_source follows the CurrentControlledVoltageSource rule. gain is a real scalar.
MutualInductor#
class MutualInductor(name, first_inductor, second_inductor, coupling)SPICE K element: magnetic coupling between two Inductor devices of the same circuit, given by name. The names must differ, ignoring case. coupling is a real scalar with abs(coupling) at most 1.
TimedSwitch#
class TimedSwitch(name, positive, negative, transition_times, *, initially_closed=False,
on_resistance=1e-06, off_resistance=1000000000000.0)Switch that toggles between two resistances at fixed times.
One-dimensional, finite, non-negative, strictly increasing times in seconds. May be empty.
FalseState before the first transition.
1e-06Closed resistance in ohms. Positive.
1000000000000.0Open resistance in ohms. Must exceed on_resistance.
PulseWaveform#
class PulseWaveform(initial, pulsed, delay=0.0, rise=0.0, fall=0.0, width=0.0, period=0.0)SPICE PULSE waveform. All values are real scalars stored as tensors; times are in seconds and must be non-negative. Before delay the value is initial. It then ramps to pulsed over rise, holds for width, and ramps back over fall. period greater than 0 repeats the pulse; 0 means a single pulse.
SineWaveform#
class SineWaveform(offset, amplitude, frequency, delay=0.0, damping=0.0, phase_degrees=0.0)SPICE SIN waveform: offset before delay, then offset + amplitude * exp(-damping*tau) * sin(2*pi*frequency*tau + phase) with tau = t - delay. frequency in hertz, delay in seconds, and damping in 1/s must be non-negative. phase_degrees is in degrees.
PiecewiseLinearWaveform#
class PiecewiseLinearWaveform(times, values)SPICE PWL waveform. times (seconds) and values are real, finite, one-dimensional, of equal length, with at least two points and strictly increasing times. The value is linearly interpolated and held constant before the first and after the last point.
CircuitData#
@dataclass(frozen=True)
class CircuitData(circuit_name, times, node_names, node_voltages, branch_names,
branch_currents, device_powers={}, energy_balance=None, diagnostics={})Time-domain circuit results, returned by result.circuit(name) and result.circuits. Tensors stay on the scene device and keep the autograd graph in a differentiable run.
| Field | Shape and meaning |
|---|---|
circuit_name | Circuit name |
times | [T] sample times in seconds, strictly increasing |
node_names | Node names, matching the columns of node_voltages |
node_voltages | [T, node] in volts |
branch_names | Branch names, matching the columns of branch_currents |
branch_currents | [T, branch] in amperes |
device_powers | Device name to [T] power samples in watts |
energy_balance | [T] energy-balance samples, or None |
diagnostics | Solver diagnostics |
All tensors share one real floating dtype and one device.
CircuitData.node_voltage#
CircuitData.node_voltage(name: str) -> torch.TensorReturn the [T] voltage of one node. Raises KeyError for an unknown node.
CircuitData.branch_current#
CircuitData.branch_current(name: str) -> torch.TensorReturn the [T] current of one branch. Raises KeyError for an unknown branch.
CircuitData.to#
CircuitData.to(device) -> CircuitDataReturn a copy with all tensors on device.
CircuitData.save, CircuitData.load#
CircuitData.save(path: str | Path) -> None
CircuitData.load(path: str | Path, map_location=None) -> CircuitDataSave or load a detached CPU snapshot (schema version 1). load uses torch.load(weights_only=True). diagnostics must hold only tensors and primitive values.
data.save("matching-circuit.pt")
restored = mw.CircuitData.load("matching-circuit.pt")CircuitDevice#
class CircuitDevice(Protocol)Runtime-checkable protocol that every circuit device satisfies: a name attribute and the properties terminals (tuple of CircuitNode), parameters (dict of parameter values), and initial_condition. Use it for isinstance checks; Circuit.add still accepts only the listed device types.
Nonlinear devices#
Experimental, not yet coupled to FDTD
Diode, PiecewiseLinearIV, PolynomialIV, and VoltageDependentCapacitor can be added to a Circuit and pass topology validation. The linear MNA and FDTD runtimes reject a circuit that contains them with NotImplementedError; FDTD coupling is a later release. BJT and MOSFET raise NotImplementedError on construction.
All nonlinear devices take a name and two distinct CircuitNode terminals; the other arguments are keyword-only. They do not have a SPICE statement.
Diode#
class Diode(name, positive, negative, *, saturation_current, ideality=1.0,
series_resistance=0.0, junction_capacitance=0.0, temperature=300.15)Shockley junction diode, i(v) = Is * (exp(v/(n*Vt)) - 1) with Vt = k*T/q.
Is in amperes. Positive.
1.0Ideality factor n. Positive.
0.0Series resistance in ohms. Non-negative.
0.0Junction capacitance in farads. Non-negative.
300.15Temperature in kelvin. Positive.
PiecewiseLinearIV#
class PiecewiseLinearIV(name, positive, negative, *, voltages, currents)Behavioral device with a piecewise-linear I-V curve. voltages (volts, strictly increasing, at least two knots) and currents (amperes, same length) are real, finite, one-dimensional. Current is interpolated between knots and extrapolated with the end slopes. Negative slopes are allowed.
PolynomialIV#
class PolynomialIV(name, positive, negative, *, coefficients)Behavioral device with i(v) = sum_k coefficients[k] * v**k. coefficients is a non-empty real one-dimensional tensor, constant term first.
VoltageDependentCapacitor#
class VoltageDependentCapacitor(name, positive, negative, *, q_coefficients)Capacitor declared by its charge q(v) = sum_k q_coefficients[k] * v**k in coulombs, so C(v) = dq/dv. It carries no DC current.
NonlinearSolveConfig#
@dataclass(frozen=True)
class NonlinearSolveConfig(relative_tolerance=1e-07, absolute_tolerance=1e-10,
update_relative_tolerance=1e-09, update_absolute_tolerance=1e-12,
max_iterations=20, line_search='backtracking',
max_line_search_steps=10, failure='raise',
gmin_start=0.0, gmin_steps=0)Newton solve settings for nonlinear devices. An iterate is accepted only when both the residual test (relative_tolerance, absolute_tolerance) and the update test (update_relative_tolerance, update_absolute_tolerance) pass. All four tolerances are positive.
| Field | Meaning |
|---|---|
max_iterations | Newton iteration cap, at least 1 |
line_search | "backtracking" or "none" |
max_line_search_steps | At least 1 |
failure | "raise", or "record_and_stop" to stop and report instead |
gmin_start, gmin_steps | Optional gmin continuation ladder; non-negative |
No public Circuit or Simulation argument takes this config in 0.5.1.
BJT, MOSFET#
class BJT(*args, **kwargs)
class MOSFET(*args, **kwargs)Reserved names. Construction raises NotImplementedError.
ESD excitations#
The ESD classes reproduce IEC 61000-4-2 contact-discharge currents for stress analysis. They do not model the discharge gun geometry, arcs, or failure probability, and using them is not a certification.
| Class | How it drives the scene |
|---|---|
ESDCurrentSource | Ideal current injected across a TerminalPort gap. Add with scene.add_source(...). |
ESDVoltageSource | Builds a Circuit with the 330 ohm and 150 pF generator network bound to a port. Add the circuit with scene.add_circuit(...). |
waveform = mw.ESDWaveform.iec_61000_4_2(8000.0)
# scene already declares a TerminalPort named "feed"
scene.add_source(mw.ESDCurrentSource("gun", port="feed", waveform=waveform))
result = mw.Simulation.fdtd(scene, frequencies=[1e9],
run_time=mw.TimeConfig(time_steps=1600)).run()
record = result.esd_waveform("gun")
print(record.diagnostics.peak_current, record.resampled.charge_ratio)result.esd_waveform_names() lists the ESDCurrentSource names; result.esd_waveform(name) returns an ESDPortRecord.
ESDWaveform#
ESDWaveform.iec_61000_4_2(level_voltage, *, discharge='contact',
standard_revision=None) -> ESDWaveformStandard ESD current as a two-term Heidler sum (exponent 1.8). The direct constructor raises TypeError; use this factory.
Test level in volts. Positive and finite. Amplitudes scale linearly from the 4 kV reference set.
'contact'Only "contact" is supported.
NoneParameter set. None uses "ed2-contact", the only supported value.
Attributes: level_voltage, discharge, standard_revision. The waveform support is (0.0, 20 * 37e-9) seconds.
Waveform methods#
ESDWaveform and MeasuredWaveform share these members:
waveform.current(t) -> torch.Tensor
waveform.support -> tuple[float, float]
waveform.provenance -> dict
waveform.diagnostics() -> ESDDiagnostics
waveform.resample_to_grid(dt, *, t_end=None, subsamples=32) -> ESDResampledWaveform
waveform.to_source_time(*, samples=4001) -> CustomSourceTime
waveform.to_circuit_waveform(*, t_end=None, samples=4001, scale=1.0) -> PiecewiseLinearWaveform| Member | What it does |
|---|---|
current(t) | Current in amperes at times t in seconds |
support | (start, stop) time span in seconds |
provenance | Standard, revision, level, model version, units |
diagnostics() | Peak, 30 ns and 60 ns currents, 10-90 % rise time, charge, action integral |
resample_to_grid(dt, ...) | Bin-averaged samples on a dt grid that conserve charge; subsamples is at least 2 |
to_source_time(...) | Dense CustomSourceTime table with characteristic frequency 0.35 / rise_time |
to_circuit_waveform(...) | PiecewiseLinearWaveform of scale * current(t); samples at least 2 |
MeasuredWaveform#
class MeasuredWaveform(time, values, *, units='A', bandwidth=None, provenance=None)Measured ESD current samples. Linearly interpolated, zero outside the sampled span.
Strictly increasing sample times in seconds, at least two.
Current in amperes, same length as time.
'A'Only "A" is supported.
NoneMeasurement bandwidth in hertz. Positive and finite.
NoneExtra metadata merged into provenance.
ESDCurrentSource#
class ESDCurrentSource(name, *, port, waveform, direction='+')Injects a waveform as an ideal current across the gap of a TerminalPort. It lowers to a UniformCurrentSource named "<name>::current" whose density is the current divided by the port's current-contour area.
Source name.
Name of a TerminalPort in the scene.
Current waveform.
'+'"+" drives current toward the positive terminal; "-" reverses it.
ESDCurrentSource.injection_geometry#
ESDCurrentSource.injection_geometry(scene) -> dictReturn the injection box: axis, axis_index, center, size, footprint_area, gap, and polarization_sign. Raises ValueError when the port is missing, is not a TerminalPort, or has a zero-area contour.
ESDCurrentSource.resolve#
ESDCurrentSource.resolve(scene) -> UniformCurrentSourceLower to the uniform current source. Scene.resolved_sources() calls this.
ESDCurrentSource.provenance#
ESDCurrentSource.provenance(scene=None) -> dictReturn source metadata; with scene, it adds the injection geometry.
ESDVoltageSource#
class ESDVoltageSource(name, *, port, waveform, discharge_resistance=330.0,
storage_capacitance=1.5e-10)ESD generator driven through a source network. The generator voltage is discharge_resistance * i_esd(t), so the short-circuit current equals the standard current.
Generator name.
Name of the TerminalPort or LumpedPort to drive.
Current waveform.
330.0Discharge resistance in ohms. Positive and finite.
1.5e-10Storage capacitance in farads. Positive and finite.
ESDVoltageSource.build_circuit#
ESDVoltageSource.build_circuit(*, t_end=None, samples=4001, circuit_name=None,
config=None) -> CircuitReturn a Circuit named circuit_name (default "esd_<name>") with nodes gen and tip: a PWL voltage source from gen to ground, the discharge resistor from gen to tip, the storage capacitor from tip to ground, and the port bound from tip to ground. metadata["esd_generator"] holds the provenance. config is an optional MNAConfig.
generator = mw.ESDVoltageSource("gun", port="feed",
waveform=mw.ESDWaveform.iec_61000_4_2(8000.0))
scene.add_circuit(generator.build_circuit(t_end=60e-9))ESDVoltageSource.circuit_waveform#
ESDVoltageSource.circuit_waveform(*, t_end=None, samples=4001) -> PiecewiseLinearWaveformReturn the generator drive discharge_resistance * i_esd(t).
ESDVoltageSource.provenance#
ESDVoltageSource.provenance() -> dictReturn generator metadata, element values, and units.
ESDDiagnostics#
@dataclass(frozen=True)
class ESDDiagnosticsScalar summary of a waveform in SI units: peak_current, current_at_30ns, current_at_60ns, rise_time_10_90, charge (integral of i dt), action_integral (integral of i**2 dt), and peak_time. as_dict() returns the same values as a dict.
ESDResampledWaveform#
@dataclass(frozen=True)
class ESDResampledWaveformCharge-conserving resampling onto a time grid. Fields: dt, times (bin centers), currents (bin-mean current), charge, action_integral, analytic_charge, analytic_action_integral. Properties: charge_ratio, action_ratio, and aliasing_metric (abs(1 - action_ratio)).
ESDPortRecord#
@dataclass(frozen=True)
class ESDPortRecordESD summary returned by result.esd_waveform(name).
| Field | Meaning |
|---|---|
name, port_name | Source and port names |
diagnostics | ESDDiagnostics of the target waveform |
resampled | ESDResampledWaveform on the run time grid, or None if no time step is known |
provenance | Source provenance dict |
measured | PortData for the port if the run recorded it, otherwise None |
Properties target_times and target_currents return resampled.times and resampled.currents, or None.
For ESDCurrentSource runs, measured is normally None because the ideal injection does not use the port recorder. Add a FieldTimeMonitor across the gap to observe the gap field.