|
Version 0.2, June 2007. Upinder S. Bhalla, National Centre for Biological Sciences, Bangalore 560065 India Copyright 2005-07 1. Introduction 1.1. What is MOOSE? MOOSE is the Multiscale Object-oriented Simulation Environment. It is a general framework for making large, complex models, typically of biological and neuronal networks. 1.2. Intended Audience Who should read this MOOSE developer's guide? - Anyone who wants to write MOOSE code.
- Anyone who wants to port old GENESIS code. You should read the GENESIS to MOOSE developers guide first, and there are several sections you can skip.
Who does not need to read it? - Someone who just wants to use MOOSE without developing stuff for it.
Assumed knowledge - C++ programming up to and including templates.
Useful knowledge: 1.3. MOOSE licensing MOOSE is licensed under the terms of the Lesser GNU Public License. Details of the license are in the COPYING.LIB file, but in a nutshell it means you can freely use and modify the code provided the licensing terms remain intact and attribution remains. It also means you can use the code as a library basis for closed code. There are no warranties of any kind on the code. 1.4. MOOSE Coding standards 1.4.1. Version control 1.4.2. Testing MOOSE requires extensive testing. Ideally, everything written for MOOSE should be accompanied by a test suite. The tests should be set up as separate functions and located either in the appropriate code file or in a separate file in the same directory as the unit under test. The compiler flag -DDO_UNIT_TESTS will incorporate these files into the compiled version, and the tests should execute on startup. Currently some 500 unit tests are done at startup. All Subversion checkins must pass the unit tests. This rule is in place so that the developers can rely on the recent Subversion entry to build upon. When run, the test suite should print out a line(s) of the form:
Checking (functionality name) ..... done where each dot represents an individual test. We typically do simple unit tests as well as more complete functional tests that include the ability of simulation objects to perform their calculations. For instance, we test the compartments and channels against the original Hodkin-Huxley action potential simulation. A failure in the unit tests causes immediate termination. The macro ASSERT( bool, const char* ) is used to report error location. Further levels of testing should also be done routinely: - Memory tests should look for obvious memory leaks. Currently we run valgrid periodically on the code.
- Architecture tests are done less often, but will eventually become mandatory.
If possible, we should also compare against some reference. For example, message speed tests are compared against equivalent calls made directly. For an example of a unit test please see the messaging directory. Further levels of MOOSE testing are done by running models. These are pretty stringent tests and a good way to make sure that things work well together. For example, the test for the basic biophysics library was to run the Squid model and compare the output with the simulated values from GENESIS. They match perfectly. This is a pretty good check of about ten classes: scheduling related, biophysics related, Interpols, I/O, the parser. 1.4.3. Programming style MOOSE more or less follows the following style guide: C++ Programming Style Guidelines Version 3.0, January 2002 Geotechnical Software Service The document is available at http://geosoft.no/development/cppstyle.html The main idiosyncrasy in MOOSE style is that I believe in the use of tabs for indentation, while the above style guide does not. 1.4.4. Documentation Developers: We use doxygen. The code is internally documented in the comments. Doxygen then scans these comments to build a complete set of documentation, by default on a web page. It can also generate PDF and other static forms of documentation. User level: How to use the code, with example. Template for use documentation of an object. Developer level: What the code does, design decisions. See this manual. 1.5. What does MOOSE mean? Messaging/Multiscale Object Oriented Simulation Environment. Here I summarize MOOSE technical features in a nutshell: 1.5.1. Message passing architecture - A message looks like a function call, with type-safe arguments.
- Persistent. Once a message is set up, it is like a wire that connects two objects until further notice.
- Fine grained: At the level of simple function calls.
- Efficient: 2 pointer lookup cost in time and memory. Specializations for synaptic messaging Specializations for logical groups of messages
- Class independent: Neither source nor destination needs to know about other class. Only the argument types must match.
- Parallelizable: Maps naturally to MPI messaging.
- Traversable: Messages form the 'wiring diagram' of a simulation.
- Universal: Every field can be source or destination of a message.
1.5.2. Object-oriented - Class hierarchy including inheritance and polymorphism
- High-level class specification similar to C++ class definition, allows very compact specification of complex class. Conversion to C++ occurs using a preprocessor.
- Easy to derive from C++ classes, provides a wrapper class for existing C++ classes.
- Uniform and highly modular interface from each class to the system.
1.5.3. Simulation - Designed for simulations, especially time-series calculations.
- One-to-one mapping between objects and simulation concepts.
- Scheduler: System for handling complex timing dependencies:
- lockstep computations,
- periodic updates,
- external events.
- Also handles internode scheduling automatically.
- Solvers:
- Many problems are more efficiently solved using array-type calculations rather than object-oriented descriptions.
- Support for 'solvers' that take over calculations of large numbers of previously independent objects.
- Completely transparent. Original API to and from objects is retained. It only looks like they work faster.
- Solvers will be basis for specialized SMP optimizations.
1.5.4. Environment - Programming in ANSI C++ with extensive use of STL.
- MOOSE is multi-OS.
- Tested and developed on UNIX / Linux
- Also compiled natively under Windows .net
- Will compile under Mac OSX.
- Graphics will also be platform independent. Possibly FLTK.
- Supports a range of interpreted parsers.
- GENESIS parser SLI for backward compatibility
- JAVA, OCAML, Python, PERL, Ruby, many others through SWIG.
- Shell environment(s) for interactive modeling.
- Can have multiple shells, possibly with different parsers, talking to same model simultaneously.
1.6. MOOSE design overview 1.6.1. Introduction and examples A MOOSE application is a set of functional objects that communicate via messages. Think of it like a circuit, or if you like, the brain. There are circuit elements such as neurons which are predefined building blocks coded by the developers. Wires (that is, MOOSE messages) run between these circuit elements carrying information. Wires, once defined, become persistent parts of the circuit. Example 1: Building a circuit
create Neutral /ckt // create a placeholder called ckt create IntegFire /ckt/n1 // Create Integrate and Fire neuron n1 create IntegFire /ckt/n2 // Create Integrate and Fire neuron n2 // Connect the axon of n1 to the dendrite of n2 addmsg /ckt/n1/axon /ckt/n2/dendrite When the MOOSE application is run, user events or an internal scheduler will trigger functions in objects, which in turn will make function calls along the pre-wired messages to trigger further calls in other objects. Again, the circuit analogy applies. When power is applied to a circuit, information begins to flow along the wires between the circuit elements, which trigger suitable responses in each other. Example 2: Connecting up the scheduler to the neurons addmsg /sched/cj/ct0/process /ckt/n1/process addmsg /sched/cj/ct0/process /ckt/n2/process For backward compatibility, and to enable automatic rebuilding of the scheduling system, the above commands could be replaced by useclock /##[TYPE==IntegFire] 0 // use clock 0 for all objects of type IntegFire
Please note that this example indicates what happens under the hood. The MOOSE system is designed to handle all the scheduling automatically, and in due course will magically schedule objects as soon as they are created. step 100 Once a circuit has been completed, it too can be treated as a self-contained circuit module. It can be duplicated in its entirety, and groups of these modules can be further interconnected. Example 4: Duplicating modules copy /ckt /ckt2 // Duplicates the entire object tree of ckt. copy /ckt /ckt3 // Does it again for ckt3. Now ckt3 also has // in it the IntegFires n1 and n2. addmsg /ckt/n2/axon /ckt2/n1/dendrite addmsg /ckt2/n2/axon /ckt3/n1/dendrite Note that the internal connections on ckt were also duplicated by the copy function. This resulting simulation is a chain of six IntegFire neurons, each going from axon to the dendrite of the next. This circuit-like assembly, in a nutshell, is how one designs applications in MOOSE. Functional elements are connected up by wires/messages, and entire circuit modules can be treated as higher-level functional elements. MOOSE applications have three parts: the objects, the setup script, and the base code. The objects are the things that you, the developer, design and code. The script is a program that the user (or sometimes the interface designer) writes to create (instantiate) MOOSE objects and connect them up. The base code is the glue that executes the scripts, creates the objects, and manages the messaging. Your main role as a developer is to design functional elements in MOOSE, which is done by writing C++ classes. The design principally involves a good perspective of what the objects will do in relation to other objects, and how to decompose a complex set of operations into a set of objects. Not that this is easy, but such decomposition is the heart of a lot programming, so I won't try to describe it here. What this document does describe is how to build the objects you design. 1.6.2. Messaging C++ already supports a rather formal class interface using member functions, and MOOSE simply formalizes this a bit further. This formalization is the concept of messages. MOOSE messages are quite different from the usual C++ messages, which are calls to member functions of other objects. Rather than directly call the member function of another object, you call a message. It is the job of the basecode to then deliver this function request to all targets of the message. This is also known as the 'observer' design pattern and is used, among other places, by Qt. This design has three critical implications. First, your class does not need to know anything about the target class or function other than the arguments it takes. Second, the actual targets are defined at run-time by scripting. Third, it is easy to traverse the message calling sequence. The first two implications give flexibility. The third makes it possible for the system to analyze function flow and carry out parallelization. We will discuss these points later. What is the cost of all this? It is just a couple of pointer lookups. For all except the simplest function call, there is a neglible overhead for doing things through messages. Messages are strongly typed: You cannot create a message with the wrong number or type of arguments. This caveat aside, there is almost complete freedom in connecting message source to message destinations. MOOSE messages have a direction: source to destination. The source object calls the function and supplies arguments. The destination object executes the function. This may seem restrictive: suppose A wanted to get a value from object B. How do we get B to initiate the transfer? This is done as two message calls. First A calls a function in B to register a request for the value. In response, B sends a message to A with the value. This may seem wasteful compared to the GENESIS messaging approach of just peeking into the memory of another object. Actually it is even more efficient, because other overheads are smaller. Furthermore, more complicated value lookups (for example, looking up an interpolated value from a table) can use exactly the same form, except that the query message from A itself carries a value. Finally, this approach works better for parallelization, as we shall see later. Although message calls are directional, MOOSE message traversal can occur in either direction. One can identify all sources of messages, and all destinations. An important feature of message traversal is that it can be followed through the entire sequence of function/message calls. The call sequence does not stop when the message reaches an object. Consider the case where object A calls a message to object B, which in turn calls a message to object C. Clearly the flow of events does not stop at B, but goes on to C. This is handled transparently in MOOSE. Virtual messages within each class tie incoming messages to outgoing messages that they trigger. So the above message sequence would look like this: outA-->inB-->outB-->inC where the arrows represent messages. The crucial message inB-->outB is the virtual message. Now that we have this link in place, a program can analyze the complete flow of signals from A through to C. In a later section we will consider the different kinds of MOOSE messages. 1.6.3. MOOSE class design A MOOSE class is just an ordinary C++ class, with attention to passing information only through messages. In particular, your C++ class must not have pointers to any other object unless they are to be merged together as a single MOOSE class. There are three basic kinds of information flow in and out of the class, and all go through messages. First, there are fields within the class. These are typically parameters and variables that can be assigned and read, both through messages. Second, there are functions: these are simply targets of message calls. Finally, there are outgoing commands and calls to external functions: outgoing messages. Your class is designed to do its stuff with these as the only links to the outside world. As an example of using messages to handle all forms of communication, let us consider how MOOSE implements object hierarchies. MOOSE, like GENESIS, works with a tree structure for its objects. It looks rather like a directory tree: each object has a parent, and most object classes can manage other child objects. There is usually no restriction on the kind of children that an object can manage. How does MOOSE use messages to handle this object hierarchy? The solution is simply to have each parent object connected to all its children via messages. The ability to traverse messages translates to the ability to traverse the object hierarchy. As a bonus, we get to set up a function that parent objects always have to invoke on their children. It turns out there is an essential function to call in this manner: destruction. This apparently infanticidal twist is necessary because whenever we delete a parent, it has to delete all its children, otherwise there will be orphans dangling in MOOSE space with no way to access them. MOOSE classes can be derived from each other. Polymorphism works too. When you code MOOSE classes, you will be writing to a well-defined programming interface, which is the subject of much of this manual. You start as usual writing a C++ class with private values and public interface functions. Your classes must be instantiable with the default T() function, that is, without arguments. When the class is set up to your satisfaction, you need to ornament it with interface functions. This is done in two places: first, you need to build up a static initializer for the class that defines all the interface functions. Second, you need to implement the interface functions themselves. We'll discuss these in more detail below. 1.6.4. The MOOSE simulation framework The above aspects of MOOSE design rest on the base code: the nuts and bolts of building classes and connecting them. There is a small infrastructure already in place for your MOOSE coding. - The scheduler. Most simulations work by calling regularly scheduled operations on all objects of a given type, followed by updating the current simulation time. The scheduler handles ordering of these operations. The scheduler is simply a set of objects that collectively ensure that a special PROCESS messages (or equivalent) goes out to each computational object in the correct sequence.
- The parser. The default GENESIS parser is implemented in MOOSE, MOOSE can also use SWIG. Currently we use SWIG to provide a Python interface, but it can also give access to at least ten popular scripting languages, including Perl, Ocaml, flavors of C, and others.
2. The MOOSE programming interface 2.1. Introduction MOOSE is designed to be extremely modular. There are three essential code libraries: The basecode, the parser, and the scheduler. Even the parser library can be swapped for other parsers, or if you feel like it, you could hard-code your simulation in C++ and set it up using the C++ API only. The only headers you usually need are from the basecode. All other libraries are optional. MOOSE puts all its libraries in separate subdirectories. To make your own library, it is usally best to copy one of the existing ones as a template. I categorize MOOSE development into three levels: - New MOOSE classes
- MOOSE solvers
- Core moose functionality
2.1.1. MOOSE class development This is meant to be much simpler than the old GENESIS class development process, which involved various header, .g as well as source code work. In MOOSE all the class information is in the .h and .cpp file for the class, so that is simpler. On the other hand, putting the field information into C++ means that the start of each class .cpp file has a somewhat tedious list of all the fields and their arguments. 2.1.2. MOOSE solver development This is also meant to be much simpler than the GENESIS solvers, of which there were a grand total of two. However solver development by its very nature requires that the solver be able to represent information that MOOSE currently distributes into lots of classes. So you will certainly need to know the details of the data structures that the solver takes over, and also obviously need a very good idea of the computations you wish to undertake. MOOSE provides some special messaging facilities for making this process easier. A Solver programming manual will be added later. 2.1.3. Core Moose functionality Here you delve into the MOOSE architecture. This chapter is for you if you want to implement a new kind of messaging, or develop a new way of accessing field information. 2.2. Compiling There are a couple of compiler flags and #defines to set for various environments. I'll put them in the Makefile eventually but right now they are in basecode/header.h. 2.2.1. Linux/Unix make moose from the command line in the main moose directory. The compiler will visit each subdirectory and do its stuff. To clean out all code, type make clean To make the preprocessor, type make mpp 2.2.1.1. Compiler flags The default Makefile is set up for Linux and uses -O3 -Wall options. -O3 means a high level of optimization, including loop unrolling and function inlining. May as well be fast! -Wall means complain a lot if there are problems. There should be no complaints at all. 2.2.1.2. Compilers tested 2.2.2. Compiling under Windows This is much nastier. We are trying to set up NMAKE to do so from the command line in essentially the same way that MOOSE compiles under Linux. So far it has been done laboriously by making a VisualC++ .net project. 2.3. Elements, i.e., MOOSE objects basecode/Element.h basecode/Neutral.h These are always included from header.h. You should always manipulate objects through the pointer Element* All elements are subclassed from Element and it provides the API. 2.3.1. Common Commands -
const string& Element::name() const | Provides the name of the object | const string& Element::id() const | Provides the Id of the object. | Id Element::parent() const | Provides the full parent Id of the object | static Element* Element::root() | Returns the root element. | static Element* Element::classes() | Returns the element holding the class handler elements. | const Cinfo* cinfo() const | Returns the Class information pointer. | When Elements are deleted, all children are deleted and so are all messages impinging on the element and children. 2.4. Fields Fields in MOOSE are managed by structures called Finfo, defined in Finfo.h. Finfos are static data in the class definition, so they do not occupy space in each Element. There is a special composite class called simply 'Field', which contains both an Element pointer and a Finfo pointer, so as to completely identify and locate a specific instance of a field in a specific element. For this section I will use the terms Finfo and Field to refer to the static data, and the composite handler class, respectively. Finfo is an abstract base class for all field information. Despite the many variants on Finfo, they share a large number of common functions. 2.4.1. Variants of Finfo There are three main kinds of Finfo: - ValueFinfos: Subclassed from ValueFinfoBase< T >. These handle set and get for regular value fields of objects.
- SrcFinfos: Message sources. These call functions in remote objects.
- DestFinfos: Message destinations. These provide targets for SrcFinfos.
In addition, there are supplementary Finfos: - DynamicFinfo: These are created on the fly to handle messaging and calls to ValueFinfos, as well as to permit various unusual permutations on messaging such as a SrcFinfo being the target of a message.
- SharedFinfo: These are used to group multiple Src and Dest Finfos together when they share a common target(s). This saves on space because the target information is stored only once. It also simplifies message creation.
2.4.2. Field typing Fields in MOOSE are strongly typed. Operations and messages between incompatible types are not permitted. These are enforced through C++ Real Time Type Information, which is regarded as a Bad Thing as compared to compile-type typing. The saving grace is that it is done in MOOSE only at setup time. For example, when you connect up messages the type check is done when the messages are created. If it succeeds, the actual message passing during computations does not use RTTI. This is why it is possible to pass type-safe messages between classes that are completely independently defined. Field typing is done through the Ftype class, defined in Ftype.h. The Finfo base class defines a function ftype() to return its Ftype, and also has a couple of type comparison functions:
isSameType( const Finfo* ) isSameType( const Ftype* ) The Ftype classes handle string conversion as well as comparisons of fields. This is somewhat of a restriction for Ftype1< T >, which handles value fields. It requires that all type T have the equality operator as well as the less than '<' operator. In return it means that a wide range of wildcard searches are possible where values are tested. 2.4.3. Common commands for Finfo -
const string& name() const | Returns the name of the Finfo. | virtual Field match( const string& s ) | Checks if this Finfo corresponds to the string. In some cases (e.g., ArrayFinfos) the match function allocates a new Finfo with specific information such as index value gleaned from the string. | virtual RecvFunc recvFunc( ) const | Finfos manage a 'recieve function' for their fields. This recvFunc is called when the field receives a message, or when the function is called from the script language, or when an assignment is done. For a ValueFinfo the recvFunc would hold the 'set' function. For a DestFinfo the recvFunc would hold the message execution function. This function is what is returned by recvFunc. | virtual bool add( Element* e, Element* dest, const Finfo* destFinfo ) | Adds a message from this Finfo to the target field destfield. The flag tells it if the function is called as part of a SharedFinfo. | virtual Finfo* respondToAdd( Element* e, Element* src, const Ftype *srcType, FuncList& srcfl, FuncList& returnFl, unsigned int& destIndex, unsigned int& numDest) | Called on the destination Finfo when a message source asks to add a message. Returns a Finfo to use to build the message. Sometimes is self, but often is a Relay. Does type checking to ensure message compatibility Returns 0 if it fails. | virtual bool drop( Element* e, unsigned int index ) | Removes a message. Usually called from the dest with the src as argument, because synapses (destinations) have to override this function. | virtual bool strGet( Element* e, string& val ) | Does conversion of field value to a string | virtual bool strSet( Element* e, const string& val ) | Does assignment of field value from a string. The string can also be parsed into argument(s) for a Finfo that handles functions (such as DestFinfo). |
2.5. Messages There are seven kinds of messages: Regular Internal Relay Synaptic Shared Return Solver Each is accessed through the same API, though they do very different things. 2.5.1. Regular messages Regular messages are from a SrcFinfo to a DestFinfo. The Src sends info to the Dest by calling a RecvFunc with the info as arguments. The RecvFunc is a function provided by the Dest and operating on the destination element. The Src provides a 'send( T1, T2...)' function to pass a given set of arguments to all targets. The actual function executed for these arguments may differ between targets: it is obtain from the RecvFunc for each target. The user does not worry about this, she just calls the send function. Each message uses a Conn at either end of the message, for maintaining the link. In addition, at message creation time the RecvFunc is passed to the Src. The SrcFinfo manages a MsgSrc class, which in turn manages the Conn and provides the 'send' function. The Dest returns a RecvFunc to the MsgSrc. 2.5.2. Internal messages Information flows within an object from Dest messages to Src messages. This happens because many destination functions in turn trigger send function calls, going out on Srcs. To traverse this information flow, virtual 'messages' connect the Dest to all the Srcs that it triggers. These are purely informational and allocate no storage on the objects. They are set up by the Src and DestFinfo constructors as a simple list of Finfo names. Leaving them out does not hurt the basic functionality, but will mess up any programs that need to traverse message trees. 2.5.3. Relay messages Relays are for irregular messages. If a message needs to go to a target outside the regular Src/Dest route, a Relay can be set up. Not all irregular cases can be set up like this, but most can. Some examples: - Messages to fields, to set them.
- Messages to and from fields, to request a value
- Messages from a Dest to a Src. Suppose you wanted the receipt of a message to trigger some other event.
- Extra messages to or from a SingleSrc or Dest target
2.5.4. Synaptic messages Synaptic messages have to manage additional information for each message target. For example, each synaptic message might hold the synaptic weight and delay. This is done by having a SynapseConn< T > where T is a class embedded in the SynapseConn, holding the extra information. The SynapseConn itself handles a single incoming message. When the message arrives, the recvfunc typecasts the Conn into the SynapseConn and then performs whatever calculations are needed, using T. Multiple SynapseConns are managed as a vector, by the SynapseFinfo class. Individual synapses can be looked up and fields manipulated. 2.5.5. Shared messages In many if not most cases, it is necessary to send multiple messages between two elements. For example, molecules connect to reactions using a source message that passes n, the molecule count, and a dest message where the reaction comes back with the change in count. Clock ticks need to call both Process and Reinit on their targets. Shared messages are a way to streamline this. There are two advantages: - They share the same Conn, saving on memory.
- A single 'add' call sets up all the messages.
As far as the individual message Src and Dest are concerned, they are just the same as before. If someone really wants to send a single one of the messages, relays are a possibility though this is not cleanly done as yet. 2.5.6. Return messages Sometimes we need to have a message target that performs its operations and immediately sends a function call back to the originating message source. This is easy using shared messages, provided there is a single source and destination. However, what if this target had a whole lot of incoming messages, each of which asks it to do the operation independently? For example, the HHGate for a K channel may be referred to by hundreds of instances of the K channel. Each of these wants the gate to perform its calculation and get back with the updated state variables. The usual 'send' function does not work here: The 'send' function goes through the entire list of targets, not at all what we want. So we have the 'Return messages.' These use a vector of ReturnConns, each of which holds the RecvFunc for the return call, and the return Conn pointer. Each incoming message connects to a distinct ReturnConn in the array. When a call comes to such a message, it executes its destination function, then immediately calls the return RecvFunc with the return Conn pointer to send required info back. These messages are managed as a vector by the ReturnFinfo class. Classes involved: ReturnFinfo, ReturnConn, MultiReturnConn. The ReturnFinfo is used as the return msg source. The corresponding msg dests for a shared message are just regular Destfinfos. The ReturnConn is for each individual connection. The MultiReturnConn holds a vector of ReturnConns and handles their creation and interfacing with the ReturnFinfo. 2.5.7. Solver messages These connect a solver to an object; they mediate the takeover of object function and interconnects by the solver. I won't write about these yet because I haven't implemented them. 3. Writing new classes from scratch 3.1. Designing the classes MOOSE classes are designed both in terms of what calculations they do, and also in terms of how they will communicate with other classes. It usually takes a couple of iterations to get the classes nicely aligned with each other and with their internal calculations. Some considerations: - Never use pointers to other elements. This ruins the modularity of MOOSE, makes messages non-portable to multiprocessors, introduces dependencies between classes, and other horrible things. Messages are designed specifically to handle all cases of inter-object communication, and if there is some case which is not covered we want to know about it.
- Use Messages (src and dest explained below) as function interfaces when speed is a primary concern, or when you know that a given message will almost always be used. Messages have a starting run-time overhead of about 6 words, and then less than 2 words per message.
- Use Shared Messages whenever you have a set of messages that consistently pass between two objects. This saves significantly on memory use. It also makes the model building process simpler and less prone to error.
- Use Value Fields when you need access to a value or function, but only occasionally or only through script assignments. You can always connect messages to Fields using a DynamicFinfo, so you do not lose flexibility, but DynamicFinfos are slightly slower and are memory expensive. There is no run-time overhead for Value Fields, but don't unnecessarily expose private object state information.
We'll assume we have a decent first design. Now on to the implementation. The internal calculations of most classes are typically straightforward C/C++. I will assume you know what you are doing here, so the focus of this chapter is on communication using the MOOSE framework. This part is also what makes the code look like exceptionally ugly C++. Most MOOSE classes are set up as 2 files:
MyClass.h MyClass.cpp
These are initially designed as regular C++ classes. Defining your calculations is the easy part. Now to the communications.
|