diff --git a/gillespy2/core/__init__.py b/gillespy2/core/__init__.py index 2959a97ad..3ba8556d2 100644 --- a/gillespy2/core/__init__.py +++ b/gillespy2/core/__init__.py @@ -30,6 +30,7 @@ from .results import * from .sortableobject import * from .species import * +from .timespan import TimeSpan from gillespy2.__version__ import __version__ _formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') diff --git a/gillespy2/core/gillespyError.py b/gillespy2/core/gillespyError.py index 977970827..4da00039b 100644 --- a/gillespy2/core/gillespyError.py +++ b/gillespy2/core/gillespyError.py @@ -33,6 +33,10 @@ class ParameterError(ModelError): pass +class TimespanError(ModelError): + pass + + # Solver specific errors class SolverError(Exception): pass diff --git a/gillespy2/core/gillespySolver.py b/gillespy2/core/gillespySolver.py index cd1882db3..b4c8c50bf 100644 --- a/gillespy2/core/gillespySolver.py +++ b/gillespy2/core/gillespySolver.py @@ -18,6 +18,7 @@ import copy import numpy +from .timespan import TimeSpan from .gillespyError import SimulationError, ModelError from typing import Set, Type @@ -97,18 +98,16 @@ def validate_tspan(self, increment, t): ) if self.model.tspan is None: - end = 20 + increment if t is None else t + increment - self.model.timespan(numpy.arange(0, end, increment)) + if t is None: + tspan = TimeSpan.arange(increment) + else: + tspan = TimeSpan.arange(increment, t=t) + self.model.timespan(tspan) + elif not isinstance(self.model.tspan, TimeSpan) or type(self.model.tspan).__name__ != "TimeSpan": + tspan = TimeSpan(self.model.tspan) + self.model.timespan(tspan) else: - if self.model.tspan[0] < 0: - raise SimulationError("Simulation must run from t=0 to end time (t must always be positive).") - - first_diff = self.model.tspan[1] - self.model.tspan[0] - other_diff = self.model.tspan[2:] - self.model.tspan[1:-1] - isuniform = numpy.isclose(other_diff, first_diff).all() - - if not isuniform: - raise SimulationError("StochKit only supports uniform timespans") + self.model.tspan.validate() @classmethod def get_supported_features(cls) -> "Set[Type]": diff --git a/gillespy2/core/model.py b/gillespy2/core/model.py index 015a7fa61..ea3613620 100644 --- a/gillespy2/core/model.py +++ b/gillespy2/core/model.py @@ -15,19 +15,29 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import numpy as np +from typing import Set, Type +from collections import OrderedDict + import gillespy2 -from gillespy2.core.jsonify import TranslationTable -from gillespy2.core.reaction import * -from gillespy2.core.raterule import RateRule +from gillespy2.core.assignmentrule import AssignmentRule +from gillespy2.core.events import Event +from gillespy2.core.functiondefinition import FunctionDefinition from gillespy2.core.parameter import Parameter -from gillespy2.core.species import Species +from gillespy2.core.raterule import RateRule from gillespy2.core.reaction import Reaction -import numpy as np -from gillespy2.core.results import Trajectory,Results -from collections import OrderedDict -from gillespy2.core.gillespyError import * -from .gillespyError import SimulationError -from typing import Set, Type +from gillespy2.core.species import Species +from gillespy2.core.timespan import TimeSpan +from gillespy2.core.sortableobject import SortableObject +from gillespy2.core.jsonify import Jsonify, TranslationTable +from gillespy2.core.results import Trajectory, Results +from gillespy2.core.gillespyError import ( + ParameterError, + ModelError, + SimulationError, + StochMLImportError, + InvalidStochMLError +) try: import lxml.etree as eTree @@ -229,6 +239,55 @@ def decorate(header): return print_string + def add(self, components): + """ + Adds a component, or list of components to the model. If a list is provided, Species + and Parameters are added before other components. Lists may contain any combination + of accepted types other than lists and do not need to be in any particular order. + + :param components: The component or list of components to be added the the model. + :type components: Species, Parameters, Reactions, Events, Rate Rules, Assignment Rules, \ + FunctionDefinitions, and TimeSpan or list + + :returns: The components that were added to the model. + :rtype: Species, Parameters, Reactions, Events, Rate Rules, Assignment Rules, \ + FunctionDefinitions, and TimeSpan or list + + :raises ModelError: Component is invalid. + """ + if isinstance(components, list): + p_types = (Species, Parameter, FunctionDefinition, TimeSpan) + p_names = (p_type.__name__ for p_type in p_types) + + others = [] + for component in components: + if isinstance(component, p_types) or type(component).__name__ in p_names: + self.add(component) + else: + others.append(component) + + for component in others: + self.add(component) + elif isinstance(components, AssignmentRule) or type(components).__name__ == AssignmentRule.__name__: + self.add_assignment_rule(components) + elif isinstance(components, Event) or type(components).__name__ == Event.__name__: + self.add_event(components) + elif isinstance(components, FunctionDefinition) or type(components).__name__ == FunctionDefinition.__name__: + self.add_function_definition(components) + elif isinstance(components, Parameter) or type(components).__name__ == Parameter.__name__: + self.add_parameter(components) + elif isinstance(components, RateRule) or type(components).__name__ == RateRule.__name__: + self.add_rate_rule(components) + elif isinstance(components, Reaction) or type(components).__name__ == Reaction.__name__: + self.add_reaction(components) + elif isinstance(components, Species) or type(components).__name__ == Species.__name__: + self.add_species(components) + elif isinstance(components, TimeSpan) or type(components).__name__ == TimeSpan.__name__: + self.timespan(components) + else: + raise ModelError(f"Unsupported component: {type(components)} is not a valid component.") + return components + def make_translation_table(self): from collections import ChainMap @@ -653,10 +712,13 @@ def timespan(self, time_span): timespans. :param time_span: Evenly-spaced list of times at which to sample the species populations during the simulation. - Best to use the form np.linspace(, , ) - :type time_span: numpy ndarray + Best to use the form gillespy2.TimeSpan(np.linspace(, , )) + :type time_span: gillespy2.TimeSpan | iterator """ - self.tspan = time_span + if isinstance(time_span, TimeSpan) or type(time_span).__name__ == "TimeSpan": + self.tspan = time_span + else: + self.tspan = TimeSpan(time_span) def get_reaction(self, rname): """ diff --git a/gillespy2/core/timespan.py b/gillespy2/core/timespan.py new file mode 100644 index 000000000..b1be6aaa5 --- /dev/null +++ b/gillespy2/core/timespan.py @@ -0,0 +1,134 @@ +""" +GillesPy2 is a modeling toolkit for biochemical simulation. +Copyright (C) 2019-2021 GillesPy2 developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +import numpy as np +from collections.abc import Iterator + +from gillespy2.core.jsonify import Jsonify +from .gillespyError import TimespanError + +class TimeSpan(Iterator, Jsonify): + """ + Model timespan that describes the duration to run the simulation and at which timepoint to sample + the species populations during the simulation. + + :param items: Evenly-spaced list of times at which to sample the species populations during the simulation. + Best to use the form np.linspace(, , ) + :type items: list, tuple, range, or numpy.ndarray + + :raises TimespanError: items is an invalid type. + """ + def __init__(self, items): + if isinstance(items, np.ndarray): + self.items = items + elif isinstance(items, (list, tuple, range)): + self.items = np.array(items) + else: + raise TimespanError("Timespan must be of type: list, tuple, range, or numpy.ndarray.") + + self.validate() + + def __eq__(self, o): + return self.items.__eq__(o).all() + + def __getitem__(self, key): + return self.items.__getitem__(key) + + def __iter__(self): + return self.items.__iter__() + + def __len__(self): + return self.items.__len__() + + def __next__(self): + return self.items.__next__() + + @classmethod + def linspace(cls, t=20, num_points=None): + """ + Creates a timespan using the form np.linspace(0, , ). + + :param t: End time for the simulation. + :type t: int + + :param num_points: Number of sample points for the species populations during the simulation. + :type num_points: int + + :returns: Timespan for the model. + :rtype: gillespy2.TimeSpan + + :raises TimespanError: t or num_points are None, <= 0, or invalid type. + """ + if t is None or not isinstance(t, int) or t <= 0: + raise TimespanError("t must be a positive int.") + if num_points is not None and (not isinstance(num_points, int) or num_points <= 0): + raise TimespanError("num_points must be a positive int.") + + if num_points is None: + num_points = int(t / 0.05) + 1 + items = np.linspace(0, t, num_points) + return cls(items) + + @classmethod + def arange(cls, increment, t=20): + """ + Creates a timespan using the form np.arange(0, , ). + + :param increment: Distance between sample points for the species populations during the simulation. + :type increment: float | int + + :param t: End time for the simulation. + :type t: int + + :returns: Timespan for the model. + :rtype: gillespy2.TimeSpan + + :raises TimespanError: t or increment are None, <= 0, or invalid type. + """ + if t is None or not isinstance(t, int) or t <= 0: + raise TimespanError("t must be a positive int.") + if not isinstance(increment, (float, int)) or increment <= 0: + raise TimespanError("increment must be a positive float or int.") + + items = np.arange(0, t + increment, increment) + return cls(items) + + def validate(self): + """ + Validate the models time span + + :raises TimespanError: Timespan is an invalid type, empty, not uniform, contains a single \ + repeated value, or contains a negative initial time. + """ + if not isinstance(self.items, np.ndarray): + if not isinstance(self.items, (list, tuple, range)): + raise TimespanError("Timespan must be of type: list, tuple, range, or numpy.ndarray.") + self.items = np.array(self.items) + + if len(self.items) == 0: + raise TimespanError("Timespans must contain values.") + if self.items[0] < 0: + raise TimespanError("Simulation must run from t=0 to end time (t must always be positive).") + + first_diff = self.items[1] - self.items[0] + other_diff = self.items[2:] - self.items[1:-1] + isuniform = np.isclose(other_diff, first_diff).all() + + if not isuniform: + raise TimespanError("StochKit only supports uniform timespans.") + if first_diff == 0 or np.count_nonzero(other_diff) != len(other_diff): + raise TimespanError("Timespan can't contain a single repeating value.") diff --git a/test/run_tests.py b/test/run_tests.py index 2b0771be7..d5691290c 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -58,8 +58,10 @@ import test_jsonify import test_notebooks from unit_tests import test_model as unittest_model + from unit_tests import test_timespan as unittest_timespan modules = [ + unittest_timespan, unittest_model, test_empty_model, test_build_engine, diff --git a/test/test_model.py b/test/test_model.py index aca54849d..83d35a7dd 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -69,7 +69,7 @@ def test_model_reordered_equality(self): def test_uniform_timespan(self): model = Model() model.timespan(np.linspace(0, 1, 100)) - with self.assertRaises(SimulationError): + with self.assertRaises(TimespanError): model.timespan(np.array([0, 0.1, 0.5])) model.run() diff --git a/test/unit_tests/test_model.py b/test/unit_tests/test_model.py index 6eaba58cc..6c18f1e18 100644 --- a/test/unit_tests/test_model.py +++ b/test/unit_tests/test_model.py @@ -27,6 +27,112 @@ class TestModel(unittest.TestCase): def setUp(self): self.model = RobustModel() + def test_model_add__assignment_rule(self): + from gillespy2 import AssignmentRule + ar1 = AssignmentRule(name="ar1", variable="k1", formula="29") + self.model.add(ar1) + self.assertIn("ar1", self.model.listOfAssignmentRules) + + def test_model_add__event(self): + from gillespy2 import Event, EventTrigger, EventAssignment + ea = EventAssignment(name="ea", variable="k1", expression="29") + et = EventTrigger(expression="t > 29") + e2 = Event(name="e2", trigger=et, assignments=[ea]) + self.model.add(e2) + self.assertIn("e2", self.model.listOfEvents) + + def test_model_add__function_definition(self): + from gillespy2 import FunctionDefinition + divide = FunctionDefinition(name="divide", function="x / y", args=["x", "y"]) + self.model.add(divide) + self.assertIn("divide", self.model.listOfFunctionDefinitions) + + def test_model_add__invalid_component(self): + with self.assertRaises(ModelError): + self.model.add("29") + + def test_model_add__parameter(self): + from gillespy2 import Parameter + k3 = Parameter(name="k3", expression=29) + self.model.add(k3) + self.assertIn("k3", self.model.listOfParameters) + + def test_model_add__rate_rule(self): + from gillespy2 import RateRule + rr3 = RateRule(name="rr3", variable="k1", formula="29") + self.model.add(rr3) + self.assertIn("rr3", self.model.listOfRateRules) + + def test_model_add__reaction(self): + from gillespy2 import Reaction + r4 = Reaction(name="r4", reactants={"s1": 1}, products={"s2": 1}, rate="k1") + self.model.add(r4) + self.assertIn("r4", self.model.listOfReactions) + + def test_model_add__species(self): + from gillespy2 import Species + s3 = Species(name="s3", initial_value=29) + self.model.add(s3) + self.assertIn("s3", self.model.listOfSpecies) + + def test_model_add__timespan(self): + from gillespy2 import TimeSpan + tspan = TimeSpan(range(100)) + self.model.add(tspan) + self.assertEqual(tspan, self.model.tspan) + + def test_model_add__multiple_components__in_order(self): + import gillespy2 + + s1 = gillespy2.Species(name="s1", initial_value=29) + k1 = gillespy2.Parameter(name="k1", expression=29) + r1 = gillespy2.Reaction(name="r1", reactants={"s1": 1}, rate="k1") + rr1 = gillespy2.RateRule(name="rr1", variable="k1", formula="29") + ar1 = gillespy2.AssignmentRule(name="ar1", variable="s1", formula="29") + ea = gillespy2.EventAssignment(name="ea", variable="k1", expression="29") + et = gillespy2.EventTrigger(expression="t > 29") + e1 = gillespy2.Event(name="e1", trigger=et, assignments=[ea]) + divide = gillespy2.FunctionDefinition(name="divide", function="x / y", args=["x", "y"]) + tspan = gillespy2.TimeSpan(range(100)) + + model = gillespy2.Model(name="Test Model") + model.add([s1, k1, r1, rr1, ar1, e1, divide, tspan]) + + self.assertIn("ar1", model.listOfAssignmentRules) + self.assertIn("e1", model.listOfEvents) + self.assertIn("divide", model.listOfFunctionDefinitions) + self.assertIn("k1", model.listOfParameters) + self.assertIn("rr1", model.listOfRateRules) + self.assertIn("r1", model.listOfReactions) + self.assertIn("s1", model.listOfSpecies) + self.assertEqual(tspan, model.tspan) + + def test_model_add__multiple_components__in_order(self): + import gillespy2 + + s1 = gillespy2.Species(name="s1", initial_value=29) + k1 = gillespy2.Parameter(name="k1", expression=29) + r1 = gillespy2.Reaction(name="r1", reactants={"s1": 1}, rate="k1") + rr1 = gillespy2.RateRule(name="rr1", variable="k1", formula="29") + ar1 = gillespy2.AssignmentRule(name="ar1", variable="s1", formula="29") + ea = gillespy2.EventAssignment(name="ea", variable="k1", expression="29") + et = gillespy2.EventTrigger(expression="t > 29") + e1 = gillespy2.Event(name="e1", trigger=et, assignments=[ea]) + divide = gillespy2.FunctionDefinition(name="divide", function="x / y", args=["x", "y"]) + tspan = gillespy2.TimeSpan(range(100)) + + model = gillespy2.Model(name="Test Model") + model.add([ar1, divide, e1, k1, s1, r1, rr1, tspan]) + + self.assertIn("ar1", model.listOfAssignmentRules) + self.assertIn("e1", model.listOfEvents) + self.assertIn("divide", model.listOfFunctionDefinitions) + self.assertIn("k1", model.listOfParameters) + self.assertIn("rr1", model.listOfRateRules) + self.assertIn("r1", model.listOfReactions) + self.assertIn("s1", model.listOfSpecies) + self.assertEqual(tspan, model.tspan) + def test_delete_assignment_rule(self): self.model.delete_assignment_rule('rr2') self.assertNotIn('rr2', self.model.listOfAssignmentRules) diff --git a/test/unit_tests/test_timespan.py b/test/unit_tests/test_timespan.py new file mode 100644 index 000000000..2cb61e4e0 --- /dev/null +++ b/test/unit_tests/test_timespan.py @@ -0,0 +1,214 @@ +""" +GillesPy2 is a modeling toolkit for biochemical simulation. +Copyright (C) 2019-2021 GillesPy2 developers. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +import numpy +import unittest + +from gillespy2.core.timespan import TimeSpan +from gillespy2.core.gillespyError import TimespanError + +class TestTimeSpan(unittest.TestCase): + ''' + ################################################################################################ + Unit tests for gillespy2.TimeSpan. + ################################################################################################ + ''' + def test_constructor(self): + """ Test the TimeSpan constructor. """ + test_tspan = numpy.linspace(0, 20, 401) + tspan = TimeSpan(test_tspan) + self.assertEqual(tspan, test_tspan) + + def test_constructor__list(self): + """ Test the TimeSpan constructor with list data structure. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_constructor__tuple(self): + """ Test the TimeSpan constructor with tuple data structure. """ + test_tspan = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + tspan = TimeSpan(test_tspan) + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_constructor__range(self): + """ Test the TimeSpan constructor with range data structure. """ + test_tspan = range(11) + tspan = TimeSpan(test_tspan) + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_constructor__invalid_type(self): + """ Test the TimeSpan constructor with an invalid data structure type. """ + test_tspan = set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + with self.assertRaises(TimespanError): + TimeSpan(test_tspan) + + def test_linspace(self): + """ Test TimeSpan.linspace. """ + tspan = TimeSpan.linspace(t=30, num_points=301) + self.assertEqual(tspan, numpy.linspace(0, 30, 301)) + + def test_linspace__no_t(self): + """ Test TimeSpan.linspace without passing t. """ + tspan = TimeSpan.linspace(num_points=201) + self.assertEqual(tspan, numpy.linspace(0, 20, 201)) + + def test_linspace__no_num_points(self): + """ Test TimeSpan.linspace without passing num_points. """ + tspan = TimeSpan.linspace(t=30) + self.assertEqual(tspan, numpy.linspace(0, 30, int(30 / 0.05) + 1)) + + def test_linspace__no_args(self): + """ Test TimeSpan.linspace without passing any args. """ + tspan = TimeSpan.linspace() + self.assertEqual(tspan, numpy.linspace(0, 20, 401)) + + def test_linspace__t_less_than_1(self): + """ Test TimeSpan.linspace with t<1. """ + test_values = [0, -1, -2, -5, -10] + for test_val in test_values: + with self.subTest(t=test_val): + with self.assertRaises(TimespanError): + tspan = TimeSpan.linspace(t=test_val, num_points=301) + + def test_linspace__num_points_less_than_1(self): + """ Test TimeSpan.linspace with num_points<1. """ + test_values = [0, -1, -2, -5, -10] + for test_val in test_values: + with self.subTest(num_points=test_val): + with self.assertRaises(TimespanError): + tspan = TimeSpan.linspace(t=30, num_points=test_val) + + def test_linspace__t_is_none(self): + """ Test TimeSpan.linspace with t=None. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.linspace(t=None, num_points=401) + + def test_linspace__invalid_t_type(self): + """ Test TimeSpan.linspace with invalid t type. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.linspace(t=20.5, num_points=401) + + def test_linspace__invalid_num_points_type(self): + """ Test TimeSpan.linspace with invalid num_points type. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.linspace(t=20, num_points=40.1) + + def test_arange(self): + """ Test TimeSpan.arange. """ + tspan = TimeSpan.arange(0.1, t=30) + self.assertEqual(tspan, numpy.arange(0, 30.1, 0.1)) + + def test_arange__no_t(self): + """ Test TimeSpan.arange. """ + tspan = TimeSpan.arange(0.1) + self.assertEqual(tspan, numpy.arange(0, 20.1, 0.1)) + + def test_arange__t_less_than_1(self): + """ Test TimeSpan.arange with t<1. """ + test_values = [0, -1, -2, -5, -10] + for test_val in test_values: + with self.subTest(t=test_val): + with self.assertRaises(TimespanError): + tspan = TimeSpan.arange(0.1, t=test_val) + + def test_arange__num_points_less_than_1(self): + """ Test TimeSpan.arange with increment<1. """ + test_values = [0, -1, -2, -5, -10] + for test_val in test_values: + with self.subTest(imcrement=test_val): + with self.assertRaises(TimespanError): + tspan = TimeSpan.arange(test_val, t=30) + + def test_arange__t_is_none(self): + """ Test TimeSpan.arange with t=None. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.arange(0.1, t=None) + + def test_arange__invalid_t_type(self): + """ Test TimeSpan.arange with invalid t type. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.arange(0.05, t=20.5) + + def test_arange__invalid_increment(self): + """ Test TimeSpan.arange with invalid increment type. """ + with self.assertRaises(TimespanError): + tspan = TimeSpan.arange("0.05", t=20) + + def test_validate__list(self): + """ Test TimeSpan.validate with list data structure. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + tspan.items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan.validate() + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_validate__tuple(self): + """ Test TimeSpan.validate with tuple data structure. """ + test_tspan = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + tspan = TimeSpan(test_tspan) + tspan.items = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + tspan.validate() + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_validate__range(self): + """ Test TimeSpan.validate with range data structure. """ + test_tspan = range(11) + tspan = TimeSpan(test_tspan) + tspan.items = range(11) + tspan.validate() + self.assertEqual(tspan, numpy.array(test_tspan)) + + def test_validate__invalid_type(self): + """ Test TimeSpan.validate with an invalid data structure type. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + with self.assertRaises(TimespanError): + tspan.items = set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + tspan.validate() + + def test_validate__empty_timespan(self): + """ Test TimeSpan.validate with an empty data structure. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + with self.assertRaises(TimespanError): + tspan.items = [] + tspan.validate() + + def test_validate__all_same_values(self): + """ Test TimeSpan.validate with an empty data structure. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + with self.assertRaises(TimespanError): + tspan.items = [2, 2, 2, 2, 2, 2, 2, 2, 2] + tspan.validate() + + def test_validate__negative_start(self): + """ Test TimeSpan.validate with an initial time < 0. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + with self.assertRaises(TimespanError): + tspan.items = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan.validate() + + def test_validate__non_uniform_timespan(self): + """ Test TimeSpan.validate with a non-uniform timespan. """ + test_tspan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tspan = TimeSpan(test_tspan) + with self.assertRaises(TimespanError): + tspan.items = [2, 1, 3, 4, 5, 6, 7, 8, 9, 10] + tspan.validate()