diff --git a/.gitignore b/.gitignore index 8e685b7..101621f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ cache.sqlite # Dev env venv/* .vscode/* + +# Coverage generated files +.coverage +.coveragerc diff --git a/README.md b/README.md index 631fb99..c513509 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Purple Air API -A Python 3.x API Class to turn data from the PurpleAir/ThingSpeak API into a Pandas DataFrame with several utility methods. +A Python 3.x module to turn data from the PurpleAir/ThingSpeak API into a Pandas DataFrame safely, with many utility methods. ![Global Sensor Map with Celsius Scale](maps/sensor_map.png) @@ -39,7 +39,7 @@ print(len(p.useful_sensors)) # 10047, List of sensors with no defects ```python from purpleair.sensor import Sensor -s = Sensor('2890', parse_location=True) +s = Sensor(2890, parse_location=True) print(s) # Sensor 2891 at 10834, Canyon Road, Omaha, Douglas County, Nebraska, 68112, USA ``` @@ -67,7 +67,7 @@ id ```python from purpleair.sensor import Sensor -se = Sensor('2890') +se = Sensor(2890) df = se.parent.get_historical(weeks_to_get=1, thingspeak_field='secondary') print(df.head()) @@ -89,9 +89,9 @@ entry_id ```python from purpleair.sensor import Sensor -se = Sensor('2890') +se = Sensor(2890) df = se.child.get_historical(weeks_to_get=1, - thingspeak_field='secondary') + thingspeak_field='secondary') print(df.head()) ``` diff --git a/docs/api/sensor_methods.md b/docs/api/sensor_methods.md index 908c1ef..c299f4d 100644 --- a/docs/api/sensor_methods.md +++ b/docs/api/sensor_methods.md @@ -118,7 +118,8 @@ Return a dictionary representation of a sensor. The data is shaped like this: { 'parent': { 'meta': { - 'id': identifier, + 'id': a.identifier, + 'parent': None, 'lat': a.lat, 'lon': a.lon, 'name': a.name, @@ -129,7 +130,19 @@ Return a dictionary representation of a sensor. The data is shaped like this: 'temp_f': a.current_temp_f, 'temp_c': a.current_temp_c, 'humidity': a.current_humidity, - 'pressure': a.current_pressure + 'pressure': a.current_pressure, + 'p_0_3_um': a.current_p_0_3_um, + 'p_0_5_um': a.current_p_0_5_um, + 'p_1_0_um': a.current_p_1_0_um, + 'p_2_5_um': a.current_p_2_5_um, + 'p_5_0_um': a.current_p_5_0_um, + 'p_10_0_um': a.current_p_10_0_um, + 'pm1_0_cf_1': a.current_pm1_0_cf_1, + 'pm2_5_cf_1': a.current_pm2_5_cf_1, + 'pm10_0_cf_1': a.current_pm10_0_cf_1, + 'pm1_0_atm': a.current_pm1_0_atm, + 'pm2_5_atm': a.current_pm2_5_atm, + 'pm10_0_atm': a.current_pm10_0_atm }, 'diagnostic': { 'last_seen': a.last_seen, @@ -137,8 +150,15 @@ Return a dictionary representation of a sensor. The data is shaped like this: 'hidden': a.hidden, 'flagged': a.flagged, 'downgraded': a.downgraded, - 'age': a.age - } + 'age': a.age, + 'brightness': a.brightness, + 'hardware': a.hardware, + 'version': a.version, + 'last_update_check': a.last_update_check, + 'created': a.created, + 'uptime': a.uptime, + 'is_owner': a.is_owner + }, 'statistics': { '10min_avg': a.m10avg, '30min_avg': a.m30avg, @@ -149,33 +169,53 @@ Return a dictionary representation of a sensor. The data is shaped like this: }, 'child':{ 'meta': { - 'id': identifier, - 'lat': b.lat, - 'lon': b.lon, - 'name': b.name, - 'location_type': b.location_type, + 'id': a.identifier, + 'parent': None, + 'lat': a.lat, + 'lon': a.lon, + 'name': a.name, + 'location_type': a.location_type }, 'data': { - 'pm_2.5': b.current_pm2_5, - 'temp_f': b.current_temp_f, - 'temp_c': b.current_temp_c, - 'humidity': b.current_humidity, - 'pressure': b.current_pressure, + 'pm_2.5': a.current_pm2_5, + 'temp_f': a.current_temp_f, + 'temp_c': a.current_temp_c, + 'humidity': a.current_humidity, + 'pressure': a.current_pressure, + 'p_0_3_um': a.current_p_0_3_um, + 'p_0_5_um': a.current_p_0_5_um, + 'p_1_0_um': a.current_p_1_0_um, + 'p_2_5_um': a.current_p_2_5_um, + 'p_5_0_um': a.current_p_5_0_um, + 'p_10_0_um': a.current_p_10_0_um, + 'pm1_0_cf_1': a.current_pm1_0_cf_1, + 'pm2_5_cf_1': a.current_pm2_5_cf_1, + 'pm10_0_cf_1': a.current_pm10_0_cf_1, + 'pm1_0_atm': a.current_pm1_0_atm, + 'pm2_5_atm': a.current_pm2_5_atm, + 'pm10_0_atm': a.current_pm10_0_atm }, 'diagnostic': { - 'last_seen': b.last_seen, - 'model': b.model, - 'hidden': b.hidden, - 'flagged': b.flagged, - 'downgraded': b.downgraded, - 'age': b.age - } + 'last_seen': a.last_seen, + 'model': a.model, + 'hidden': a.hidden, + 'flagged': a.flagged, + 'downgraded': a.downgraded, + 'age': a.age, + 'brightness': a.brightness, + 'hardware': a.hardware, + 'version': a.version, + 'last_update_check': a.last_update_check, + 'created': a.created, + 'uptime': a.uptime, + 'is_owner': a.is_owner + }, 'statistics': { - '10min_avg': b.m10avg, - '30min_avg': b.m30avg, - '1hour_avg': b.h1ravg, - '6hour_avg': b.h6ravg, - '1week_avg': b.w1avg + '10min_avg': a.m10avg, + '30min_avg': a.m30avg, + '1hour_avg': a.h1ravg, + '6hour_avg': a.h6ravg, + '1week_avg': a.w1avg } } } @@ -191,26 +231,45 @@ The data is shaped like this: ```python { - 'id': 14633, - 'lat': 37.275561, - 'lon': -121.964134, - 'name': 'Hazelwood canary', - 'location_type': 'outside', - 'pm_2.5': 92.25, - 'temp_f': 73.0, - 'temp_c': 22.77777777777778, - 'humidity': 0.53, - 'pressure': '1007.15', - 'last_seen': datetime.datetime(2020, 9, 13, 15, 16, 52), - 'model': 'PMS5003+PMS5003+BME280', - 'hidden': False, - 'flagged': False, - 'downgraded': False, + 'parent': 0, + 'lat': 0, + 'lon': 0, + 'name': 0, + 'location_type': 0, + 'pm_2.5': 0, + 'temp_f': 0, + 'temp_c': 0, + 'humidity': 0, + 'pressure': 0, + 'p_0_3_um': 0, + 'p_0_5_um': 0, + 'p_1_0_um': 0, + 'p_2_5_um': 0, + 'p_5_0_um': 0, + 'p_10_0_um': 0, + 'pm1_0_cf_1': 0, + 'pm2_5_cf_1': 0, + 'pm10_0_cf_1': 0, + 'pm1_0_atm': 0, + 'pm2_5_atm': 0, + 'pm10_0_atm': 0, + 'last_seen': 0, + 'model': 0, + 'hidden': 0, + 'flagged': 0, + 'downgraded': 0, 'age': 0, - '10min_avg': 93.13, - '30min_avg': 93.67, - '1hour_avg': 93.93, - '6hour_avg': 98.92, - '1week_avg': 41.49 + 'brightness': 0, + 'hardware': 0, + 'version': 0, + 'last_update_check': 0, + 'created': 0, + 'uptime': 0, + 'is_owner': 0, + '10min_avg': 0, + '30min_avg': 0, + '1hour_avg': 0, + '6hour_avg': 0, + '1week_avg': 0 } ``` diff --git a/docs/documentation.md b/docs/documentation.md index ef882a1..256f555 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -92,6 +92,30 @@ Representation of a sensor channel, either `a` or `b`. For channel `b` (child) s * Current humidity expressed as decimal (i.e., 0.1 = 10%) * `current_pressure` * Current atmospheric pressure + * `p_0_3_um` + * Current pm0.3 / um + * `p_0_5_um` + * Current pm0.5 / um + * `p_1_0_um` + * Current pm1.0 / um + * `p_2_5_um` + * Current pm2.5 / um + * `p_5_0_um` + * Current pm5.0 / um + * `p_10_0_um` + * Current pm10.0 / um + * `pm1_0_cf_1` + * Current pm1.0 / um secondary reading + * `pm2_5_cf_1` + * Current pm2.5 / um secondary reading + * `pm10_0_cf_1` + * Current pm10.0 / um secondary reading + * `pm1_0_atm` + * Current pm1.0 / atm + * `pm2_5_atm` + * Current pm2.5 / atm + * `pm10_0_atm` + * Current pm10.0 / atm * `m10avg` * Average pm2.5 value for the most recent 10 minutes * `m30avg` @@ -132,5 +156,19 @@ Representation of a sensor channel, either `a` or `b`. For channel `b` (child) s * Whether a sensor has previously been flagged for bad data * `age` * Number of minutes old the data returned by the sensor is + * `brightness` + * Ambient brightness + * `hardware` + * Hardware model IDs + * `version` + * Software version + * `last_update_check` + * Last software update check + * `created` + * Date first seen + * `uptime` + * Time since boot in seconds + * `is_owner` + * Unknown See [api/channel_methods.md](api/channel_methods.md) for method documentation. diff --git a/maps/sensor_map.png b/maps/sensor_map.png index cc8d513..a9e7372 100644 Binary files a/maps/sensor_map.png and b/maps/sensor_map.png differ diff --git a/purpleair/channel.py b/purpleair/channel.py index 2ac79e4..e2f837a 100644 --- a/purpleair/channel.py +++ b/purpleair/channel.py @@ -22,116 +22,127 @@ def __init__(self, channel_data: dict): self.channel_data = channel_data self.setup() + def safe_float(self, key: str) -> Optional[float]: + """ + Convert to float if the item exists, otherwise return none + """ + result: Optional[float] = self.channel_data.get(key) + if result is not None: + try: + result = float(result) + except TypeError: + return None + except ValueError: + return None + return result + def setup(self) -> None: """ Initialize metadata and real data for a sensor; for detailed info see docs """ # Meta - self.lat = self.channel_data.get('Lat', None) - self.lon = self.channel_data.get('Lon', None) - self.identifier = self.channel_data.get('ID', None) - self.parent = self.channel_data.get('ParentID', None) - self.type = 'parent' if self.parent is None else 'child' - self.name = self.channel_data.get('Label', None) + self.lat: Optional[float] = self.safe_float('Lat') + self.lon: Optional[float] = self.safe_float('Lon') + self.identifier: Optional[int] = self.channel_data.get('ID') + self.parent: Optional[int] = self.channel_data.get('ParentID') + self.type: str = 'parent' if self.parent is None else 'child' + self.name: Optional[str] = self.channel_data.get('Label') # pylint: disable=line-too-long - self.location_type = self.channel_data['DEVICE_LOCATIONTYPE'] if 'DEVICE_LOCATIONTYPE' in self.channel_data else '' - - # Data - if 'PM2_5Value' in self.channel_data: - if self.channel_data['PM2_5Value'] is not None: - self.current_pm2_5: Optional[float] = float( - self.channel_data['PM2_5Value']) - else: - self.current_pm2_5 = self.channel_data['PM2_5Value'] - else: - self.current_pm2_5 = None - try: - f_temp = float(self.channel_data['temp_f']) - if f_temp > 150 or f_temp < -100: - self.current_temp_f = None - self.current_temp_c = None - else: - self.current_temp_f = float(self.channel_data['temp_f']) - self.current_temp_c = (self.current_temp_f - 32) * (5 / 9) - except TypeError: - self.current_temp_f = None - self.current_temp_c = None - except ValueError: - self.current_temp_f = None - self.current_temp_c = None - except KeyError: - self.current_temp_f = None - self.current_temp_c = None - - try: - self.current_humidity: Optional[float] = int( - self.channel_data['humidity']) / 100 - except TypeError: - self.current_humidity = None - except ValueError: - self.current_humidity = None - except KeyError: - self.current_humidity = None + self.location_type: Optional[str] = self.channel_data.get( + 'DEVICE_LOCATIONTYPE') + + # Data, possible TODO: abstract to class + self.current_pm2_5: Optional[float] = self.safe_float('PM2_5Value') + self.current_temp_f: Optional[float] = self.safe_float('temp_f') + self.current_temp_c = (self.current_temp_f - 32) * (5 / 9) \ + if self.current_temp_f is not None else None + self.current_humidity: Optional[float] = self.safe_float('humidity') + self.current_pressure: Optional[float] = self.safe_float('pressure') + self.current_p_0_3_um: Optional[float] = self.safe_float('p_0_3_um') + self.current_p_0_5_um: Optional[float] = self.safe_float('p_0_5_um') + self.current_p_1_0_um: Optional[float] = self.safe_float('p_1_0_um') + self.current_p_2_5_um: Optional[float] = self.safe_float('p_2_5_um') + self.current_p_5_0_um: Optional[float] = self.safe_float('p_5_0_um') + self.current_p_10_0_um: Optional[float] = self.safe_float('p_10_0_um') + self.current_pm1_0_cf_1: Optional[float] = self.safe_float( + 'pm1_0_cf_1') + self.current_pm2_5_cf_1: Optional[float] = self.safe_float( + 'pm2_5_cf_1') + self.current_pm10_0_cf_1: Optional[float] = self.safe_float( + 'pm10_0_cf_1') + self.current_pm1_0_atm: Optional[float] = self.safe_float('pm1_0_atm') + self.current_pm2_5_atm: Optional[float] = self.safe_float('pm2_5_atm') + self.current_pm10_0_atm: Optional[float] = self.safe_float( + 'pm10_0_atm') + # Statistics + self.pm2_5stats: Optional[dict] = json.loads(self.channel_data['Stats']) \ + if 'Stats' in self.channel_data else None + self.m10avg: Optional[float] = self.safe_float('v1') + self.m30avg: Optional[float] = self.safe_float('v2') + self.h1ravg: Optional[float] = self.safe_float('v3') + self.h6ravg: Optional[float] = self.safe_float('v4') + self.d1avg: Optional[float] = self.safe_float('v5') + self.w1avg: Optional[float] = self.safe_float('v6') + self.last_modified_stats: Optional[datetime] = None + last_mod = self.pm2_5stats.get('lastModified') \ + if self.pm2_5stats is not None else None + if last_mod is not None: + self.last_modified_stats = datetime.utcfromtimestamp( + int(last_mod) / 1000) + self.last2_modified: Optional[int] = self.pm2_5stats.get( + 'timeSinceModified') if self.pm2_5stats is not None else None + + # Thingspeak IDs, if these are missing do not crash, just set to None try: - self.current_pressure: Optional[float] = self.channel_data['pressure'] - except TypeError: - self.current_pressure = None - except ValueError: - self.current_pressure = None + self.tp_primary_channel: Optional[str] = self.channel_data['THINGSPEAK_PRIMARY_ID'] + self.tp_primary_key: Optional[str] = self.channel_data['THINGSPEAK_PRIMARY_ID_READ_KEY'] + self.tp_secondary_channel: Optional[str] = self.channel_data['THINGSPEAK_SECONDARY_ID'] + self.tp_secondary_key: Optional[str] = self.channel_data['THINGSPEAK_SECONDARY_ID_READ_KEY'] + self.thingspeak_primary: Optional[thingspeak.Channel] = thingspeak.Channel( + id=self.tp_primary_channel, api_key=self.tp_primary_key) + self.thingspeak_secondary: Optional[thingspeak.Channel] = thingspeak.Channel( + id=self.tp_secondary_channel, api_key=self.tp_secondary_key) except KeyError: - self.current_pressure = None - - # Statistics - stats = self.channel_data.get('Stats', None) - if stats: - self.pm2_5stats = json.loads(self.channel_data['Stats']) - self.m10avg = self.pm2_5stats['v1'] - self.m30avg = self.pm2_5stats['v2'] - self.h1ravg = self.pm2_5stats['v3'] - self.h6ravg = self.pm2_5stats['v4'] - self.d1avg = self.pm2_5stats['v5'] - self.w1avg = self.pm2_5stats['v6'] - try: - self.last_modified_stats: Optional[datetime] = datetime.utcfromtimestamp( - int(self.pm2_5stats['lastModified']) / 1000) - except TypeError: - self.last_modified_stats = None - except ValueError: - self.last_modified_stats = None - except KeyError: - self.last_modified_stats = None - - try: - # MS since last update to stats - self.last2_modified = self.pm2_5stats['timeSinceModified'] - except KeyError: - self.last2_modified = None - - # Thingspeak IDs - self.tp_primary_channel = self.channel_data['THINGSPEAK_PRIMARY_ID'] - self.tp_primary_key = self.channel_data['THINGSPEAK_PRIMARY_ID_READ_KEY'] - self.tp_secondary_channel = self.channel_data['THINGSPEAK_SECONDARY_ID'] - self.tp_secondary_key = self.channel_data['THINGSPEAK_SECONDARY_ID_READ_KEY'] - self.thingspeak_primary = thingspeak.Channel( - id=self.tp_primary_channel, api_key=self.tp_primary_key) - self.thingspeak_secondary = thingspeak.Channel( - id=self.tp_secondary_channel, api_key=self.tp_secondary_key) + # Doing this prevents a crash until we actually access ThingSpeak data + # which the user may not do + self.tp_primary_channel = None + self.tp_primary_key = None + self.tp_secondary_channel = None + self.tp_secondary_key = None + self.thingspeak_primary = None + self.thingspeak_secondary = None # Diagnostic - self.last_seen = datetime.utcfromtimestamp( - self.channel_data['LastSeen']) - self.model = self.channel_data['Type'] if 'Type' in self.channel_data else '' + last_seen = self.channel_data.get('LastSeen') + if last_seen is not None: + self.last_seen: Optional[datetime] = datetime.utcfromtimestamp( + int(last_seen) / 1000) + else: + self.last_seen = last_seen + self.model: Optional[str] = self.channel_data.get('Type') + self.adc: Optional[str] = self.channel_data.get('Adc') + self.rssi: Optional[str] = self.channel_data.get('RSSI') # pylint: disable=simplifiable-if-expression - self.hidden = False if self.channel_data['Hidden'] == 'false' else True + self.hidden = False if self.channel_data.get( + 'Hidden') == 'false' else True # pylint: disable=simplifiable-if-expression - self.flagged = True if 'Flag' in self.channel_data and self.channel_data[ - 'Flag'] == 1 else False + self.flagged = True if self.channel_data.get('Flag') == 1 else False # pylint: disable=simplifiable-if-expression - self.downgraded = True if 'A_H' in self.channel_data and self.channel_data[ - 'A_H'] == 'true' else False + self.downgraded = True if self.channel_data.get( + 'A_H') == 'true' else False # Number of minutes old the data is - self.age = int(self.channel_data['AGE']) + self.age: Optional[int] = self.channel_data.get('AGE') + self.brightness: Optional[str] = self.channel_data.get( + 'DEVICE_BRIGHTNESS') + self.hardware: Optional[str] = self.channel_data.get( + 'DEVICE_HARDWAREDISCOVERED') + self.version: Optional[str] = self.channel_data.get('Version') + self.last_update_check: Optional[int] = self.channel_data.get( + 'LastUpdateCheck') + self.created: Optional[int] = self.channel_data.get('Created') + self.uptime: Optional[int] = self.channel_data.get('Uptime') + self.is_owner: Optional[bool] = bool(self.channel_data.get('isOwner')) def get_historical(self, weeks_to_get: int, diff --git a/purpleair/sensor.py b/purpleair/sensor.py index 0561ad5..5506091 100644 --- a/purpleair/sensor.py +++ b/purpleair/sensor.py @@ -6,6 +6,7 @@ import json import os from re import sub +from typing import Optional import requests from geopy.geocoders import Nominatim @@ -19,35 +20,48 @@ class Sensor(): Representation of a single PurpleAir sensor """ - def __init__(self, identifier, json_data=None, parse_location=False): + def __init__(self, identifier: int, json_data: list = None, parse_location=False): self.identifier = identifier - self.data = json_data if json_data is not None else self.get_data() - self.parent_data = self.data[0] - self.child_data = self.data[1] if len(self.data) > 1 else None - self.parse_location = parse_location - self.thingspeak_data = {} - self.parent = Channel(channel_data=self.parent_data,) - self.child = Channel( + self.data: Optional[list] = json_data \ + if json_data is not None else self.get_data() + + # Validate the data we recieved + if not self.data: + raise ValueError( + f'Invalid sensor: no configuration found for {identifier}') + if not isinstance(self.data, list): + raise ValueError( + f'Sensor {identifier} created without valid data') + + self.parent_data: dict = self.data[0] + self.child_data: Optional[dict] = self.data[1] if len( + self.data) > 1 else None + self.parse_location: bool = parse_location + self.thingspeak_data: dict = {} + self.parent: Channel = Channel(channel_data=self.parent_data,) + self.child: Optional[Channel] = Channel( channel_data=self.child_data) if self.child_data else None - self.location_type = self.parent.location_type + self.location_type: Optional[str] = self.parent.location_type # Parse the location (slow, so must be manually enabled) - self.location = '' + self.location: str = '' if self.parse_location: self.get_location() - def get_data(self) -> dict: + def get_data(self) -> Optional[list]: """ Get new data if no data is provided """ + # Santize ID + if not isinstance(self.identifier, int): + raise ValueError(f'Invalid sensor ID: {self.identifier}') + # Fetch the JSON for parent and child sensors response = requests.get(f'{API_ROOT}?show={self.identifier}') data = json.loads(response.content) - channel_data = data.get('results') + channel_data: Optional[list] = data.get('results') # Handle various API problems - if channel_data is None: - raise ValueError(f'Results missing from data: {data}') - if len(channel_data) == 1: + if channel_data and len(channel_data) == 1: print('Child sensor requested, acquiring parent instead.') try: parent_id = channel_data[0]["ParentID"] @@ -57,28 +71,33 @@ def get_data(self) -> dict: response = requests.get(f'{API_ROOT}?show={parent_id}') data = json.loads(response.content) channel_data = data.get('results') - elif len(channel_data) > 2: + elif channel_data and len(channel_data) > 2: + print(json.dumps(data, indent=4)) raise ValueError( f'More than 2 channels found for {self.identifier}') return channel_data def get_field(self, field) -> None: """ - Gets the thingspeak data for a sensor + Gets the thingspeak data for a sensor, setting None if the data is missing """ self.thingspeak_data[field] = {'primary': {}, 'secondary': {}} # Primary self.thingspeak_data[field]['primary']['channel_a'] = json.loads( - self.parent.thingspeak_primary.get_field(field=field)) + self.parent.thingspeak_primary.get_field(field=field)) \ + if self.parent.thingspeak_primary else None self.thingspeak_data[field]['primary']['channel_b'] = json.loads( - self.child.thingspeak_primary.get_field(field=field)) + self.child.thingspeak_primary.get_field(field=field)) \ + if self.child and self.child.thingspeak_primary else None # Secondary self.thingspeak_data[field]['secondary']['channel_a'] = json.loads( - self.parent.thingspeak_secondary.get_field(field=field)) + self.parent.thingspeak_secondary.get_field(field=field)) \ + if self.parent.thingspeak_secondary else None self.thingspeak_data[field]['secondary']['channel_b'] = json.loads( - self.child.thingspeak_secondary.get_field(field=field)) + self.child.thingspeak_secondary.get_field(field=field)) \ + if self.child and self.child.thingspeak_secondary else None def is_useful(self) -> bool: """ @@ -142,7 +161,8 @@ def as_dict(self) -> dict: out_d = { 'parent': { 'meta': { - 'id': self.identifier, + 'id': a.identifier, + 'parent': None, 'lat': a.lat, 'lon': a.lon, 'name': a.name, @@ -153,7 +173,19 @@ def as_dict(self) -> dict: 'temp_f': a.current_temp_f, 'temp_c': a.current_temp_c, 'humidity': a.current_humidity, - 'pressure': a.current_pressure + 'pressure': a.current_pressure, + 'p_0_3_um': a.current_p_0_3_um, + 'p_0_5_um': a.current_p_0_5_um, + 'p_1_0_um': a.current_p_1_0_um, + 'p_2_5_um': a.current_p_2_5_um, + 'p_5_0_um': a.current_p_5_0_um, + 'p_10_0_um': a.current_p_10_0_um, + 'pm1_0_cf_1': a.current_pm1_0_cf_1, + 'pm2_5_cf_1': a.current_pm2_5_cf_1, + 'pm10_0_cf_1': a.current_pm10_0_cf_1, + 'pm1_0_atm': a.current_pm1_0_atm, + 'pm2_5_atm': a.current_pm2_5_atm, + 'pm10_0_atm': a.current_pm10_0_atm }, 'diagnostic': { 'last_seen': a.last_seen, @@ -161,31 +193,58 @@ def as_dict(self) -> dict: 'hidden': a.hidden, 'flagged': a.flagged, 'downgraded': a.downgraded, - 'age': a.age + 'age': a.age, + 'brightness': a.brightness, + 'hardware': a.hardware, + 'version': a.version, + 'last_update_check': a.last_update_check, + 'created': a.created, + 'uptime': a.uptime, + 'is_owner': a.is_owner } }, 'child': { 'meta': { - 'id': self.identifier, - 'lat': b.lat if b is not None else None, - 'lon': b.lon if b is not None else None, - 'name': b.name if b is not None else None, - 'location_type': b.location_type if b is not None else None, + 'id': b.identifier if b else None, + 'parent': a.identifier if b else None, + 'lat': b.lat if b else None, + 'lon': b.lon if b else None, + 'name': b.name if b else None, + 'location_type': b.location_type if b else None }, 'data': { - 'pm_2.5': b.current_pm2_5 if b is not None else None, - 'temp_f': b.current_temp_f if b is not None else None, - 'temp_c': b.current_temp_c if b is not None else None, - 'humidity': b.current_humidity if b is not None else None, - 'pressure': b.current_pressure if b is not None else None, + 'pm_2.5': b.current_pm2_5 if b else None, + 'temp_f': b.current_temp_f if b else None, + 'temp_c': b.current_temp_c if b else None, + 'humidity': b.current_humidity if b else None, + 'pressure': b.current_pressure if b else None, + 'p_0_3_um': b.current_p_0_3_um if b else None, + 'p_0_5_um': b.current_p_0_5_um if b else None, + 'p_1_0_um': b.current_p_1_0_um if b else None, + 'p_2_5_um': b.current_p_2_5_um if b else None, + 'p_5_0_um': b.current_p_5_0_um if b else None, + 'p_10_0_um': b.current_p_10_0_um if b else None, + 'pm1_0_cf_1': b.current_pm1_0_cf_1 if b else None, + 'pm2_5_cf_1': b.current_pm2_5_cf_1 if b else None, + 'pm10_0_cf_1': b.current_pm10_0_cf_1 if b else None, + 'pm1_0_atm': b.current_pm1_0_atm if b else None, + 'pm2_5_atm': b.current_pm2_5_atm if b else None, + 'pm10_0_atm': b.current_pm10_0_atm if b else None }, 'diagnostic': { - 'last_seen': b.last_seen if b is not None else None, - 'model': b.model if b is not None else None, - 'hidden': b.hidden if b is not None else None, - 'flagged': b.flagged if b is not None else None, - 'downgraded': b.downgraded if b is not None else None, - 'age': b.age if b is not None else None + 'last_seen': b.last_seen if b else None, + 'model': b.model if b else None, + 'hidden': b.hidden if b else None, + 'flagged': b.flagged if b else None, + 'downgraded': b.downgraded if b else None, + 'age': b.age if b else None, + 'brightness': b.brightness if b else None, + 'hardware': b.hardware if b else None, + 'version': b.version if b else None, + 'last_update_check': b.last_update_check if b else None, + 'created': b.created if b else None, + 'uptime': b.uptime if b else None, + 'is_owner': b.is_owner if b else None } } } @@ -209,11 +268,11 @@ def as_dict(self) -> dict: if b and 'Stats' in b.channel_data and b.channel_data['Stats']: out_d['child']['statistics'] = { - '10min_avg': b.m10avg if b is not None else None, - '30min_avg': b.m30avg if b is not None else None, - '1hour_avg': b.h1ravg if b is not None else None, - '6hour_avg': b.h6ravg if b is not None else None, - '1week_avg': b.w1avg if b is not None else None + '10min_avg': b.m10avg if b else None, + '30min_avg': b.m30avg if b else None, + '1hour_avg': b.h1ravg if b else None, + '6hour_avg': b.h6ravg if b else None, + '1week_avg': b.w1avg if b else None } else: out_d['child']['statistics'] = { diff --git a/requirements/dev.txt b/requirements/dev.txt index 0b50ae5..2179fd8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -6,3 +6,7 @@ mypy pylint autopep8 bandit + +# coverage run --omit venv -m unittest discover -s tests/ +# coverage report '--omit=*/venv/*' -m --skip-covered +coverage diff --git a/scripts/run.py b/scripts/run.py index f1446d3..61e137c 100644 --- a/scripts/run.py +++ b/scripts/run.py @@ -15,7 +15,7 @@ print(df.head()) # Single sensor -se = Sensor('2890') +se = Sensor(2890) print(se) print(se.parent) print(se.child) diff --git a/setup.py b/setup.py index d31bf12..2413843 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='purpleair', - version='1.1.1', + version='1.1.2', description='Python API Client to get and transform PurpleAir data.', long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", diff --git a/tests/test_purpleair.py b/tests/test_purpleair.py index 9e9d7dd..f7062bf 100644 --- a/tests/test_purpleair.py +++ b/tests/test_purpleair.py @@ -41,6 +41,11 @@ def test_to_dataframe_filtering(self): self.assertEqual(len(p.to_dataframe('useful', 'a')), len(p.useful_sensors)) self.assertEqual(len(p.to_dataframe('useful', 'b')), len(p.useful_sensors)) + def test_to_dataframe(self): + p = network.SensorList() + df_a = p.to_dataframe(sensor_filter='all', channel='a') + df_b = p.to_dataframe(sensor_filter='all', channel='b') + self.assertListEqual(list(df_a.columns), list(df_b.columns)) if __name__ == '__main__': unittest.main() diff --git a/tests/test_sensor.py b/tests/test_sensor.py index d2c9b2e..e94983b 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,3 +1,4 @@ +from purpleair.sensor import Sensor import unittest from purpleair import sensor @@ -12,67 +13,87 @@ def test_create_sensor_location(self): """ Test that we properly parse the location of an arbitrary sensor """ - se = sensor.Sensor('2891', parse_location=True) + se = sensor.Sensor(2891, parse_location=True) self.assertEqual( se.__repr__(), 'Sensor 2891 at 10834, Canyon Road, Omaha, Douglas County, Nebraska, 68112, United States of America' ) + def test_cannot_create_sensor_bad_id(self): + """ + Test that we cannot create a sensor without an integer ID + """ + with self.assertRaises(ValueError): + se = sensor.Sensor('a') + + def test_cannot_create_sensor_bad_json(self): + """ + Test that we cannot create a sensor without valid json + """ + with self.assertRaises(ValueError): + se = sensor.Sensor('1', {'a': 1}) + def test_create_sensor_no_location(self): """ Test that we can initialize a sensor without location enabled """ - se = sensor.Sensor('2891') + se = sensor.Sensor(2891) self.assertEqual(se.__repr__(), 'Sensor 2891') def test_is_useful(self): """ Test that we ensure a useful sensor is useful """ - se = sensor.Sensor('14633') + se = sensor.Sensor(14633) self.assertEqual(se.is_useful(), True) def test_is_not_useful_flagged(self): """ Test that we ensure a not useful sensor is flagged """ - se = sensor.Sensor('61639') + se = sensor.Sensor(61639) self.assertEqual(se.is_useful(), False) def test_is_not_useful_downgraded(self): """ Test that we ensure a not useful sensor is downgraded """ - se = sensor.Sensor('18463') + se = sensor.Sensor(18463) self.assertEqual(se.is_useful(), False) def test_as_dict(self): """ Test that the dictionary export data is shaped correctly """ - se = sensor.Sensor('2891') + se = sensor.Sensor(2891) expected_shape = { 'parent': { 'meta': { 'id': 0, + 'parent': None, 'lat': 0, 'lon': 0, 'name': 0, - 'location_type': 0 + 'location_type': 0, }, 'data': { 'pm_2.5': 0, 'temp_f': 0, 'temp_c': 0, 'humidity': 0, - 'pressure': 0 - }, - 'statistics': { - '10min_avg': 0, - '30min_avg': 0, - '1hour_avg': 0, - '6hour_avg': 0, - '1week_avg': 0 + 'pressure': 0, + 'p_0_3_um': 0, + 'p_0_5_um': 0, + 'p_1_0_um': 0, + 'p_2_5_um': 0, + 'p_5_0_um': 0, + 'p_10_0_um': 0, + 'pm1_0_cf_1': 0, + 'pm2_5_cf_1': 0, + 'pm10_0_cf_1': 0, + 'pm1_0_atm': 0, + 'pm2_5_atm': 0, + 'pm10_0_atm': 0, }, 'diagnostic': { 'last_seen': 0, @@ -80,30 +101,50 @@ def test_as_dict(self): 'hidden': 0, 'flagged': 0, 'downgraded': 0, - 'age': 0 + 'age': 0, + 'brightness': 0, + 'hardware': 0, + 'version': 0, + 'last_update_check': 0, + 'created': 0, + 'uptime': 0, + 'is_owner': 0, + }, + 'statistics': { + '10min_avg': 0, + '30min_avg': 0, + '1hour_avg': 0, + '6hour_avg': 0, + '1week_avg': 0 } }, 'child': { 'meta': { 'id': 0, + 'parent': None, 'lat': 0, 'lon': 0, 'name': 0, - 'location_type': 0 + 'location_type': 0, }, 'data': { 'pm_2.5': 0, 'temp_f': 0, 'temp_c': 0, 'humidity': 0, - 'pressure': 0 - }, - 'statistics': { - '10min_avg': 0, - '30min_avg': 0, - '1hour_avg': 0, - '6hour_avg': 0, - '1week_avg': 0 + 'pressure': 0, + 'p_0_3_um': 0, + 'p_0_5_um': 0, + 'p_1_0_um': 0, + 'p_2_5_um': 0, + 'p_5_0_um': 0, + 'p_10_0_um': 0, + 'pm1_0_cf_1': 0, + 'pm2_5_cf_1': 0, + 'pm10_0_cf_1': 0, + 'pm1_0_atm': 0, + 'pm2_5_atm': 0, + 'pm10_0_atm': 0, }, 'diagnostic': { 'last_seen': 0, @@ -111,7 +152,21 @@ def test_as_dict(self): 'hidden': 0, 'flagged': 0, 'downgraded': 0, - 'age': 0 + 'age': 0, + 'brightness': 0, + 'hardware': 0, + 'version': 0, + 'last_update_check': 0, + 'created': 0, + 'uptime': 0, + 'is_owner': 0, + }, + 'statistics': { + '10min_avg': 0, + '30min_avg': 0, + '1hour_avg': 0, + '6hour_avg': 0, + '1week_avg': 0 } } } @@ -125,9 +180,10 @@ def test_as_flat_dict(self): """ Test that the flat dictionary export data is shaped correctly """ - se = sensor.Sensor('2891') + se = sensor.Sensor(2891) expected_shape = { 'id': 0, + 'parent': 0, 'lat': 0, 'lon': 0, 'name': 0, @@ -137,24 +193,52 @@ def test_as_flat_dict(self): 'temp_c': 0, 'humidity': 0, 'pressure': 0, + 'p_0_3_um': 0, + 'p_0_5_um': 0, + 'p_1_0_um': 0, + 'p_2_5_um': 0, + 'p_5_0_um': 0, + 'p_10_0_um': 0, + 'pm1_0_cf_1': 0, + 'pm2_5_cf_1': 0, + 'pm10_0_cf_1': 0, + 'pm1_0_atm': 0, + 'pm2_5_atm': 0, + 'pm10_0_atm': 0, 'last_seen': 0, 'model': 0, 'hidden': 0, 'flagged': 0, 'downgraded': 0, 'age': 0, + 'brightness': 0, + 'hardware': 0, + 'version': 0, + 'last_update_check': 0, + 'created': 0, + 'uptime': 0, + 'is_owner': 0, '10min_avg': 0, '30min_avg': 0, '1hour_avg': 0, '6hour_avg': 0, - '1week_avg': 0 + '1week_avg': 0, } + + # Test channel b src = se.as_flat_dict(channel='a') for data_category in expected_shape: self.assertIn(data_category, src) for data in src: self.assertNotIsInstance(src[data], dict) + # Test channel a + src = se.as_flat_dict(channel='b') + for data_category in expected_shape: + self.assertIn(data_category, src) + for data in src: + self.assertNotIsInstance(src[data], dict) + if __name__ == '__main__': unittest.main()