The symbolic_poly toolkit

pycircuit.circuit selects its math backend through a toolkit: the same analysis code runs numerically or symbolically depending on the toolkit passed to it. symbolic_poly is an experimental symbolic toolkit that works in sympy’s polynomial/rational domains instead of on free-form expressions.

It behaves like the stock symbolic toolkit but

  • solves the MNA system fraction-free (DomainMatrix.solve_den) so intermediate results stay polynomials instead of the nested fractions Matrix.LUsolve produces — which lets it handle larger circuits, and

  • exposes the result as a rational transfer function N(s)/D(s) with poles, zeros, gain and a fast numeric evaluator.

It is opt-in; the symbolic toolkit is unchanged.

Note

Declare the frequency variable as sympy.Symbol('s', imaginary=True) (it represents \(j\omega\)). This lets conjugation resolve cleanly in the noise analysis and keeps results expressed in s.

Note

A fully symbolic circuit (every component a distinct symbol) has a network determinant with exponentially many terms — no method yields a compact closed form. symbolic_poly wins when there are few symbolic generators; numeric component values with only s symbolic is the sweet spot and scales to circuits with many nodes.

Quick start

import sympy
from pycircuit.circuit import symbolic_poly, use_toolkit, SubCircuit, R, C, VS, gnd
from pycircuit.circuit.analysis_ss import AC

s = sympy.Symbol('s', imaginary=True)
R1, C1 = sympy.symbols('R C', positive=True)

with use_toolkit(symbolic_poly):
    cir = SubCircuit()
    cir['VS'] = VS('in', gnd, vac=1)
    cir['R']  = R('in', 'out', r=R1)
    cir['C']  = C('out', gnd, c=C1)

res = AC(cir, toolkit=symbolic_poly).solve(s, complexfreq=True)

H = res.tf('out', gnd)          # the transfer function to v(out)
H.canonical()                   # -> 1/(C*R*s + 1)
H.poles()                       # -> {-1/(C*R): 1}
H.dcgain()                      # -> 1

Transfer functions

An AC solve with symbolic_poly returns a CircuitResultACPoly that keeps the numerator vector N and the shared denominator D (the network determinant). From it you get transfer functions without a swelling cancel:

  • res.tf(plus, minus=None) — the voltage transfer function to v(plus[, minus]).

  • res.tf_i(branch_or_term) — the current transfer function into a terminal or branch (transimpedance / current gain).

  • res.poles() — the circuit poles, computed once from the shared denominator for the whole circuit.

Each res.tf* returns a TransferFunction:

H = res.tf('out', gnd)
H.canonical()          # cancelled num/den expression
H.num, H.den           # raw numerator / denominator
H.poles()              # {root: multiplicity}
H.zeros()
H.dcgain()             # H(s=0)
H.bode()               # a fast lambdified callable f(s)
H.frequencyresponse([1e3, 1e6, 1e9])   # complex response at those Hz

Current transfer functions and input impedance:

# source current of the RC low-pass above
res.tf_i('R.plus').canonical()     # -> C*s/(C*R*s + 1)
res.tf_i('R.plus').zeros()         # -> {0: 1}   (no current at DC)

# input impedance: drive with a unit current source, then Zin = v(node)
from pycircuit.circuit import IS
with use_toolkit(symbolic_poly):
    cir = SubCircuit()
    cir['IS'] = IS(gnd, 'in', iac=1)
    cir['R']  = R('in', gnd, r=R1)
    cir['C']  = C('in', gnd, c=C1)          # R || C
res = AC(cir, toolkit=symbolic_poly).solve(s, complexfreq=True)
res.tf('in', gnd).canonical()      # -> R/(C*R*s + 1)

Poles and zeros at scale

Symbolic root finding has no closed form for degree \(\ge 5\). When the transfer function has numeric coefficients, pass numeric=True to find the roots with numpy.roots — fast and reliable regardless of degree:

with use_toolkit(symbolic_poly):
    cir = SubCircuit()
    cir['VS'] = VS('in', gnd, vac=1)
    prev = 'in'
    for i in range(6):                       # a 6-section RC ladder
        cir['R%d' % i] = R(prev, 'n%d' % i, r=1e3)
        cir['C%d' % i] = C('n%d' % i, gnd, c=1e-9)
        prev = 'n%d' % i

res = AC(cir, toolkit=symbolic_poly).solve(s, complexfreq=True)
res.poles(numeric=True)          # 6 complex poles, all on the negative real axis

numeric=True raises ValueError if any coefficient is still symbolic — substitute parameter values first.

Noise

The noise analysis works with symbolic_poly unchanged. Internally the output noise power zm**T CY conj(zm) is kept as a single shared-denominator rational N**T CY conj(N) / (D conj(D)) instead of an \(O(n^2)\) sum of divided rationals — the same value, a far smaller expression:

from pycircuit.circuit import Noise
with use_toolkit(symbolic_poly):
    cir = SubCircuit()
    cir['vs'] = VS(1, gnd, vac=1)
    cir['R']  = R(1, 2, r=R1)
    cir['C1'] = C(2, gnd, c=C1)
    noise = Noise(cir, inputsrc='vs', outputnodes=('2', gnd), toolkit=symbolic_poly)
res = noise.solve(s, complexfreq=True)
sympy.simplify(res['Svnout'])    # -> -4*R*T*k/(C**2*R**2*s**2 - 1)

Selecting the toolkit

Pass toolkit= to an analysis, or scope the construction-time default with the use_toolkit context manager (the recommended replacement for assigning the deprecated circuit.default_toolkit global):

from pycircuit.circuit import use_toolkit, symbolic_poly

with use_toolkit(symbolic_poly):
    cir = SubCircuit()
    ...                     # circuits built here use symbolic_poly
# default restored on exit