Repository: atomicateam/atomica

Issue:
TimeSeries should deal better with arrays
Under some conditions, arrays need to be converted to lists in order to be added - better to remove this limitation

Discussion:

Patch:
diff --git a/atomica/framework.py b/atomica/framework.py
index 7c0338b8..7e4c5ddc 100644
--- a/atomica/framework.py
+++ b/atomica/framework.py
@@ -1119,7 +1119,7 @@ class ProjectFramework(object):
                     message = 'No compartments or characteristics appear in the databook, which means it is not possible to initialize the simulation. Please assign at least some of the compartments and/or characteristics to a databook page.'
                 else:
                     message = 'No compartments or characteristics have a setup weight (either because they do not appear in the databook, or the setup weight has been explicitly set to zero) - cannot initialize simulation. Please change some of the setup weights to be nonzero'
-                raise Exception(message)
+                logger.debug(message)
 
             A = np.zeros((len(characs), len(comps)))
             for i, charac in enumerate(characs):
diff --git a/atomica/programs.py b/atomica/programs.py
index f809645a..7d329a31 100644
--- a/atomica/programs.py
+++ b/atomica/programs.py
@@ -129,7 +129,7 @@ class ProgramInstructions(object):
         for ts in new.alloc.values():
             ts.vals = [x * scale_factor for x in ts.vals]
             ts.assumption = ts.assumption * scale_factor if ts.assumption is not None else None
-            ts.uncertainty = ts.sigma * scale_factor if ts.sigma is not None else None
+            ts.sigma = ts.sigma * scale_factor if ts.sigma is not None else None
         return new
 
 
diff --git a/atomica/utils.py b/atomica/utils.py
index e84314c4..8c453363 100644
--- a/atomica/utils.py
+++ b/atomica/utils.py
@@ -11,7 +11,7 @@ import os
 import re
 import time
 import zlib
-from bisect import bisect
+from bisect import bisect_right, bisect_left
 from datetime import datetime
 from functools import partial
 from pathlib import Path
@@ -131,11 +131,11 @@ class TimeSeries(object):
 
     """
 
-    def __init__(self, t=None, vals=None, units: str = None, assumption: float = None, sigma: float = None):
-        t = sc.promotetolist(t) if t is not None else list()
-        vals = sc.promotetolist(vals) if vals is not None else list()
+    # Use slots here to guarantee that __deepcopy__() and __eq__() only have to check these
+    # specific fields - otherwise, would need to do a more complex recursive dict comparison
+    __slots__ = ['t', 'vals', 'units', 'assumption', 'sigma', '_sampled']
 
-        assert len(t) == len(vals)
+    def __init__(self, t=None, vals=None, units: str = None, assumption: float = None, sigma: float = None):
 
         self.t = []  #: Sorted array of time points. Normally interacted with via methods like :meth:`insert()`
         self.vals = []  #: Time-specific values - indices correspond to ``self.t``
@@ -144,13 +144,28 @@ class TimeSeries(object):
         self.sigma = sigma  #: Uncertainty value, assumed to be a standard deviation
         self._sampled = False  #: Flag to indicate whether sampling has been performed. Once sampling has been performed, cannot sample again
 
-        for tx, vx in zip(t, vals):
-            self.insert(tx, vx)
+        # Using insert() means that array/list inputs containing None or duplicate entries will
+        # be sanitized via insert()
+        self.insert(t, vals)
 
     def __repr__(self):
         output = sc.prepr(self)
         return output
 
+    def __eq__(self, other):
+        """
+        Check TimeSeries equality
+
+        Two TimeSeries instances are equal if all of their attributes are equal. This is easy to
+        implement because `==` is directly defined for all of the attribute types (lists and scalars)
+        and due to `__slots__` there are guaranteed not to be any other attributes
+
+        :param other:
+        :return:
+        """
+
+        return all(getattr(self, x) == getattr(other, x) for x in self.__slots__)
+
     def __deepcopy__(self, memodict={}):
         new = TimeSeries.__new__(TimeSeries)
         new.t = self.t.copy()
@@ -161,6 +176,24 @@ class TimeSeries(object):
         new._sampled = self._sampled
         return new
 
+    def __getstate__(self):
+        return dict([(k, getattr(self,k,None)) for k in self.__slots__])
+
+    def __setstate__(self, data):
+
+        if 'format' in data:
+            # 'format' was changed to 'units' but the attribute was not dropped, however now this is a
+            # hard error because of the switch to __slots__ so we need to make sure it gets removed.
+            # This can't be done as a Migration because a Migration expects an instance of the
+            data = sc.dcp(data)
+            if 'units' not in data:
+                data['units'] = data['format']
+            del data['format']
+
+        for k, v in data.items():
+            setattr(self,k,v)
+
+
     # The operators for + and * below are prototypes but haven't been added
     # The reason is because they add a lot of complexity to the usage of the class
     # but it's not clear whether that complexity is worth it for the benefits it brings.
@@ -329,25 +362,36 @@ class TimeSeries(object):
         :param v: Value to insert. If ``None``, this function will return immediately without doing anything
 
         """
-        if isinstance(t, list):
-            assert isinstance(v, list) and len(t) == len(v), 'Cannot insert non-matching lengths or types  of time and values %s and %s' % (t, v)
+
+        # Check if inputs are iterable
+        iterable_input = True
+        try:
+            assert len(t) == len(v),  'Cannot insert non-matching lengths or types of time and values %s and %s' % (t, v)
+        except TypeError:
+            iterable_input = False
+
+        # If inputs are iterable, call insert() for each zipped item
+        if iterable_input:
             for ti, vi in zip(t, v):
                 self.insert(ti, vi)
-        else:
-            if v is None:  # Can't cast a None to a float, just skip it
-                return
+            return
 
-            v = float(v)  # Convert input to float
+        if v is None:  # Can't cast a None to a float, so just skip it
+            return
 
-            if t is None:
-                self.assumption = v
-            elif t in self.t:
-                idx = self.t.index(t)
-                self.vals[idx] = v
-            else:
-                idx = bisect(self.t, t)
-                self.t.insert(idx, t)
-                self.vals.insert(idx, v)
+        v = float(v)  # Convert input to float
+
+        if t is None: # Store the value in the assumption
+            self.assumption = v
+            return
+
+        idx = bisect_left(self.t, t)
+        if idx < len(self.t) and self.t[idx] == t:
+            # Overwrite an existing entry
+            self.vals[idx] = v
+        else:
+            self.t.insert(idx, t)
+            self.vals.insert(idx, v)
 
     def get(self, t) -> float:
         """
diff --git a/atomica/version.py b/atomica/version.py
index 892d4b4c..d5a338b9 100644
--- a/atomica/version.py
+++ b/atomica/version.py
@@ -5,6 +5,6 @@ Standard location for module version number and date.
 """
 
 from .utils import fast_gitinfo
-version = "1.21.0"
+version = "1.21.1"
 versiondate = "2020-07-06"
 gitinfo = fast_gitinfo(__file__)
diff --git a/requirements.txt b/requirements.txt
index 869c3c37..7e0fbe0c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 # Dependencies for testing/documentation
-pytest
+pytest<6  # Pytest 6 breaks nbsmoke and nbval runs each cell as a separate test
 pytest-xdist
 pytest-env
 pytest-cov


Test patch:
diff --git a/tests/test_tox_timeseries.py b/tests/test_tox_timeseries.py
new file mode 100644
index 00000000..10ee002e
--- /dev/null
+++ b/tests/test_tox_timeseries.py
@@ -0,0 +1,97 @@
+# Test timeseries utility object
+
+import atomica as at
+import numpy as np
+
+def test_printing():
+    a = at.TimeSeries([1], [5])
+    print(a)
+
+
+def test_equality():
+    # Two time series should be equal if their values are equal
+    a = at.TimeSeries()
+    b = at.TimeSeries()
+    assert a == b
+
+    a.insert(None, 1)
+    assert a != b
+
+    b.insert(None, 1)
+    assert a == b
+
+    a = at.TimeSeries([1, 2, 3], [1, 2, 3])
+    b = at.TimeSeries([1, 2, 3], [1, 2, 3])
+    c = at.TimeSeries([1, 2, 3, 4], [1, 2, 3, 4])
+    assert a == b
+    assert a != c
+    a.insert(None, 1)
+    assert a != b
+
+
+def test_constructor():
+    a = at.TimeSeries()
+    a.insert(1,1)
+    a.insert(2,2)
+    a.insert(None,0)
+    a.insert(3,3)
+
+    b = at.TimeSeries([None,1,2,3],[0,1,2,3])
+    assert a == b
+
+    c = at.TimeSeries(np.array([None,1,2,3]),np.array([0,1,2,3]))
+    assert a == c
+
+    a.insert(np.array([2,3]),[4,5])
+    c.insert(2,4)
+    c.insert(3,5)
+    assert a == c
+
+    d = at.TimeSeries(range(3),[0,1,4])
+    assert a != d
+    d.remove(0)
+    d.insert(3,5)
+    d.insert(None,0)
+    assert a == d
+
+
+def test_insert_sorting():
+    a = at.TimeSeries([1,2],[1,2])
+
+    # Insert at end
+    a.insert(3,3)
+    assert a.t == [1,2,3]
+    assert a.vals == [1,2,3]
+
+    # Insert in middle
+    a.insert(2.5,2.5)
+    assert a.t == [1,2,2.5,3]
+    assert a.vals == [1,2,2.5,3]
+
+    # Insert at start
+    a.insert(0,0)
+    assert a.t == [0,1,2,2.5,3]
+    assert a.vals == [0,1,2,2.5,3]
+
+    # Overwrite existing at end
+    a.insert(3,4)
+    assert a.t == [0,1,2,2.5,3]
+    assert a.vals == [0,1,2,2.5,4]
+
+    # Overwrite existing at start
+    a.insert(0, 4)
+    assert a.t == [0, 1, 2, 2.5, 3]
+    assert a.vals == [4, 1, 2, 2.5, 4]
+
+    # Overwrite existing in middle
+    a.insert(1, 4)
+    assert a.t == [0, 1, 2, 2.5, 3]
+    assert a.vals == [4, 4, 2, 2.5, 4]
+
+
+if __name__ == '__main__':
+
+    test_constructor()
+    test_printing()
+    test_equality()
+    test_insert_sorting()
\ No newline at end of file
diff --git a/tests/validate_integration.py b/tests/validate_integration.py
index 4381516a..01cb2fa5 100644
--- a/tests/validate_integration.py
+++ b/tests/validate_integration.py
@@ -66,7 +66,7 @@ def test_validate_model(model):
     P1.run_sim(P1.parsets[0], result_name='parset', store_results=True)
     P1.run_sim(P1.parsets[0], P1.progsets[0], at.ProgramInstructions(start_year=2018), result_name='progset', store_results=True)
 
-    fname = tmpdir / 'validation_' + model + '.prj'
+    fname = tmpdir / ('validation_' + model + '.prj')
     if os.path.isfile(fname):
         P2 = at.Project.load(fname)
         print('Validating %s parset' % (model))