Skip to main content

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 two syntax details that are easiest to get wrong.

Install

pip install symbulator

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

Quick start

from symbulator import dc, ac, fd, tr, th, er, port

# 5V source through a 1k/1k voltage divider
res = dc("e1,1,0,5:r1,1,2,1'k:r2,2,0,1'k")
print(res.v("2"))     # 5/2
print(res.i("r1"))    # 1/400 (2.5 mA)
print(res["p_r1"])    # power dissipated in r1

# Series RLC driven at omega = 1000 rad/s
res = 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 ground
eq = 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 domain
res = 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.

Prefix Element Fields
r resistor name,n1,n2,value
l inductor name,n1,n2,value[,initial_current]
c capacitor name,n1,n2,value[,initial_voltage]
e voltage source (indep. or dependent) name,n1,n2,value
j current source (indep. or dependent) name,n1,n2,value
o ideal op-amp (nullor) name,n_plus,n_minus,n_out
m mutual inductance name,Lname1,Lname2,M
s short circuit name,n1,n2
t ideal transformer name,n1,n2,turns1,turns2
z,y,h,g,a,b grounded two-port block name,n1,n2 (params passed separately, see below)

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.

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) are supplied via a params dict, since on the calculator they were either predefined variables or entered interactively:

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)

If an element's params are omitted, they're left as free symbols named <name>11, <name>12, etc. (matching the original's "leave them symbolic" default). 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) voltage
eq.ino    # short-circuit (Norton) current
eq.z      # Req (dc) or Zeq (ac) = vth/ino
eq.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.

from symbulator import fd, 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 time
res_s = fd("e1,1,0,5/s:r1,1,2,1000:c1,2,0,1e-6")    # the same source, in s
res_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 condition
res_t = tr("l1,0,2,0.2,3:r1,2,0,100", variables=["i_l1"])  # I0=3A, L=0.2H, R=100 ohm
res_t["i_l1"]   # 3*exp(-500*t)

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

Source value Read as
a function of t -- u(t), t, 2*exp(-4*t) transformed with t2s()
a constant -- 12, vs a step of that amplitude (value/s)
already written in s -- 5/s left alone
a reference to another answer -- 2*i_r1 left 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.

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 ac
ex("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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

symbulator-0.5.19.tar.gz (117.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

symbulator-0.5.19-py3-none-any.whl (124.2 kB view details)

Uploaded Python 3

File details

Details for the file symbulator-0.5.19.tar.gz.

File metadata

  • Download URL: symbulator-0.5.19.tar.gz
  • Upload date:
  • Size: 117.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for symbulator-0.5.19.tar.gz
Algorithm Hash digest
SHA256 56604f8ce6e3b67cbea1187cfc7cf48ac00c0f06c29df10e1034fc876eb1d7f0
MD5 4aa7f77e37120d0c145f534a40b40c0f
BLAKE2b-256 321221d6957ae26ac4f073f6e0b9536db65bbc67d650618b20223bab33fee0b7

See more details on using hashes here.

File details

Details for the file symbulator-0.5.19-py3-none-any.whl.

File metadata

  • Download URL: symbulator-0.5.19-py3-none-any.whl
  • Upload date:
  • Size: 124.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for symbulator-0.5.19-py3-none-any.whl
Algorithm Hash digest
SHA256 1f02bfd935c232a6b7b90e1bd2408d04c362f8568f6dbe071a3d112bc2859a9a
MD5 739aa48b9052226e2471c6acf28c8f49
BLAKE2b-256 a6c1ab002095db7d09bd8770d2b43d785c1a3febf2becac690f13aed46df9b9a

See more details on using hashes here.

Release history Release notifications | RSS feed

0.6.11

2 files

0.6.10

2 files

0.6.9

2 files

0.6.8

2 files

0.6.7

2 files

0.6.6

2 files

0.6.5

2 files

0.6.4

2 files

0.6.3

2 files

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.33

2 files

0.5.32

2 files

0.5.31

2 files

0.5.30

2 files

0.5.29

2 files

0.5.28

2 files

0.5.27

2 files

0.5.26

2 files

0.5.25

2 files

0.5.24

2 files

0.5.23

2 files

0.5.22

2 files

0.5.21

2 files

0.5.20

2 files

This release

0.5.19 This release

2 files

0.5.18

2 files

0.5.17

2 files

0.5.16

2 files

0.5.15

2 files

0.5.14

2 files

0.5.13

2 files

0.5.12

2 files

0.5.11

2 files

0.5.10

2 files

0.5.9

2 files

0.5.8

2 files

0.5.7

2 files

0.5.6

2 files

0.5.5

2 files

0.5.4

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.6

2 files

0.4.5

2 files

0.4.4

2 files

0.4.3

2 files

0.4.2

2 files

0.4.0

2 files

0.3.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page