# HG changeset patch # User Paul Fisher # Date 1569634118 14400 # Node ID 18dc6245c91a7207140296300a74b30e4c4cc8d2 Create initial version of weather sensor reader. diff -r 000000000000 -r 18dc6245c91a .vscode/.ropeproject/config.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.vscode/.ropeproject/config.py Fri Sep 27 21:28:38 2019 -0400 @@ -0,0 +1,112 @@ +# The default ``config.py`` +# flake8: noqa + + +def set_prefs(prefs): + """This function is called before opening the project""" + + # Specify which files and folders to ignore in the project. + # Changes to ignored resources are not added to the history and + # VCSs. Also they are not returned in `Project.get_files()`. + # Note that ``?`` and ``*`` match all characters but slashes. + # '*.pyc': matches 'test.pyc' and 'pkg/test.pyc' + # 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc' + # '.svn': matches 'pkg/.svn' and all of its children + # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o' + # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o' + prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject', + '.hg', '.svn', '_svn', '.git', '.tox'] + + # Specifies which files should be considered python files. It is + # useful when you have scripts inside your project. Only files + # ending with ``.py`` are considered to be python files by + # default. + #prefs['python_files'] = ['*.py'] + + # Custom source folders: By default rope searches the project + # for finding source folders (folders that should be searched + # for finding modules). You can add paths to that list. Note + # that rope guesses project source folders correctly most of the + # time; use this if you have any problems. + # The folders should be relative to project root and use '/' for + # separating folders regardless of the platform rope is running on. + # 'src/my_source_folder' for instance. + #prefs.add('source_folders', 'src') + + # You can extend python path for looking up modules + #prefs.add('python_path', '~/python/') + + # Should rope save object information or not. + prefs['save_objectdb'] = True + prefs['compress_objectdb'] = False + + # If `True`, rope analyzes each module when it is being saved. + prefs['automatic_soa'] = True + # The depth of calls to follow in static object analysis + prefs['soa_followed_calls'] = 0 + + # If `False` when running modules or unit tests "dynamic object + # analysis" is turned off. This makes them much faster. + prefs['perform_doa'] = True + + # Rope can check the validity of its object DB when running. + prefs['validate_objectdb'] = True + + # How many undos to hold? + prefs['max_history_items'] = 32 + + # Shows whether to save history across sessions. + prefs['save_history'] = True + prefs['compress_history'] = False + + # Set the number spaces used for indenting. According to + # :PEP:`8`, it is best to use 4 spaces. Since most of rope's + # unit-tests use 4 spaces it is more reliable, too. + prefs['indent_size'] = 4 + + # Builtin and c-extension modules that are allowed to be imported + # and inspected by rope. + prefs['extension_modules'] = [] + + # Add all standard c-extensions to extension_modules list. + prefs['import_dynload_stdmods'] = True + + # If `True` modules with syntax errors are considered to be empty. + # The default value is `False`; When `False` syntax errors raise + # `rope.base.exceptions.ModuleSyntaxError` exception. + prefs['ignore_syntax_errors'] = False + + # If `True`, rope ignores unresolvable imports. Otherwise, they + # appear in the importing namespace. + prefs['ignore_bad_imports'] = False + + # If `True`, rope will insert new module imports as + # `from import ` by default. + prefs['prefer_module_from_imports'] = False + + # If `True`, rope will transform a comma list of imports into + # multiple separate import statements when organizing + # imports. + prefs['split_imports'] = False + + # If `True`, rope will remove all top-level import statements and + # reinsert them at the top of the module when making changes. + prefs['pull_imports_to_top'] = True + + # If `True`, rope will sort imports alphabetically by module name instead of + # alphabetically by import statement, with from imports after normal + # imports. + prefs['sort_imports_alphabetically'] = False + + # Location of implementation of rope.base.oi.type_hinting.interfaces.ITypeHintingFactory + # In general case, you don't have to change this value, unless you're an rope expert. + # Change this value to inject you own implementations of interfaces + # listed in module rope.base.oi.type_hinting.providers.interfaces + # For example, you can add you own providers for Django Models, or disable the search + # type-hinting in a class hierarchy, etc. + prefs['type_hinting_factory'] = 'rope.base.oi.type_hinting.factory.default_type_hinting_factory' + + +def project_opened(project): + """This function is called after opening the project""" + # Do whatever you like here! diff -r 000000000000 -r 18dc6245c91a __init__.py diff -r 000000000000 -r 18dc6245c91a weatherlog/__init__.py diff -r 000000000000 -r 18dc6245c91a weatherlog/reader.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/weatherlog/reader.py Fri Sep 27 21:28:38 2019 -0400 @@ -0,0 +1,121 @@ +"""A module for reading data from the actual temperature sensor. + +You should probably assume that this is wildly thread-unsafe. +""" + +import abc +import datetime +import time +import typing as t + +import adafruit_dht +import attr +import board +import pytz + + +def _utc_now() -> datetime.datetime: + """utcnow, but timezone-aware.""" + return datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + + +@attr.s(frozen=True, slots=True) +class Reading(object): + """A single reading from a temperature/humidity sensor.""" + + # The timestamp of the reading. + timestamp = attr.ib(type=datetime.datetime, factory=_utc_now) + + # The temperature, in degrees Celsius. + temp_c = attr.ib(type=float) + + # The relative humidity, in percent. + rh_pct = attr.ib(type=float) + + +class Reader(metaclass=abc.ABCMeta): + """Interface for a thing which reads temperatures.""" + + @abc.abstractmethod + def read(self) -> Reading: + """Reads a value from the weather sensor.""" + raise NotImplementedError() + + +class DHT22Reader(Reader): + """A reader for the DHT22 sensor, which attempts to make good readings.""" + + def __init__(self, pin: t.Any = board.D4): + """Initializes a DHT22 reader connected to the specified pin.""" + self.device = adafruit_dht.DHT22(pin) + + _TEMP_C_EPSILON = 0.51 + _RH_PCT_EPSILON = 1.01 + _NEW_READING_SECS = 2.5 + + def read(self) -> Reading: + """Reads a value from the sensor. This will block until done.""" + temps: t.List[float] = [] + humids: t.List[float] = [] + while True: + temp, rh = self._read_once() + if _is_reasonable_temp(temp): + temps.append(temp) + if _is_reasonable_rh(rh): + humids.append(rh) + try: + return Reading( + temp_c=_last_stable(temps, epsilon=self._TEMP_C_EPSILON), + rh_pct=_last_stable(humids, epsilon=self._RH_PCT_EPSILON)) + except ValueError: + # There is not yet enough data to see if the value is stable. + # Wait for a new reading. + time.sleep(self._NEW_READING_SECS) + + _RETRY_WAIT_SECS = 0.5 + + def _read_once(self) -> t.Tuple[float, float]: + """Tries very very hard to read a single value from the sensor.""" + while True: + try: + temp, rh = self.device.temperature, self.device.humidity + if None not in {temp, rh}: + return temp, rh + except RuntimeError: + pass # This will happen a lot. Just try again. + time.sleep(self._RETRY_WAIT_SECS) + + +def _last_stable( + vals: t.Iterable[float], + *, + epsilon: float, + count: int = 3) -> float: + """Returns the last stable value from the given sequence. + + The stable value is defined as the last value for which there are `count` + values in the sequence within `epsilon` of some value. This is used to + eliminate misread values in the sequence, usually due to timing issues. + The `count` values need not be in sequence. + + The value returned is the median value of the group (or the next-to-median + in the case of an even count). + """ + buckets: t.Dict[float, t.List[float]] = {} + for v in vals: + buckets.setdefault(v, []) + for bucket, bucket_vals in buckets.items(): + if abs(v - bucket) <= epsilon: + bucket_vals.append(v) + if len(bucket_vals) == count: + return sorted(bucket_vals)[count / 2] + + +def _is_reasonable_temp(temp: float) -> bool: + """True if the temperature is a reasonable level for the boathouse.""" + return -40 <= temp <= 60 + + +def _is_reasonable_rh(rh: float) -> bool: + """True if the relative humidity is plausible.""" + return 0 <= rh <= 100 diff -r 000000000000 -r 18dc6245c91a weatherlog/reader_test.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/weatherlog/reader_test.py Fri Sep 27 21:28:38 2019 -0400 @@ -0,0 +1,51 @@ +import itertools +import unittest + +from . import reader + + +class ReasonableTest(unittest.TestCase): + + def test_temp(self): + for good_temp in (-40, 0, 20, 36.4, 40, 60): + self.assertTrue(reader._is_reasonable_temp(good_temp)) + for bad_temp in (-273, -41, 69): + self.assertFalse(reader._is_reasonable_temp(bad_temp)) + + def test_rh(self): + for good_rh in (0, 10.0, 55.5, 90): + self.assertTrue(reader._is_reasonable_rh(good_rh)) + for bad_rh in (-1, 101): + self.assertFalse(reader._is_reasonable_rh(bad_rh)) + + +class StableTest(unittest.TestCase): + + def test_not_enough(self): + with self.assertRaises(ValueError): + reader._last_stable((), epsilon=1.0) + with self.assertRaises(ValueError): + reader._last_stable((1.0,), epsilon=1.0) + with self.assertRaises(ValueError): + reader._last_stable((1.0, 1.5), epsilon=1.0) + with self.assertRaises(ValueError): + reader._last_stable((1.0, 1.0, 1.0), epsilon=1.0, count=4) + + def test_wild(self): + perms = itertools.permutations((1.0, 99.9, 1.2)) + for perm in perms: + with self.assertRaises(ValueError): + reader._last_stable(perm, epsilon=0.5) + + def test_good(self): + perms = itertools.permutations((1.0, 1.3, 1.5, 66.6, -1)) + for perm in perms: + self.assertEqual(reader._last_stable(perm, epsilon=1.01), 1.3) + perms_less = itertools.permutations((1.0, 1.5)) + for perm in perms_less: + self.assertEqual( + reader._last_stable(perm, epsilon=0.51, count=2), 1.0) + + +if __name__ == '__main__': + unittest.main()