#!/usr/bin/env python
# pywws - Python software for USB Wireless Weather Stations
# http://github.com/jim-easterbrook/pywws
# Copyright (C) 2008-13 Jim Easterbrook jim@jim-easterbrook.me.uk
# 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.
"""Plot graphs of weather data according to an XML recipe
::
%s
"""
__docformat__ = "restructuredtext en"
__usage__ = """
usage: python -m pywws.Plot [options] data_dir temp_dir xml_file output_file
options are:
-h or --help display this help
data_dir is the root directory of the weather data
temp_dir is a workspace for temporary files e.g. /tmp
xml_file is the name of the source file that describes the plot
output_file is the name of the image file to be created e.g. 24hrs.png
"""
__doc__ %= __usage__
__usage__ = __doc__.split('\n')[0] + __usage__
import codecs
from datetime import datetime, timedelta
import getopt
import os
import subprocess
import sys
import xml.dom.minidom
from .conversions import (
illuminance_wm2, pressure_inhg, rain_inch, temp_f,
winddir_degrees, winddir_text, wind_kmph, wind_mph, wind_kn, wind_bft,
cadhumidex)
from . import DataStore
from . import Localisation
from .TimeZone import Local
from .WeatherStation import dew_point, wind_chill, apparent_temp
[docs]class BasePlotter(object):
def __init__(self, params, status, raw_data, hourly_data,
daily_data, monthly_data, work_dir):
self.raw_data = raw_data
self.hourly_data = hourly_data
self.daily_data = daily_data
self.monthly_data = monthly_data
self.work_dir = work_dir
self.pressure_offset = eval(status.get('fixed', 'pressure offset'))
# set language related stuff
self.encoding = params.get('config', 'gnuplot encoding', 'iso_8859_1')
# create work directory
if not os.path.isdir(self.work_dir):
os.makedirs(self.work_dir)
[docs] def DoPlot(self, input_file, output_file):
# read XML graph description
self.doc = xml.dom.minidom.parse(input_file)
self.graph = self.doc.childNodes[0]
# get list of plots
plot_list = self.GetPlotList()
self.plot_count = len(plot_list)
if self.plot_count < 1:
# nothing to plot
self.doc.unlink()
return 1
# get start and end datetimes
self.x_lo = self.GetValue(self.graph, 'start', None)
self.x_hi = self.GetValue(self.graph, 'stop', None)
self.duration = self.GetValue(self.graph, 'duration', None)
if self.duration == None:
self.duration = timedelta(hours=24)
else:
self.duration = eval('timedelta(%s)' % self.duration)
if self.x_lo != None:
self.x_lo = eval('datetime(%s)' % self.x_lo)
if self.x_hi != None:
self.x_hi = eval('datetime(%s)' % self.x_hi)
self.duration = self.x_hi - self.x_lo
else:
self.x_hi = self.x_lo + self.duration
elif self.x_hi != None:
self.x_hi = eval('datetime(%s)' % self.x_hi)
self.x_lo = self.x_hi - self.duration
else:
self.x_hi = self.hourly_data.before(datetime.max)
if self.x_hi == None:
self.x_hi = datetime.utcnow() # only if no hourly data
# set end of graph to start of the next hour after last item
self.x_hi = self.x_hi + timedelta(minutes=55)
self.x_hi = self.x_hi.replace(minute=0, second=0)
self.x_lo = self.x_hi - self.duration
self.x_hi = self.x_hi + Local.utcoffset(self.x_lo)
self.x_lo = self.x_hi - self.duration
self.utcoffset = Local.utcoffset(self.x_lo)
# open gnuplot command file
self.tmp_files = []
cmd_file = os.path.join(self.work_dir, 'plot.cmd')
self.tmp_files.append(cmd_file)
if sys.version_info[0] >= 3:
of = open(cmd_file, 'w', encoding=self.encoding)
else:
of = open(cmd_file, 'w')
# write gnuplot set up
self.rows = self.GetDefaultRows()
self.cols = (self.plot_count + self.rows - 1) // self.rows
self.rows, self.cols = eval(self.GetValue(
self.graph, 'layout', '%d, %d' % (self.rows, self.cols)))
w, h = self.GetDefaultPlotSize()
w = w * self.cols
h = h * self.rows
w, h = eval(self.GetValue(self.graph, 'size', '(%d, %d)' % (w, h)))
fileformat = self.GetValue(self.graph, 'fileformat', 'png')
if fileformat == 'svg':
terminal = '%s enhanced font "arial,9" size %d,%d dynamic rounded' % (
fileformat, w, h)
else:
terminal = '%s large size %d,%d' % (fileformat, w, h)
terminal = self.GetValue(self.graph, 'terminal', terminal)
of.write('set encoding %s\n' % (self.encoding))
of.write('set terminal %s\n' % (terminal))
of.write('set output "%s"\n' % (output_file))
# set overall title
title = self.GetValue(self.graph, 'title', '')
if title:
if sys.version_info[0] < 3:
title = title.encode(self.encoding)
title = 'title "%s"' % title
of.write('set multiplot layout %d, %d %s\n' % (self.rows, self.cols, title))
# do actual plots
of.write(self.GetPreamble())
for plot_no in range(self.plot_count):
plot = plot_list[plot_no]
# set key / title location
title = self.GetValue(plot, 'title', '')
if sys.version_info[0] < 3:
title = title.encode(self.encoding)
of.write('set key horizontal title "%s"\n' % title)
# optional yaxis labels
ylabel = self.GetValue(plot, 'ylabel', '')
if ylabel:
if sys.version_info[0] < 3:
ylabel = ylabel.encode(self.encoding)
ylabelangle = self.GetValue(plot, 'ylabelangle', '')
if ylabelangle:
ylabelangle = ' rotate by %s' % (ylabelangle)
of.write('set ylabel "%s"%s\n' % (ylabel, ylabelangle))
else:
of.write('set ylabel\n')
y2label = self.GetValue(plot, 'y2label', '')
if y2label:
if sys.version_info[0] < 3:
y2label = y2label.encode(self.encoding)
y2labelangle = self.GetValue(plot, 'y2labelangle', '')
if y2labelangle:
y2labelangle = ' rotate by %s' % (y2labelangle)
of.write('set y2label "%s"%s\n' % (y2label, y2labelangle))
else:
of.write('set y2label\n')
# set data source
source = self.GetValue(plot, 'source', 'raw')
if source == 'raw':
source = self.raw_data
elif source == 'hourly':
source = self.hourly_data
elif source == 'monthly':
source = self.monthly_data
else:
source = self.daily_data
# do the plot
of.write(self.PlotData(plot_no, plot, source))
of.close()
self.doc.unlink()
# run gnuplot on file
subprocess.check_call(['gnuplot', cmd_file])
for file in self.tmp_files:
os.unlink(file)
return 0
[docs] def GetChildren(self, node, name):
result = []
for child in node.childNodes:
if child.localName == name:
result.append(child)
return result
[docs] def GetValue(self, node, name, default):
for child in node.childNodes:
if child.localName == name:
if child.childNodes:
return child.childNodes[0].data.strip()
else:
return ''
return default
[docs]class Record(object):
pass
[docs]class GraphPlotter(BasePlotter):
[docs] def GetPlotList(self):
return self.GetChildren(self.graph, 'plot')
[docs] def GetDefaultRows(self):
return self.plot_count
[docs] def GetDefaultPlotSize(self):
return 200 // self.cols, 600 // self.cols
[docs] def GetPreamble(self):
result = """set style fill solid
set xdata time
set timefmt "%Y-%m-%dT%H:%M:%S"
"""
result += 'set xrange ["%s":"%s"]\n' % (
self.x_lo.isoformat(), self.x_hi.isoformat())
lmargin = eval(self.GetValue(self.graph, 'lmargin', '5'))
result += 'set lmargin %g\n' % (lmargin)
rmargin = eval(self.GetValue(self.graph, 'rmargin', '-1'))
result += 'set rmargin %g\n' % (rmargin)
if self.duration <= timedelta(hours=24):
xformat = '%H%M'
elif self.duration <= timedelta(days=7):
xformat = '%a %d'
else:
xformat = '%Y/%m/%d'
xformat = self.GetValue(self.graph, 'xformat', xformat)
result += 'set format x "%s"\n' % xformat
xtics = self.GetValue(self.graph, 'xtics', None)
if xtics:
result += 'set xtics %d\n' % (eval(xtics) * 3600)
if sys.version_info[0] < 3:
result = result.encode(self.encoding)
return result
[docs] def PlotData(self, plot_no, plot, source):
_ = Localisation.translation.ugettext
subplot_list = self.GetChildren(plot, 'subplot')
subplot_count = len(subplot_list)
if subplot_count < 1:
return ''
result = ''
pressure_offset = self.pressure_offset
# label x axis of last plot
if plot_no == self.plot_count - 1:
if self.duration <= timedelta(hours=24):
xlabel = _('Time (%Z)')
elif self.duration <= timedelta(days=7):
xlabel = _('Day')
else:
xlabel = _('Date')
xlabel = self.GetValue(self.graph, 'xlabel', xlabel)
if sys.version_info[0] < 3:
xlabel = xlabel.encode(self.encoding)
result += 'set xlabel "%s"\n' % (
self.x_lo.replace(tzinfo=Local).strftime(xlabel))
dateformat = '%Y/%m/%d'
dateformat = self.GetValue(self.graph, 'dateformat', dateformat)
if sys.version_info[0] < 3:
dateformat = dateformat.encode(self.encoding)
ldat = self.x_lo.replace(tzinfo=Local).strftime(dateformat)
rdat = self.x_hi.replace(tzinfo=Local).strftime(dateformat)
if ldat != '':
result += 'set label "%s" at "%s", graph -0.3 left\n' % (
ldat, self.x_lo.isoformat())
if rdat != ldat:
result += 'set label "%s" at "%s", graph -0.3 right\n' % (
rdat, self.x_hi.isoformat())
# set bottom margin
bmargin = eval(self.GetValue(plot, 'bmargin', '-1'))
result += 'set bmargin %g\n' % (bmargin)
# set y ranges and tics
yrange = self.GetValue(plot, 'yrange', None)
y2range = self.GetValue(plot, 'y2range', None)
ytics = self.GetValue(plot, 'ytics', 'autofreq')
y2tics = self.GetValue(plot, 'y2tics', '')
if y2tics and not y2range:
y2range = yrange
elif y2range and not y2tics:
y2tics = 'autofreq'
if yrange:
result += 'set yrange [%s]\n' % (yrange.replace(',', ':'))
else:
result += 'set yrange [*:*]\n'
if y2range:
result += 'set y2range [%s]\n' % (y2range.replace(',', ':'))
if y2tics:
result += 'set ytics nomirror %s; set y2tics %s\n' % (ytics, y2tics)
else:
result += 'unset y2tics; set ytics mirror %s\n' % (ytics)
# set grid
result += 'unset grid\n'
grid = self.GetValue(plot, 'grid', None)
if grid != None:
result += 'set grid %s\n' % grid
# x_lo & x_hi are in local time, data is indexed in UTC
start = self.x_lo - self.utcoffset
stop = self.x_hi - self.utcoffset
cumu_start = start
if source == self.raw_data:
boxwidth = 240 # assume 5 minute data interval
start = source.before(start)
elif source == self.hourly_data:
boxwidth = 2800
start = source.before(start)
interval = timedelta(minutes=90)
elif source == self.monthly_data:
boxwidth = 2800 * 24 * 30
interval = timedelta(days=46)
else:
interval = timedelta(hours=36)
boxwidth = 2800 * 24
boxwidth = eval(self.GetValue(plot, 'boxwidth', str(boxwidth)))
result += 'set boxwidth %d\n' % boxwidth
command = self.GetValue(plot, 'command', None)
if command:
result += '%s\n' % command
stop = source.after(stop)
if stop:
stop = stop + timedelta(minutes=1)
# write data files
subplots = []
for subplot_no in range(subplot_count):
subplot = Record()
subplot.subplot = subplot_list[subplot_no]
subplot.dat_file = os.path.join(self.work_dir, 'plot_%d_%d.dat' % (
plot_no, subplot_no))
self.tmp_files.append(subplot.dat_file)
subplot.dat = open(subplot.dat_file, 'w')
subplot.xcalc = self.GetValue(subplot.subplot, 'xcalc', None)
subplot.ycalc = self.GetValue(subplot.subplot, 'ycalc', None)
subplot.cummulative = 'last_ycalc' in subplot.ycalc
if subplot.xcalc:
subplot.xcalc = compile(subplot.xcalc, '<string>', 'eval')
subplot.ycalc = compile(subplot.ycalc, '<string>', 'eval')
subplot.last_ycalcs = 0.0
subplot.last_idx = None
subplots.append(subplot)
for data in source[start:stop]:
for subplot in subplots:
if subplot.xcalc:
idx = eval(subplot.xcalc)
if idx is None:
continue
else:
idx = data['idx']
idx += self.utcoffset
if not subplot.cummulative and subplot.last_idx:
if source == self.raw_data:
interval = timedelta(minutes=((data['delay']*3)+1)//2)
if idx - subplot.last_idx > interval:
# missing data
subplot.dat.write('%s ?\n' % (idx.isoformat()))
subplot.last_idx = idx
try:
if subplot.cummulative and data['idx'] <= cumu_start:
value = 0.0
else:
last_ycalc = subplot.last_ycalcs
value = eval(subplot.ycalc)
subplot.dat.write('%s %g\n' % (idx.isoformat(), value))
subplot.last_ycalcs = value
except TypeError:
if not subplot.cummulative:
subplot.dat.write('%s ?\n' % (idx.isoformat()))
subplot.last_ycalcs = 0.0
for subplot in subplots:
# ensure the data file isn't empty
idx = self.x_hi + self.duration
subplot.dat.write('%s ?\n' % (idx.isoformat()))
subplot.dat.close()
# plot data
result += 'plot '
colour = 0
for subplot_no in range(subplot_count):
subplot = subplots[subplot_no]
colour = eval(self.GetValue(subplot.subplot, 'colour', str(colour+1)))
style = self.GetValue(
subplot.subplot, 'style', 'smooth unique lc %d lw 1' % (colour))
words = style.split()
if len(words) > 1 and words[0] in ('+', 'x', 'line'):
width = int(words[1])
else:
width = 1
if style == 'box':
style = 'lc %d lw 0 with boxes' % (colour)
elif words[0] == '+':
style = 'lc %d lw %d pt 1 with points' % (colour, width)
elif words[0] == 'x':
style = 'lc %d lw %d pt 2 with points' % (colour, width)
elif words[0] == 'line':
style = 'smooth unique lc %d lw %d' % (colour, width)
axes = self.GetValue(subplot.subplot, 'axes', 'x1y1')
title = self.GetValue(subplot.subplot, 'title', '')
result += ' "%s" using 1:($2) axes %s title "%s" %s' % (
subplot.dat_file, axes, title, style)
if subplot_no != subplot_count - 1:
result += ', \\'
result += '\n'
if sys.version_info[0] < 3:
result = result.encode(self.encoding)
return result
[docs]def main(argv=None):
if argv is None:
argv = sys.argv
try:
opts, args = getopt.getopt(argv[1:], "h", ['help'])
except getopt.error, msg:
print >>sys.stderr, 'Error: %s\n' % msg
print >>sys.stderr, __usage__.strip()
return 1
# process options
for o, a in opts:
if o == '-h' or o == '--help':
print __usage__.strip()
return 0
# check arguments
if len(args) != 4:
print >>sys.stderr, 'Error: 4 arguments required\n'
print >>sys.stderr, __usage__.strip()
return 2
params = DataStore.params(args[0])
status = DataStore.status(args[0])
Localisation.SetApplicationLanguage(params)
return GraphPlotter(
params, status,
DataStore.calib_store(args[0]), DataStore.hourly_store(args[0]),
DataStore.daily_store(args[0]), DataStore.monthly_store(args[0]),
args[1]
).DoPlot(args[2], args[3])
if __name__ == "__main__":
sys.exit(main())