RT-WDF / rt-wdf_lib

RT-WDF - Real-Time Wave Digital Filter Library

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Code Refactoring

maxprod2016 opened this issue · comments

First, I understand that you want fractional updates to the refactored code of the core of rt-wdf. Unfortunately most of the ideas for reworking the code are subject to trials and variations that can not be predicted.

The same goes for the multiplatform compilation that I could only test under windows (msvc / clang) and linux (gcc / clang) at first. As such, I would rely on only one file that will include some compilation subtleties depending on the compilers.

You may need to update this file with the specific desirable target machines (osx ...). But do not be afraid, it will be only a few definitions such as specific operators like inline. The good news is that I have almost finished refactoring and before I can share this, I still have a few tests to carry out in order to validate my code.

Documentation

The current code is not documented, I will have to reinsert the documentation even if I do not see the interest to reinsert doxygen documentation in such API. I want to say :

  • Most of the core API methods are not useful for end-user.
  • Most of the elements used by the user are self-explain. (eg. instance of Resistor with a variable type renamed : resistanceInOhms)

It is in my opinion better to have an external documentation (github blog) that describes the project with schema and reference equations, and a good set of examples of use that documentation doxygen much less talkative.

Of course, this is my opinion, if the documentation doxygen is absolutely necessary, I would reintegrate it without problem.

Semantic

I think it's better to slightly modify the semantic of some method to avoid ambiguity. For example, i've renamed all the virtual std::string getType () const = 0; to a more generic and readable virtual std::string toString () const = 0; because getType refere more to a true type, not a string. A alternative could have been toTypeAsString, but I think toString it's a better explicit semantic.

There are methods that deserve renaming, for example :

In wdf::Root :
virtual void processAscendingWaves (vec* ascendingWaves, vec* descendingWaves) = 0;
can become :
virtual void process (vec* ascendingWaves, vec* descendingWaves) = 0;

that avoid redundancy of the AscendingWave and just because the method do not just process AscendingWaves but return DescendingWaves. A true full name should have been processAscendingAndReturnDescendingWaves that is too long.

And alternate name could be :

virtual void processWaves (vec* ascending, vec* descending) = 0;

Because C++ is (today) limited in term of return values. Something you put the output references in the input references as is the case here, it's possible to avoid ambiguity by a small trick described in the following document: http://eli.thegreenplace.net/2016/returning-multiple-values-from-functions-in-c/

By creating a dummy macro named OUT somewhere in your API,
#define OUT
You're method become :
virtual void processWaves (vec* ascending, OUT vec* descending) = 0;

Technically, there's no change, this is just code decoration.

I take time on this example to make you understand that it is sometimes important to revise the semantics of the methods because it is better to have an explicit API than a long documentation.

In the actual code of TreeNode, few methods needs to be revisited in the same way. Perhaps, in the same way of the (un)adapted / (un)terminated semantic, keep something close to the actual WDF litterature (perhaps something like incident/reflection).

The methods are :

virtual double calculateUpRes (double T) = 0;
virtual void calculateScatterCoeffs () = 0;
virtual double calculateUpB () = 0;
virtual void calculateDownB (double descendingWave) = 0;

For the moment, I keep the same name but I am convinced that these methods could be renamed to become explicit. Note that "calculate" name is generic, it sometimes corresponds to an update or a processing (compute).

Stack vs Heap / Reference vs Pointer

I notice that most matrices and vectors (currently armadillo) are instantiated on the heap. I think, that as far as possible, these objects should be allocated on the stack.

There are several advantages of using the stack for matrices, the instantiation is faster but the main advantage is the use of matrices / vectors in the code. By passing pointers () and not references (&), you force the code to get the references of the classes by the redundancy of the operator ().

For example, the actual code of the diode process :

void diodeApModel::calculate( vec* fNL,
                              mat* JNL,
                              vec* x,
                              int* currentPort) {

    const double vd = (*x)(*currentPort);
    const double arg1 = vd/VT_DIODE;

    (*fNL)(*currentPort) = Is_DIODE*(exp(arg1)-1)-Is_DIODE*(exp(-arg1)-1);
    (*JNL)(*currentPort,*currentPort) = (Is_DIODE/VT_DIODE)*(exp(arg1)+exp(-arg1));

    (*currentPort) = (*currentPort)+getNumPorts();
}

become

template <typename Type>
void Shockley<Type>::evaluate (Vector<Type>& fNL,
					   Matrix<Type>& JNL,
					   Vector<Type>& x,
						    int& currentPort)
{
	const Type Vd = x(currentPort); // Voltage across the diode
	const Type dt = Vd / Vt;
	const Type pe = std::exp(dt);

	if (mode == AntiParallel)
	{
		const Type ne = std::exp(-dt);

		fNL(currentPort) = Is * (pe - Type(1)) 
					 - Is * (ne - Type(1));

		JNL(currentPort, currentPort) = (Is / Vt) * (pe + ne);
	}
	else
	{
		fNL(currentPort) = Is * (pe - Type(1));

		JNL(currentPort, currentPort) = (Is / Vt) * pe;
	}

	currentPort += Model<Type>::getNumPorts();
}

Note that my code is slightly optimized (less std::exp call in the AntiParallel case) and is a combination of the two diodes models. As you can see, the code to affect the vectors & matrices is clean now.

Model Revisited

I started to rework the models in order to allow the modularity in terms of algorithm, here is the definition of a part of the transistor.hpp, the process of the BJT is in the process of modification since it integrates henceforth the notion of sign (NPN / PNP).

#include "../model.hpp"

namespace wdf {

namespace model {

namespace transistor {

namespace internal {

//-----------------------------------------------------------------------------
// Transistor Models using Ebers-Moll equations as described in : 
// J.J. Ebers, J.L. Moll, 
// "Large-signal behavior of junction transistors"
// Article in Proceedings of the IRE 40(12):1761-1772 - January 1955
//-----------------------------------------------------------------------------

template <typename Type>
class EbersMoll : public Model<Type>
{
public:
	EbersMoll (Type sign);

	void setParameters (Type Vt, Type Is, Type BetaF, Type BetaR);
	
	void evaluate (Vector<Type>& fNL,
				   Matrix<Type>& JNL,
				   Vector<Type>& x,
			   	    int& currentPort) final;

protected:
	Type Vt;	// Thermal voltage (~= 0.026V)
	Type Is;	// Saturation current (Ampere)

	Type bF;	// Forward common-emitter current gain
	Type bR;	// Reverse common-emitter current gain

	Type aF;	// Forward common-base current gain
	Type aR;	// Reverse common-base current gain

private:
	Type sign;	// NPN (1) or PNP (-1)
};

template <typename Type>
EbersMoll<Type>::EbersMoll (Type sign)
	: Model<Type> (2)
	, sign (sign)
{
	setParameters (0.02585, 5.911e-15, 1.434e3, 1.262);
}

template <typename Type>
void EbersMoll<Type>::setParameters (Type Vt, Type Is, Type BetaF, Type BetaR)
{
	this->Vt = Vt;
	this->Is = Is;
	
	bF = BetaF;
	bR = BetaR;

	aF = bF / (Type(1) + bF);
	aR = bR / (Type(1) + bR);
}

template <typename Type>
void EbersMoll<Type>::evaluate (Vector<Type>& fNL,
						    Matrix<Type>& JNL,
						    Vector<Type>& x,
			   			 int& currentPort)
{
	const Type vBC = sign * x(currentPort);	  // base-common voltage
	const Type vBE = sign * x(currentPort + 1); // base-emitter voltage

	const Type iF = Is * (std::exp(vBE / Vt) - Type(1));
	const Type iR = Is * (std::exp(vBC / Vt) - Type(1));

        // TODO

	fNL(currentPort) = 0;
	JNL(currentPort, currentPort) = 0;
	JNL(currentPort, currentPort + 1) = 0;

	fNL(currentPort + 1) = 0;
	JNL(currentPort + 1, currentPort) = 0;
	JNL(currentPort + 1, currentPort + 1) = 0;

	currentPort += Model<Type>::getNumPorts();
}

//-----------------------------------------------------------------------------
// Bipolar Junction Transistor (internal)
//-----------------------------------------------------------------------------

static const int NPN = +1;
static const int PNP = -1;

template <typename Type, class Algorithm, int Sign>
class BJT : public Algorithm { public: BJT (); };

template <typename Type, class Algorithm, int Sign>
BJT<Type, Algorithm, Sign>::BJT ()
	: Algorithm (Sign)
{}

} // namespace internal

//-----------------------------------------------------------------------------
// Bipolar Junction Transistor (specialization)
//-----------------------------------------------------------------------------

namespace BJT {

template <typename Type>
using NPN = internal::BJT<Type, internal::EbersMoll<Type>, internal::NPN>;

template <typename Type>
using PNP = internal::BJT<Type, internal::EbersMoll<Type>, internal::PNP>;

} // namespace BJT

In this way, the BJT instantiation become :

wdf::model::transistor::BJT::NPN<double> npn;
wdf::model::transistor::BJT::PNP<double> pnp;

I also prepare the integration of the Field-Effect Transistor (Shichman and Hodges) model with a similar instantiation:

wdf::model::transistor::JFET::N<double> n;
wdf::model::transistor::JFET::P<double> p;

The diode instantiation :

wdf::model::diode::Ideal<double> d;
wdf::model::diode::AntiParallel<double> ap;

For the moment the models are fixed, but as you can see, the template definition allow to select differents algorithm.

WDF Tree

I just revisited the Tree class to make the instantiation safer. I think the current version suffers of memory leak (no delete of subtreeEntryNodes and Rp in the actual destructor). I've just rewrite the Switch Attenuator example with the new changes (It misses the virtual method makeParameters)

template <typename T>
class SwitchTree : public wdf::Tree<T>
{
public:
	SwitchTree ()
		: wdf::Tree<T> ("Switchable Attenuator", 1)
	{
		wdf::Tree<T>::setSampleRate (44100);
	}

	void makeWiring () override
	{
		Vres.reset (new wdf::adapted::ResVoltSrc<T>	(0, 1));
		Res1.reset (new wdf::adapted::Resistor<T>	(250e3));
		Res2.reset (new wdf::adapted::Resistor<T>	(250e3));
		 RT1.reset (new Rtype_RT1<T>			(Vres.get(), Res1.get()));
		  S1.reset (new wdf::adaptor::Series<T>		(Vres.get(), Res1.get()));
		  P1.reset (new wdf::adaptor::Parallel<T>	(  S1.get(), Res2.get()));
		 SW1.reset (new wdf::unadapted::Switch<T>	(0));

		SW1->setSwitch (1);
	}

	void setAccessors (std::function<void(T)>& input,
		            std::function<   T( )>& output) override
	{
		input = [this](T signal)
		{ 
			this->Vres->Vs = signal; 
		};

		output = [this]() -> T
		{
			return -this->Res1->upPort->getPortVoltage();
		};
	}

	wdf::Root<T>* getRoot () override
	{
		return new wdf::root::Simple<T>(SW1.get());
	}

	wdf::Tree<T>::Node* getSubtreeEntryNode (int subtree) override
	{
		switch (subtree)
		{
			case 0:		return P1.get();
			default:	break;
		}

		return nullptr;
	}

	int setRootMatrixData (wdf::Matrices<T>* rootMatrixData, T* Rp) override
	{
		return 0;
	}

private:
	Ptr<wdf::adapted::Resistor<T>>		Res1;
	Ptr<wdf::adapted::Resistor<T>>		Res2;
	Ptr<wdf::adapted::ResVoltSrc<T>>	Vres;
	Ptr<wdf::adaptor::Series<T>>		S1;
	Ptr<wdf::adaptor::Parallel<T>>		P1;
	Ptr<wdf::unadapted::Switch<T>>		SW1;
	Ptr<wdf::adaptor::Rtype<T>>		RT1;
};

In this way, the user no longer has to deal with memory management: subtreeEntryNodes & Rp. The tree constructor is done with the name as well as the number of subtrees.

The Tree constructor is defined as :

template <typename Type>
Tree<Type>::Tree (std::string name, size_t numSubtree)
	: subtreeCount (numSubtree)
	, tSampleRate  (1)
	, iSampleRate  (1) 
	, name	   (name)
{
	makeWiring ();

	setAccessors (signalInput, signalOutput);
	
	subtreeEntryNodes = new Node*[subtreeCount];
		   Rp = new Type [subtreeCount]();

	for (int i = 0; i < subtreeCount; ++i)
	{
		subtreeEntryNodes[i] = getSubtreeEntryNode (i);
		
		subtreeEntryNodes[i]->setParentInChildren ();
		subtreeEntryNodes[i]->createPorts ();
	}

	 ascendingWaves = Vector<Type>(subtreeCount);
	descendingWaves = Vector<Type>(subtreeCount);
}

The user provide the tree name and the number of subtree nodes.

  • Call "makeWiring". This is where the user circuit is allocated and wired.
  • Call "setAccessors". The user defined the two lambda functions to inject and recover the signal in the circuit.
  • Call "getRoot". The user provide a Root class instantiated as is needs.
  • The subtreeEntryNodes & Rp are allocated.
  • Call N times "getSubtreeEntryNode" and init each one.
  • Init the waves vectors.

Now, the user need only to set the sample rate in the constructor of the tree. The adaptTree method is now merged with the setSampleRate method.

The main cycleWave is now with resumed as a simple process method :

template <typename Type>
Type Tree<Type>::process (Type signal)
{
	signalInput (signal);

	for (int i = 0; i < subtreeCount; ++i)
		ascendingWaves(i) = subtreeEntryNodes[i]->pullWaveUp();

	root->processAscendingWaves (ascendingWaves, descendingWaves);

	for (int i = 0; i < subtreeCount; ++i)
		subtreeEntryNodes[i]->pushWaveDown(descendingWaves(i));

	return signalOutput();
}

All of theses changes may be subject to discussion / modification.
I think I will able to provide a functionnal refactored version current of next week.