# # Copyright (c) 2009 Tom Keffer # # See the file LICENSE.txt for your full rights. # # $Revision$ # $Author$ # $Date$ # """Various utilities used by the plot package. """ import datetime import time import math import weeplot def scale(fmn, fmx, prescale = (None, None, None), nsteps = 10): """Calculates an appropriate min, max, and step size for scaling axes on a plot. The origin (zero) is guaranteed to be on an interval boundary. fmn: The minimum data value fmx: The maximum data value. Must be greater than or equal to fmn. prescale: A 3-way tuple. A non-None min or max value (positions 0 and 1, respectively) will be fixed to that value. A non-None interval (position 2) be at least as big as that value. Default = (None, None, None) nsteps: The nominal number of desired steps. Default = 10 Returns: a three-way tuple. First value is the lowest scale value, second the highest. The third value is the step (increment) between them. """ if all(x is not None for x in prescale): return prescale (minscale, maxscale, min_interval) = prescale # Make sure fmn and fmx are float values, in case a user passed # in integers: fmn = float(fmn) fmx = float(fmx) if fmx < fmn : raise weeplot.ViolatedPrecondition, "scale() called with max value less than min value" if fmx == fmn : if fmn == 0.0 : fmx = 1.0 else : fmx = fmn + .01*abs(fmn) range = fmx - fmn steps = range / nsteps mag = math.floor(math.log10(steps)) magPow = math.pow(10.0, mag) magMsd = math.floor(steps/magPow + 0.5) if magMsd > 5.0: magMsd = 10.0 elif magMsd > 2.0: magMsd = 5.0 else : # magMsd > 1.0 magMsd = 2 # This will be the nominal interval size interval = magMsd * magPow # Test it against the desired minimum, if any if min_interval is None or interval >= min_interval: # Either no min interval was specified, or its safely # less than the chosen interval. if minscale is None: minscale = interval * math.floor(fmn / interval) if maxscale is None: maxscale = interval * math.ceil(fmx / interval) else: # The request for a minimum interval has kicked in. # Sometimes this can make for a plot with just one or # two intervals in it. Adjust the min and max values # to get a nice plot interval = min_interval if minscale is None: if maxscale is None: # Both can float. Pick values so the range is near the bottom # of the scale: minscale = interval * math.floor(fmn / interval) maxscale = minscale + interval * nsteps else: # Only minscale can float minscale = maxscale - interval * nsteps else: if maxscale is None: # Only maxscale can float maxscale = minscale + interval * nsteps else: # Both are fixed --- nothing to be done pass return (minscale, maxscale, interval) def scaletime(tmin_ts, tmax_ts) : """Picks a time scaling suitable for a time plot. tmin_ts, tmax_ts: The time stamps in epoch time around which the times will be picked. Returns a scaling 3-tuple. First element is the start time, second the stop time, third the increment. All are in seconds (epoch time in the case of the first two). """ if tmax_ts <= tmin_ts : raise weeplot.ViolatedPrecondition, "scaletime called with tmax <= tmin" tdelta = tmax_ts - tmin_ts tmin_dt = datetime.datetime.fromtimestamp(tmin_ts) tmax_dt = datetime.datetime.fromtimestamp(tmax_ts) # How big a time delta are we talking about? if tdelta <= 27 * 3600 : # A day plot is wanted. A time increment of 3 hours is appropriate # h is the hour of tmin_dt h = tmin_dt.timetuple()[3] # Subtract off enough to get to the lower 3-hour boundary, # zeroing out everything else start_dt = tmin_dt.replace(minute=0, second=0, microsecond=0) - datetime.timedelta(hours = h % 3) # Now figure the upper time boundary, which is a bit more complicated if tmax_dt lies # near the 3-hour boundary tmax_tt = tmax_dt.timetuple() # stop_dt is the lower 3-hour boundary from tmax_dt stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) # If the tmax_dt was close to the 3-hour boundary, we're done. Otherwise, go up to # the next 3-hour boundary. if tmax_tt[3] % 3 != 0 or tmax_tt[4] != 0 : stop_dt += datetime.timedelta(hours = 3 - tmax_tt[3] % 3) interval = 3 * 3600 elif tdelta > 27 * 3600 and tdelta <= 31 * 24 * 3600 : # The time scale is between a day and a month. A time increment of one day is appropriate start_dt = tmin_dt.replace(hour=0, minute=0, second=0, microsecond=0) stop_dt = tmax_dt.replace(hour=0, minute=0, second=0, microsecond=0) tmax_tt = tmax_dt.timetuple() if tmax_tt[3]!=0 or tmax_tt[4]!=0 : stop_dt += datetime.timedelta(days=1) interval = 24 * 3600 else : # The time scale is more than a month. A time increment of a month is appropriate start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) (year , mon, day) = tmax_dt.timetuple()[0:3] if day != 1 : mon += 1 if mon==13 : mon = 1 year += 1 stop_dt = datetime.datetime(year, mon, 1) # Average month length: interval = 365.25/12 * 24 * 3600 # Convert to epoch time stamps start_ts = int(time.mktime(start_dt.timetuple())) stop_ts = int(time.mktime(stop_dt.timetuple())) return (start_ts, stop_ts, interval) class ScaledDraw(object): """Like an ImageDraw object, but lines are scaled. """ def __init__(self, draw, imagebox, scaledbox): """Initialize a ScaledDraw object. Example: scaledraw = ScaledDraw(draw, ((10, 10), (118, 246)), ((0.0, 0.0), (10.0, 1.0))) would create a scaled drawing where the upper-left image coordinate (10, 10) would correspond to the scaled coordinate( 0.0, 1.0). The lower-left image coordinate would correspond to the scaled coordinate (10.0, 0.0). draw: an instance of ImageDraw imagebox: a 2-tuple of the box coordinates on the image ((ulx, uly), (lrx, lry)) scaledbox: a 2-tuple of the box coordinates of the scaled plot ((llx, lly), (urx, ury)) """ uli = imagebox[0] lri = imagebox[1] lls = scaledbox[0] urs = scaledbox[1] self.xscale = float(lri[0] - uli[0]) / float(urs[0] - lls[0]) self.yscale = -float(lri[1] - uli[1]) / float(urs[1] - lls[1]) self.xoffset = int(lri[0] - urs[0] * self.xscale + 0.5) self.yoffset = int(uli[1] - urs[1] * self.yscale + 0.5) self.draw = draw def line(self, x, y, **options) : """Draw a scaled line on the instance's ImageDraw object. x: sequence of x coordinates y: sequence of y coordinates, some of which are possibly null (value of None) """ # Break the line up around any nulls for (x_seq, y_seq) in seq_line(x, y): # Scale it xy_seq_scaled = zip([self.xtranslate(x) for x in x_seq], [self.ytranslate(y) for y in y_seq]) # Draw it: if len(xy_seq_scaled) == 1 : self.draw.point(xy_seq_scaled, fill = options['fill']) else : self.draw.line(xy_seq_scaled, **options) def rectangle(self, box, **options) : """Draw a scaled rectangle. box: A pair of 2-way tuples, containing coordinates of opposing corners of the box. options: passed on to draw.rectangle. Usually contains 'fill' (the color) """ box_scaled = [(coord[0]*self.xscale + self.xoffset + 0.5, coord[1]*self.yscale + self.yoffset + 0.5) for coord in box] self.draw.rectangle(box_scaled, **options) def vector(self, x, vec, vector_rotate, **options): if vec is None: return xstart_scaled = self.xtranslate(x) ystart_scaled = self.ytranslate(0) vecinc_scaled = vec * self.yscale if vector_rotate: vecinc_scaled *= complex(math.cos(math.radians(vector_rotate)), math.sin(math.radians(vector_rotate))) # Subtract off the x increment because the x-axis # *increases* to the right, unlike y, which increases # downwards xend_scaled = xstart_scaled - vecinc_scaled.real yend_scaled = ystart_scaled + vecinc_scaled.imag self.draw.line(((xstart_scaled, ystart_scaled), (xend_scaled, yend_scaled)), **options) def xtranslate(self, x): return int(x * self.xscale + self.xoffset + 0.5) def ytranslate(self, y): return int(y * self.yscale + self.yoffset + 0.5) def seq_line(x, y): """Generator function that breaks a line up into individual segments around any nulls held in y. Example: if x=[0, 1, 2, 3, 4, 5, 6, 7] y=[10, 20, None, 40, None, 60, 70, None] then seq_line(x,y) yields ([0,1], [10,20]) ([3], [40]) ([5,6], [60,70]) x: iterable sequence of x coordinates. All values must be non-null y: iterable sequence of y coordinates, possibly with some embedded nulls (that is, their value==None) yields: tuples, first value of which is a list of x-coordinates, and second value a list of y-coordinates, of a contiguous line """ istart = iend = 0 while iend < len(y): if y[iend] is None: if istart != iend : yield (x[istart:iend], y[istart:iend]) istart = iend + 1 while istart < len(y) : if y[istart] is not None : break istart += 1 iend = istart iend += 1 if istart < len(y) : yield (x[istart:iend], y[istart:iend]) def pickLabelFormat(increment): i_log = math.log10(increment) if i_log < 0 : i_log = abs(i_log) decimal_places = int(i_log) if i_log != decimal_places : decimal_places += 1 else : decimal_places = 0 return "%%.%df" % decimal_places if __name__ == '__main__' : # Unit test: assert(scale(1.1, 12.3) == (0.0, 14.0, 2.0)) assert(scale(-1.1, 12.3) == (-2.0, 14.0, 2.0)) assert(scale(-12.1, -5.3) == (-13.0, -5.0, 1.0)) assert(scale(0.0, 0.05, (None, None, .1), 10) == (0.0, 1.0, 0.1)) assert(scale(0.0, 0.21, (None, None, .02)) == (0.0, 0.22, 0.02)) t= time.time() scaletime(t - 24*3600 - 20, t) assert(pickLabelFormat(1) == "%.0f") assert(pickLabelFormat(20) == "%.0f") assert(pickLabelFormat(.2) == "%.1f") assert(pickLabelFormat(.1) == "%.1f") print "test successful"