The numerical solution procedure was compactly and conveniently implemented in
a Python function solver
in the section Mathematical problem and solution technique.
In more complicated problems it might
be beneficial to use classes instead of functions only. Here we shall
describe a class-based software design well suited for scientific problems
where there is a mathematical model of some physical phenomenon and
some numerical methods to solve the equations involved in the model.
We introduce a class Problem
to hold the definition of the physical
problem, and a class Solver
to hold the data and methods needed to
numerically solve the problem. The forthcoming text will explain the
inner workings of these classes and how they represent an alternative
to the solver
and experiment_*
functions in the decay
module.
Explaining the details of class programming in Python is considered far beyond the scope of this text. Readers who are unfamiliar with Python class programming should first consult one of the many electronic Python tutorials or textbooks to come up to speed with concepts and syntax of Python classes before reading on. The author has a gentle introduction to class programming for scientific applications in [Ref2], see Chapter 7 and 9 and Appendix E. Other useful resources are
- The Python Tutorial: http://docs.python.org/2/tutorial/classes.html
- Wiki book on Python Programming: http://en.wikibooks.org/wiki/Python_Programming/Classes
tutorialspoint.com
: http://www.tutorialspoint.com/python/python_classes_objects.htm
The purpose of the problem class is to store all information about the mathematical model. This usually means the physical parameters and formulas in the problem. Looking at our model problem (3.1)-(3.2), the physical data cover \(I\), \(a\), and \(T\). Since we have an analytical solution of the ODE problem, we may add this solution in terms of a Python function (or method) to the problem class as well. A possible problem class is therefore
from numpy import exp
class Problem(object):
def __init__(self, I=1, a=1, T=10):
self.T, self.I, self.a = I, float(a), T
def u_exact(self, t):
I, a = self.I, self.a
return I*exp(-a*t)
We could in the u_exact
method have written
self.I*exp(-self.a*t)
, but using local variables I
and a
allows
the nicer formula I*exp(-a*t)
, which looks much closer to the mathematical
expression \(Ie^{-at}\). This is not an important issue with the
current compact formula, but is beneficial in more complicated
problems with longer formulas to obtain the closest possible
relationship between code and mathematics. The coding style in
this standalone is to strip
off the self
prefix when the code expresses mathematical formulas.
The class data can be set either as arguments in the constructor or at any time later, e.g.,
problem = Problem(T=5)
problem.T = 8
problem.dt = 1.5
(Some programmers prefer set
and get
functions for setting and getting
data in classes, often implemented via properties in Python, but
this author considers that overkill when there are just a few data items
in a class.)
It would be convenient if class Problem
could also initialize
the data from the command line. To this end, we add a method for
defining a set of command-line options and a method that sets the
local attributes equal to what was found on the command line.
The default values associated with the command-line options are taken
as the values provided to the constructor. Class Problem
now becomes
class Problem(object):
def __init__(self, I=1, a=1, T=10):
self.T, self.I, self.a = I, float(a), T
def define_command_line_options(self, parser=None):
"""Return updated (parser) or new ArgumentParser object."""
if parser is None:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
'--I', '--initial_condition', type=float,
default=1.0, help='initial condition, u(0)',
metavar='I')
parser.add_argument(
'--a', type=float, default=1.0,
help='coefficient in ODE', metavar='a')
parser.add_argument(
'--T', '--stop_time', type=float,
default=1.0, help='end time of simulation',
metavar='T')
return parser
def init_from_command_line(self, args):
"""Load attributes from ArgumentParser into instance."""
self.I, self.a, self.T = args.I, args.a, args.T
def u_exact(self, t):
"""Return the exact solution u(t)=I*exp(-a*t)."""
I, a = self.I, self.a
return I*exp(-a*t)
Observe that if the user already has an ArgumentParser
object it can be
supplied, but if she does not have any, class Problem
makes one.
Python’s None
object is used to indicate that a variable is not
initialized with a proper value.
The solver class stores parameters related to the numerical solution method
and provides a function solve
for solving the problem.
For convenience, a problem object is given to the constructor
in a solver object such that the object gets access to the
physical data. In the present example,
the numerical solution method involves the parameters \(\Delta t\)
and \(\theta\), which then constitute the data part of the solver class.
We include, as in the problem class, functionality for
reading \(\Delta t\) and \(\theta\) from the command line:
class Solver(object):
def __init__(self, problem, dt=0.1, theta=0.5):
self.problem = problem
self.dt, self.theta = float(dt), theta
def define_command_line_options(self, parser):
"""Return updated (parser) or new ArgumentParser object."""
parser.add_argument(
'--scheme', type=str, default='CN',
help='FE, BE, or CN')
parser.add_argument(
'--dt', '--time_step_values', type=float,
default=[1.0], help='time step values',
metavar='dt', nargs='+', dest='dt_values')
return parser
def init_from_command_line(self, args):
"""Load attributes from ArgumentParser into instance."""
self.dt, self.theta = args.dt, args.theta
def solve(self):
self.u, self.t = solver(
self.problem.I, self.problem.a, self.problem.T,
self.dt, self.theta)
def error(self):
"""Return norm of error at the mesh points."""
u_e = self.problem.u_exact(self.t)
e = u_e - self.u
E = np.sqrt(self.dt*np.sum(e**2))
return E
Note that we see no need to repeat the body of the previously
developed and tested solver
function. We just call that function from
the solve
method. In this way, class Solver
is merely a class wrapper
of the stand-alone solver
function. With a single object of class Solver
we have all the physical and numerical data bundled together with the numerical
solution method.
Eventually we need to show how the classes Problem
and Solver
play together. We read parameters from the command line and make a
plot with the numerical and exact solution:
def experiment_classes():
problem = Problem()
solver = Solver(problem)
# Read input from the command line
parser = problem.define_command_line_options()
parser = solver. define_command_line_options(parser)
args = parser.parse_args()
problem.init_from_command_line(args)
solver. init_from_command_line(args)
# Solve and plot
solver.solve()
import matplotlib.pyplot as plt
t_e = np.linspace(0, T, 1001) # very fine mesh for u_e
u_e = problem.u_exact(t_e)
print 'Error:', solver.error()
plt.plot(t, u, 'r--o')
plt.plot(t_e, u_e, 'b-')
plt.legend(['numerical, theta=%g' % theta, 'exact'])
plt.xlabel('t')
plt.ylabel('u')
plotfile = 'tmp'
plt.savefig(plotfile + '.png'); plt.savefig(plotfile + '.pdf')
plt.show()
The previous Problem
and Solver
classes containing parameters
soon get much repetitive code when the number of parameters increases.
Much of this code can be parameterized and be made more compact.
For this purpose, we decide to collect all parameters in a dictionary,
self.prm
, with two associated dictionaries self.type
and
self.help
for holding associated object types and help strings.
The reason is that processing dictionaries is easier than processing
a set of individual attributes.
For the specific ODE example we deal with, the three dictionaries in
the problem class are typically
self.prm = dict(I=1, a=1, T=10)
self.type = dict(I=float, a=float, T=float)
self.help = dict(I='initial condition, u(0)',
a='coefficient in ODE',
T='end time of simulation')
Provided a problem or solver class defines these three
dictionaries in the constructor,
we can create a super class Parameters
with general code
for defining command-line options and reading them as well as
methods for setting and getting each parameter. A Problem
or Solver
for
a particular mathematical problem can then
inherit most of the needed functionality and code
from the Parameters
class. For example,
class Problem(Parameters):
def __init__(self):
self.prm = dict(I=1, a=1, T=10)
self.type = dict(I=float, a=float, T=float)
self.help = dict(I='initial condition, u(0)',
a='coefficient in ODE',
T='end time of simulation')
def u_exact(self, t):
I, a = self['I a'.split()]
return I*np.exp(-a*t)
class Solver(Parameters):
def __init__(self, problem):
self.problem = problem # class Problem object
self.prm = dict(dt=0.5, theta=0.5)
self.type = dict(dt=float, theta=float)
self.help = dict(dt='time step value',
theta='time discretization parameter')
def solve(self):
from decay import solver
I, a, T = self.problem['I a T'.split()]
dt, theta = self['dt theta'.split()]
self.u, self.t = solver(I, a, T, dt, theta)
By inheritance, these classes can automatically do a lot more when it comes to reading and assigning parameter values:
problem = Problem()
solver = Solver(problem)
# Read input from the command line
parser = problem.define_command_line_options()
parser = solver. define_command_line_options(parser)
args = parser.parse_args()
problem.init_from_command_line(args)
solver. init_from_command_line(args)
# Other syntax for setting/getting parameter values
problem['T'] = 6
print 'Time step:', solver['dt']
solver.solve()
u, t = solver.u, solver.t
A simplified version of the parameter class looks as follows:
class Parameters(object):
def __getitem__(self, name):
"""obj[name] syntax for getting parameters."""
if isinstance(name, (list,tuple)): # get many?
return [self.prm[n] for n in name]
else:
return self.prm[name]
def __setitem__(self, name, value):
"""obj[name] = value syntax for setting a parameter."""
self.prm[name] = value
def define_command_line_options(self, parser=None):
"""Automatic registering of options."""
if parser is None:
import argparse
parser = argparse.ArgumentParser()
for name in self.prm:
tp = self.type[name] if name in self.type else str
help = self.help[name] if name in self.help else None
parser.add_argument(
'--' + name, default=self.get(name), metavar=name,
type=tp, help=help)
return parser
def init_from_command_line(self, args):
for name in self.prm:
self.prm[name] = getattr(args, name)
The file decay_oo.py contains
a slightly more advanced version of class Parameters
where we
in the functions for getting and setting parameters
test for valid parameter names and
raise exceptions with informative messages if any name is not registered.
We have already sketched the Problem
and Solver
classes that build
on inheritance from Parameters
. We have also shown how they are
used. The only remaining code is to make the plot, but this code is
identical to previous versions when the numerical solution is
available in an object t
and the exact one in u_e
.
The advantage with the Parameters
class is that it scales to problems
with a large number of physical and numerical parameters:
as long as the parameters are defined once via a dictionary,
the compact code in class Parameters
can handle any collection of
parameters of any size.