I'll perform a few of the steps by hand, below. But the process you see here can be easily coded up in Python or any other language you prefer.
(In fact, I'll write all the code needed at the end, below. That will show just how easy it is.)
I'm going to use a circuit that is simple but also complicated enough to illustrate the details required. I don't want you to miss something important.
step 1
Use a schematic capture program (for example, LTspice performs that function) to draw out your schematic and get its netlist. Then remove any comment lines or Spice command lines from the list. This will provide a simple list of conductances of length \$N\$.
For example:
simulate this circuit – Schematic created using CircuitLab
When I put that into LTspice I get the following netlist:
R1 n1 in R
R2 n2 n1 R
R3 n3 n2 R
R4 out n3 R
C1 n1 0 C
C2 n2 0 C
C3 n3 0 C
C4 out 0 C
.backanno
.end
The last two lines aren't needed (obviously.)
This leaves a conductance list that has these \$N=8\$ elements:
R1 n1 in R
R2 n2 n1 R
R3 n3 n2 R
R4 out n3 R
C1 n1 0 C
C2 n2 0 C
C3 n3 0 C
C4 out 0 C
Should be very easy to code this up.
step 2
Scan the resulting list to create a unique list of nodes.
Do not include ground (0) in the list. It's not needed.
Also make sure that your input node is at the bottom of the list. The ordering of the rest isn't important. But that singular point of placing the input node at the bottom is important to simplify the code.
I find:
n1
n2
n3
out
in
Note again that ground is not included in the list.
This list has \$M=5\$ elements.
This will also be easily coded up. (You will need to know your input source node name, of course.)
step 3
Create an \$N\times N\$ conductance matrix, \$C\$. Each conductance is listed just once along its diagonal. Everywhere else is zero.
I'll do this manually in SymPy:
C = Matrix( [ [1/R1,0,0,0,0,0,0,0],
[0,1/R2,0,0,0,0,0,0],
[0,0,1/R3,0,0,0,0,0],
[0,0,0,1/R4,0,0,0,0],
[0,0,0,0,s*C1,0,0,0],
[0,0,0,0,0,s*C2,0,0],
[0,0,0,0,0,0,s*C3,0],
[0,0,0,0,0,0,0,s*C4] ] )
Obviously easy to code since I just coded it up above, by hand. You can do this automatically given the list from step 1 and knowledge of the conductance of resistors, inductors, and capacitors.
step 4
Using the unique node list and also the simple list of conductances, create an \$N\times M\$ incidence matrix, \$A\$. This matrix starts out as all zero but will have a -1 and +1 placed on the row for each conductance found in the length-\$N\$ conductance list located at the two columns associated with the two nodes it connects.
Looking over the list from step 1 above, I get the following code:
A = Matrix( [ [-1,0,0,0,1], # R1 n1 in R
[1,-1,0,0,0], # R2 n2 n1 R
[0,1,-1,0,0], # R3 n3 n2 R
[0,0,1,-1,0], # R4 out n3 R
[-1,0,0,0,0], # C1 n1 0 C
[0,-1,0,0,0], # C2 n2 0 C
[0,0,-1,0,0], # C3 n3 0 C
[0,0,0,-1,0] ] ) # C4 out 0 C
That process will require re-parsing though the list from step 1. But you can see that the generation of the above isn't difficult to understand.
Note how the grounded capacitors only show one value, -1. It doesn't matter whether this is -1 or +1, as that only affects the assumed current (flux) direction. So just pick something in grounded device cases.
step 5
Now perform the following steps, using the Schur complement idea:
F = A.T * C * A # resulting F must be square
N,M = F.shape # so N == M.
P = F.extract( [i for i in range(N-1)], [i for i in range(N-1)] )
QT = F.extract( [i for i in range(N-1)], [N-1] )
(-P.inv()*QT)[N-2,0] # invert P, solve, and extract the desired transfer function.
1/(C1*C2*C3*C4*R1*R2*R3*R4*s**4 + C1*C2*C3*R1*R2*R3*s**3 + C1*C2*C4*R1*R2*R3*s**3 + C1*C2*C4*R1*R2*R4*s**3 + C1*C2*R1*R2*s**2 + C1*C3*C4*R1*R2*R4*s**3 + C1*C3*C4*R1*R3*R4*s**3 + C1*C3*R1*R2*s**2 + C1*C3*R1*R3*s**2 + C1*C4*R1*R2*s**2 + C1*C4*R1*R3*s**2 + C1*C4*R1*R4*s**2 + C1*R1*s + C2*C3*C4*R1*R3*R4*s**3 + C2*C3*C4*R2*R3*R4*s**3 + C2*C3*R1*R3*s**2 + C2*C3*R2*R3*s**2 + C2*C4*R1*R3*s**2 + C2*C4*R1*R4*s**2 + C2*C4*R2*R3*s**2 + C2*C4*R2*R4*s**2 + C2*R1*s + C2*R2*s + C3*C4*R1*R4*s**2 + C3*C4*R2*R4*s**2 + C3*C4*R3*R4*s**2 + C3*R1*s + C3*R2*s + C3*R3*s + C4*R1*s + C4*R2*s + C4*R3*s + C4*R4*s + 1)
That actually is correct, by the way. It's not in a form that will communicate well. But at least it is correct.
Note that I used \$N-2\$ as an index. This is because the output was at that position in the list from step 2. If you wanted a different transfer function, say one from \$n_2\$ for example, then all you would do is change that index.
All the answers are computed by this process. So don't use a specific index and you will get all the transfer functions at once. Or, otherwise, just pick the one you want to see (as I do, above) by specifying an index.
summary
I've demonstrated most of the directed-graph algorithmic code. All you need to do is fill in a few relatively simple blanks. However, I'll provide a complete implementation below.
Also, you may wish to read through this EESE answer to fill out a few more details.
final addition
The parsing details were interesting enough that I went ahead and wrote a quick and dirty SymPy function to handle the question:
def transfer(netlist=None,innode=None,outnode=None):
if netlist is None:
netlist = sys.stdin.read()
rawlist = [i.split() for i in netlist.splitlines()]
if len(rawlist) < 1: return 'empty netlist'
cookedlist = []
nodelist = []
for i in rawlist:
if len(i) > 2 and len(i[0]) > 1 and i[0][0].isalpha():
device = i[0][0].upper()
if not (device in ['R','C','L']): return 'netlist may only have R, L, or C devices'
cookedlist.append(i[:3])
for e in range(1,3):
if not (i[e] == '0'): nodelist.append(i[e].upper())
if len(cookedlist) < 1: return 'empty netlist'
nodelist = list(set(nodelist))
if len(nodelist) < 1: return 'enpty list of nodes'
if innode is None or outnode is None:
print('List of available nodes: ', ','.join(map(str, nodelist)))
exitlist = ['', 'Q', 'E', 'QUIT', 'EXIT']
if innode is None:
while True:
innode = input('Enter desired input node name: ').upper()
if innode in nodelist: break
if innode in exitlist: return 'user-requested termination'
print(innode, ' not found in above list.')
else:
innode = innode.upper()
if outnode is None:
while True:
outnode = input("Enter desired output node name (or '*' for all): ").upper()
if outnode in nodelist: break
if outnode == '*': break
if outnode in exitlist: return 'user-requested termination'
print(outnode, ' not found in above list.')
else:
outnode = outnode.upper()
if not (outnode == '*'):
temp = nodelist.pop(nodelist.index(outnode))
nodelist.append(temp)
temp = nodelist.pop(nodelist.index(innode))
nodelist.append(temp)
N = len(cookedlist)
M = len(nodelist)
C = zeros(N,N)
A = zeros(N,M)
s = symbols('s')
for i in range(0,N):
part = cookedlist[i][0].upper()
device = part[0]
partname = symbols(part, real=True, positive=True)
if device == 'R': C[i,i] = 1/partname
elif device == 'C': C[i,i] = s*partname
elif device == 'L': C[i,i] = 1/(s*partname)
else: return 'fatal internal error'
for j in range(1,3):
nodename = cookedlist[i][j].upper()
if nodename == '0': continue
if not (nodename in nodelist): return 'fatal internal error'
nodeidx = nodelist.index(nodename)
A[i,nodeidx] = 2*j-3
F = A.T * C * A
FN,FM = F.shape
P = F.extract( [i for i in range(FN-1)], [i for i in range(FN-1)] )
QT = F.extract( [i for i in range(FN-1)], [FN-1] )
Answer = -P.inv()*QT
Result = {}
if not (outnode == '*'):
Result[outnode] = simplify(Answer[nodelist.index(outnode),0])
else:
for i in range(0,M-1):
Result[nodelist[i]] = simplify(Answer[i,0])
return Result
It will produce just the one transfer function or all of them (when entering '*' for the output node.)
Using the above example, here's a run:
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 9.5, Release Date: 2022-01-30 │
│ Using Python 3.10.12. Type "help()" for help. │
└────────────────────────────────────────────────────────────────────┘
sage: transfer()
R1 n1 vin R
R2 n2 n1 R
R3 n3 n2 R
R4 vout n3 R
C1 n1 0 C
C2 n2 0 C
C3 n3 0 C
C4 vout 0 C
.backanno
.end
List of available nodes: N2,N3,VIN,VOUT,N1
Enter desired input node name: vin
Enter desired output node name (or '*' for all): vout
{'VOUT': 1/(C1*C2*C3*C4*R1*R2*R3*R4*s**4 + C1*C2*C3*R1*R2*R3*s**3 + C1*C2*C4*R1*R2*R3*s**3 + C1*C2*C4*R1*R2*R4*s**3 + C1*C2*R1*R2*s**2 + C1*C3*C4*R1*R2*R4*s**3 + C1*C3*C4*R1*R3*R4*s**3 + C1*C3*R1*R2*s**2 + C1*C3*R1*R3*s**2 + C1*C4*R1*R2*s**2 + C1*C4*R1*R3*s**2 + C1*C4*R1*R4*s**2 + C1*R1*s + C2*C3*C4*R1*R3*R4*s**3 + C2*C3*C4*R2*R3*R4*s**3 + C2*C3*R1*R3*s**2 + C2*C3*R2*R3*s**2 + C2*C4*R1*R3*s**2 + C2*C4*R1*R4*s**2 + C2*C4*R2*R3*s**2 + C2*C4*R2*R4*s**2 + C2*R1*s + C2*R2*s + C3*C4*R1*R4*s**2 + C3*C4*R2*R4*s**2 + C3*C4*R3*R4*s**2 + C3*R1*s + C3*R2*s + C3*R3*s + C4*R1*s + C4*R2*s + C4*R3*s + C4*R4*s + 1)}
So the idea does work and can be generalized enough to be slightly useful.