From a400141f0d195f124408fa053aa43dd6107747f2 Mon Sep 17 00:00:00 2001 From: "SANDIA\\jaraco" Date: Fri, 30 Dec 2005 04:48:39 +0000 Subject: [PATCH] Fixed more bugs. Testing now shows high correlation with Ruby code for Plot. Fixed some bugs in TimeSeries, and re-wired the date-handling so it now uses integers for the x-axis. These may be converted back to date/time later, but not unless the datetime module can handle timedelta division correctly. Even the dateutil module doesn't handle this. --- Plot.py | 16 ++++++------- TimeSeries.py | 54 ++++++++++++++++++++++++++--------------- __init__.py | 66 ++++++++++++++++++++++++++++++++++----------------- testing.py | 27 ++++++++++++++++++--- testing.rb | 32 +++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 testing.rb diff --git a/Plot.py b/Plot.py index 1269c76..7d21624 100644 --- a/Plot.py +++ b/Plot.py @@ -9,7 +9,7 @@ def get_pairs( i ): def float_range( start = 0, stop = None, step = 1 ): "Much like the built-in function range, but accepts floats" while start < stop: - yield start + yield float( start ) start += step class Plot( SVG.Graph ): @@ -145,12 +145,12 @@ class Plot( SVG.Graph ): data['data'] = zip( *pairs ) def calculate_left_margin( self ): - super( self.__class__, self ).calculate_left_margin() + super( Plot, self ).calculate_left_margin() label_left = len( str( self.get_x_labels()[0] ) ) / 2 * self.font_size * 0.6 self.border_left = max( label_left, self.border_left ) def calculate_right_margin( self ): - super( self.__class__, self ).calculate_right_margin() + super( Plot, self ).calculate_right_margin() label_right = len( str( self.get_x_labels()[-1] ) ) / 2 * self.font_size * 0.6 self.border_right = max( label_right, self.border_right ) @@ -166,6 +166,7 @@ class Plot( SVG.Graph ): min_value = min( min_value, spec_min ) range = max_value - min_value + #side_pad = '%s_pad' % side side_pad = range / 20.0 or 10 scale_range = ( max_value + side_pad ) - min_value @@ -176,7 +177,7 @@ class Plot( SVG.Graph ): scale_division = scale_division.round() or 1 return min_value, max_value, scale_division - + def x_range( self ): return self.data_range( 'x' ) def y_range( self ): return self.data_range( 'y' ) @@ -204,7 +205,6 @@ class Plot( SVG.Graph ): side_align = getattr( self, '%s_align' % side ) result = ( float( graph_size ) - self.font_size*2*side_font ) / \ ( len( values ) + dx - side_align ) - for key,val in vars().items(): print key, val return result def field_width( self ): return self.field_size( 'x' ) @@ -223,11 +223,11 @@ class Plot( SVG.Graph ): if self.area_fill: graph_height = self.graph_height path = self._create_element( 'path', { - 'd': 'M%(x_start)s %(graph_height)d %(lpath)s V%(graph_height)d Z' % vars(), + 'd': 'M%(x_start)f %(graph_height)f %(lpath)s V%(graph_height)f Z' % vars(), 'class': 'fill%(line)d' % vars() } ) self.graph.appendChild( path ) path = self._create_element( 'path', { - 'd': 'M%(x_start)d %(y_start)d %(lpath)s' % vars(), + 'd': 'M%(x_start)f %(y_start)f %(lpath)s' % vars(), 'class': 'line%(line)d' % vars() } ) self.graph.appendChild( path ) self.draw_data_points( line, data_points, graph_points ) @@ -248,7 +248,7 @@ class Plot( SVG.Graph ): return map( self.transform_output_coordinates, data_points ) def get_lpath( self, points ): - points = map( lambda p: "%d %d" % p, points ) + points = map( lambda p: "%f %f" % p, points ) return 'L' + ' '.join( points ) def transform_output_coordinates( self, (x,y) ): diff --git a/TimeSeries.py b/TimeSeries.py index 78780b7..6e8d79c 100644 --- a/TimeSeries.py +++ b/TimeSeries.py @@ -1,5 +1,12 @@ #!/usr/bin/env python import SVG +import re +#requires python date-util from http://labix.org/python-dateutil +from dateutil.parser import parse +from dateutil.relativedelta import relativedelta +from time import mktime +import datetime +fromtimestamp = datetime.datetime.fromtimestamp class Plot( SVG.Plot.Plot ): """=== For creating SVG plots of scalar temporal data @@ -98,6 +105,18 @@ class Plot( SVG.Plot.Plot ): __doc_popup_format_ = "The formatting usped for the popups. See x_label_format" __doc_x_label_format_ = "The format string used to format the X axis labels. See strftime." + timescale_divisions = None + __doc_timescale_divisions_ = """Use this to set the spacing between dates on the axis. The value + must be of the form + "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" + + EG: + + graph.timescale_divisions = "2 weeks" + + will cause the chart to try to divide the X axis up into segments of + two week periods.""" + def add_data( self, data ): """Add data to the plot. d1 = [ "12:30", 2 ] # A data set with 1 point: ("12:30",2) @@ -114,13 +133,14 @@ class Plot( SVG.Plot.Plot ): Note that the data must be in time,value pairs, and that the date format may be any date that is parseable by ParseDate.""" - super( self.__class__, self ).add_data( data ) + super( Plot, self ).add_data( data ) def process_data( self, data ): - super( self.__class__, self ).process_data( data ) + super( Plot, self ).process_data( data ) # the date should be in the first element, so parse it out data['data'][0] = map( self.parse_date, data['data'][0] ) - + + _min_x_value = SVG.Plot.Plot.min_x_value def get_min_x_value( self ): return self._min_x_value def set_min_x_value( self, date ): @@ -128,38 +148,34 @@ class Plot( SVG.Plot.Plot ): min_x_value = property( get_min_x_value, set_min_x_value ) def format( self, x, y ): - return x.strftime( self.popup_format ) + return fromtimestamp( x ).strftime( self.popup_format ) def get_x_labels( self ): - return map( lambda t: t.strftime( self.x_label_format ), self.get_x_values() ) + return map( lambda t: fromtimestamp( t ).strftime( self.x_label_format ), self.get_x_values() ) def get_x_values( self ): result = self.get_x_timescale_division_values() if result: return result - return range( *self.x_range() ) + return SVG.Plot.float_range( *self.x_range() ) def get_x_timescale_division_values( self ): if not self.timescale_divisions: return min, max, scale_division = self.x_range() m = re.match( '(?P\d+) ?(?Pdays|weeks|months|years|hours|minutes|seconds)?', self.timescale_divisions ) # copy amount and division_units into the local namespace - vars.update( m.groupdict() ) - division_units = division_units or 'days' - amount = int( amount ) + division_units = m.groupdict()['division_units'] or 'days' + amount = int( m.groupdict()['amount'] ) if not amount: return - if division_units == 'weeks': - amount *= 7 - division_units = 'days' - # strip off the plural (s) - division_units = division_units[:-1] - result = self.get_time_range( min, max, step, units ) + delta = relativedelta( **{ division_units: amount } ) + result = self.get_time_range( min, max, delta ) return result - def get_time_range( self, start, stop, step, units ): + def get_time_range( self, start, stop, delta ): + start, stop = map( fromtimestamp, (start, stop ) ) current = start while current < stop: - yield current - current.replace( **{ units: current.getattr( units ) + step } ) + yield mktime( current.timetuple() ) + current += delta def parse_date( self, date_string ): - return strptime( date_string, '%Y-%m-%dT%H:%M:%S' ) \ No newline at end of file + return mktime( parse( date_string ).timetuple() ) \ No newline at end of file diff --git a/__init__.py b/__init__.py index b81d2c2..b3cc1d2 100644 --- a/__init__.py +++ b/__init__.py @@ -110,14 +110,14 @@ Copyright 2005 Sandia National Laboratories top_align = top_font = right_align = right_font = 0 - def __init__( self, config ): + def __init__( self, config = {} ): """Initialize the graph object with the graph settings. You won't instantiate this class directly; see the subclass for options.""" + self.load_config( config ) self.clear_data() - def load_config( self, config = None ): - if not config: config = self.defaults - map( lambda pair: setattr( self, *pair ), config.items() ) + def load_config( self, config ): + self.__dict__.update( config ) def add_data( self, conf ): """This method allows you do add data to the graph object. @@ -314,7 +314,14 @@ Copyright 2005 Sandia National Laboratories 'class': 'dataPointLabel', 'style': '%(style)s stroke: #fff; stroke-width: 2;' % vars(), } ) - e.nodeValue = value + e.appendChild( self._doc.createTextNode( str( value ) ) ) + self.foreground.appendChild( e ) + e = self._create_element( 'text', { + 'x': str( x ), + 'y': str( y ), + 'class': 'dataPointLabel' } ) + e.appendChild( self._doc.createTextNode( str( value ) ) ) + if style: e.setAttribute( 'style', style ) self.foreground.appendChild( e ) def draw_x_labels( self ): @@ -345,7 +352,7 @@ Copyright 2005 Sandia National Laboratories y += stagger graph_height = self.graph_height path = self._create_element( 'path', { - 'd': 'M%(x)d %(graph_height)d v%(stagger)d' % vars(), + 'd': 'M%(x)f %(graph_height)f v%(stagger)d' % vars(), 'class': 'staggerGuideLine' } ) self.graph.appendChild( path ) @@ -391,7 +398,7 @@ Copyright 2005 Sandia National Laboratories def get_y_offset( self ): #result = self.graph_height + self.y_label_offset( label_height ) result = self.graph_height + self.y_label_offset( self.field_height() ) - if self.rotate_y_labels: result += self.font_size/1.2 + if not self.rotate_y_labels: result += self.font_size/1.2 return result y_offset = property( get_y_offset ) @@ -405,11 +412,11 @@ Copyright 2005 Sandia National Laboratories y = self.y_offset - ( label_height * index ) x = {True: 0, False:-3}[self.rotate_y_labels] - if self.stagger_x_labels and (index % 2 ): + if self.stagger_y_labels and (index % 2 ): stagger = self.y_label_font_size + 5 x -= stagger path = self._create_element( 'path', { - 'd': 'M%(x)d %(y)d v%(stagger)d' % vars(), + 'd': 'M%(x)f %(y)f h%(stagger)d' % vars(), 'class': 'staggerGuideLine' } ) self.graph.appendChild( path ) @@ -424,10 +431,11 @@ Copyright 2005 Sandia National Laboratories text.setAttribute( 'style', 'text-anchor: middle' ) else: text.setAttribute( 'y', str( y - self.y_label_font_size/2 ) ) - text.setAttribute( 'style', 'text-anchor: middle' ) + text.setAttribute( 'style', 'text-anchor: end' ) def draw_x_guidelines( self, label_height, count ): "Draw the X-axis guidelines" + if not self.show_x_guidelines: return # skip the first one for count in range(1,count): start = label_height*count @@ -440,11 +448,12 @@ Copyright 2005 Sandia National Laboratories def draw_y_guidelines( self, label_height, count ): "Draw the Y-axis guidelines" + if not self.show_y_guidelines: return for count in range( 1, count ): start = self.graph_height - label_height*count stop = self.graph_width path = self._create_element( 'path', { - 'd': 'MO %(start)s h%(stop)s' % vars(), + 'd': 'M 0 %(start)s h%(stop)s' % vars(), 'class': 'guideLines' } ) self.graph.appendChild( path ) @@ -582,7 +591,7 @@ Copyright 2005 Sandia National Laboratories self.graph_height = self.height - self.border_top - self.border_bottom def get_style( self ): - return """/* Copy from here for external style sheet */ + result = """/* Copy from here for external style sheet */ .svgBackground{ fill:#ffffff; } @@ -594,14 +603,14 @@ Copyright 2005 Sandia National Laboratories .mainTitle{ text-anchor: middle; fill: #000000; - font-size: #{title_font_size}px; + font-size: %(title_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } .subTitle{ text-anchor: middle; fill: #999999; - font-size: #{subtitle_font_size}px; + font-size: %(subtitle_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } @@ -620,7 +629,7 @@ Copyright 2005 Sandia National Laboratories .xAxisLabels{ text-anchor: middle; fill: #000000; - font-size: #{x_label_font_size}px; + font-size: %(x_label_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } @@ -628,7 +637,7 @@ Copyright 2005 Sandia National Laboratories .yAxisLabels{ text-anchor: end; fill: #000000; - font-size: #{y_label_font_size}px; + font-size: %(y_label_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } @@ -636,7 +645,7 @@ Copyright 2005 Sandia National Laboratories .xAxisTitle{ text-anchor: middle; fill: #ff0000; - font-size: #{x_title_font_size}px; + font-size: %(x_title_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } @@ -644,7 +653,7 @@ Copyright 2005 Sandia National Laboratories .yAxisTitle{ fill: #ff0000; text-anchor: middle; - font-size: #{y_title_font_size}px; + font-size: %(y_title_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } @@ -663,21 +672,34 @@ Copyright 2005 Sandia National Laboratories stroke-width: 0.5px; } -%s +%%s .keyText{ fill: #000000; text-anchor:start; - font-size: #{key_font_size}px; + font-size: %(key_font_size)dpx; font-family: "Arial", sans-serif; font-weight: normal; } /* End copy for external style sheet */ -""" % self.get_css() +""" % class_dict( self ) + result = result % self.get_css() + return result def _create_element( self, nodeName, attributes={} ): "Create an XML node and set the attributes from a dict" node = self._doc.createElement( nodeName ) map( lambda a: node.setAttribute( *a ), attributes.items() ) return node - + +class class_dict( object ): + "Emulates a dictionary, but retrieves class attributes" + def __init__( self, obj ): + self.__obj__ = obj + + def __getitem__( self, item ): + return getattr( self.__obj__, item ) + + def keys( self ): + # dir returns a good guess of what attributes might be available + return dir( self.__obj__ ) \ No newline at end of file diff --git a/testing.py b/testing.py index f8e4466..fdf6923 100644 --- a/testing.py +++ b/testing.py @@ -4,10 +4,31 @@ import SVG from SVG import Plot reload( SVG ) reload( Plot ) -g = Plot.Plot({}) -data1 = [ 1, 25, 2, 30, 3, 45 ] -g.add_data( { 'data': data1, 'title': 'foo' } ) +g = Plot.Plot( { + 'min_x_value': 0, + 'min_y_value': 0, + 'area_fill': True, + 'stagger_x_labels': True, + 'stagger_y_labels': True, + 'show_x_guidelines': True + }) +g.add_data( { 'data': [ 1, 25, 2, 30, 3, 45 ], 'title': 'foo' } ) +g.add_data( { 'data': [ 1,30, 2, 31, 3, 40 ], 'title': 'foo2' } ) +g.add_data( { 'data': [ .5,35, 1, 20, 3, 10.5 ], 'title': 'foo2' } ) res = g.burn() f = open( r'c:\sample.svg', 'w' ) f.write( res ) f.close() + +from SVG import TimeSeries +reload( TimeSeries ) + +g = TimeSeries.Plot( { } ) +#g.timescale_divisions = '4 hours' +g.stagger_x_labels = True +g.add_data( { 'data': [ '2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21 ], 'title': 'foo' } ) + +res = g.burn() +f = open( r'c:\timeseries.py.svg', 'w' ) +f.write( res ) +f.close() diff --git a/testing.rb b/testing.rb new file mode 100644 index 0000000..2b307f6 --- /dev/null +++ b/testing.rb @@ -0,0 +1,32 @@ +require 'SVG/Graph/Graph' +require 'SVG/Graph/Plot' + +graph = SVG::Graph::Plot.new( { + :min_x_value=>0, + :min_y_value=>0, + :area_fill=> true, + :stagger_x_labels=>true, + :stagger_y_labels=>true +}) + +#data1 = [ 1,25, 2,30, 3,45 ] + +graph.add_data( { :data=>[ 1,25, 2,30, 3,45 ], :title=>'foo' } ) + +graph.add_data( { :data=>[ 1,30, 2, 31, 3, 40 ], :title=>'foo2' } ) + +res = graph.burn() + +f = File.new( 'c:\ruby.svg', 'w' ) +f.write( res ) +f.close() + +require 'SVG/Graph/TimeSeries' + +g = SVG::Graph::TimeSeries.new( { } ) +g.add_data( { :data=> [ '2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21 ], :title=> 'foo' } ) + +res = g.burn() +f = File.new( 'c:\timeseries.rb.svg', 'w' ) +f.write( res ) +f.close() \ No newline at end of file