changeset 0:18dc6245c91a

Create initial version of weather sensor reader.
author Paul Fisher <paul@pfish.zone>
date Fri, 27 Sep 2019 21:28:38 -0400
parents
children b52600fade25
files .vscode/.ropeproject/config.py __init__.py weatherlog/__init__.py weatherlog/reader.py weatherlog/reader_test.py
diffstat 3 files changed, 284 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /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 <package> import <module>` 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!
--- /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
--- /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()