Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ jobs:
python3 -m pip install coverage

- name: Run tests
run: coverage run test/run_integration_tests.py
run: coverage run --source=spatialpy test/run_integration_tests.py
2 changes: 1 addition & 1 deletion .github/workflows/run-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
python3 -m pip install coverage

- name: Run tests
run: coverage run test/run_unit_tests.py
run: coverage run --source=spatialpy test/run_unit_tests.py
7 changes: 7 additions & 0 deletions run_coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
#pip3 install coverage
#pip3 install python-libsbml

coverage run --source=spatialpy test/run_unit_tests.py -m develop
coverage run --source=spatialpy test/run_integration_tests.py -m develop
coverage html
1 change: 1 addition & 0 deletions spatialpy/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .result import *
from .spatialpyerror import *
from .species import *
from .timespan import TimeSpan
from .visualization import Visualization
from .vtkreader import *

Expand Down
60 changes: 16 additions & 44 deletions spatialpy/core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import numpy
import scipy

from spatialpy.core.timespan import TimeSpan
from spatialpy.solvers.build_expression import BuildExpression

from spatialpy.core.spatialpyerror import ModelError
Expand Down Expand Up @@ -90,10 +91,6 @@ def __init__(self, name="spatialpy"):

######################
self.tspan = None
self.timestep_size = None
self.num_timesteps = None
self.output_freq = None
self.output_steps = None

######################
# Expression utility used by the solver
Expand Down Expand Up @@ -150,12 +147,6 @@ def __eq__(self, other):
self.listOfReactions == other.listOfReactions and \
self.name == other.name

def __check_if_complete(self):
if self.timestep_size is None or self.num_timesteps is None:
raise ModelError("The model's timespan is not set. Use 'timespan()' or 'set_timesteps()'.")
if self.domain is None:
raise ModelError("The model's domain is not set. Use 'add_domain()'.")

def __problem_with_name(self, name):
if name in Model.reserved_names:
errmsg = f'Name "{name}" is unavailable. It is reserved for internal SpatialPy use. '
Expand Down Expand Up @@ -304,13 +295,13 @@ def compile_prep(self):

:raises ModelError: Timestep size exceeds output frequency or Model is missing a domain
"""
if self.timestep_size is None:
self.timestep_size = 1e-5
if self.output_freq < self.timestep_size:
raise ModelError("Timestep size exceeds output frequency.")

self.__check_if_complete()
try:
self.tspan.validate(coverage="all")
except TimespanError as err:
raise ModelError(f"Failed to validate timespan. Reason given: {err}") from err

if self.domain is None:
raise ModelError("The model's domain is not set. Use 'add_domain()'.")
self.domain.compile_prep()

self.__update_diffusion_restrictions()
Expand Down Expand Up @@ -627,26 +618,10 @@ def set_timesteps(self, output_interval, num_steps, timestep_size=None):

:param timestep_size: Size of each timestep in seconds
:type timestep_size: float

:raises ModelError: Incompatible combination of timestep_size and output_interval
"""
if timestep_size is not None:
self.timestep_size = timestep_size
if self.timestep_size is None:
self.timestep_size = output_interval

self.output_freq = output_interval/self.timestep_size
if self.output_freq < self.timestep_size:
raise ModelError("Timestep size exceeds output frequency.")

self.num_timesteps = math.ceil(num_steps * self.output_freq)

# array of step numbers corresponding to the simulation times in the timespan
output_steps = numpy.arange(0, self.num_timesteps + self.timestep_size, self.output_freq)
self.output_steps = numpy.unique(numpy.round(output_steps).astype(int))
self.tspan = numpy.zeros((self.output_steps.size), dtype=float)
for i, step in enumerate(self.output_steps):
self.tspan[i] = step*self.timestep_size
self.tspan = TimeSpan.arange(
output_interval, t=num_steps * output_interval, timestep_size=timestep_size
)

def timespan(self, time_span, timestep_size=None):
"""
Expand All @@ -658,17 +633,14 @@ def timespan(self, time_span, timestep_size=None):

:param timestep_size: Size of each timestep in seconds
:type timestep_size: float

:raises ModelError: non-uniform timespan not supported
"""
items_diff = numpy.diff(time_span)
items = map(lambda x: round(x, 10), items_diff)
isuniform = (len(set(items)) == 1)

if isuniform:
self.set_timesteps(items_diff[0], len(items_diff), timestep_size=timestep_size)
if isinstance(time_span, TimeSpan) or type(time_span).__name__ == "TimeSpan":
self.tspan = time_span
if timestep_size is not None:
self.tspan.timestep_size = timestep_size
self.tspan.validate(coverage="all")
else:
raise ModelError("Only uniform timespans are supported")
self.tspan = TimeSpan(time_span, timestep_size=timestep_size)

def add_domain(self, domain):
"""
Expand Down
5 changes: 5 additions & 0 deletions spatialpy/core/spatialpyerror.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ class SpeciesError(ModelError):
Class for exceptions in the species module.
"""

class TimespanError(ModelError):
"""
Class for exceptions in the timespan module.
"""

# Result Exceptions


Expand Down
212 changes: 212 additions & 0 deletions spatialpy/core/timespan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# SpatialPy is a Python 3 package for simulation of
# spatial deterministic/stochastic reaction-diffusion-advection problems
# Copyright (C) 2019 - 2022 SpatialPy developers.

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU GENERAL PUBLIC LICENSE Version 3 as
# published by the Free Software Foundation.

# 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 Version 3 for more details.

# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import math
import numpy as np
from collections.abc import Iterator

from .spatialpyerror import TimespanError

class TimeSpan(Iterator):
"""
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(<start time>, <end time>, <number of time-points, inclusive>)
:type items: list, tuple, range, or numpy.ndarray

:param timestep_size: Size of each timestep in seconds
:type timestep_size: int | float

:raises TimespanError: items is an invalid type.
"""
def __init__(self, items, timestep_size=None):
if isinstance(items, (list, tuple, range)):
items = np.array(items)

self.validate(items=items, timestep_size=timestep_size)

if timestep_size is None:
timestep_size = items[1] - items[0]
self.timestep_size = timestep_size

items_diff = np.diff(items)
self._set_timesteps(items_diff[0], len(items_diff))

self.validate(coverage="initialized")

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__()

def __str__(self):
return self.items.__str__()

def _ipython_display_(self):
print(self)

def _set_timesteps(self, output_interval, num_steps):
if self.timestep_size is None:
self.timestep_size = output_interval

self.output_freq = output_interval / self.timestep_size
self.num_timesteps = math.ceil(num_steps * self.output_freq)

output_steps = np.arange(
0, self.num_timesteps + self.timestep_size, self.output_freq
)
self.output_steps = np.unique(np.round(output_steps).astype(int))
self.items = np.zeros((self.output_steps.size), dtype=float)
for i, step in enumerate(self.output_steps):
self.items[i] = step * self.timestep_size

@classmethod
def linspace(cls, t=20, num_points=None, timestep_size=None):
"""
Creates a timespan using the form np.linspace(0, <t>, <num_points, inclusive>).

:param t: End time for the simulation.
:type t: float | int

:param num_points: Number of sample points for the species populations during the simulation.
:type num_points: int

:param timestep_size: Size of each timestep in seconds
:type timestep_size: int | float

:returns: Timespan for the model.
:rtype: spatialpy.TimeSpan

:raises TimespanError: t or num_points are None, <= 0, or invalid type.
"""
if t is None or not isinstance(t, (int, float)) or t <= 0:
raise TimespanError("t must be a positive float or 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, timestep_size=timestep_size)

@classmethod
def arange(cls, increment, t=20, timestep_size=None):
"""
Creates a timespan using the form np.arange(0, <t, inclusive>, <increment>).

: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: float | int

:param timestep_size: Size of each timestep in seconds
:type timestep_size: int | float

:returns: Timespan for the model.
:rtype: spatialpy.TimeSpan

:raises TimespanError: t or increment are None, <= 0, or invalid type.
"""
if t is None or not isinstance(t, (int, float)) or t <= 0:
raise TimespanError("t must be a positive floar or 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, timestep_size=timestep_size)

def validate(self, items=None, timestep_size=None, coverage="build"):
"""
Validate the models time span

:param timestep_size: Size of each timestep in seconds
:type timestep_size: int | float

:param coverage: The scope of attributes to validate. Set to an attribute name to restrict validation \
to a specific attribute.
:type coverage: str

:raises TimespanError: Timespan is an invalid type, empty, not uniform, contains a single \
repeated value, or contains a negative initial time.
"""
if coverage in ("all", "build"):
if hasattr(self, "items") and items is None:
items = self.items

if not isinstance(items, np.ndarray):
if not isinstance(items, (list, tuple, range)):
raise TimespanError("Timespan must be of type: list, tuple, range, or numpy.ndarray.")
items = np.array(items)
if items is not None:
self.items = items

if len(items) == 0:
raise TimespanError("Timespans must contain values.")
if items[0] < 0:
raise TimespanError("Simulation must run from t=0 to end time (t must always be positive).")

first_diff = items[1] - items[0]
other_diff = items[2:] - items[1:-1]
isuniform = np.isclose(other_diff, first_diff).all()

if coverage == "build" and 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.")

if coverage in ("all", "build", "timestep_size"):
if hasattr(self, "timestep_size") and timestep_size is None:
timestep_size = self.timestep_size

if timestep_size is not None:
if not isinstance(timestep_size, (int, float)):
raise TimespanError("timestep_size must be of type int or float.")
if timestep_size <= 0:
raise TimespanError("timestep_size must be a positive value.")

if coverage in ("all", "initialized"):
if self.timestep_size is None:
raise TimespanError("timestep_size can't be None type.")
if self.output_freq is None:
raise TimespanError("output_freq can't be None type.")
if not isinstance(self.output_freq, (int, float)):
raise TimespanError("output_freq must be of type int or float.")
if self.output_freq < self.timestep_size:
raise TimespanError("timestep_size exceeds output_frequency.")
if self.num_timesteps is None:
raise TimespanError("num_timesteps can't be None type.")
if not isinstance(self.num_timesteps, int):
raise TimespanError("num_timesteps must be of type int.")
if self.num_timesteps <= 0:
raise TimespanError("num_timesteps must be a positive int.")
if self.output_steps is None:
raise TimespanError("output_steps can't be None type.")
if not isinstance(self.output_steps, (np.ndarray)):
raise TimespanError("output_steps must be of type numpy.ndarray.")
if self.items.size != self.output_steps.size:
raise TimespanError("output_steps must be the same size as items.")
6 changes: 3 additions & 3 deletions spatialpy/solvers/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ def __get_next_output(self):
output_step = "unsigned int get_next_output(ParticleSystem* system)\n{\n"
output_step += "static int index = 0;\n"
output_step += "const std::vector<unsigned int> output_steps = {"
output_step += f"{', '.join(self.model.output_steps.astype(str).tolist())}"
output_step += f"{', '.join(self.model.tspan.output_steps.astype(str).tolist())}"
output_step += "};\nunsigned int next_step = output_steps[index];\n"
output_step += "index++;\n"
output_step += "return next_step;\n}\n"
Expand Down Expand Up @@ -386,8 +386,8 @@ def __get_system_config(self, num_types, num_chem_species, num_chem_rxns,
system_config += "system->stoch_rxn_propensity_functions = ALLOC_propensities();\n"
system_config += "system->species_names = input_species_names;\n"

system_config += f"system->dt = {self.model.timestep_size};\n"
system_config += f"system->nt = {self.model.num_timesteps};\n"
system_config += f"system->dt = {self.model.tspan.timestep_size};\n"
system_config += f"system->nt = {self.model.tspan.num_timesteps};\n"
if self.h is None:
self.h = self.model.domain.find_h()
if self.h == 0.0:
Expand Down
6 changes: 3 additions & 3 deletions spatialpy/stochss/stochss_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ def export(model, path=None, return_stochss_model=False):
if path is None:
path = f"{model.name}.smdl"

end_sim = model.num_timesteps * model.timestep_size
time_step = model.output_freq * model.timestep_size
end_sim = model.tspan.num_timesteps * model.tspan.timestep_size
time_step = model.tspan.output_freq * model.tspan.timestep_size

s_model = {"is_spatial": True,
"defaultID": 1,
Expand All @@ -225,7 +225,7 @@ def export(model, path=None, return_stochss_model=False):
"modelSettings": {
"endSim": end_sim,
"timeStep": time_step,
"timestepSize": model.timestep_size
"timestepSize": model.tspan.timestep_size
},
"species": [],
"initialConditions": [],
Expand Down
Loading