"""Behavioral sources (SPICE ``B`` elements).
Two flavours, both letting the user pin an arbitrary symbolic expression
to a pair of nodes:
* :class:`BehavioralCurrent` — drives the current ``I = f(V(...), …)``
from ``n_plus`` to ``n_minus``. Equivalent to SPICE
``B1 N+ N- I=...`` and ngspice's ``Vxxx ... value={...}`` form.
* :class:`BehavioralVoltage` — enforces the voltage
``V(n_plus) - V(n_minus) = f(V(...), …)``. Equivalent to SPICE
``B1 N+ N- V=...``.
The expression may reference any sympy ``Symbol``. Node voltages are
addressed using the same naming convention as the MNA solution vector:
``Symbol("V(<node>)")``. A behavioural element therefore captures
nonlinear control laws — squarers, multipliers, sign extractors,
saturating amplifiers — without having to wire them up from primitives.
* **DC** — the expression is used directly via :meth:`stamp_nonlinear`,
so even non-polynomial control laws (``tanh``, ``Min``, ``Max``,
exponentials, etc.) flow through the existing damped-Newton path.
* **AC** — the expression is linearised around an operating point
``V_op_subs`` (a mapping ``{Symbol: value}``). Partial derivatives
with respect to every referenced node voltage are stamped as VCCS /
VCVS contributions. If ``V_op_subs`` is ``None``, the operating
point is left symbolic — fine for symbolic transfer-function work.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import ClassVar, Mapping, Optional
from sycan import cas as cas
from sycan.mna import Component, NoiseSpec, StampContext
def _node_symbols(expr: cas.Expr) -> list[cas.Symbol]:
"""Return all ``V(<node>)`` symbols appearing in ``expr``."""
out: list[cas.Symbol] = []
for sym in sorted(expr.free_symbols, key=lambda s: str(s)):
name = str(sym)
if name.startswith("V(") and name.endswith(")"):
out.append(sym)
return out
def _substitute_op(expr: cas.Expr, op: Optional[Mapping]) -> cas.Expr:
if not op:
return expr
return expr.subs({cas.sympify(k): cas.sympify(v) for k, v in op.items()})
[docs]
@dataclass
class BehavioralCurrent(Component):
"""Current source whose value is an arbitrary symbolic expression.
DC: residual contribution adds ``+expr`` to ``n_plus`` and
``-expr`` at ``n_minus`` (current flowing from + to - internally).
AC: ``expr`` is linearised around ``V_op_subs``; each partial
derivative ``∂expr/∂V(k)`` is stamped as a VCCS term from node ``k``
to the (n+, n-) pair. Constant terms in the linearisation are
dropped — they belong to the DC operating point, not the small
signal.
"""
name: str
n_plus: str
n_minus: str
expr: cas.Expr
V_op_subs: Optional[dict] = field(default=None, kw_only=True)
include_noise: NoiseSpec = field(default=None, kw_only=True)
ports: ClassVar[tuple[str, ...]] = ("n_plus", "n_minus")
has_nonlinear: ClassVar[bool] = True
SUPPORTED_NOISE: ClassVar[frozenset[str]] = frozenset()
def __post_init__(self) -> None:
self.expr = cas.sympify(self.expr)
self.include_noise = self._normalize_noise(self.include_noise)
[docs]
def stamp(self, ctx: StampContext) -> None:
if ctx.mode != "ac":
return
i, j = ctx.n(self.n_plus), ctx.n(self.n_minus)
for v_sym in _node_symbols(self.expr):
gm = cas.diff(self.expr, v_sym)
gm = _substitute_op(gm, self.V_op_subs)
if gm == 0:
continue
node = str(v_sym)[2:-1]
try:
k = ctx.n(node)
except ValueError:
continue # symbol references an unregistered node
if i >= 0 and k >= 0:
ctx.A[i, k] += gm
if j >= 0 and k >= 0:
ctx.A[j, k] -= gm
[docs]
def stamp_nonlinear(self, ctx: StampContext) -> None:
if ctx.mode != "dc":
return
assert ctx.x is not None and ctx.residuals is not None
# Map V(node) symbols in the user expression onto the actual
# MNA unknowns ctx.x[node_row].
subs = {}
for v_sym in _node_symbols(self.expr):
node = str(v_sym)[2:-1]
try:
idx = ctx.n(node)
except ValueError:
continue
subs[v_sym] = ctx.x[idx] if idx >= 0 else cas.Integer(0)
I_b = self.expr.subs(subs) if subs else self.expr
i, j = ctx.n(self.n_plus), ctx.n(self.n_minus)
if i >= 0:
ctx.residuals[i] += I_b
if j >= 0:
ctx.residuals[j] -= I_b
[docs]
@dataclass
class BehavioralVoltage(Component):
"""Voltage source whose value is an arbitrary symbolic expression.
Enforces ``V(n_plus) - V(n_minus) = expr``. Behaves like a normal
voltage source for stamping (introduces an aux branch current),
but with a non-constant right-hand side.
DC: the constraint row directly gets ``-expr`` from the node
unknowns. Linear references resolve in the linear path; nonlinear
expressions go through the nonlinear residual.
AC: linearised around ``V_op_subs`` — only first-order terms remain.
"""
name: str
n_plus: str
n_minus: str
expr: cas.Expr
V_op_subs: Optional[dict] = field(default=None, kw_only=True)
include_noise: NoiseSpec = field(default=None, kw_only=True)
ports: ClassVar[tuple[str, ...]] = ("n_plus", "n_minus")
has_aux: ClassVar[bool] = True
has_nonlinear: ClassVar[bool] = True
SUPPORTED_NOISE: ClassVar[frozenset[str]] = frozenset()
def __post_init__(self) -> None:
self.expr = cas.sympify(self.expr)
self.include_noise = self._normalize_noise(self.include_noise)
[docs]
def stamp(self, ctx: StampContext) -> None:
aux = ctx.aux(self.name)
i, j = ctx.n(self.n_plus), ctx.n(self.n_minus)
if i >= 0:
ctx.A[i, aux] += 1
ctx.A[aux, i] += 1
if j >= 0:
ctx.A[j, aux] -= 1
ctx.A[aux, j] -= 1
if ctx.mode == "ac":
# Linearise: V(+) - V(-) = sum_k (d expr / d V(k))_op · v(k).
# The aux row is V(+) - V(-) - sum_k ... = 0 (constant op-point
# term contributes only to the DC bias, which has been zeroed in
# the small-signal system).
for v_sym in _node_symbols(self.expr):
gm = cas.diff(self.expr, v_sym)
gm = _substitute_op(gm, self.V_op_subs)
if gm == 0:
continue
node = str(v_sym)[2:-1]
try:
k = ctx.n(node)
except ValueError:
continue
if k >= 0:
ctx.A[aux, k] -= gm
return
# DC: rhs is the expression evaluated at node unknowns.
# Linear chunks resolve symbolically inside MNA; nonlinear
# chunks contribute through stamp_nonlinear.
# Leave b[aux] = 0 here; the residual pass writes
# V(+) - V(-) - expr(unknowns) into the aux residual row.
ctx.b[aux] = 0
[docs]
def stamp_nonlinear(self, ctx: StampContext) -> None:
if ctx.mode != "dc":
return
assert ctx.x is not None and ctx.residuals is not None
aux = ctx.aux(self.name)
subs = {}
for v_sym in _node_symbols(self.expr):
node = str(v_sym)[2:-1]
try:
idx = ctx.n(node)
except ValueError:
continue
subs[v_sym] = ctx.x[idx] if idx >= 0 else cas.Integer(0)
rhs = self.expr.subs(subs) if subs else self.expr
# Aux row currently encodes V(+) - V(-) = 0; add `-rhs` so it
# becomes V(+) - V(-) - rhs = 0.
ctx.residuals[aux] -= rhs