Skip to content

Commit 3ca1dc7

Browse files
committed
implement thresholds
1 parent f9a8107 commit 3ca1dc7

File tree

4 files changed

+197
-28
lines changed

4 files changed

+197
-28
lines changed

README.rst

+2
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ The end result of this process for a given survey (Title) should be 8 ``.png`` i
114114
* **tcp_upload_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, TCP, uploading from client to server.
115115
* **udp_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, UDP, uploading from client to server.
116116

117+
If you'd like to synchronize the colors/thresholds across multiple heatmaps, such as when comparing different AP placements, you can run ``wifi-heatmap-thresholds`` passing it each of the titles / output JSON filenames. This will generate a ``thresholds.json`` file in the current directory, suitable for passing to the ``wifi-heatmap`` ``-t`` / ``--thresholds`` option.
118+
117119
Running In Docker
118120
-----------------
119121

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
'console_scripts': [
8787
'wifi-scan = wifi_survey_heatmap.scancli:main',
8888
'wifi-survey = wifi_survey_heatmap.ui:main',
89-
'wifi-heatmap = wifi_survey_heatmap.heatmap:main'
89+
'wifi-heatmap = wifi_survey_heatmap.heatmap:main',
90+
'wifi-heatmap-thresholds = wifi_survey_heatmap.thresholds:main'
9091
]
9192
},
9293
cffi_modules=[

wifi_survey_heatmap/heatmap.py

+63-27
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,29 @@
127127

128128
class HeatMapGenerator(object):
129129

130-
def __init__(self, image_path, title, ignore_ssids=[], aps=None):
130+
graphs = {
131+
'rssi': 'RSSI (level)',
132+
'quality': 'iwstats Quality',
133+
'tcp_upload_Mbps': 'TCP Upload Mbps',
134+
'tcp_download_Mbps': 'TCP Download Mbps',
135+
'udp_Mbps': 'UDP Upload Mbps',
136+
'jitter': 'UDP Jitter (ms)'
137+
}
138+
139+
def __init__(
140+
self, image_path, title, ignore_ssids=[], aps=None, thresholds=None
141+
):
131142
self._ap_names = {}
132143
if aps is not None:
133144
with open(aps, 'r') as fh:
134145
self._ap_names = {
135146
x.upper(): y for x, y in json.loads(fh.read()).items()
136147
}
137148
self._image_path = image_path
149+
self._layout = None
150+
self._image_width = 0
151+
self._image_height = 0
152+
self._corners = [(0, 0), (0, 0), (0, 0), (0, 0)]
138153
self._title = title
139154
if not self._title.endswith('.json'):
140155
self._title += '.json'
@@ -143,22 +158,17 @@ def __init__(self, image_path, title, ignore_ssids=[], aps=None):
143158
'Initialized HeatMapGenerator; image_path=%s title=%s',
144159
self._image_path, self._title
145160
)
146-
self._layout = imread(self._image_path)
147-
self._image_width = len(self._layout[0])
148-
self._image_height = len(self._layout) - 1
149-
self._corners = [
150-
(0, 0), (0, self._image_height),
151-
(self._image_width, 0), (self._image_width, self._image_height)
152-
]
153-
logger.debug(
154-
'Loaded image with width=%d height=%d',
155-
self._image_width, self._image_height
156-
)
157161
with open(self._title, 'r') as fh:
158162
self._data = json.loads(fh.read())
159163
logger.info('Loaded %d measurement points', len(self._data))
160-
161-
def generate(self):
164+
self.thresholds = {}
165+
if thresholds is not None:
166+
logger.info('Loading thresholds from: %s', thresholds)
167+
with open(thresholds, 'r') as fh:
168+
self.thresholds = json.loads(fh.read())
169+
logger.debug('Thresholds: %s', self.thresholds)
170+
171+
def load_data(self):
162172
a = defaultdict(list)
163173
for row in self._data:
164174
a['x'].append(row['x'])
@@ -179,6 +189,24 @@ def generate(self):
179189
a['ap'].append(ap + '_2.4')
180190
else:
181191
a['ap'].append(ap + '_5G')
192+
return a
193+
194+
def _load_image(self):
195+
self._layout = imread(self._image_path)
196+
self._image_width = len(self._layout[0])
197+
self._image_height = len(self._layout) - 1
198+
self._corners = [
199+
(0, 0), (0, self._image_height),
200+
(self._image_width, 0), (self._image_width, self._image_height)
201+
]
202+
logger.debug(
203+
'Loaded image with width=%d height=%d',
204+
self._image_width, self._image_height
205+
)
206+
207+
def generate(self):
208+
self._load_image()
209+
a = self.load_data()
182210
for x, y in self._corners:
183211
a['x'].append(x)
184212
a['y'].append(y)
@@ -195,14 +223,7 @@ def generate(self):
195223
y = np.linspace(0, self._image_height, num_y)
196224
gx, gy = np.meshgrid(x, y)
197225
gx, gy = gx.flatten(), gy.flatten()
198-
for k, ptitle in {
199-
'rssi': 'RSSI (level)',
200-
'quality': 'iwstats Quality',
201-
'tcp_upload_Mbps': 'TCP Upload Mbps',
202-
'tcp_download_Mbps': 'TCP Download Mbps',
203-
'udp_Mbps': 'UDP Upload Mbps',
204-
'jitter': 'UDP Jitter (ms)'
205-
}.items():
226+
for k, ptitle in self.graphs.items():
206227
self._plot(
207228
a, k, '%s - %s' % (self._title, ptitle), gx, gy, num_x, num_y
208229
)
@@ -300,6 +321,7 @@ def _add_inner_title(self, ax, title, loc, size=None, **kwargs):
300321
return at
301322

302323
def _plot(self, a, key, title, gx, gy, num_x, num_y):
324+
logger.debug('Plotting: %s', key)
303325
pp.rcParams['figure.figsize'] = (
304326
self._image_width / 300, self._image_height / 300
305327
)
@@ -313,15 +335,26 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y):
313335
# Render the interpolated data to the plot
314336
pp.axis('off')
315337
# begin color mapping
316-
norm = matplotlib.colors.Normalize(
317-
vmin=min(a[key]), vmax=max(a[key]), clip=True
318-
)
338+
if 'min' in self.thresholds.get(key, {}):
339+
vmin = self.thresholds[key]['min']
340+
logger.debug('Using min threshold from thresholds: %s', vmin)
341+
else:
342+
vmin = min(a[key])
343+
logger.debug('Using calculated min threshold: %s', vmin)
344+
if 'max' in self.thresholds.get(key, {}):
345+
vmax = self.thresholds[key]['max']
346+
logger.debug('Using max threshold from thresholds: %s', vmax)
347+
else:
348+
vmax = max(a[key])
349+
logger.debug('Using calculated max threshold: %s', vmax)
350+
norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax, clip=True)
319351
mapper = cm.ScalarMappable(norm=norm, cmap='RdYlBu_r')
320352
# end color mapping
321353
image = pp.imshow(
322354
z,
323355
extent=(0, self._image_width, self._image_height, 0),
324-
cmap='RdYlBu_r', alpha=0.5, zorder=100
356+
cmap='RdYlBu_r', alpha=0.5, zorder=100,
357+
vmin=vmin, vmax=vmax
325358
)
326359
pp.colorbar(image)
327360
pp.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1)
@@ -359,6 +392,8 @@ def parse_args(argv):
359392
help='verbose output. specify twice for debug-level output.')
360393
p.add_argument('-i', '--ignore', dest='ignore', action='append',
361394
default=[], help='SSIDs to ignore from channel graph')
395+
p.add_argument('-t', '--thresholds', dest='thresholds', action='store',
396+
type=str, help='thresholds JSON file path')
362397
p.add_argument('-a', '--ap-names', type=str, dest='aps', action='store',
363398
default=None,
364399
help='If specified, a JSON file mapping AP MAC/BSSID to '
@@ -412,7 +447,8 @@ def main():
412447
set_log_info()
413448

414449
HeatMapGenerator(
415-
args.IMAGE, args.TITLE, ignore_ssids=args.ignore, aps=args.aps
450+
args.IMAGE, args.TITLE, ignore_ssids=args.ignore, aps=args.aps,
451+
thresholds=args.thresholds
416452
).generate()
417453

418454

wifi_survey_heatmap/thresholds.py

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
The latest version of this package is available at:
3+
<http://github.com/jantman/wifi-survey-heatmap>
4+
5+
##################################################################################
6+
Copyright 2017 Jason Antman <[email protected]> <http://www.jasonantman.com>
7+
8+
This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap.
9+
10+
wifi-survey-heatmap is free software: you can redistribute it and/or modify
11+
it under the terms of the GNU Affero General Public License as published by
12+
the Free Software Foundation, either version 3 of the License, or
13+
(at your option) any later version.
14+
15+
wifi-survey-heatmap is distributed in the hope that it will be useful,
16+
but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
GNU Affero General Public License for more details.
19+
20+
You should have received a copy of the GNU Affero General Public License
21+
along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>.
22+
23+
The Copyright and Authors attributions contained herein may not be removed or
24+
otherwise altered, except to add the Author attribution of a contributor to
25+
this work. (Additional Terms pursuant to Section 7b of the AGPL v3)
26+
##################################################################################
27+
While not legally required, I sincerely request that anyone who finds
28+
bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or
29+
to me via email, and that you send any contributions or improvements
30+
either as a pull request on GitHub, or to me via email.
31+
##################################################################################
32+
33+
AUTHORS:
34+
Jason Antman <[email protected]> <http://www.jasonantman.com>
35+
##################################################################################
36+
"""
37+
38+
import sys
39+
import argparse
40+
import logging
41+
import json
42+
from collections import defaultdict
43+
44+
from wifi_survey_heatmap.heatmap import HeatMapGenerator
45+
46+
FORMAT = "[%(asctime)s %(levelname)s] %(message)s"
47+
logging.basicConfig(level=logging.WARNING, format=FORMAT)
48+
logger = logging.getLogger()
49+
50+
51+
class ThresholdGenerator(object):
52+
53+
def generate(self, titles):
54+
res = defaultdict(dict)
55+
items = [HeatMapGenerator(None, t).load_data() for t in titles]
56+
for key in HeatMapGenerator.graphs.keys():
57+
res[key]['min'] = min([
58+
min(x[key]) for x in items
59+
])
60+
res[key]['max'] = max([
61+
max(x[key]) for x in items
62+
])
63+
with open('thresholds.json', 'w') as fh:
64+
fh.write(json.dumps(res))
65+
logger.info('Wrote: thresholds.json')
66+
67+
68+
def parse_args(argv):
69+
"""
70+
parse arguments/options
71+
72+
this uses the new argparse module instead of optparse
73+
see: <https://docs.python.org/2/library/argparse.html>
74+
"""
75+
p = argparse.ArgumentParser(
76+
description='wifi survey heatmap threshold generator'
77+
)
78+
p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0,
79+
help='verbose output. specify twice for debug-level output.')
80+
p.add_argument(
81+
'TITLE', type=str, help='Title for survey (and data filename)',
82+
nargs='+'
83+
)
84+
args = p.parse_args(argv)
85+
return args
86+
87+
88+
def set_log_info():
89+
"""set logger level to INFO"""
90+
set_log_level_format(logging.INFO,
91+
'%(asctime)s %(levelname)s:%(name)s:%(message)s')
92+
93+
94+
def set_log_debug():
95+
"""set logger level to DEBUG, and debug-level output format"""
96+
set_log_level_format(
97+
logging.DEBUG,
98+
"%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - "
99+
"%(name)s.%(funcName)s() ] %(message)s"
100+
)
101+
102+
103+
def set_log_level_format(level, format):
104+
"""
105+
Set logger level and format.
106+
107+
:param level: logging level; see the :py:mod:`logging` constants.
108+
:type level: int
109+
:param format: logging formatter format string
110+
:type format: str
111+
"""
112+
formatter = logging.Formatter(fmt=format)
113+
logger.handlers[0].setFormatter(formatter)
114+
logger.setLevel(level)
115+
116+
117+
def main():
118+
args = parse_args(sys.argv[1:])
119+
120+
# set logging level
121+
if args.verbose > 1:
122+
set_log_debug()
123+
elif args.verbose == 1:
124+
set_log_info()
125+
126+
ThresholdGenerator().generate(args.TITLE)
127+
128+
129+
if __name__ == '__main__':
130+
main()

0 commit comments

Comments
 (0)