Merge branch 'v2/kcompile-by-nodes' into v2/master
diff --git a/Makefile b/Makefile
index bd8e95f..4392d5f 100644
--- a/Makefile
+++ b/Makefile
@@ -15,13 +15,13 @@
 PYLIB	:= 	$(DESTDIR)$(shell python -c 'import distutils.sysconfig;  print distutils.sysconfig.get_python_lib()')
 LOADDIR	:=	loadsource
 
-KLOAD	:=	$(LOADDIR)/linux-2.6.39.tar.bz2
+KLOAD	:=	$(LOADDIR)/linux-4.9.tar.xz
 BLOAD	:=	$(LOADDIR)/dbench-4.0.tar.gz
 LOADS	:=	$(KLOAD) $(BLOAD)
 
 runit:
 	[ -d $(HERE)/run ] || mkdir run
-	python rteval-cmd -D -L -v --workdir=$(HERE)/run --loaddir=$(HERE)/loadsource --duration=$(D) -f $(HERE)/rteval.conf -i $(HERE)/rteval
+	python rteval-cmd -D -L -v --workdir=$(HERE)/run --loaddir=$(HERE)/loadsource --duration=$(D) -f $(HERE)/rteval.conf -i $(HERE)/rteval $(EXTRA)
 
 load:
 	[ -d ./run ] || mkdir run
@@ -85,7 +85,7 @@
 	rm -rf rpm
 	mkdir -p rpm/{BUILD,RPMS,SRPMS,SOURCES,SPECS}
 
-rpms rpm: rpm_prep rtevalrpm loadrpm xmlrpcrpm
+rpms rpm: rpm_prep rtevalrpm loadrpm
 
 rtevalrpm: rteval-$(VERSION).tar.bz2
 	cp $^ rpm/SOURCES
diff --git a/rteval-loads.spec b/rteval-loads.spec
index 02956a5..96e4a59 100644
--- a/rteval-loads.spec
+++ b/rteval-loads.spec
@@ -1,11 +1,11 @@
 Name:		rteval-loads
-Version:	1.3
-Release:	3%{?dist}
+Version:	1.4
+Release:	1%{?dist}
 Summary:	Source files for rteval loads
 Group:		Development/Tools
 License:	GPLv2
 URL:		http://git.kernel.org/?p=linux/kernel/git/clrkwllms/rteval.git
-Source0:	http://www.kernel.org/pub/linux/kernel/v2.6/linux-2.6.39.tar.bz2
+Source0:	http://www.kernel.org/pub/linux/kernel/v4.9/linux-4.9.tar.xz
 
 BuildRoot:	%{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
 Requires:	gcc binutils make kernel-headers
@@ -37,6 +37,9 @@
 %doc
 
 %changelog
+* Tue Jan 10 2017 Clark Williams <williams@redhat.com> - 1.4-1
+- updated kernel tarball to 4.9 [1432625]
+
 * Fri Jun  5 2015 Clark Williams <williams@redhat.com> - 1.3-3
 - add requires for kernel-header package [1228740]
 
diff --git a/rteval.spec b/rteval.spec
index 4a76c5e..a41a342 100644
--- a/rteval.spec
+++ b/rteval.spec
@@ -2,7 +2,7 @@
 %{!?python_ver: %define python_ver %(%{__python} -c "import sys ; print sys.version[:3]")}
 
 Name:		rteval
-Version:	2.12
+Version:	2.14
 Release:	1%{?dist}
 Summary:	Utility to evaluate system suitability for RT Linux
 
@@ -17,7 +17,7 @@
 Requires:	python-schedutils python-ethtool python-lxml
 Requires:	python-dmidecode >= 3.10
 Requires:	rt-tests >= 0.97
-Requires:	rteval-loads >= 1.2
+Requires:	rteval-loads >= 1.4
 Requires:	rteval-common => %{version}-%{release}
 Requires:	trace-cmd
 Requires:	sysstat
@@ -74,7 +74,7 @@
 %{python_sitelib}/rteval/version.py*
 %{python_sitelib}/rteval/Log.py*
 %{python_sitelib}/rteval/misc.py*
-
+%{python_sitelib}/rteval/systopology.py*
 
 %files
 %defattr(-,root,root,-)
@@ -95,6 +95,12 @@
 /usr/bin/rteval
 
 %changelog
+* Thu Mar 16 2017 Clark Williams <williams@redhat.com> - 2.14-1
+- removed leftover import of systopology from sysinfo
+
+* Wed Mar 15 2017 Clark Williams <williams@redhat.com> - 2.13-2
+- Updated specfile to correct version and bz [1382155]
+
 * Tue Sep 20 2016 Clark Williams <williams@rehdat.com> - 2.12-1
 - handle empty environment variables SUDO_USER and USER [1312057]
 
diff --git a/rteval/modules/loads/kcompile.py b/rteval/modules/loads/kcompile.py
index 1eb2cde..ef636c2 100644
--- a/rteval/modules/loads/kcompile.py
+++ b/rteval/modules/loads/kcompile.py
@@ -1,6 +1,7 @@
 #
 #   Copyright 2009 - 2013   Clark Williams <williams@redhat.com>
 #   Copyright 2012 - 2013   David Sommerseth <davids@redhat.com>
+#   Copyright 2014 - 2017   Clark Williams <williams@redhat.com>
 #
 #   This program is free software; you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License as published by
@@ -23,19 +24,89 @@
 #   are deemed to be part of the source code.
 #
 
-import sys, os, glob, subprocess
+import sys, os, os.path, glob, subprocess
 from signal import SIGTERM
 from rteval.modules import rtevalRuntimeError
 from rteval.modules.loads import CommandLineLoad
 from rteval.Log import Log
 from rteval.misc import expand_cpulist
+from rteval.systopology import SysTopology
 
-kernel_prefix="linux-2.6"
+kernel_prefix="linux-4.9"
+
+class KBuildJob(object):
+    '''Class to manage a build job bound to a particular node'''
+
+    def __init__(self, node, kdir, logger=None):
+        self.kdir = kdir
+        self.jobid = None
+        self.node = node
+        self.logger = logger
+        self.builddir = os.path.dirname(kdir)
+        self.objdir = "%s/node%d" % (self.builddir, int(node))
+        if not os.path.isdir(self.objdir):
+            os.mkdir(self.objdir)
+        if os.path.exists('/usr/bin/numactl'):
+            self.binder = 'numactl --cpunodebind %d' % int(self.node)
+        else:
+            self.binder = 'taskset -c %s' % str(self.node)
+        self.jobs = self.calc_jobs_per_cpu() * len(self.node)
+        self.log(Log.DEBUG, "node %d: jobs == %d" % (int(node), self.jobs))
+        self.runcmd = "%s make O=%s -C %s -j%d bzImage modules" % (self.binder, self.objdir, self.kdir, self.jobs)
+        self.cleancmd = "%s make O=%s -C %s clean allmodconfig" % (self.binder, self.objdir, self.kdir)
+        self.log(Log.DEBUG, "node%d kcompile command: %s" % (int(node), self.runcmd))
+
+    def __str__(self):
+        return self.runcmd
+
+    def log(self, logtype, msg):
+        if self.logger:
+            self.logger.log(logtype, "[kcompile node%d] %s" % (int(self.node), msg))
+
+    def calc_jobs_per_cpu(self):
+        mult = 2
+        self.log(Log.DEBUG, "calulating jobs for node %d" % int(self.node))
+        # get memory total in gigabytes
+        mem = int(self.node.meminfo['MemTotal']) / 1024.0 / 1024.0 / 1024.0
+        # ratio of gigabytes to #cores
+        ratio = float(mem) / float(len(self.node))
+        if ratio < 1.0:
+            ratio = 1.0
+        if ratio < 1.0 or ratio > 2.0:
+            mult = 1
+        self.log(Log.DEBUG, "memory/cores ratio on node %d: %f" % (int(self.node), ratio))
+        self.log(Log.DEBUG, "returning jobs/core value of: %d" % int(ratio) * mult)
+        return int(int(ratio) * int(mult))
+
+    def clean(self, sin=None, sout=None, serr=None):
+        self.log(Log.DEBUG, "cleaning objdir %s" % self.objdir)
+        subprocess.call(self.cleancmd, shell=True,
+                        stdin=sin, stdout=sout, stderr=serr)
+
+    def run(self, sin=None, sout=None, serr=None):
+        self.log(Log.INFO, "starting workload on node %d" % int(self.node))
+        self.log(Log.DEBUG, "running on node %d: %s" % (int(self.node), self.runcmd))
+        self.jobid = subprocess.Popen(self.runcmd, shell=True,
+                                      stdin=sin, stdout=sout, stderr=serr)
+
+    def isrunning(self):
+        if self.jobid == None:
+            return False
+        return (self.jobid.poll() == None)
+
+    def stop(self):
+        if not self.jobid:
+            return True
+        return self.jobid.terminate()
+
 
 class Kcompile(CommandLineLoad):
     def __init__(self, config, logger):
+        self.buildjobs = {}
+        self.config = config
+        self.topology = SysTopology()
         CommandLineLoad.__init__(self, "kcompile", config, logger)
-
+        self.logger = logger
 
     def _WorkloadSetup(self):
         # find our source tarball
@@ -80,13 +151,18 @@
                     break
         if kdir == None:
             raise rtevalRuntimeError(self, "Can't find kernel directory!")
-        self.jobs = 1 # We only run one instance of the kcompile job
         self.mydir = os.path.join(self.builddir, kdir)
         self._log(Log.DEBUG, "mydir = %s" % self.mydir)
+        self._log(Log.DEBUG, "systopology: %s" % self.topology)
+        self.jobs = len(self.topology)
+        self.args = []
+        for n in self.topology:
+            self._log(Log.DEBUG, "Configuring build job for node %d" % int(n))
+            self.buildjobs[n] = KBuildJob(n, self.mydir, self.logger)
+            self.args.append(str(self.buildjobs[n])+";")
 
 
     def _WorkloadBuild(self):
-        self._log(Log.DEBUG, "setting up all module config file in %s" % self.mydir)
         null = os.open("/dev/null", os.O_RDWR)
         if self._logging:
             out = self.open_logfile("kcompile-build.stdout")
@@ -94,9 +170,9 @@
         else:
             out = err = null
 
-        # clean up from potential previous run
+        # clean up any damage from previous runs
         try:
-            ret = subprocess.call(["make", "-C", self.mydir, "mrproper", "allmodconfig"],
+            ret = subprocess.call(["make", "-C", self.mydir, "mrproper"],
                                   stdin=null, stdout=out, stderr=err)
             if ret:
                 raise rtevalRuntimeError(self, "kcompile setup failed: %d" % ret)
@@ -104,31 +180,15 @@
             self._log(Log.DEBUG, "keyboard interrupt, aborting")
             return
         self._log(Log.DEBUG, "ready to run")
-        os.close(null)
         if self._logging:
             os.close(out)
             os.close(err)
+        # clean up object dirs and make sure each has a config file
+        for n in self.topology:
+            self.buildjobs[n].clean(sin=null,sout=null,serr=null)
+        os.close(null)
         self._setReady()
 
-
-    def __calc_numjobs(self):
-        mult = int(self._cfg.setdefault('jobspercore', 1))
-        mem = self.memsize[0]
-        if self.memsize[1] == 'KB':
-            mem = mem / (1024.0 * 1024.0)
-        elif self.memsize[1] == 'MB':
-            mem = mem / 1024.0
-        elif self.memsize[1] == 'TB':
-            mem = mem * 1024
-        ratio = float(mem) / float(self.num_cpus)
-        if ratio > 1.0:
-            njobs = self.num_cpus * mult
-        else:
-            self._log(Log.DEBUG, "Low memory system (%f GB/core)! Dropping jobs to one per core" % ratio)
-            njobs = self.num_cpus
-        return njobs
-
-
     def _WorkloadPrepare(self):
         self.__nullfd = os.open("/dev/null", os.O_RDWR)
         if self._logging:
@@ -143,41 +203,30 @@
         else:
             cpulist = ""
 
-        self.jobs = self.__calc_numjobs()
-        self._log(Log.DEBUG, "starting loop (jobs: %d)" % self.jobs)
-
-        self.args = ["make", "-C", self.mydir,
-                     "-j%d" % self.jobs ]
-
-        if cpulist:
-            self.args = ["taskset", '-c', cpulist] + self.args
-
-        self.__kcompileproc = None
-
-
     def _WorkloadTask(self):
-        if not self.__kcompileproc or self.__kcompileproc.poll() is not None:
-            # If kcompile has not been kicked off yet, or have completed,
-            # restart it
-            self._log(Log.DEBUG, "Kicking off kcompile: %s" % " ".join(self.args))
-            self.__kcompileproc = subprocess.Popen(self.args,
-                                                   stdin=self.__nullfd,
-                                                   stdout=self.__outfd,
-                                                   stderr=self.__errfd)
-
+        for n in self.topology:
+            if not self.buildjobs[n]:
+                raise RuntimeError, "Build job not set up for node %d" % int(n)
+            if self.buildjobs[n].jobid is None or self.buildjobs[n].jobid.poll() is not None:
+                self._log(Log.INFO, "Starting load on node %d" % n)
+                self.buildjobs[n].run(self.__nullfd, self.__outfd, self.__errfd)
 
     def WorkloadAlive(self):
-        # Let _WorkloadTask() kick off new runs, if it stops - thus
-        # kcompile will always be alive
+        # if any of the jobs has stopped, return False
+        for n in self.topology:
+            if self.buildjobs[n].jobid.poll() is not None:
+                return False
         return True
 
 
     def _WorkloadCleanup(self):
         self._log(Log.DEBUG, "out of stopevent loop")
-        if self.__kcompileproc.poll() == None:
-            self._log(Log.DEBUG, "killing compile job with SIGTERM")
-            os.kill(self.__kcompileproc.pid, SIGTERM)
-        self.__kcompileproc.wait()
+        for n in self.buildjobs:
+            if self.buildjobs[n].jobid.poll() == None:
+                self._log(Log.DEBUG, "stopping job on node %d" % int(n))
+                self.buildjobs[n].jobid.terminate()
+                self.buildjobs[n].jobid.wait()
+                del self.buildjobs[n].jobid
         os.close(self.__nullfd)
         del self.__nullfd
         if self._logging:
@@ -185,14 +234,12 @@
             del self.__outfd
             os.close(self.__errfd)
             del self.__errfd
-        del self.__kcompileproc
         self._setFinished()
 
 
-
 def ModuleParameters():
     return {"source":   {"descr": "Source tar ball",
-                         "default": "linux-2.6.21.tar.bz2",
+                         "default": "linux-4.9.tar.xz",
                          "metavar": "TARBALL"},
             "jobspercore": {"descr": "Number of working threads per core",
                             "default": 2,
diff --git a/rteval/modules/measurement/cyclictest.py b/rteval/modules/measurement/cyclictest.py
index b871695..c5b3055 100644
--- a/rteval/modules/measurement/cyclictest.py
+++ b/rteval/modules/measurement/cyclictest.py
@@ -259,7 +259,7 @@
 
         self.__cmd = ['cyclictest',
                       self.__interval,
-                      '-qmu',
+                      '-qmun',
                       '-h %d' % self.__buckets,
                       "-p%d" % int(self.__priority),
                       ]
diff --git a/rteval/rtevalConfig.py b/rteval/rtevalConfig.py
index 5a4aa5f..16ac2d0 100644
--- a/rteval/rtevalConfig.py
+++ b/rteval/rtevalConfig.py
@@ -33,7 +33,7 @@
 import os, sys
 import ConfigParser
 from Log import Log
-
+from systopology import SysTopology
 
 def get_user_name():
     name = os.getenv('SUDO_USER')
@@ -188,6 +188,10 @@
         self.__config_files = []
         self.__logger = logger
 
+        # get our system topology info
+        self.__systopology = SysTopology()
+        print("got system topology: %s" % self.__systopology)
+
         # Import the default config first
         for sect, vals in default_config.items():
             self.__update_section(sect, vals)
diff --git a/rteval/sysinfo/__init__.py b/rteval/sysinfo/__init__.py
index fe5fbd6..0de985b 100644
--- a/rteval/sysinfo/__init__.py
+++ b/rteval/sysinfo/__init__.py
@@ -34,7 +34,6 @@
 from network import NetworkInfo
 import dmi
 
-
 class SystemInfo(KernelInfo, SystemServices, dmi.DMIinfo, CPUtopology, MemoryInfo, OSInfo, NetworkInfo):
     def __init__(self, config, logger=None):
         self.__logger = logger
diff --git a/rteval/sysinfo/dmi.py b/rteval/sysinfo/dmi.py
index 84ca97f..f8e2500 100644
--- a/rteval/sysinfo/dmi.py
+++ b/rteval/sysinfo/dmi.py
@@ -27,8 +27,9 @@
 
 import sys, os
 import libxml2, lxml.etree
-from rteval import rtevalConfig, xmlout
 from rteval.Log import Log
+from rteval import xmlout
+from rteval import rtevalConfig
 
 try:
     import dmidecode
@@ -38,7 +39,7 @@
     pass
 
 def ProcessWarnings():
-    
+
     if not hasattr(dmidecode, 'get_warnings'):
         return
 
diff --git a/rteval/systopology.py b/rteval/systopology.py
new file mode 100644
index 0000000..7c0985e
--- /dev/null
+++ b/rteval/systopology.py
@@ -0,0 +1,246 @@
+# -*- coding: utf-8 -*-
+#
+#   Copyright 2016 - Clark Williams <williams@redhat.com>
+#
+#   This program is free software; you can redistribute it and/or modify
+#   it under the terms of the GNU General Public License as published by
+#   the Free Software Foundation; either version 2 of the License, or
+#   (at your option) any later version.
+#
+#   This program is distributed in the hope that it will be useful,
+#   but WITHOUT ANY WARRANTY; without even the implied warranty of
+#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#   GNU General Public License for more details.
+#
+#   You should have received a copy of the GNU General Public License along
+#   with this program; if not, write to the Free Software Foundation, Inc.,
+#   51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+#   For the avoidance of doubt the "preferred form" of this code is one which
+#   is in an open unpatent encumbered format. Where cryptographic key signing
+#   forms part of the process of creating an executable the information
+#   including keys needed to generate an equivalently functional executable
+#   are deemed to be part of the source code.
+#
+
+import os, sys
+import os.path
+import glob
+
+def _sysread(path, obj):
+    fp = open(os.path.join(path,obj), "r")
+    return fp.readline().strip()
+
+#
+# class to provide access to a list of cpus
+#
+
+class CpuList(object):
+    "Object that represents a group of system cpus"
+
+    cpupath = '/sys/devices/system/cpu'
+
+    def __init__(self, cpulist):
+        if type(cpulist) is list:
+            self.cpulist = cpulist
+        elif type(cpulist) is str:
+            self.cpulist = self.__expand_cpulist(cpulist)
+        self.cpulist.sort()
+
+    def __str__(self):
+        return self.__collapse_cpulist(self.cpulist)
+
+    def __contains__(self, cpu):
+        return cpu in self.cpulist
+
+    def __len__(self):
+        return len(self.cpulist)
+
+
+    # return the index of the last element of a sequence
+    # that steps by one
+    def __longest_sequence(self, cpulist):
+        lim = len(cpulist)
+        for idx,val in enumerate(cpulist):
+            if idx+1 == lim:
+                break
+            if int(cpulist[idx+1]) != (int(cpulist[idx])+1):
+                return idx
+        return lim - 1
+
+
+    #
+    # collapse a list of cpu numbers into a string range
+    # of cpus (e.g. 0-5, 7, 9)
+    #
+    def __collapse_cpulist(self, cpulist):
+        if len(cpulist) == 0:
+            return ""
+        idx = self.__longest_sequence(cpulist)
+        if idx == 0:
+            seq = str(cpulist[0])
+        else:
+            if idx == 1:
+                seq = "%d,%d" % (cpulist[0], cpulist[idx])
+            else:
+                seq = "%d-%d" % (cpulist[0], cpulist[idx])
+
+        rest = self.__collapse_cpulist(cpulist[idx+1:])
+        if rest == "":
+            return seq
+        return ",".join((seq, rest))
+
+    # expand a string range into a list
+    # don't error check against online cpus
+    def __expand_cpulist(self, cpulist):
+        '''expand a range string into an array of cpu numbers'''
+        result = []
+        for part in cpulist.split(','):
+            if '-' in part:
+                a, b = part.split('-')
+                a, b = int(a), int(b)
+                result.extend(range(a, b + 1))
+            else:
+                a = int(part)
+                result.append(a)
+        return [ int(i) for i in list(set(result)) ]
+
+    # returns the list of cpus tracked
+    def getcpulist(self):
+        return self.cpulist
+
+    # check whether cpu n is online
+    def isonline(self, n):
+        if n not in self.cpulist:
+            raise RuntimeError, "invalid cpu number %d" % n
+        if n == 0:
+            return True
+        path = os.path.join(CpuList.cpupath,'cpu%d' % n)
+        if os.path.exists(path):
+            return _sysread(path, "online") == 1
+        return False
+
+#
+# class to abstract access to NUMA nodes in /sys filesystem
+#
+
+class NumaNode(object):
+    "class representing a system NUMA node"
+
+    # constructor argument is the full path to the /sys node file
+    # e.g. /sys/devices/system/node/node0
+    def __init__(self, path):
+        self.path = path
+        self.nodeid = int(os.path.basename(path)[4:].strip())
+        self.cpus = CpuList(_sysread(self.path, "cpulist"))
+        self.getmeminfo()
+
+    # function for the 'in' operator
+    def __contains__(self, cpu):
+        return cpu in self.cpus
+
+    # allow the 'len' builtin
+    def __len__(self):
+        return len(self.cpus)
+
+    # string representation of the cpus for this node
+    def __str__(self):
+        return self.getcpustr()
+
+    def __int__(self):
+        return self.nodeid
+
+    # read info about memory attached to this node
+    def getmeminfo(self):
+        self.meminfo = {}
+        for l in open(os.path.join(self.path, "meminfo"), "r"):
+            elements = l.split()
+            key=elements[2][0:-1]
+            val=int(elements[3])
+            if len(elements) == 5 and elements[4] == "kB":
+                val *= 1024
+            self.meminfo[key] = val
+
+    # return list of cpus for this node as a string
+    def getcpustr(self):
+        return str(self.cpus)
+
+    # return list of cpus for this node
+    def getcpulist(self):
+        return self.cpus.getcpulist()
+
+#
+# Class to abstract the system topology of numa nodes and cpus
+#
+class SysTopology(object):
+    "Object that represents the system's NUMA-node/cpu topology"
+
+    cpupath = '/sys/devices/system/cpu'
+    nodepath = '/sys/devices/system/node'
+
+    def __init__(self):
+        self.nodes = {}
+        self.getinfo()
+
+    def __len__(self):
+        return len(self.nodes.keys())
+
+    def __str__(self):
+        s = "%d node system" % len(self.nodes.keys())
+        s += " (%d cores per node)" % (len(self.nodes[self.nodes.keys()[0]]))
+        return s
+
+    # inplement the 'in' function
+    def __contains__(self, node):
+        for n in self.nodes:
+            if self.nodes[n].nodeid == node:
+                return True
+        return False
+
+    # allow indexing for the nodes
+    def __getitem__(self, key):
+        return self.nodes[key]
+
+    # allow iteration over the cpus for the node
+    def __iter__(self):
+        self.current = 0
+        return self
+
+    # iterator function
+    def next(self):
+        if self.current >= len(self.nodes):
+            raise StopIteration
+        n = self.nodes[self.current]
+        self.current += 1
+        return n
+
+    def getinfo(self):
+        nodes = glob.glob(os.path.join(SysTopology.nodepath, 'node[0-9]*'))
+        if not nodes:
+            raise RuntimeError, "No valid nodes found in %s!" % SysTopology.nodepath
+        nodes.sort()
+        for n in nodes:
+            node = int(os.path.basename(n)[4:])
+            self.nodes[node] = NumaNode(n)
+
+    def getnodes(self):
+        return self.nodes.keys()
+
+    def getcpus(self, node):
+        return self.nodes[node]
+
+
+
+if __name__ == "__main__":
+
+    def unit_test():
+        s = SysTopology()
+        print s
+        print "number of nodes: %d" % len(s)
+        for n in s:
+            print "node[%d]: %s" % (n.nodeid, n)
+        print "system has numa node 0: %s" % (0 in s)
+        print "system has numa node 2: %s" % (2 in s)
+        print "system has numa node 24: %s" % (24 in s)
+
+    unit_test()
diff --git a/rteval/version.py b/rteval/version.py
index b67143a..fdc6b43 100644
--- a/rteval/version.py
+++ b/rteval/version.py
@@ -23,4 +23,4 @@
 #   are deemed to be part of the source code.
 #
 
-RTEVAL_VERSION = '2.12'
+RTEVAL_VERSION = '2.14'