Analog modelling: The Moog ladder filter emulation in Python

This entry is part 9 of 13 in the series Analog modelling

After my previous post on SPICE modelling in Python, I need to use a good support example to go up to on the fly compilation in C++. This schema will also require some changes to support more than simple nodal analysis, so this now becomes Modified Nodal Analysis with state equations.

The simple model

I won’t use the full Moog ladder model, but I’ll use the simplest model found in http://www.timstinchcombe.co.uk/synth/Moog_ladder_tf.pdf:

The simple Moog ladder filter

All the base of the transistors are set to a specific voltage instead of using a bank of resistors, the current sink at the bottom of the circuit is now perfect and we use a perfect feedback loop. This is done to have a simple netlist that can be easily parsed and then transformed in C++.

SPICE simulation

We can get the steady state for this schema from LTSpice, and this is going to be important to check that we have a good match:

V(in+) 0.0
V(in-) -3.94937960664e-05
V(lt) -0.528278529644
V(out) 0.000118481344543
V(s1+) 2.47182297707
V(s1-) 2.47178339958
V(s2+) 3.4718849659
V(s2-) 3.47184562683
V(s3+) 4.47194719315
V(s3-) 4.47190761566
V(s4+) 5.47200918198
V(s4-) 5.47196960449

From this, we can ask LTSPice also to simulate. Let’s use a 100kHz sampling over 100ms and use a 50Hz input frequency:

The Moog ladder results with SPICE

The missing Python pieces

We need to add a few new circuits to our SPICE modeler. The easiest one is the current source:

class Current(object):
    """
    Class that implements a perfect current generator between two pins
    """
    nb_pins = 2
 
    def __init__(self, current):
        self.current = current
 
    def __repr__(self):
        return "%02.2eA between pins (%s,%s)" % (self.current, self.pins[0], self.pins[1])
 
    def update_model(self, model):
        pass
 
    def update_steady_state(self, state, dt):
        pass
 
    def update_state(self, state):
        pass
 
    def get_current(self, pin_index, state, steady_state):
        return self.current * (-1 if 0 == pin_index else 1)
 
    def get_gradient(self, pin_index_ref, pin_index, state, steady_state):
        return 0
 
    def precompute(self, state, steady_state):
        pass

And we need the perfect amplifier. For this one, we can’t use nodal analysis, so we will ask the modeller to replace the equation by the perfect voltage amplification equation:

class VoltageGain(object):
    """
    Class that implements a voltage gain between 4 pins, Vi+, Vi-, Vo+, Vo-
    """
    nb_pins = 4
 
    def __init__(self, gain):
        self.gain = gain
 
    def __repr__(self):
        return "Voltage gain between pins (%s,%s,%s,%s) overriding equation at pin %s" % (self.pins[0], self.pins[1], self.pins[2], self.pins[3], self.pins[2])
 
    def update_model(self, model):
        assert self.pins[2][0] == "D"
        model.dynamic_pins_equation[self.pins[2][1]] = (self, 0)
 
    def update_steady_state(self, state, dt):
        pass
 
    def update_state(self, state):
        pass
 
    def get_current(self, pin_index, state, steady_state):
        return 0
 
    def get_gradient(self, pin_index_ref, pin_index, state, steady_state):
        return 0
 
    def add_equation(self, state, steady_state, eq_number):
        eq = self.gain * (retrieve_voltage(state, self.pins[0]) - retrieve_voltage(state, self.pins[1])) - (retrieve_voltage(state, self.pins[2]) - retrieve_voltage(state, self.pins[3]))
        jac = np.zeros(len(state["D"]))
        if self.pins[0][0] == "D":
            jac[self.pins[0][1]] = self.gain
        if self.pins[1][0] == "D":
            jac[self.pins[1][1]] = -self.gain
        if self.pins[2][0] == "D":
            jac[self.pins[2][1]] = -1
        if self.pins[3][0] == "D":
            jac[self.pins[3][1]] = 1
        return eq, jac
 
    def precompute(self, state, steady_state):
        pass

Simulation in Python

Before we check the steady state of our models in Python, we need to remember that our models are not the same as LTSpice. For instance, the transistors are using a Gummel-Poon model, whereas this Python modeller uses a more simple Ebers-Moll model. This means that the results won’t match, but should still be visually close enough (I won’t really try to check is they are close enough).

V(in+) 0.0
V(in-) -3.95065417e-05
V(lt) -0.668570123
V(out) 0.000118519624
V(s1+) 2.33154790
V(s1-) 2.33150839
V(s2+) 3.33162642
V(s2-) 3.33158691
V(s3+) 4.33170494
V(s3-) 4.33166543
V(s4+) 5.33177780
V(s4-) 5.33173830

Clearly, there is a bias in all the stages of around 0.1V, but thanks to the DC removal, the output is more or less the same. But what about the 50Hz signal?

The Moog ladder results with Python

We see that we still have a good match, the results seems identical, with similar behavior on the lt voltage, and of course the output.

So mission accomplished, we have a good Moog ladder prototype in Python!

Conclusion

We now have a reference in Python for quite a complex schema (even if it’s simplified). Our next step will move the implementation to C++, without focusing on performance just now.

Buy Me a Coffee!
Other Amount:
Your Email Address:
Series Navigation<< From netlist to code: strategies to implement schematics modellingAnalog modelling: A prototype generic modeller in Python >>

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.