Creating an ARDI Historical Driver

This tutorial will re-create the ARDI Historical Text driver as an example of how to create your own ARDI driver in Python.

Many of the steps are very similar to those from our Creating an ARDI Live Driver tutorial - however the functions that we create are a little different.

One thing to keep in mind when building a historian driver are the ARDI Driver Guidelines, which lay out the way your history driver is expected to function.

The Sketch

To begin, we will sketch out the basic structure of our Python script.

First, create a new folder in /opt/ardi/drivers/hist with the name of your driver (ie. /opt/ardi/drivers/hist/text2.

Now create a matching python source file - a plain text file - in that folder, named <foldername>.py - ie /opt/ardi/drivers/hist/text2/text2.py.

Paste in the following content…

basehistdriver.py
from ardi.driver import histdriverbase
 
class MyDriver:
 
   def __init__(self):
      self.connected = False
 
   def SetAddress(self,addr):
      pass
 
   def Connect(self):
      return True
 
   def Disconnect(self):
      pass
 
  def RunQuery(self, query):
      return query.Finish()
 
class driverfactory:
    def createinstance(self):
        return MyDriver()
 
if __name__ == "__main__":
    sdf = driverfactory()
    base = histdriverbase.historian()
    base.start(sdf)
 

This is the basic layout of every ARDI Python historical driver.

It's almost identical to that of the live driver, however in this case we don't have the Optimise or Poll functions, replacing them instead with Query.

Core

Your class has access to some important functions and variables.

Query.AddLine()

This function is responsible for outputting a line of historical data to be sent to ARDI.

ParameterPurpose
AddressThe address that this value was read from
Date StampThe date that the measurement was made (as a datetime)
ValueThe value read from your data source

Note that if your historical data link has a transform attached, this function will automatically apply the transform for you.

self.core.logger

This member is a Python logging object that can be used to record any information of note, through calls like self.core.logger.info and self.core.logger.debug.

Functions

There are four key functions required in your driver class.

SetAddress

This function is called after your driver is first created. It includes an encoded address for your data source from ARDI.

You'll need to split it into its component parts and store them for when its time to connect.

Connect

The connect function is called whenever your driver is to connect to a data source.

It returns True if connection was successful and False if connection fails.

Disconnect

This does the opposite of connect - it closes the connection to your data source and does any cleanup required.

RunQuery

The RunQuery function is used to actually perform a query on the historical data.

It includes a single python object that contains all of the information you need to run your query.

AttributeDescription
pointsA list of the points to fetch
startThe start date for the query
endThe end date for the query (may be the same as above)
functionThe function to apply (see below)
grainIf positive, the number of seconds between each sample. If negative, the total number of samples.
Function

The function determines what kind of response the calling application wants. Normally, this will be “interp”, meaning that where possible the numbers should be processed and averaged out to reduce the total amount of data being sent. Note that this is actually a suggestion - drivers don't have to implement this.

A function of “raw” indicates that the data should not be interpolated and sent as-is (in which case, the grain parameter is completely ignored).

Points

The points list is of addresses / lookup values for the driver.

Note that the sequence of these addresses is very important, as the values returned to ARDI use the sequence number of the point (ie. point 1, point 2, point 3 etc.) in the response.

Implementation

Splitting Our Address

The address for our text file driver will include the following options…

OptionDescription
File NameThe name of the text file to open
DelimiterThe delimiting character. If blank, it defaults to TAB (/t)
Lookup ColumnThe number of the column that contains the value name
Value ColumnThe number of the column that contains the value itself

The address lists these options, separated by a colon (:) character. IE. /tmp/data.txt::1:2.

It's up to this function to…

  • Split the address string into its component parts
  • Record this information for use in the connect, optimise and poll functions
        bits = addr.split(':')
        self.filename = bits[0]
        self.delimiter = bits[1]
        if self.delimiter == "\\t":
            self.delimiter = "\t"
        self.lookupcol = bits[2]
        self.valuecol = bits[3]

Connecting to a Text File

It's up to our connect function to…

  • Check that the file exists

Because we want to allow applications to change the file between polling it, we won't actually open the file at this point - we will open the file each time we poll it.

   if os.path.isfile(self.file):
      return True
 
   self.core.logger.warn("File not found - Data source could not be opened")
   return False

RunQuery

This is where the magic happens. This function is responsible for reading the data source and returning time-stamped values for the points that have been requested.

         #Begin the Query
 
	self.core.logger.info("Query Started: '" + query.function + "' between " + query.start + " and " + query.end + " - " + str(query.grain) + " samples")
 
	#Note, as a very simple data source, this pays no attention to function or grain.
	#If this was to use SQL, we would pay much closer attention to it. 								
 
	#Each point is given a number (from 0). This lookup table allows us to find it quickly.
	n = 0
	lookupid = {}
	lastvalue = {}
	for p in points:
		lookupid[p] = n
		n=n+1
 
	any = False
 
	#Open and process the file...
	with open(self.filename,'r') as f:
		for line in f:
			#Split each line by the delimiter...
			bits = line.split(self.delimiter)
 
			#Is this date in the range?
			ldt = bits[self.datecol]
			TT = datetime.datetime.strptime( ldt, "%Y-%m-%d %H:%M:%S" )
			if TT < sd:
				lastvalue[bits[self.namecol]] = str(bits[self.valuecol]).strip()
				continue
 
			if TT > ed:
				#This is AFTER the date range - break out of the loop.
				break
 
			#Add this value to the data to be transmitted.
			if bits[self.namecol] in self.lookup:
				vl = str(bits[self.valuecol]).strip()
				query.AddLine(bits[self.namecol],ldt,vl)
				any = True
 
	#If you didn't find any particular values, output the most recent.
	if any == False:
		for ky,vl in lastvalue.iteritems():
			if ky in lookupid:
				n = lookupid[ky]
				query.AddLine(ky,sd,vl)
 
	#That's it - lets output our results.
	return query.Finish()

Creating a Web Interface

The last step is to create a web interface for our driver, so that we can set the drivers various options (file name, delimiter, column numbers etc.) in a friendly manner.

This will require some basic HTML & PHP.

Firstly, copy one of the existing drivers user interfaces by copying /opt/ardi/web/data/live/text to /opt/ardi/web/data/live/text2 (the folder name must match the name of the driver folder & file).

There are several PHP files in this folder. Click on them for examples and documentation on what they do.

FilePurpose
info.incProvides details on the driver
configure-source.phpThe user interface for setting up the data source
saveconfig-source.phpConvert the properties set in configure-source.php to a machine-readable address string
friendlyname.phpConvert the address from saveconfig-source.php to a human-readable text string
link.phpThe user interface for setting up a data link between the source and an asset property
encode.phpConvert the properties set in link.php to a machine-readable address string
decode.phpConvert the address from encode.php to a human-readable description