Skip to content

Calculate Input Ref. Thermal Noise for Any Circuit!

Published: at 03:29 AM (17 min read)

Hola peepsicles! So, a lot of what I was learning about this past summer revolved around optimizing circuit topology with respect to thermal noise. See this post for some more background!

Specifically, I spent time learning about various methods of circuit synthesis (Foster-Cauer synthesis, Multiport Synthesis - Brune’s method, etc). Of course, much of my work was purely exploratory, and I spent most of my time developing the necessary mathematical tools to understand concepts. My advisor phrased it as learning to think ‘precisely’, which I’ve come to appreciate over the course of the summer.

This is a post explaining a script I made in Mathematica. It calculates the ‘input-referred’ thermal noise for any circuit with strictly passive elements (resistors, capacitors, inductors).

Table of Contents

Open Table of Contents

TLDR & Usage

Will update with an example. For now: placeholder

Introduction

In this post, I will dissect a Mathematica function calcInputRefVNoise, which computes the input-referred thermal noise Power Spectral Density (PSD) for an aribitrary circuit given its SPICE netlist. The code leverages principles of circuit graph theory using incidence (cutset) and admittance matrices to derive a symbolic expression for noise. I’ll attempt to explain each part of the code in detail, assuming the reader has some background in circuit analysis and noise fundamentals.

Parsing and Preprocessing the SPICE Netlist

The function first begins by importing and cleaning the SPICE netlist text:

fileContent = Import[filePath, "Text"];
lines = StringSplit[fileContent, "\n"];
netlistLines = Drop[lines, 1];
netlistLines = Drop[netlistLines, -2];
netlist = StringTrim[netlistLines];

The first and last two lines are dropped (often a title/comment in SPICE).

StringTrim is applied to remove and leading/trailing whitespace on each remaining line, and the remaining netlist is a list of cleaned strings, each representing one circuit element’s definition in SPICE format. For example, netlist may look like

{"R1 1 2 1k", "C1 2 0 1u", "L1 2 3 5m", ...}

Each entry is a component definition that follows this convention: {Name, Node1, Node2, Value}.

Tokenizing Components and Identifying Nodes

Next, the code tokenizes each netlist line into its constituent parts (component name, nodes, value, etc.) and gathers all unique node identifiers:

components = StringSplit[#, " "] & /@ netlist;
nodes = Union[Flatten[components[[All, 2 ;; 3]]]] // DeleteCases["0"];
nodes = Append[nodes, "0"];
nodeOrder = Association[Thread[nodes -> Range[Length[nodes]]]];
numNodes = Length[nodes];

At this point, nodes might be something like {"1", "2", "3", ..., "0"}. The ground node “0” is placed at the end. The ordering is important later. By mapping node labels to indices, the code can easily reference matrix rows by node name.

Incidence Matrix Construction (Node-Branch Matrix)

Using the node list, the code builds an incidence matrix that describes how each branch (component) connects to the nodes:

incidenceMatrix = ConstantArray[0, {numNodes, Length[netlist]}];
incidenceMatrix = Module[{incMat = incidenceMatrix},
  MapIndexed[(
      incMat[[ nodeOrder[#1[[1]]], #2[[1]] ]] = 1;
      incMat[[ nodeOrder[#1[[2]]], #2[[1]] ]] = -1;
    ) &, components[[All, {2, 3}]]];
  incMat
];
A = Most[incidenceMatrix];

After this, each column of the incidenceMatrix has exactly two nonzero entries: a +1 for the node where the branch current is considered to leave, and a -1 for the node where the branch current enters (based on an assumed orientation). All other entries are 0 (the branch is not incident on those nodes). This corresponds to a directed graph representation of the circuit: each branch is oriented from its first-listed node towards its second-listed node.

A quick mathematical note: The incidence matrix AA (after dropping ground) will have dimensions (n1)×B(n-1) \times B. If we treat branch currents as oriented according to the incidence signs, the matrix relates branch currents ib\mathbf{i}_b to node currents (KCL) as in=Aibi_n = Ai_b, where in\mathbf{i}_n is an (n1)(n-1)-vector of net currents injected into each non-ground node. Each row of AA enforces Kirchhoff’s Current Law (KCL) at that node: the sum of currents leaving the node (positive entries) minus the sum entering (negative entries) equals the net injection (which will be zero for passive branches unless an independent current source is connected).

Computing Admittances and Thermal Noise of Components

The next portion of code defines a helper to obtain each component’s admittance (in the Laplace domain) and its thermal noise PSD, then applies it to all components:

admittanceAndNoisePowerMap[component_] := Module[{nestedAdmittances, nestedNoisePSDs,
                                                 name, value, componentType},
  name = component[[1]];
  value = ToExpression[component[[4]]];
  componentType = StringTake[name, 1];
  (* Determine the admittance and noise PSD based on the component type *)
  nestedAdmittances = Switch[componentType,
    "C", s*value,        (* Capacitance: Admittance = s C *)
    "R", 1/value,        (* Resistance: Admittance = 1/R *)
    "L", 1/(s*value)     (* Inductance: Admittance = 1/(s L) *)
  ];
  nestedNoisePSDs = Switch[componentType,
    "R", 4*value*k*T,
    "C", 0,
    "L", 0
  ];
  {nestedAdmittances, nestedNoisePSDs}
];
{admittances, noisePSDs} = Transpose[admittanceAndNoisePowerMap /@ components];
branchAdmittances = DiagonalMatrix[admittances];

Note: The code assumes a single global temperature T and Boltzmann constant k are defined in the environment. It treats each resistor’s noise as an equivalent series voltage noise source with PSD 4kBTR4k_BTR. I could equally have used an equivalent parallel current noise 4kBT/R4k_B T / R, but using the series voltage model aligns with the transfer function approach.

Nodal Admittance Equations and Solving for Transfer Functions

With the incidence matrix AA and the branch admittance matrix in hand, the code constructs the system equations and derives a transfer function matrix GG. This matrix GG relates each branch’s noise source to the node voltages:

(* Calculate the transfer function matrix G *)
G = -1 * Inverse[A . branchAdmittances . Transpose[A]] . A . branchAdmittances;

Recall that:

Ynode=AYbranchATY_{\text{node}} = A Y_{\text{branch}}A^T

This matrix represents the conductance connections between nodes (it’s the graph-theoretic formulation of Kirchhoff’s nodal equations). It is generally invertible.

Now, consider how a series voltage source in a branch affects node voltages. Suppose branch kk (connecting node pp to node qq) has a small voltage source vkv_k in series (for noise, vkv_k is the random noise voltage). In nodal analysis, a series voltage source can be handled by introducing an equivalent current source injection at the nodes. A voltage vkv_k across an admittance YkY_k will drive a current i=Yk,vki = Y_k , v_k through that branch. That current enters one node and leaves the other, effectively injecting current +Ykvk+Y_k v_k into one node and Ykvk-Y_k v_k into the other. This can be represented by an injection vector equal to YkvkY_k v_k times the column of AA corresponding to branch kk. In superposition terms, the effect of branch kk’s noise on the node voltages can be solved via the nodal admittance matrix:

The code includes a prefactor of -1, which arises from sign conventions in KCL for a voltage source insertion. In the formulation AA already encodes a +1 and -1 for the two nodes of each branch. When we moved the known source to the right side of the KCL equations, it enters with a negative sign (since Iinj=Yk(+vk)I_{\text{inj}} = Y_k * (+v_k) at one node and Ykvk-Y_k * v_k at the other, effectively we are subtracting the effect of the source from the KCL sum). The -1 in the code makes G(s)G(s) correspond to the transfer function from a positive series voltage as the first node of the branch of the node voltages.

Thus, G(s)G(s) is an (n1)×B(n-1) \times B matrix of transfer functions. Its entry Gi,k(s)G_{i,k}(s) represents the influence of branch kk’s series noise source on node ii’s voltage (except ground). In other words, if the kkth branch has a noise voltage vk(s)v_k(s), then the voltage at node ii due to this source alone is Gi,k(s)vk(s)G_{i,k}(s) \cdot v_k(s).

G(s)=(AY(s)AT)1AY(s)G(s) = -(AY(s)A^T)^{-1}AY(s)

where Y(s)=YbranchY(s) = Y_{\text{branch}}. Each column kk of G(s)G(s) is essentially Ynode1(Ykak)Y_{\text{node}}^{-1} (Y_k a_k), which matches the derivation above.

This procedure is a cutset-based approach where KCL is applied. The matrix AA (reduced incidence) is closely related to the fundamental cutset matrix of the circuit graph, enforcing KCL. An alternative would be a KVL mesh/loop analysis using fundamental tie-set (loop) matrix BB to enforce KVL. This approach would for loop impedance matrices. I chose the nodal analysis since it was bit more intuitive with regards to current injection from noise sources.

Frequency Domain Conversion of Transfer Functions

After deriving the symbolic transfer function matrix G(s)G(s), the code converts it to the frequency domain by substituting s=jωs = j\omega (where j=1j = \sqrt{-1}):

Giw = G /. s -> I*w;

At this point, Giw is ready to be used for computing noise spectral densities. I was interested in the magnitude of these transfer functions since noise PSD contributions depend on the squared magnitude of transfer gains (because power spectral density of the output due to a source is the input PSD times the gain magnitude squared, for linear systems).

GMSquared = # * Conjugate[#] & /@ Giw;

Summing Noies Contributions

Finally, the code combines the squared gains with each branch’s noise PSD to compute the total noise at each node (except ground). This uses the principle of superposition for independent noise sources (RSS addition):

inputRefNoisePSDs = FullSimplify[
  GMSquared . noisePSDs,
  Assumptions -> Flatten[{
    Element[w, Reals], Element[R, Reals], Element[L, Reals], Element[C, Reals],
    Table[Element[ToExpression[symbol <> ToString(i)], Reals], {symbol, {"R", "L", "C"}}, {i, 1, 100}]
  }]
];
(* Return the input-referred noise PSDs *)
inputRefNoisePSDs

The iith element of

k=1BGi,k(jω)2Sk,\sum_{k=1}^{B} \left| G_{i,k}(j\omega) \right|^2 S_k,

where SkS_k is the PSD of the noise source in branch kk. This sum represents the total noise PSD at node ii, contributed by all independent noise sources in the circuit, assuming they are uncorrelated (which thermal noise sources are).

If we denote the noise PSD at node ii as SVi(ω)S_{V_i}(\omega):

SVi(ω)=k=1BGi,k(jω)2Sk,S_{V_i}(\omega) = \sum_{k=1}^{B} \left| G_{i,k}(j\omega) \right|^2 S_k,

for i=1,2,,(n1)i = 1,2,\dots,(n-1), where SkS_{k} is the PSD of branch kk‘s noise source (e.g. 4kBTRk4k_BT R_k for a resistor, or 0 for noiseless components).

FullSimplify & Assumptions

The code then uses FullSimplify with a set of assumptions to simplify the resulting expressions for noise PSDs. The assumptions include:

These assumptions help Mathematica simplify the algebraic form of the noise PSD. For example, if an expression contains (jω)2(j\omega)^2 it can simplify to ω2-\omega^2 under these assumptions, etc. The end result is a simpler symbolic expression or vector of expressions for the noise PSD at each node as a function of frequency and component values.

The function returns inputRefNoisePSDs. This is the vector of noise spectral densities at each node. Often in amplifier noise analysis, we are often interested in the noise referred to the input node. If the netlist is arranged such that the first node (after ground) is the input of the circuit, then the first element of this vector would be the input-referred noise PSD of the entire circuit. In general, the vector form allows us to see noise at multiple nodes if needed.

Future Features

While I know circuit simulators definitely incorporate features like AC noise analysis, total output/input referred noise over a bandwidth, PSD plots, etc., I’m not sure to what extent they are ‘customizable’. This function could be a good starting point to incorporate some cooler features, such as parameter sensitivity of noise (SR\frac{\partial S}{\partial R}), self-heating thermal feedback into noise, quantum noise, Fisher information / estimation theory tools.

Complete Function

calcInputRefVNoise[filePath_] :=
  Module[{fileContent, lines, netlistLines, netlist, components,
    nodes, nodeOrder, numNodes, incidenceMatrix, A,
    admittanceAndNoisePowerMap, admittances, branchAdmittances,
    noisePSDs, G, Giw, GMSquared,
    inputRefNoisePSDs},(*Import the SPICE netlist file as text*)
   fileContent = Import[filePath, "Text"];
   (*Split the content into individual lines*)
   lines = StringSplit[fileContent, "\n"];
   (*Remove the first line and last two lines (comments)*)
   netlistLines = Drop[lines, 1];
   netlistLines = Drop[netlistLines, -2];
   netlist = StringTrim[netlistLines];
   (*Parse the netlist into components*)
   components = StringSplit[#, " "] & /@ netlist;
   (*Extract unique nodes from the components,
   excluding the ground node "0"*)
   nodes =
    Union[Flatten[components[[All, 2 ;; 3]]]] // DeleteCases["0"];
   nodes = Append[nodes, "0"];
   nodeOrder = Association[Thread[nodes -> Range[Length[nodes]]]];
   numNodes = Length[nodes];
   incidenceMatrix = ConstantArray[0, {numNodes, Length[netlist]}];
   (*Incidence matrix based on the connections between nodes*)
   incidenceMatrix =
    Module[{incMat = incidenceMatrix},
     MapIndexed[(incMat[[nodeOrder[#1[[1]]], #2[[1]]]] = 1;
        incMat[[nodeOrder[#1[[2]]], #2[[1]]]] = -1;) &,
      components[[All, {2, 3}]]];
     incMat];
   A = Most[incidenceMatrix];
   (*Define a function to compute branch admittances and noise power s\
pectral densities (PSDs)*)
   admittanceAndNoisePowerMap[component_] :=
    Module[{nestedAdmittances, nestedNoisePSDs, name, value,
      componentType}, name = component[[1]];
     value = ToExpression[component[[4]]];
     componentType = StringTake[name, 1];
     (*Determine the admittance and noise PSD based on the component t\
ype*)nestedAdmittances =
      Switch[componentType, "C", s*value,(*Capacitance:Admittance=sC*)
       "R", 1/value,(*Resistance:Admittance=1/R*)
       "L", 1/(s*value)(*Inductance:Admittance=1/sL*)];
     nestedNoisePSDs =
      Switch[componentType, "R", 4*value*k*T, "C", 0, "L",
       0]; {nestedAdmittances, nestedNoisePSDs}];
   {admittances, noisePSDs} =
    Transpose[admittanceAndNoisePowerMap /@ components];
   branchAdmittances = DiagonalMatrix[admittances];
   (*Calculate the transfer function matrix G*)
   G = -1*
     Inverse[A . branchAdmittances . Transpose[A]] . A .
      branchAdmittances;
   Giw = G /. s -> I*w;
   (*Calculate|G|^2,the squared magnitude of the transfer function*)
   GMSquared = #*Conjugate[#] & /@ Giw;
   inputRefNoisePSDs =
    FullSimplify[GMSquared . noisePSDs,
     Assumptions ->
      Flatten[{Element[w, Reals], Element[R, Reals], Element[L, Reals],
         Element[C, Reals],
        Table[Element[ToExpression[symbol <> ToString[i]],
          Reals], {symbol, {"R", "L", "C"}}, {i, 1, 100}]}]];
   (*Return the input-referred noise PSDs*)
   inputRefNoisePSDs];