-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpolarwindplot.py
2433 lines (2143 loc) · 109 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 various polar wind plots.
The Polar Wind Plot Image Generator generates polar plots of wind related
observations from WeeWX archive data. The polar plots are generated as image
files suitable for publishing on a web page, inclusion in a WeeWX template or
for use elsewhere. The Polar Wind Plot Image Generator can generate the
following polar wind plots:
- Wind rose. Traditional wind rose showing dominant wind directions and speed
ranges.
- Scatter. Plot showing variation in wind speed and direction over time.
- Spiral. Plot showing wind direction over time with colour coded wind
speed.
- Trail. Plot showing vector wind run over time.
Various parameters including the plot type, period, source data field, units
of measure and colours can be controlled by the user through various
configuration options similar to other image generators.
Copyright (c) 2017-2024 Gary Roderick gjroderick<at>gmail.com
Neil Trimboy 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 https://www.gnu.org/licenses/.
Version: 0.1.3b1 Date: 9 November 2024
Revision History
xx November 2024 v0.1.3
- added searchlist extension that provides $polar_version_number tag
9 November 2024 v0.1.2
- generator version string can now be optionally included on each plot
- fix error in processing of timestamp location config option
- fix error when all wind speed values are None
- handle TypeError raised when parse_color() is asked to parse the
value None as a colour
24 December 2023 v0.1.1
- fix issue when wind source speed vector contains one or more None values
- fix error when setting max_speed_range property
16 June 2022 v0.1.0
- initial release
"""
# TODO: Testing. Test trail plot net vector positioning for various timestamp positions
# TODO: Testing. Test use of data_binding config option
# python imports
import datetime
import math
import os.path
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
import ImageColor
import ImageDraw
# compatibility shims
import six
# WeeWX imports
import weewx
import weewx.units
import weeplot.utilities
import weeutil.weeutil
import weewx.reportengine
# import/setup logging, WeeWX v3 is syslog based but WeeWX v4 is logging based,
# try v4 logging and if it fails use v3 logging
try:
# WeeWX4 logging
import logging
log = logging.getLogger(__name__)
def logdbg(msg):
log.debug(msg)
def loginf(msg):
log.info(msg)
def logerr(msg):
log.error(msg)
except ImportError:
# WeeWX legacy (v3) logging via syslog
import syslog
def logmsg(level, msg):
syslog.syslog(level, '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)
POLAR_WIND_PLOT_VERSION = '0.1.3b1'
DEFAULT_PLOT_COLORS = ['lightblue', 'blue', 'midnightblue', 'forestgreen',
'limegreen', 'green', 'greenyellow']
DEFAULT_NUM_RINGS = 5
DEFAULT_NO_PETALS = 16
DEFAULT_PETAL_WIDTH = 0.8
DEFAULT_BULLSEYE = 0.1
DEFAULT_LINE_WIDTH = 1
DEFAULT_MARKER_SIZE = 2
DEFAULT_PLOT_FONT_COLOR = 'black'
DEFAULT_RING_LABEL_TIME_FORMAT = '%H:%M'
DEFAULT_MAX_SPEED = 30
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'}
DEGREE_SYMBOL = u'\N{DEGREE SIGN}'
PREFERRED_LABEL_QUADRANTS = [1, 2, 0, 3]
# =============================================================================
# Class PolarWindPlotGenerator
# =============================================================================
class PolarWindPlotGenerator(weewx.reportengine.ReportGenerator):
"""Class used to control generation of polar wind plots.
The PolarWindPlotGenerator class is a customised image generator that
produces polar wind plots based upon WeeWX archive data. The generator
produces image files that may be included in a web page, a WeeWX web page
template or elsewhere as required.
The polar wind plot characteristics may be controlled through option
settings in the relevant skin.conf or under the relevant report stanza in
the [StdReport] 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 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 = weeutil.weeutil.tobool(self.polar_dict.get('log_success',
True))
# initialise the plot period
self.period = None
def run(self):
"""Main entry point for generator."""
# do any setup required before we generate the plots
self.setup()
# generate the plots
self.genPlots(self.gen_ts)
def setup(self):
"""Setup for a plot run."""
# 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 = weeutil.weeutil.accumulateLeaves(self.polar_dict[span][plot])
# get a polar wind plot object from the factory
plot_obj = self._polar_plot_factory(plot_options)
# obtain a dbmanager so we can access the database
binding = plot_options['data_binding']
dbmanager = self.db_binder.get_manager(binding)
# 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 = dbmanager.lastGoodStamp()
if not plotgen_ts:
plotgen_ts = time.time()
# set the plot timestamp
plot_obj.timestamp = plotgen_ts
# get the period for the plot, default to 24 hours if no period
# set
self.period = int(plot_options.get('period', 86400))
# give the polar wind plot object a formatter to use
plot_obj.formatter = self.formatter
# 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
image_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,
image_format))
# check whether this plot needs to be done at all, if not move
# onto the next plot
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 = weeutil.weeutil.accumulateLeaves(self.polar_dict[span][plot][source])
# 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, windDir and
# windGust, windGustDir. If anything else default to
# windSpeed, windDir.`
sp_field = source_options.get('data_type', source)
if sp_field == 'windSpeed':
dir_field = 'windDir'
elif sp_field == 'windGust':
dir_field = 'windGustDir'
else:
sp_field = 'windSpeed'
dir_field = 'windDir'
# hit the archive to get speed and direction plot data
t_span = weeutil.weeutil.TimeSpan(plotgen_ts - self.period + 1,
plotgen_ts)
(_, sp_t_vec, sp_vec_raw) = dbmanager.getSqlVectors(t_span,
sp_field)
(_, dir_t_vec, dir_vec) = dbmanager.getSqlVectors(t_span,
dir_field)
# convert the speed values to the units to be used in the
# plot
speed_vec = self.converter.convert(sp_vec_raw)
# get the units label for our speed data
units = self.skin_dict['Units']['Labels'][speed_vec.unit].strip()
# add the source data to be plotted to our plot object
plot_obj.add_data(sp_field,
speed_vec,
dir_vec,
sp_t_vec,
len(sp_t_vec.value),
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 as 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, default to wind rose
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, self.formatter)
elif plot_type == 'trail':
return PolarWindTrailPlot(self.skin_dict, plot_dict, self.formatter)
elif plot_type == 'spiral':
return PolarWindSpiralPlot(self.skin_dict, plot_dict, self.formatter)
elif plot_type == 'scatter':
return PolarWindScatterPlot(self.skin_dict, plot_dict, self.formatter)
# 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, plot_name):
"""Determine whether the plot is to be skipped or not.
Successive report cycles 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
plot_name: name of plot
Returns:
True if plot is to be generated, False if plot is to be skipped.
"""
# 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" % plot_name)
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. As a minimum a
render() method must be defined for each type of plot.
"""
def __init__(self, skin_dict, plot_dict, formatter):
"""Initialise an instance of PolarWindPlot."""
# save the formatter
self.formatter = formatter
# set image attributes
# overall image width and height
self.image_width = int(plot_dict.get('image_width', 300))
self.image_height = int(plot_dict.get('image_height', 180))
# background colour of the image
_image_back_box_color = plot_dict.get('image_background_color')
self.image_background_color = parse_color(_image_back_box_color, '#96C6F5')
# background colour of the polar plot area
_image_back_circle_color = plot_dict.get('image_background_circle_color')
self.image_back_circle_color = parse_color(_image_back_circle_color, '#F5F5F5')
# colour of the polar plot area range rings
_image_back_range_ring_color = plot_dict.get('image_background_range_ring_color')
self.image_back_range_ring_color = parse_color(_image_back_range_ring_color, '#DDD9C3')
# background image to be used for the overall image background
self.image_back_image = plot_dict.get('image_background_image')
# resample filter
_resample_filter = plot_dict.get('resample_filter', 'NEAREST').upper()
try:
self.resample_filter = getattr(Image, _resample_filter)
except AttributeError:
self.resample_filter = Image.NEAREST
# plot attributes
self.plot_border = int(plot_dict.get('plot_border', 5))
self.font_path = plot_dict.get('font_path')
self.plot_font_size = int(plot_dict.get('plot_font_size', 10))
_plot_font_color = plot_dict.get('plot_font_color')
self.plot_font_color = parse_color(_plot_font_color,
DEFAULT_PLOT_FONT_COLOR)
# colours to be used in the plot
_colors = weeutil.weeutil.option_as_list(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
# do we display a legend, default to True
self.legend = weeutil.weeutil.tobool(plot_dict.get('legend',
True))
self.legend_bar_width = int(plot_dict.get('legend_bar_width', 10))
self.legend_font_size = int(plot_dict.get('legend_font_size', 10))
_legend_font_color = plot_dict.get('legend_font_color')
self.legend_font_color = parse_color(_legend_font_color, '#000000')
self.legend_width = 0
# title/plot label attributes
self.label_font_size = int(plot_dict.get('label_font_size', 12))
_label_font_color = plot_dict.get('label_font_color')
self.label_font_color = parse_color(_label_font_color, '#000000')
# compass point abbreviations
compass = weeutil.weeutil.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(plot_dict.get('polar_rings', DEFAULT_NUM_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]
# set up a list with speed range boundaries
self.speed_list = []
# get the timestamp format, use a sane default that should display
# sensibly for all locales
self.timestamp_format = plot_dict.get('timestamp_format', '%x %X')
# get the timestamp location.
# First get the option, if the option includes a comma we will have a
# list otherwise it will be a string. The default will return a list
# ['bottom', 'right'].
_ts_loc = plot_dict.get('timestamp_location', 'bottom, right')
# If we have the string 'None' in any case combination take that as no
# timestamp label is to be shown. Try to convert the option to lower
# case, if it is a list we will get an AttributeError.
try:
_ts_loc = _ts_loc.lower()
except AttributeError:
# _ts_loc is not a string, do nothing, we will pick this up shortly
pass
# do we have the string 'None' in any case combination
if _ts_loc == 'none':
# we have the string 'None', so we don't display the timestamp
self.timestamp_location = None
else:
# we have something other than the string 'None', so we will be
# displaying a timestamp label, but where?
# get our option as a set
_ts_loc = set(weeutil.weeutil.option_as_list(_ts_loc))
# if we don't have a valid vertical position specified default to
# 'bottom'
if not _ts_loc & {'top', 'bottom'}:
_ts_loc.add('bottom')
# if we don't have a valid horizontal position specified default to
# 'right'
if not _ts_loc & {'left', 'centre', 'center', 'right'}:
_ts_loc.add('right')
# assign the resulting set to the timestamp_location property
self.timestamp_location = _ts_loc
# Get the version string location, the default is {top left}, but we
# need to de-conflict with the timestamp location. Also, unless the
# version string display has been explicitly disabled with the string
# 'None' (in any case combination), display the version string whenever
# debug >= 1.
# first get the option, if the options does not exist None will be
# returned
_v_loc_opt = plot_dict.get('version_location')
if _v_loc_opt is None:
# version_location was not set, so unless debug >= 1 we will not
# display the version string
if weewx.debug >= 1:
# debug is >= 1 so check if we can use our default location of
# {top, left}, we will be fine unless the timestamp is there
if {'top', 'left'} == self.timestamp_location:
# the timestamp has {top, left} so we will use {top, right}
self.version_location = {'top', 'right'}
else:
# we are clear to use {top, left}
self.version_location = {'top', 'left'}
else:
# version_location was not set and debug == 0 so don't display
# the version string
self.version_location = None
else:
# version_location was set, but was it explicitly disabled by use
# of the string 'None' (in any variation of case)? Try to convert
# the option to lower case, if it is a list we will get an
# AttributeError.
try:
_v_loc_opt = _v_loc_opt.lower()
except AttributeError:
# _v_loc_opt is not a string, do nothing, we will pick this up
# shortly
pass
if _v_loc_opt == 'none':
# version string display has been explicitly disabled
self.version_location = None
else:
# obtain the version_location option as a set of strings
_v_loc = set(weeutil.weeutil.option_as_list(_v_loc_opt))
# if we don't have a valid vertical position specified default
# to 'top'
if not _v_loc & {'top', 'bottom'}:
_v_loc.add('top')
# if we don't have a valid horizontal position specified
# default to 'right' but only if timestamp is not using
# 'right', in that case use 'left'
if not _v_loc & {'left', 'centre', 'center', 'right'}:
# there is no horizontal position specified so de-conflict
# with timestamp location
_temp_loc = _v_loc | {'left'}
if _temp_loc not in self.timestamp_location:
_v_loc.add('left')
else:
_v_loc.add('right')
# assign the resulting set to the version_location property
self.version_location = _v_loc
# get size of the arc to be kept clear for ring labels
self.ring_label_clear_arc = plot_dict.get('ring_label_clear_arc', 30)
# initialise a number of properties to be used later
self.speed_field = None
self.max_speed_range = None
self.speed_vec = None
self.dir_vec = None
self.time_vec = None
self.samples = None
self.units = None
self.title = None
self.title_width = None
self.title_height = None
self.max_plot_dia = None
self.origin_x = None
self.origin_y = None
self.plot_font = None
self.legend_font = None
self.label_font = None
self.draw = None
self.legend_percentage = None
self.legend_title = None
self.speed_bin = None
self.label_dir = None
self.timestamp = None
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: ValueTuple containing vector of speed data to be
plotted
dir_vec: ValueTuple containing 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, be careful as some or all values
# could be None
try:
max_speed = weeutil.weeutil.max_with_none(speed_vec.value)
except TypeError:
# likely all our speed_vec values are None
max_speed = None
# set upper speed range for our plot, set to a multiple of 10 for a
# neater display
if max_speed is not None:
self.max_speed_range = (int(max_speed / 10.0) + 1) * 10
else:
self.max_speed_range = DEFAULT_MAX_SPEED
# 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 = six.ensure_text(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_polar_grid(self):
"""Set up the polar plot grid.
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):
"""Set up 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."""
# do we need to render a legend?
if self.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, t_height = 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 * t_height / 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, t_height = self.draw.textsize('(' + self.units + ')',
font=self.legend_font)
x = org_x + self.legend_bar_width / 2 - t_width / 2
y = org_y - 3 * t_height / 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,
# remember the angle is in radians.
angle = (3.5 + 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 height 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 innermost 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)
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: