-
Notifications
You must be signed in to change notification settings - Fork 0
/
polarWindPlot.py
2038 lines (1772 loc) · 88.2 KB
/
polarWindPlot.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# polarWindPlot.py
#
# A weeWX generator to generate a various polar wind plots.
#
# Copyright (c) 2017 Gary Roderick gjroderick<at>gmail.com
# Neil Trimboy <at>gmail.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 3 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, see http://www.gnu.org/licenses/.
#
# Version: 0.1.0 Date: ?? ???????ber 2017
#
# Revision History
# ?? ???????ber 2017 v0.1.0
# - initial release
#
"""
legend - used for some plots and not others, presence or absence affects plot size/location calcs. Fixed by defaulting width to 0 and setting/rendering it in render method or just leving it out if not required.
set_plot method must set self.max_ring_value
chnaged delta in trail setuplot
"""
import datetime
import math
import os.path
import syslog
import time
# first try to import from PIL then revert to python-imaging if an error
try:
from PIL import Image, ImageColor, ImageDraw
except ImportError:
import Image, ImageColor, ImageDraw
import weewx.reportengine
# from datetime import datetime as dt
from weeplot.utilities import get_font_handle, tobgr
from weeutil.weeutil import accumulateLeaves, option_as_list, TimeSpan, tobool, to_unicode, to_int
from weewx.units import Converter
POLAR_WIND_PLOT_VERSION = '0.1.0'
DEFAULT_PLOT_COLORS = ['lightblue', 'blue', 'midnightblue', 'forestgreen',
'limegreen', 'green', 'greenyellow']
DEFAULT_NO_RINGS = 5
DISTANCE_LOOKUP = {'km_per_hour': 'km',
'mile_per_hour': 'mile',
'meter_per_second': 'km',
'knot': 'Nm'}
SPEED_LOOKUP = {'km_per_hour': 'km/h',
'mile_per_hour': 'mph',
'meter_per_second': 'm/s',
'knot': 'kn'}
def logmsg(lvl, msg):
syslog.syslog(lvl, 'polarwindplot: %s' % msg)
def logdbg(msg):
logmsg(syslog.LOG_DEBUG, msg)
def loginf(msg):
logmsg(syslog.LOG_INFO, msg)
def logerr(msg):
logmsg(syslog.LOG_ERR, msg)
def logcrt(msg):
logmsg(syslog.LOG_CRIT, msg)
#=============================================================================
# Class PolarWindPlotGenerator
#=============================================================================
class PolarWindPlotGenerator(weewx.reportengine.ReportGenerator):
"""Class to manage the polar wind plot generator.
The ImageStackedWindRoseGenerator class is a customised report generator
that produces polar wind rose plots based upon weewx archive data. The
generator produces image files that may be used included in a web page, a
weewx web page template or elsewhere as required.
The wind rose plot charatcteristics may be controlled through option
settings in the [Stdreport] [[StackedWindRose]] section of weewx.conf.
"""
def __init__(self, config_dict, skin_dict, gen_ts, first_run, stn_info,
record=None):
# initialise my superclass
super(PolarWindPlotGenerator, self).__init__(config_dict,
skin_dict,
gen_ts,
first_run,
stn_info,
record)
# get a db manager for our archive
_binding = self.config_dict['StdArchive'].get('data_binding',
'wx_binding')
self.dbmanager = self.db_binder.get_manager(_binding)
def run(self):
"""Main entry point for generator."""
# do some setup so we may generate plots
self.setup()
# generate the plots
self.genPlots(self.gen_ts)
def setup(self):
"""Setup for a plot run."""
# get the config options for our plots
self.polar_dict = self.skin_dict['PolarWindPlotGenerator']
# get the formatter and converter to be used
self.formatter = weewx.units.Formatter.fromSkinDict(self.skin_dict)
self.converter = weewx.units.Converter.fromSkinDict(self.skin_dict)
# determine how much logging is desired
self.log_success = tobool(self.polar_dict.get('log_success', True))
# ensure that we are in a consistent (and correct) location
os.chdir(os.path.join(self.config_dict['WEEWX_ROOT'],
self.skin_dict['SKIN_ROOT'],
self.skin_dict['skin']))
def genPlots(self, gen_ts):
"""Generate the plots.
Iterate over each stanza under [PolarWindPlotGenerator] and generate
plots as required.
"""
# time period taken to generate plots
t1 = time.time()
# set plot count to 0
ngen = 0
# loop over each 'time span' section (eg day, week, month, etc)
for span in self.polar_dict.sections:
# now loop over all plot names in this 'time span' section
for plot in self.polar_dict[span].sections:
# accumulate all options from parent nodes:
plot_options = accumulateLeaves(self.polar_dict[span][plot])
# get a polar wind plot object from the factory
plot_obj = self._polar_plot_factory(plot_options)
# Get the end time for plot. In order try gen_ts, last known
# good archive time stamp and then finally current time
plotgen_ts = gen_ts
if not plotgen_ts:
plotgen_ts = self.dbmanager.lastGoodStamp()
if not plotgen_ts:
plotgen_ts = time.time()
# get the period for the plot, default to 24 hours if no period
# set
self.period = int(plot_options.get('period', 86400))
# get the path of the image file we will save
image_root = os.path.join(self.config_dict['WEEWX_ROOT'],
plot_options['HTML_ROOT'])
# Get image file format. Can use any format PIL can write,
# default to png
format = self.polar_dict.get('format', 'png')
# get full file name and path for plot
img_file = os.path.join(image_root, '%s.%s' % (plot,
format))
# check whether this plot needs to be done at all
if self.skipThisPlot(plotgen_ts, img_file, plot):
continue
# create the directory in which the image will be saved, wrap
# in a try block in case it already exists
try:
os.makedirs(os.path.dirname(img_file))
except OSError:
# directory already exists (or perhaps some other error)
pass
# loop over each 'source' to be added to the plot
for source in self.polar_dict[span][plot].sections:
# accumulate options from parent nodes
source_options = accumulateLeaves(self.polar_dict[span][plot][source])
# set timestamp
plot_obj.set_timestamp(plotgen_ts, source_options)
# Get plot title if explicitly requested, default to no
# title. Config option 'label' used for consistency with
# skin.conf ImageGenerator sections.
title = source_options.get('label', '')
# Determine the speed and direction archive fields to be
# used. Can really only plot windSpeed and windGust, if
# anything else default to windSpeed, windDir.
speed_field = source_options.get('data_type', source)
if speed_field == 'windSpeed':
dir_field = 'windDir'
elif speed_field == 'windGust':
dir_field = 'windGustDir'
elif speed_field == 'windrun':
speed_field = 'windSpeed'
dir_field = 'windDir'
else:
speed_field == 'windSpeed'
dir_field = 'windDir'
# hit the archive to get speed and direction plot data
_span = TimeSpan(plotgen_ts - self.period + 1, plotgen_ts)
(_, speed_time_vec, speed_vec_raw) = self.dbmanager.getSqlVectors(_span,
speed_field)
(_, dir_time_vec, dir_vec) = self.dbmanager.getSqlVectors(_span,
dir_field)
# convert the speed values to the units to be used in the
# plot
speed_vec = self.converter.convert(speed_vec_raw)
# get the units label for our speed data
units = self.skin_dict['Units']['Labels'][speed_vec[1]].strip()
# add the source data to be plotted to our plot object
plot_obj.add_data(speed_field,
speed_vec,
dir_vec,
speed_time_vec,
len(speed_time_vec[0]),
units)
# call the render() method of the polar plot object to
# render the entire plot and produce an image
image = plot_obj.render(title)
# now save the file, wrap in a try..except in case we have
# a problem saving
try:
image.save(img_file)
ngen += 1
except IOError, e:
loginf("Unable to save to file '%s': %s" % (img_file, e))
if self.log_success:
loginf("Generated %d images for %s in %.2f seconds" % (ngen,
self.skin_dict['REPORT_NAME'],
time.time() - t1))
def _polar_plot_factory(self, plot_dict):
"""Factory method to produce a polar plot object."""
# what type of plot is it
plot_type = plot_dict.get('plot_type', 'rose').lower()
# create and return the relevant polar plot object
if plot_type == 'rose':
return PolarWindRosePlot(self.skin_dict, plot_dict)
elif plot_type == 'trail':
return PolarWindTrailPlot(self.skin_dict, plot_dict)
elif plot_type == 'spiral':
return PolarWindSpiralPlot(self.skin_dict, plot_dict)
elif plot_type == 'scatter':
return PolarWindScatterPlot(self.skin_dict, plot_dict)
# if we made it here we don't know about the specified plot so raise
raise weewx.UnsupportedFeature('Unsupported polar wind plot type: %s' % plot_type)
def skipThisPlot(self, ts, img_file, plotname):
"""Determine whether the plot is to be skipped or not.
Successive report cyles will likely produce a windrose that,
irrespective of period, would be different to the windrose from the
previous report cycle. In most cases the changes are insignificant so,
as with the weewx graphical plots, long period plots are generated
less frequently than shorter period plots. Windrose plots will be
skipped if:
(1) no period was specified (need to put entry in syslog)
(2) plot length is greater than 30 days and the plot file is less
than 24 hours old
(3) plot length is greater than 7 but less than 30 day and the plot
file is less than 1 hour old
On the other hand, a windrose must be generated if:
(1) it does not exist
(2) it is 24 hours old (or older)
These rules result in windrose plots being generated:
(1) if an existing plot does not exist
(2) an existing plot exists but it is older than 24 hours
(3) every 24 hours when period > 30 days (2592000 sec)
(4) every 1 hour when period is > 7 days (604800 sec) but
<= 30 days (2592000 sec)
(5) every report cycle when period < 7 days (604800 sec)
Input Parameters:
img_file: full path and filename of plot file
plotname: name of plot
Returns:
True if plot is to be generated, False if plot is to be skipped.
"""
### For testing only, delete before release
return False
# Images without a period must be skipped every time and a syslog
# entry added. This should never occur, but....
if self.period is None:
loginf("Plot '%s' ignored, no period specified" % plotname)
return True
# The image definitely has to be generated if it doesn't exist.
if not os.path.exists(img_file):
return False
# If the image is older than 24 hours then regenerate
if ts - os.stat(img_file).st_mtime >= 86400:
return False
# If period > 30 days and the image is less than 24 hours old then skip
if self.period > 2592000 and ts - os.stat(img_file).st_mtime < 86400:
return True
# If period > 7 days and the image is less than 1 hour old then skip
if self.period >= 604800 and ts - os.stat(img_file).st_mtime < 3600:
return True
# Otherwise we must regenerate
return False
#=============================================================================
# Class PolarWindPlot
#=============================================================================
class PolarWindPlot(object):
"""Base class for creating a polar wind plot.
This class should be specialised for each type of plot."""
def __init__(self, skin_dict, plot_dict):
"""Initialise an instance of PolarWindPlot."""
# get config dict for polar plots
self.plot_dict = plot_dict
# Set image attributes
self.image_width = int(self.plot_dict['image_width'])
self.image_height = int(self.plot_dict['image_height'])
self.image_back_box_color = int(self.plot_dict['image_background_box_color'], 0)
self.image_back_circle_color = int(self.plot_dict['image_background_circle_color'], 0)
self.image_back_range_ring_color = int(self.plot_dict['image_background_range_ring_color'], 0)
self.image_back_image = self.plot_dict['image_background_image']
# plot attributes
self.plot_border = int(self.plot_dict['plot_border'])
self.font_path = self.plot_dict['font_path']
self.plot_font_size = int(self.plot_dict['plot_font_size'])
self.plot_font_color = int(self.plot_dict['plot_font_color'], 0)
# colours to be used in the plot
_colors = option_as_list(self.plot_dict.get('plot_colors',
DEFAULT_PLOT_COLORS))
self.plot_colors = []
for _color in _colors:
if parse_color(_color, None) is not None:
# we have a valid color so add it to our list
self.plot_colors.append(_color)
# do we have at least 7 colors, if not go through DEFAULT_PLOT_COLORS
# and add any that are not already in self.plot_colors
if len(self.plot_colors) < 7:
for _color in DEFAULT_PLOT_COLORS:
if _color not in self.plot_colors:
self.plot_colors.append(_color)
# break if we have at least 7 colors
if len(self.plot_colors) >= 7:
break
# legend attributes
self.legend_bar_width = int(self.plot_dict['legend_bar_width'])
self.legend_font_size = int(self.plot_dict['legend_font_size'])
self.legend_font_color = int(self.plot_dict['legend_font_color'], 0)
self.legend_width = 0
# title/plot label attributes
self.label_font_size = int(self.plot_dict['label_font_size'])
self.label_font_color = int(self.plot_dict['label_font_color'], 0)
# compass point abbreviations
compass = option_as_list(skin_dict['Labels'].get('compass_points',
'N, S, E, W'))
self.north = compass[0]
self.south = compass[1]
self.east = compass[2]
self.west = compass[3]
# number of rings on the polar plot
self.rings = int(self.plot_dict.get('polar_rings', DEFAULT_NO_RINGS))
# Boundaries for speed range bands, these mark the colour boundaries
# on the stacked bar in the legend. 7 elements only (ie 0, 10% of max,
# 20% of max...100% of max)
self.speed_factors = [0.0, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0]
# setup a list with speed range boundaries
self.speed_list = []
def add_data(self, speed_field, speed_vec, dir_vec, time_vec, samples, units):
"""Add source data to the plot.
Inputs:
speed_field: weeWX archive field being used as the source for speed
data
speed_vec: vector of speed data to be plotted
dir_vec: vector of direction data corresponding to speed_vec
samples: number of possible vector sample points, this may be
greater than or equal to the number of speed_vec or
dir_vec elements
units: unit label for speed_vec units
"""
# weeWX archive field that was used for our speed data
self.speed_field = speed_field
# find maximum speed from our data
max_speed = max(speed_vec[0])
# set upper speed range for our plot, set to a multiple of 10 for a
# neater display
self.max_speed_range = (int(max_speed / 10.0) + 1) * 10
# save the speed and dir data vectors
self.speed_vec = speed_vec
self.dir_vec = dir_vec
self.time_vec = time_vec
# how many samples in our data
self.samples = samples
# set the speed units label
self.units = units
def set_speed_list(self):
"""Set a list of speed range values
Given the factors for each boundary point and a maximum speed value
calculate the boundary points as actual speeds. Used primarily in the
legend or wherever speeds are categorised by a speed range."""
self.speed_list = [0,0,0,0,0,0,0]
# loop though each speed range boundary
for i in range(7):
# calculate the actual boundary speed value
self.speed_list[i] = self.speed_factors[i] * self.max_speed_range
def set_title(self, title):
"""Set the plot title.
Input:
title: the title text to be displayed on the plot
"""
self.title = to_unicode(title)
if title:
self.title_width, self.title_height = self.draw.textsize(self.title,
font=self.label_font)
else:
self.title_width = 0
self.title_height = 0
def set_timestamp(self, ts, options):
"""Set the timestamp to be displayed on the plot.
Set the format and location of the timestamp to be displayed on the
plot.
Inputs:
ts: the timestamp to be displayed on th eplot
options: a 'source' plot options dict
"""
# set the actual timestamp to be used
self.timestamp = ts
# get the timestamp format, use a sane default that should display
# sensibly for all locales
self.timestamp_format = options.get('time_stamp', '%x %X')
# get the timestamp location, if not set then don't display at all
_location = options.get('time_stamp_location', None)
self.timestamp_location = _location if _location is not None else None
def set_polar_grid(self):
"""Setup the polar plot/taget.
Determine size and location of the polar grid on which the plot is to
be displayed.
"""
# calculate plot diameter
# first calculate the size of the cardinal compass direction labels
_w, _n_height = self.draw.textsize(self.north, font=self.plot_font)
_w, _s_height = self.draw.textsize(self.south, font=self.plot_font)
_w_width, _h = self.draw.textsize(self.west, font=self.plot_font)
_e_width, _h = self.draw.textsize(self.east, font=self.plot_font)
# now calculate the plot area diameter in pixels, two diameters are
# calculated, one based on image height and one based on image width
_height_based = self.image_height - 2 * self.plot_border - self.title_height - (_n_height + 1) - (_s_height + 3)
_width_based = self.image_width - 2 * self.plot_border - self.legend_width
# take the smallest so that we have a guaranteed fit
_diameter = min(_height_based, _width_based)
# to prevent optical distortion for small plots make diameter a multiple
# of 22
self.max_plot_dia = int(_diameter / 22.0) * 22
# determine plot origin
self.origin_x = int((self.image_width - self.legend_width - _e_width + _w_width) / 2)
self.origin_y = 1 + int((self.image_height + self.title_height + _n_height - _s_height) / 2.0)
def set_legend(self, percentage=False):
"""Setup the legend for a plot.
Determine the legend width and title.
"""
if self.legend:
# do we display % values against each legend speed label
self.legend_percentage = percentage
# create some worst case (width) text to use in estimating the legend
# width
if percentage:
_text = '0 (100%)'
else:
_text = '999'
# estimate width of the legend
width, height = self.draw.textsize(_text, font=self.legend_font)
self.legend_width = int(width + 2 * self.legend_bar_width + 1.5 * self.plot_border)
# get legend title
self.legend_title = self.get_legend_title(self.speed_field)
else:
self.legend_width = 0
def render(self, title):
"""Main entry point to render a plot.
Child classes should define their own render() method.
"""
pass
def render_legend(self):
"""Render a polar plot legend."""
# org_x and org_y = x,y coords of bottom left of legend stacked bar,
# everything else is relative to this point
# first get the space required between the polar plot and the legend
_width, _height = self.draw.textsize('E', font=self.plot_font)
org_x = self.origin_x + self.max_plot_dia / 2 + _width + 10
org_y = self.origin_y + self.max_plot_dia / 2 - self.max_plot_dia / 22
# bulb diameter
bulb_d = int(round(1.2 * self.legend_bar_width, 0))
# draw stacked bar and label with values
for i in range (6, 0, -1):
# draw the rectangle for the stacked bar
x0 = org_x
y0 = org_y - (0.85 * self.max_plot_dia * self.speed_factors[i])
x1 = org_x + self.legend_bar_width
y1 = org_y
self.draw.rectangle([(x0, y0), (x1,y1)],
fill=self.plot_colors[i],
outline='black')
# add the label
# first, position the label
label_width, label_height = self.draw.textsize(str(self.speed_list[i]),
font=self.legend_font)
x = org_x + 1.5 * self.legend_bar_width
y = org_y - label_height / 2 - (0.85 * self.max_plot_dia * self.speed_factors[i])
# get the basic label text
snippets = (str(int(round(self.speed_list[i], 0))), )
# if required add a bracketed percentage
if self.legend_percentage:
snippets += (' (',
str(int(round(100 * self.speed_bin[i]/sum(self.speed_bin), 0))),
'%)')
# create the final label text
text = ''.join(snippets)
# render the label text
self.draw.text((x, y),
text,
fill=self.legend_font_color,
font=self.legend_font)
# draw 'Calm' label and '0' speed label/percentage
# position the 'Calm' label
t_width, t_height = self.draw.textsize('Calm', font=self.legend_font)
x = org_x - t_width - 2
y = org_y - t_height / 2 - (0.85 * self.max_plot_dia * self.speed_factors[0])
# render the 'Calm' label
self.draw.text((x , y),
'Calm',
fill=self.legend_font_color,
font=self.legend_font)
# position the '0' speed label/percentage
t_width, t_height = self.draw.textsize(str(self.speed_list[0]),
font=self.legend_font)
x = org_x + 1.5 * self.legend_bar_width
y = org_y - t_height / 2 - (0.85 * self.max_plot_dia * self.speed_factors[0])
# get the basic label text
snippets = (str(int(self.speed_list[0])), )
# if required add a bracketed percentage
if self.legend_percentage:
snippets += (' (',
str(int(round(100.0 * self.speed_bin[0] / sum(self.speed_bin), 0))),
'%)')
# create the final label text
text = ''.join(snippets)
# render the label
self.draw.text((x, y),
text,
fill=self.legend_font_color,
font=self.legend_font)
# draw 'calm' bulb on bottom of stacked bar
bounding_box = (org_x - bulb_d / 2 + self.legend_bar_width / 2,
org_y - self.legend_bar_width / 6,
org_x + bulb_d / 2 + self.legend_bar_width / 2,
org_y - self.legend_bar_width / 6 + bulb_d)
self.draw.ellipse(bounding_box, outline='black', fill=self.plot_colors[0])
# draw legend title
# position the legend title
t_width, tHeight = self.draw.textsize(self.legend_title,
font=self.legend_font)
x = org_x + self.legend_bar_width / 2 - t_width / 2
y = org_y - 5 * tHeight / 2 - (0.85 * self.max_plot_dia)
# render the title
self.draw.text((x, y),
self.legend_title,
fill=self.legend_font_color,
font=self.legend_font)
# draw legend units label
# position the units label
t_width, tHeight = self.draw.textsize('(' + self.units + ')',
font=self.legend_font)
x = org_x + self.legend_bar_width / 2 - t_width / 2
y = org_y - 3 * tHeight / 2 - (0.85 * self.max_plot_dia)
text = ''.join(('(', self.units, ')'))
# render the units label
self.draw.text((x, y),
text,
fill=self.legend_font_color,
font=self.legend_font)
def render_polar_grid(self, bullseye=0):
"""Render polar plot grid.
Render the polar grid on which the plot will be displayed. This
includes the axes, axes labels, rings and ring labels.
Inputs:
bullseye: radius of the bullseye to be displayed on the polar grid
as a proportion of the polar grid radius
"""
# render the rings
# calculate the space in pixels between each ring
ring_space = (1 - bullseye) * self.max_plot_dia/(2.0 * self.rings)
# calculate the radius of the bullseye in pixels
bullseye_radius = bullseye * self.max_plot_dia / 2.0
# locate/size then render each ring starting from the outside
for i in range(self.rings, 0, -1):
# create a bound box for the ring
bbox = (self.origin_x - ring_space * i - bullseye_radius,
self.origin_y - ring_space * i - bullseye_radius,
self.origin_x + ring_space * i + bullseye_radius,
self.origin_y + ring_space * i + bullseye_radius)
# render the ring
self.draw.ellipse(bbox,
outline=self.image_back_range_ring_color,
fill=self.image_back_circle_color)
# render the ring labels
# first, initialise a list to hold the labels
labels = list((None for x in range(self.rings)))
# loop over the rings getting the label for each ring
for i in range (self.rings):
labels[i] = self.get_ring_label(i + 1)
# calculate location of ring labels, first we need the angle to use
angle = 7 * math.pi / 4 + int(self.label_dir / 4.0) * math.pi / 2
# Now draw ring labels. For clarity each label (except for outside
# label) is drawn on a rectangle with background colour set to that of
# the polar plot background.
# iterate over each of the rings
for i in range(self.rings):
# we only need do anything if we have a label for this ring
if labels[i] is not None:
# calculate the width and heihgt of the label text
width, height = self.draw.textsize(labels[i],
font=self.plot_font)
# find the distance of the midpoint of the text box from the
# plot origin
radius = bullseye_radius + (i + 1) * ring_space
# calculate x and y coords (top left corner) for the text
x0 = self.origin_x + int(radius * math.cos(angle) - width / 2.0)
y0 = self.origin_y + int(radius * math.sin(angle) - height / 2.0)
# the inner most labels have a background box painted first
if i < self.rings - 1:
# calculate the bottom right corner of the background box
x1 = self.origin_x + int(radius * math.cos(angle) + width / 2.0)
y1 = self.origin_y + int(radius * math.sin(angle) + height / 2.0)
# draw the background box
self.draw.rectangle([(x0, y0), (x1, y1)],
fill=self.image_back_circle_color)
# now draw the label text
self.draw.text((x0, y0),
labels[i],
fill=self.plot_font_color,
font=self.plot_font)
# render vertical centre line
x0 = self.origin_x
y0 = self.origin_y - self.max_plot_dia / 2 - 2
x1 = self.origin_x
y1 = self.origin_y + self.max_plot_dia / 2 + 2
self.draw.line([(x0, y0), (x1, y1)],
fill=self.image_back_range_ring_color)
# render horizontal centre line
x0 = self.origin_x - self.max_plot_dia / 2 - 2
y0 = self.origin_y
x1 = self.origin_x + self.max_plot_dia / 2 + 2
y1 = self.origin_y
self.draw.line([(x0, y0), (x1, y1)],
fill=self.image_back_range_ring_color)
# render N,S,E,W markers
# North
width, height = self.draw.textsize(self.north, font=self.plot_font)
x = self.origin_x - width /2
y = self.origin_y - self.max_plot_dia / 2 - 1 - height
self.draw.text((x, y),
self.north,
fill=self.plot_font_color,
font=self.plot_font)
# South
width, height = self.draw.textsize(self.south, font=self.plot_font)
x = self.origin_x - width /2
y = self.origin_y + self.max_plot_dia / 2 + 3
self.draw.text((x, y),
self.south,
fill=self.plot_font_color,
font=self.plot_font)
# West
width, height = self.draw.textsize(self.west, font=self.plot_font)
x = self.origin_x - self.max_plot_dia / 2 - 1 - width
y = self.origin_y - height / 2
self.draw.text((x, y),
self.west,
fill=self.plot_font_color,
font=self.plot_font)
# East
width, height = self.draw.textsize(self.east, font=self.plot_font)
x = self.origin_x + self.max_plot_dia / 2 + 1
y = self.origin_y - height / 2
self.draw.text((x, y),
self.east,
fill=self.plot_font_color,
font=self.plot_font)
def render_title(self):
"""Render polar plot title."""
# draw plot title (label) if any
if self.title:
try:
self.draw.text((self.origin_x-self.title_width / 2, self.title_height / 2),
self.title,
fill=self.label_font_color,
font=self.label_font)
except UnicodeEncodeError:
self.draw.text((self.origin_x - self.title_width / 2, self.title_height / 2),
self.title.encode("utf-8"),
fill=self.label_font_color,
font=self.label_font)
else:
self.title_height = 0
def render_timestamp(self):
"""Render plot timestamp."""
# we only render if we have a location to put the timestamp otherwise
# we have nothing to do
if self.timestamp_location:
_dt = datetime.datetime.fromtimestamp(self.timestamp)
text = _dt.strftime(self.timestamp_format)
width, height = self.draw.textsize(text, font=self.label_font)
if 'TOP' in self.timestamp_location:
y = self.plot_border + height
else:
y = self.image_height - self.plot_border - height
if 'LEFT' in self.timestamp_location:
x = self.plot_border
elif ('CENTER' in self.timestamp_location) or ('CENTRE' in self.timestamp_location):
x = self.origin_x - width / 2
else:
x = self.image_width - self.plot_border - width
#### Should this be using legendFont or labelFont?
self.draw.text((x, y), text,
fill=self.legend_font_color,
font=self.legend_font)
def get_image(self):
"""Get an image object on which to render the plot."""
try:
_image = Image.open(self.image_back_image)
except IOError:
_image = Image.new("RGB",
(self.image_width, self.image_height),
self.image_back_box_color)
return _image
def get_font_handles(self):
"""Get font handles for the fonts to be used."""
# font used on the plot area
self.plot_font = get_font_handle(self.font_path,
self.plot_font_size)
# font used for the legend
self.legend_font = get_font_handle(self.font_path,
self.legend_font_size)
# font used for labels/title
self.label_font = get_font_handle(self.font_path,
self.label_font_size)
def get_ring_label(self, ring):
"""Get the label to be displayed on the polar plot rings.
This method should be overridden in each PolarWindPlot child object.
Each polar plot ring is labelled. This label can be a percentage, a
value or some other text. The get_ring_label() method returns the label
to be used on a given ring. There are 5 equally spaced rings numbered
from 1 (inside) to 5 (outside). A value of None will result in no label
being displayed for the ring concerned.
Input:
ring: ring number for which a lalbel is required, will be from
1 (inside) to 5 (outside) inclusive
Returns:
label text for the given ring number
"""
return None
def renderMarker(self, x, y, size, marker_type, marker_color):
"""Render a marker.
Inputs:
x: start point plot x coordinate
y: start point plot y coordinate
size: start point vector radius
style: start point vector direction
color: color to be used
"""
if marker_type == "cross" :
line = (int(x - size), int(y), int(x + size), int(y))
self.draw.line(line, fill=marker_color, width=1)
line = (int(x), int(y - size), int(x), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
elif marker_type == "x" :
line = (int(x - size), int(y - size), int(x + size), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
line = (int(x + size), int(y - size), int(x - size), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
elif marker_type == "box" :
line = (int(x - size), int(y - size), int(x + size), int(y - size))
self.draw.line(line, fill=marker_color, width=1)
line = (int(x + size), int(y - size), int(x+size), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
line = (int(x - size), int(y - size), int(x - size), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
line = (int(x - size), int(y + size), int(x + size), int(y + size))
self.draw.line(line, fill=marker_color, width=1)
else :
# Assume circle or dot
bbox = (int(x - size), int(y - size),
int(x + size), int(y + size))
if marker_type == "dot" :
self.draw.ellipse(bbox, outline=marker_color, fill=marker_color)
else :
# Assume circle
self.draw.ellipse(bbox, outline=marker_color)
return None
def joinCurve(self, start_x, start_y, start_r, start_a, end_x, end_y, end_r, end_a, color):
"""Join two points with a curve.
Draw a smooth curve between two points by joing them with straight line
segments covering 1 degree of arc.
Inputs:
start_x: start point plot x coordinate
start_y: start point plot y coordinate
start_r: start point vector radius (in pixels)
start_a: start point vector direction (degrees True)
end_x: end point plot x coordinate
end_y: end point plot y coordinate
end_r: end point vector radius (in pixels)
end_a: end point vector direction (degrees True)
color: color to be used
"""
# calculate the angle in degrees between the start and end vectors and
# the 'direction of plotting'
if (end_a - start_a) % 360 <= 180:
start = start_a
end = end_a
dir = 1
else:
start = end_a
end = start_a
dir = -1
angle_span = (end - start) % 360
# initialise our start plot points
last_x = start_x
last_y = start_y
a = 1
# while statement to allow us to draw curve in 1 degree increments
# if angle to cover is < 2 degrees we draw NO segments
while a < angle_span:
# calculate the radius of the vector of next point we will draw to
radius = start_r + (end_r - start_r) * a / angle_span
# get the x and y plot coords of the next point
x = int(self.origin_x + radius * math.sin(math.radians(start_a + (a * dir))))
y = int(self.origin_y - radius * math.cos(math.radians(start_a + (a * dir))))
# define the start and end points of the line between the current
# point to the last
xy = (last_x, last_y, x, y)
# draw a straight line
self.draw.line(xy, fill=color, width=1)
# save our current point as the last point
last_x = x
last_y = y
# increment the angle
a += 1
# once we have finished the curve {if any was plotted at all) we need to draw the last
# incremental point to our orignal end point. In instances when the angle_span is < 2 degrees
# this will be the only segment drawn
xy = (last_x, last_y, end_x, end_y)
self.draw.line(xy, fill=color, width=1)
def get_legend_title(self, source=None):
"""Produce a title for the legend."""
if source == 'windSpeed':
return 'Wind Speed'
elif source == 'windGust':
return 'Wind Gust'
else:
return 'Legend'
def get_speed_color(self, source, speed):
"""Determine the speed based colour to be used."""
result = None
if source == "speed" :
# colour is a function of speed
for lookup in range(5, -1, -1): # TODO Yuk, 7 colours is hard coded
if speed > self.speed_list[lookup]:
result = self.plot_colors[lookup + 1]
break
else:
# constant colour
result = source
return result
#=============================================================================
# Class PolarWindRosePlot
#=============================================================================
class PolarWindRosePlot(PolarWindPlot):
"""Specialised class to generate a polar wind rose plot."""
def __init__(self, skin_dict, plot_dict):
"""Initialise a PolarWindRosePlot object."""
# initialise my superclass
super(PolarWindRosePlot, self).__init__(skin_dict, plot_dict)