diff --git a/README.rst b/README.rst index 6118607..bc49648 100644 --- a/README.rst +++ b/README.rst @@ -114,6 +114,8 @@ The end result of this process for a given survey (Title) should be 8 ``.png`` i * **tcp_upload_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, TCP, uploading from client to server. * **udp_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, UDP, uploading from client to server. +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. + Running In Docker ----------------- diff --git a/setup.py b/setup.py index b1e6b68..fed1bea 100644 --- a/setup.py +++ b/setup.py @@ -86,7 +86,8 @@ 'console_scripts': [ 'wifi-scan = wifi_survey_heatmap.scancli:main', 'wifi-survey = wifi_survey_heatmap.ui:main', - 'wifi-heatmap = wifi_survey_heatmap.heatmap:main' + 'wifi-heatmap = wifi_survey_heatmap.heatmap:main', + 'wifi-heatmap-thresholds = wifi_survey_heatmap.thresholds:main' ] }, cffi_modules=[ diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index 6c8b8c6..9a16dd8 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -127,7 +127,18 @@ class HeatMapGenerator(object): - def __init__(self, image_path, title, ignore_ssids=[], aps=None): + graphs = { + 'rssi': 'RSSI (level)', + 'quality': 'iwstats Quality', + 'tcp_upload_Mbps': 'TCP Upload Mbps', + 'tcp_download_Mbps': 'TCP Download Mbps', + 'udp_Mbps': 'UDP Upload Mbps', + 'jitter': 'UDP Jitter (ms)' + } + + def __init__( + self, image_path, title, ignore_ssids=[], aps=None, thresholds=None + ): self._ap_names = {} if aps is not None: with open(aps, 'r') as fh: @@ -135,6 +146,10 @@ def __init__(self, image_path, title, ignore_ssids=[], aps=None): x.upper(): y for x, y in json.loads(fh.read()).items() } self._image_path = image_path + self._layout = None + self._image_width = 0 + self._image_height = 0 + self._corners = [(0, 0), (0, 0), (0, 0), (0, 0)] self._title = title if not self._title.endswith('.json'): self._title += '.json' @@ -143,22 +158,17 @@ def __init__(self, image_path, title, ignore_ssids=[], aps=None): 'Initialized HeatMapGenerator; image_path=%s title=%s', self._image_path, self._title ) - self._layout = imread(self._image_path) - self._image_width = len(self._layout[0]) - self._image_height = len(self._layout) - 1 - self._corners = [ - (0, 0), (0, self._image_height), - (self._image_width, 0), (self._image_width, self._image_height) - ] - logger.debug( - 'Loaded image with width=%d height=%d', - self._image_width, self._image_height - ) with open(self._title, 'r') as fh: self._data = json.loads(fh.read()) logger.info('Loaded %d measurement points', len(self._data)) - - def generate(self): + self.thresholds = {} + if thresholds is not None: + logger.info('Loading thresholds from: %s', thresholds) + with open(thresholds, 'r') as fh: + self.thresholds = json.loads(fh.read()) + logger.debug('Thresholds: %s', self.thresholds) + + def load_data(self): a = defaultdict(list) for row in self._data: a['x'].append(row['x']) @@ -179,6 +189,24 @@ def generate(self): a['ap'].append(ap + '_2.4') else: a['ap'].append(ap + '_5G') + return a + + def _load_image(self): + self._layout = imread(self._image_path) + self._image_width = len(self._layout[0]) + self._image_height = len(self._layout) - 1 + self._corners = [ + (0, 0), (0, self._image_height), + (self._image_width, 0), (self._image_width, self._image_height) + ] + logger.debug( + 'Loaded image with width=%d height=%d', + self._image_width, self._image_height + ) + + def generate(self): + self._load_image() + a = self.load_data() for x, y in self._corners: a['x'].append(x) a['y'].append(y) @@ -195,14 +223,7 @@ def generate(self): y = np.linspace(0, self._image_height, num_y) gx, gy = np.meshgrid(x, y) gx, gy = gx.flatten(), gy.flatten() - for k, ptitle in { - 'rssi': 'RSSI (level)', - 'quality': 'iwstats Quality', - 'tcp_upload_Mbps': 'TCP Upload Mbps', - 'tcp_download_Mbps': 'TCP Download Mbps', - 'udp_Mbps': 'UDP Upload Mbps', - 'jitter': 'UDP Jitter (ms)' - }.items(): + for k, ptitle in self.graphs.items(): self._plot( a, k, '%s - %s' % (self._title, ptitle), gx, gy, num_x, num_y ) @@ -300,6 +321,7 @@ def _add_inner_title(self, ax, title, loc, size=None, **kwargs): return at def _plot(self, a, key, title, gx, gy, num_x, num_y): + logger.debug('Plotting: %s', key) pp.rcParams['figure.figsize'] = ( self._image_width / 300, self._image_height / 300 ) @@ -313,15 +335,26 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): # Render the interpolated data to the plot pp.axis('off') # begin color mapping - norm = matplotlib.colors.Normalize( - vmin=min(a[key]), vmax=max(a[key]), clip=True - ) + if 'min' in self.thresholds.get(key, {}): + vmin = self.thresholds[key]['min'] + logger.debug('Using min threshold from thresholds: %s', vmin) + else: + vmin = min(a[key]) + logger.debug('Using calculated min threshold: %s', vmin) + if 'max' in self.thresholds.get(key, {}): + vmax = self.thresholds[key]['max'] + logger.debug('Using max threshold from thresholds: %s', vmax) + else: + vmax = max(a[key]) + logger.debug('Using calculated max threshold: %s', vmax) + norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='RdYlBu_r') # end color mapping image = pp.imshow( z, extent=(0, self._image_width, self._image_height, 0), - cmap='RdYlBu_r', alpha=0.5, zorder=100 + cmap='RdYlBu_r', alpha=0.5, zorder=100, + vmin=vmin, vmax=vmax ) pp.colorbar(image) pp.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) @@ -359,6 +392,8 @@ def parse_args(argv): help='verbose output. specify twice for debug-level output.') p.add_argument('-i', '--ignore', dest='ignore', action='append', default=[], help='SSIDs to ignore from channel graph') + p.add_argument('-t', '--thresholds', dest='thresholds', action='store', + type=str, help='thresholds JSON file path') p.add_argument('-a', '--ap-names', type=str, dest='aps', action='store', default=None, help='If specified, a JSON file mapping AP MAC/BSSID to ' @@ -412,7 +447,8 @@ def main(): set_log_info() HeatMapGenerator( - args.IMAGE, args.TITLE, ignore_ssids=args.ignore, aps=args.aps + args.IMAGE, args.TITLE, ignore_ssids=args.ignore, aps=args.aps, + thresholds=args.thresholds ).generate() diff --git a/wifi_survey_heatmap/thresholds.py b/wifi_survey_heatmap/thresholds.py new file mode 100644 index 0000000..0b886c0 --- /dev/null +++ b/wifi_survey_heatmap/thresholds.py @@ -0,0 +1,130 @@ +""" +The latest version of this package is available at: + + +################################################################################## +Copyright 2017 Jason Antman + + This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. + + wifi-survey-heatmap is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + wifi-survey-heatmap 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with wifi-survey-heatmap. If not, see . + +The Copyright and Authors attributions contained herein may not be removed or +otherwise altered, except to add the Author attribution of a contributor to +this work. (Additional Terms pursuant to Section 7b of the AGPL v3) +################################################################################## +While not legally required, I sincerely request that anyone who finds +bugs please submit them at or +to me via email, and that you send any contributions or improvements +either as a pull request on GitHub, or to me via email. +################################################################################## + +AUTHORS: +Jason Antman +################################################################################## +""" + +import sys +import argparse +import logging +import json +from collections import defaultdict + +from wifi_survey_heatmap.heatmap import HeatMapGenerator + +FORMAT = "[%(asctime)s %(levelname)s] %(message)s" +logging.basicConfig(level=logging.WARNING, format=FORMAT) +logger = logging.getLogger() + + +class ThresholdGenerator(object): + + def generate(self, titles): + res = defaultdict(dict) + items = [HeatMapGenerator(None, t).load_data() for t in titles] + for key in HeatMapGenerator.graphs.keys(): + res[key]['min'] = min([ + min(x[key]) for x in items + ]) + res[key]['max'] = max([ + max(x[key]) for x in items + ]) + with open('thresholds.json', 'w') as fh: + fh.write(json.dumps(res)) + logger.info('Wrote: thresholds.json') + + +def parse_args(argv): + """ + parse arguments/options + + this uses the new argparse module instead of optparse + see: + """ + p = argparse.ArgumentParser( + description='wifi survey heatmap threshold generator' + ) + p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, + help='verbose output. specify twice for debug-level output.') + p.add_argument( + 'TITLE', type=str, help='Title for survey (and data filename)', + nargs='+' + ) + args = p.parse_args(argv) + return args + + +def set_log_info(): + """set logger level to INFO""" + set_log_level_format(logging.INFO, + '%(asctime)s %(levelname)s:%(name)s:%(message)s') + + +def set_log_debug(): + """set logger level to DEBUG, and debug-level output format""" + set_log_level_format( + logging.DEBUG, + "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " + "%(name)s.%(funcName)s() ] %(message)s" + ) + + +def set_log_level_format(level, format): + """ + Set logger level and format. + + :param level: logging level; see the :py:mod:`logging` constants. + :type level: int + :param format: logging formatter format string + :type format: str + """ + formatter = logging.Formatter(fmt=format) + logger.handlers[0].setFormatter(formatter) + logger.setLevel(level) + + +def main(): + args = parse_args(sys.argv[1:]) + + # set logging level + if args.verbose > 1: + set_log_debug() + elif args.verbose == 1: + set_log_info() + + ThresholdGenerator().generate(args.TITLE) + + +if __name__ == '__main__': + main()