Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - Symbulator-Team/solver: Symbolic linear-circuit analysis engine, built on SymPy · GitHub
Skip to content

Repository files navigation

symbulator — Symbulator 9

Symbulator 9 is a port of Symbulator 8 — Roberto Perez-Franco's symbolic linear-circuit simulator for the TI-Nspire CAS — to Python and SymPy, with minor improvements. This package is its solver core.

All of the original's analysis tools are now ported: DC, AC (phasor), s-domain (Laplace), and transient analysis; Thevenin/Norton equivalents; two-port parameter extraction; and the expert-mode dispatcher. See Scope below for the handful of things that are intentionally simplified relative to the calculator version, and why.

AI coding agent? This README is written to be read start to finish and followed directly — the Quick start and Circuit description syntax sections below have everything needed to write a correct circuit description on the first try. See also llms.txt for a short index and the three details that are easiest to get wrong.

Install

pip install symbulator

From a checkout of the repository: pip install -e .

Quick start

fromsymbulatorimportdc, ac, fd, tr, th, er, port# 5V source through a 1k/1k voltage dividerres=dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2")) # 5/2print(res.i("r1")) # 1/400 (2.5 mA)print(res["p_r1"]) # power dissipated in r1# Series RLC driven at omega = 1000 rad/sres=ac("e1,1,0,10:r1,1,2,100:l1,2,3,0.1:c1,3,0,1e-6", omega=1000)
print(res.v("2"))
print(res["z_e1"]) # input impedance seen by the source# Thevenin equivalent between node 2 and groundeq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", "2", "0", domain="dc")
print(eq.vth, eq.z, eq.pmax)
# Step response of an RC circuit, in the time domainres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6", variables=["v_2"])
print(res["v_2"]) # 5 - 5*exp(-1000*t)

Circuit description syntax

Unchanged from the calculator (minus the leading :): elements are separated by :, fields within an element by ,. Node 0 is ground.

PrefixElementFields
rresistorname,n1,n2,value
linductorname,n1,n2,value[,initial_current]
ccapacitorname,n1,n2,value[,initial_voltage]
evoltage source (indep. or dependent)name,n1,n2,value
jcurrent source (indep. or dependent)name,n1,n2,value
oideal op-amp (nullor)name,n_plus,n_minus,n_out
mmutual inductancename,Lname1,Lname2,M
sshort circuitname,n1,n2
tideal transformername,n1,n2,turns1,turns2
z,y,h,g,a,bgrounded two-port blockname,n1,n2[,[p11,p12,p21,p22]]

The optional initial-condition field on l/c (initial inductor current / capacitor voltage) is only meaningful for fd()/tr(); it's ignored by dc()/ac(). Unlike the original -- which required a different field count per element depending on which analysis tool was running -- this port always accepts the extra field and just treats it as 0 if omitted, regardless of which function you call.

Dependent (controlled) sources work "for free": a value field can be any SymPy-parseable expression referencing other node-voltage/current symbols (v_<node>, i_<element>), e.g. e2,3,0,2*v_2 for a VCVS. This mirrors how the original evaluated value strings through the calculator's own expression engine.

Case matters for variables, but not for names. Element and node names fold to lowercase (R1 and r1 are one resistor, node A is node a), and so does any reference derived from them: 2*VR1, 2*vr1 and 2*v_r1 all mean r1's voltage drop, the same spelling equivalence that makes ir1i_r1IR1. A variable that names nothing in the circuit is an ordinary SymPy symbol and IS case-sensitive: e,a,b,c and e,a,b,C are two different sources, and a condition c = 5 does not touch C. The boundary is exact: a name folds when (and only when) its folded spelling matches an answer of the circuit -- a v_.../i_.../p_... of some element or node.

Unit shorthand: the calculator's own 'k/'M/'u/... syntax (1'k = 1000) is always unambiguous, as is an explicit product with a symbol (1*k). A bare suffix like 1k could mean either one, so by default (suffix="ask") it raises AmbiguousValueError listing every such value; pass suffix="si" to read them all as SI units, or suffix="var" to read them all as number-times-variable. Use find_ambiguous_values(desc) to scan a description without solving -- that's what the web front end uses to ask the user interactively.

Two-port parameters (z/y/h/g/a/b) ride in the description as an optional last term, a four-entry list:

res=dc("e1,1,0,10:y1,1,2,[0.001,-0.001,-0.001,0.001]:rl,2,0,1'k")

Entries may be numbers, SI-prefixed values or expressions (symbols included); each binds the correspondingly-named variable (y11, y12, ... for an element named y; y111, ... for one named y1 -- the element's name prefixes the digits) through the same substitution machinery as conditions=, so an explicit condition on the same name still overrides the description. Without the term, the parameters are free symbols of those names -- the tacit term [y111,y112,y121,y122] -- matching the original's "leave them symbolic" default, and they can be pinned via conditions= or the older params dict, which is still accepted:

params= {"y1": {"11": "0.001", "12": "-0.001", "21": "-0.001", "22": "0.001"}}
res=dc("e1,1,0,10:y1,1,2:rl,2,0,1'k", params=params)

Use port() (below) to go the other way and extract z/y/h/g/a/b parameters from an actual sub-circuit.

DC / AC / s-domain results

dc(), ac(), and fd() return a Result with:

  • res.v(node) -- node voltage
  • res.i(name) -- element/branch current
  • res["p_<name>"] / res["ap_<name>"] -- real/apparent power (DC / AC only)
  • res["s_<name>"] -- complex power (AC only)
  • res["z_<name>"] / res["r_<name>"] -- impedance / resistance seen by a source (AC / DC only)

(The power/impedance derived quantities are DC/AC-only, matching the original -- fd() doesn't compute them either.)

ac() takes a use_rms=True flag to switch the power convention from peak-amplitude phasors (default, dividing by 2) to RMS phasors, matching the original's userms setting.

Thevenin / Norton: th() and er()

eq=th("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", n1="2", n2="0", domain="dc")
eq.vth# open-circuit (Thevenin) voltageeq.ino# short-circuit (Norton) currenteq.z# Req (dc) or Zeq (ac) = vth/inoeq.pmax# max power transferable to a matched load

th() is for active circuits (ones with their own independent sources) -- it raises if the open-circuit voltage comes out to 0, same as the original's redirect message. For a passive (source-free) network, use er() instead, which injects a single 1A test current and reads the equivalent resistance/impedance directly:

req=er("r1,1,2,1'k:r2,2,0,2'k", n1="1", n2="0", domain="dc") # 3000

Two-port extraction: port()

Extracts z/y/h/g/a/b parameters of a whole circuit between two grounded ports (the inverse of feeding pre-defined parameters into a z/y/... circuit element, described above):

params=port("r1,1,3,100:r2,2,3,200:r3,3,0,50", n1="1", n2="2", kind="z", domain="dc")
params["11"], params["12"], params["21"], params["22"]

Works the same way in AC (pass omega=... and domain="ac").

s-domain and transient: fd() and tr()

The two read their sources in different domains, and that is the whole point of having both.tr() reads a source value as a function of time; fd() reads it as an expression in s. A value of 5 is a 5 V step to tr() and a 5 V impulse to fd() -- different circuits, not different notations for the same one.

fromsymbulatorimportfd, tr, t2s, s2t# Step response of an RC low-pass, starting from rest.res_t=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6") # source in timeres_s=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6") # the same source, in sres_t["v_2"] # 5 - 5*exp(-1000*t)res_s["v_2"] # 5000/(s*(s + 1000))# Natural response of a discharging inductor with an initial conditionres_t=tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"]) # I0=3A, L=0.2H, R=100 ohmres_t["i_l1"] # 3*exp(-500*t)

tr() transforms each source for you, by what the value is:

Source valueRead as
a function of t -- u(t), t, 2*exp(-4*t)transformed with t2s()
a constant -- 12, vsa step of that amplitude (value/s)
already written in s -- 5/sleft alone
a reference to another answer -- 2*i_r1left alone; a controlled source is a relation, not a waveform

In fd() nothing is transformed, because fd() is the s-domain. To give it a value written in time, wrap it in braces -- {5}, {u(t)}, {2*exp(-4*t)} -- which is the calculator's shorthand for t2s(...) and works only there.

t2s()/s2t() wrap SymPy's laplace_transform/inverse_laplace_transform directly, for preparing a source value by hand or checking an answer. Both are usable inside a circuit description, an equations= entry, or any expression you hand back to the package.

tr(desc, variables=[...]) lets you limit which answers get inverse-Laplace-transformed -- useful since that step can be slow (or fail to find a closed form) for complicated expressions; omit variables to attempt every solved node voltage and element current. Any individual variable that can't be transformed is silently left out of the result rather than failing the whole call.

History, in case you meet an older version: releases before 0.5.5 skipped the forward transform. The original called out to a separate lf\\laplace calculator library that was not in the document this was ported from, so tr() inverse-transformed its answers but passed its sources through untouched -- which made every transient result one integration short, an impulse response where a step response was meant. SymPy's own laplace_transform does the job, and 0.5.5 restored the behaviour the calculator versions have always had. A description written for Symbulator 7 or 8 now gives the same answer here.

Working with the answers (SymPy)

Every answer is a SymPy expression — exact where the inputs were exact (the quick start's res.v("2") really is the rational 5/2, not the float 2.5) — so everything SymPy does applies to it directly: float(), simplify(), .subs(), limit(), plot(), lambdify().

importsympyasspfromsymbulatorimportdcres=dc("e1,1,0,vin:r1,1,2,ra:r2,2,0,rb")
sp.simplify(res.v("2")) # rb*vin/(ra + rb)

The one trap: the time symbol carries an assumption. Time-domain answers are written in Symbol("t", nonnegative=True). To SymPy, a bare Symbol("t") is a different symbol — same name, different assumptions — so substituting with one does nothing, and does it silently:

fromsymbulatorimporttr, fd, t, s# <- the package's own t and sres=tr("e1,1,0,5:r1,1,2,1000:c1,2,0,1e-6")
res["v_2"] # 5.0 - 5.0*exp(-1000.0*t)res["v_2"].subs(sp.Symbol("t"), 0.001) # unchanged — silently a no-opres["v_2"].subs(t, 0.001) # 3.16060...res.at("v_2", t=0.001) # 3.16060... — same, by name

Two escapes, either fine: res.at(...) substitutes by name, so it can never miss (res.at(t=0.001) with no key returns a whole new Result evaluated at that instant); or import the package's own t and s symbols and use them wherever an expression leaves the package. Everything below uses the imported symbols.

Initial and final values are a substitution and a limit:

res["v_2"].subs(t, 0) # 0 — starts from restsp.limit(res["v_2"], t, sp.oo) # 5 — settles at the source voltage# Same check on the s-domain answer, by the final-value theorem:resf=fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")
sp.limit(s*resf["v_2"], s, 0) # 5

Plotting a transient works with SymPy's own plot — with the imported t, not a fresh Symbol("t"), or the curve comes out constant:

sp.plot(res["v_2"], (t, 0, 0.005))

For anything beyond a quick look, lambdify turns an answer into a plain numeric function for NumPy/Matplotlib:

importnumpyasnpf=sp.lambdify(t, res["v_2"], "numpy")
f(np.linspace(0, 0.005, 400)) # ready to plot, fit, export...

Frequency response needs bode_samples(), not plot(). An ac() result is a phasor at one fixed omega — there is nothing in it to sweep — so the package samples the frequency axis for you, returning (freq_hz, mag_db, phase_deg) ready for any plotting library:

fromsymbulatorimportbode_samples, time_samplesfreq, mag_db, phase=bode_samples(
"e1,1,0,1:r1,1,2,1000:c1,2,0,1e-6", "v_2", 10, 100_000)

Its twin time_samples(desc, key, t_max) samples a transient numerically — useful when the inverse Laplace transform of a complicated answer has no closed form and tr() leaves that variable out: the sampler sidesteps the symbolic inversion entirely.

A sign note on pf() at a source. The package's convention is that v_*/i_* describe power consumed by an element, which is the wrong way round for reading a source's power factor as leading/lagging — negate the current first:

fromsymbulatorimportac, pfres=ac("e,1,0,30:r1,1,2,6:r2,2,0,-2j:r3,2,0,4",
omega=sp.Symbol("omega"), use_rms=True)
pf(res["v_e"], -res["i_e"]) # pf: 0.97342 leading — correctpf(res["v_e"], res["i_e"]) # pf: 0.97342 lagging — backwards

pf() takes raw values and cannot know whether they came from a source or a load, so it cannot do the flip for you (the calculator's version special-cased element names and could).

Expert mode: ex()

A single dispatcher over dc/ac/fd/tr, for callers that want to pick the analysis type dynamically rather than calling a specific function -- ports ex(). On the calculator this interactively asked "1:DC 2:AC 3:FD 4:TR"; as a library there's no prompt to answer, so domain is just a normal argument (the word, or the calculator's own 1-4 shorthand):

ex("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k", domain="dc")
ex("e1,1,0,5:r1,1,0,100", domain="ac", omega=1000) # omega required for acex("l1,0,2,0.2,3:r1,2,0,100", domain="tr", variables=["i_l1"])

Expert mode's "Add equations / Add unknowns / Add conditions" prompts are ported as keyword arguments, available on ex() and on dc/ac/fd/tr directly:

# Design problem: what r_b makes the divider output exactly 6 V?res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,r_b",
equations=["v_2 = 6"], unknowns=["r_b"])
res.values["r_b"] # 4000# Derived quantity: a new symbol in an equation is auto-added as an# unknown, so no unknowns list is needed for this style.res=dc("e1,1,0,12:r1,1,2,4'k:r2,2,0,2'k", equations=["pout = v_2*i_r2"])
# Conditions -- the TI's "|" (with) operator: substitutions applied to# the whole system at solve time.res=dc("e1,1,0,vin:r1,1,2,r_a:r2,2,0,r_b",
conditions=["vin = 12", "r_a = 4'k", "r_b = 2'k"])

Extra equations run through the same unit-prefix expander as circuit values (so 6*4'k works), and accept either lhs = rhs strings or a bare expression (treated as expr = 0). A symbolic component value you want solved (like r_b above) must be listed in unknowns -- the solver otherwise treats it as a fixed parameter, matching the original's separate "Add unknowns" prompt.

If a solve leaves some values symbolic instead of resolving to plain numbers, the usual cause is one fewer independent equation than unknowns: count the symbols in unknowns= and make sure there's a matching equation for each, using every given/measured fact from the problem rather than only the ones that seem to describe the unknown you're focused on. A resistor's own equation (V = R * I) is nonlinear once both are unknown, which can occasionally make a fully-specified system harder to resolve symbolically in one call than the equation count alone would suggest; if that happens, solving the unknowns by hand from the given facts and then re-running the circuit with plain numbers is a reliable fallback.

Scope: what's simplified vs. the calculator version

  • pf() is ported as a simplified, explicit-argument version (pass a voltage and current phasor directly); the original's implicit per-element-type sign convention, driven by reading calculator variables like v<name>/i<name> automatically, wasn't replicated.
  • fd() requires s-domain source values, as the calculator's does; the {...} shorthand converts a time-domain one where you write it. tr() reads its sources in the time domain, also as the calculator's does. (Before 0.5.5 neither transformed anything -- see above.)
  • No interactive prompts anywhere -- everything the calculator asked for via RequestStr (analysis type, which answers to save, expert-mode custom equations, two-port parameter values, etc.) is a plain function argument here instead.
  • No Disp progress narration -- the calculator printed step-by-step status messages during a simulation; this port just returns the answer.

Tests

pytest symbulator/tests/ -v

48 tests across six files:

  • test_circuits.py (21): DC/AC voltage & current dividers, series RLC impedance, inverting/non-inverting op-amp gain, a voltage-controlled voltage source, an ideal transformer, mutual inductance (with and without coupling), a two-port block, derived power quantities, zero-valued-capacitor handling, and parser error handling.
  • test_equiv.py (9): Thevenin voltage/impedance and its cross-check against directly solving with a load attached, er() on series/parallel passive networks, port() z/y/a-parameter extraction (including a z·y matrix-inverse consistency check and an a-parameter round trip through the Phase 1 two-port element), and an AC two-port case.
  • test_laplace.py (5): t2s/s2t round trips, an RC step response checked numerically against the closed-form exponential, an RL natural response with a nonzero initial condition checked against its closed-form solution, and zero-valued-capacitor handling carried into fd().
  • test_dispatch.py (6): ex() dispatch to each of the four analysis modes, its numeric-shorthand domain aliases, and its error handling.
  • test_expert.py (7): expert-mode extras -- solving for a symbolic component via an added equation + unknown, auto-added derived-quantity symbols, unit shorthand inside added equations, conditions as solve-time substitutions, and error handling.

About

Symbolic linear-circuit analysis engine, built on SymPy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages