Skip to content

Commit

Permalink
Fix TimeFrame bugs, add alt constructors and tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
Tyler Coles committed Sep 14, 2024
1 parent 3174dbf commit 8673f39
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 18 deletions.
63 changes: 46 additions & 17 deletions epymorph/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,25 @@ def default_rng(
########


def _iso8601(value: date | str) -> date:
"""Adapt ISO8601 strings to dates."""
if isinstance(value, str):
return date.fromisoformat(value)
return value


@dataclass(frozen=True)
class TimeFrame:
"""The time frame of a simulation."""

@classmethod
def of(cls, start_date_iso8601: str, duration_days: int) -> Self:
def of(cls, start_date: date | str, duration_days: int) -> Self:
"""
Alternate constructor for TimeFrame, parsing start date from an ISO-8601 string.
Alternate constructor for TimeFrame.
If a date is passed as a string, it will be parsed using ISO-8601 format.
"""
return cls(date.fromisoformat(start_date_iso8601), duration_days)

@classmethod
def year(cls, year: int) -> Self:
"""Alternate constructor for TimeFrame, comprising one full calendar year."""
start = date(year, 1, 1)
end = date(year + 1, 1, 1)
duration = (end - start).days
return cls(start, duration)
start_date = _iso8601(start_date)
return cls(start_date, duration_days)

@classmethod
def range(cls, start_date: date | str, end_date: date | str) -> Self:
Expand All @@ -80,27 +81,55 @@ def range(cls, start_date: date | str, end_date: date | str) -> Self:
(endpoint inclusive) date range.
If a date is passed as a string, it will be parsed using ISO-8601 format.
"""
if isinstance(start_date, str):
start_date = date.fromisoformat(start_date)
if isinstance(end_date, str):
end_date = date.fromisoformat(end_date)
duration = (end_date - start_date).days
start_date = _iso8601(start_date)
end_date = _iso8601(end_date)
duration = (end_date - start_date).days + 1
return cls(start_date, duration)

@classmethod
def rangex(cls, start_date: date | str, end_date_exclusive: date | str) -> Self:
"""
Alternate constructor for TimeFrame, comprising the
(endpoint exclusive) date range.
If a date is passed as a string, it will be parsed using ISO-8601 format.
"""
start_date = _iso8601(start_date)
end_date_exclusive = _iso8601(end_date_exclusive)
duration = (end_date_exclusive - start_date).days
return cls(start_date, duration)

@classmethod
def year(cls, year: int) -> Self:
"""Alternate constructor for TimeFrame, comprising one full calendar year."""
return cls.rangex(date(year, 1, 1), date(year + 1, 1, 1))

start_date: date
"""The first date in the simulation."""
duration_days: int
"""The number of days for which to run the simulation."""

def __post_init__(self):
if self.duration_days < 1:
err = (
"TimeFrame's end date cannot be before its start date. "
"(Its duration in days must be at least 1.)"
)
raise ValueError(err)

@cached_property
def end_date(self) -> date:
"""The last date included in the simulation."""
return self.start_date + timedelta(days=self.duration_days)
return self.start_date + timedelta(days=self.duration_days - 1)

def is_subset(self, other: "TimeFrame") -> bool:
"""Is the given TimeFrame a subset of this one?"""
return self.start_date <= other.start_date and self.end_date >= other.end_date

def __str__(self) -> str:
if self.duration_days == 1:
return f"{self.start_date} (1 day)"
return f"{self.start_date}/{self.end_date} ({self.duration_days}D)"


class Tick(NamedTuple):
"""
Expand Down
68 changes: 67 additions & 1 deletion epymorph/test/simulation_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# pylint: disable=missing-docstring,unused-variable
import unittest
from datetime import date
from functools import cached_property
Expand All @@ -14,10 +13,77 @@
SimDimensions,
SimulationFunction,
Tick,
TimeFrame,
simulation_clock,
)


class TestTimeFrame(unittest.TestCase):
def test_init_1(self):
tf = TimeFrame(date(2020, 1, 1), 30)
self.assertEqual(tf.start_date, date(2020, 1, 1))
self.assertEqual(tf.duration_days, 30)
self.assertEqual(tf.end_date, date(2020, 1, 30))

def test_init_2(self):
tf = TimeFrame.of("2020-01-01", 30)
self.assertEqual(tf.start_date, date(2020, 1, 1))
self.assertEqual(tf.duration_days, 30)
self.assertEqual(tf.end_date, date(2020, 1, 30))

def test_init_3(self):
tf = TimeFrame.range("2020-01-01", "2020-01-30")
self.assertEqual(tf.start_date, date(2020, 1, 1))
self.assertEqual(tf.duration_days, 30)
self.assertEqual(tf.end_date, date(2020, 1, 30))

def test_init_4(self):
tf = TimeFrame.rangex("2020-01-01", "2020-01-30")
self.assertEqual(tf.start_date, date(2020, 1, 1))
self.assertEqual(tf.duration_days, 29)
self.assertEqual(tf.end_date, date(2020, 1, 29))

def test_init_5(self):
tf = TimeFrame.year(2020)
self.assertEqual(tf.start_date, date(2020, 1, 1))
self.assertEqual(tf.duration_days, 366)
self.assertEqual(tf.end_date, date(2020, 12, 31))

def test_init_6(self):
# ERROR: negative duration
with self.assertRaises(ValueError):
TimeFrame(date(2020, 1, 1), -7)

def test_init_7(self):
# ERROR: negative duration
with self.assertRaises(ValueError):
TimeFrame.range(date(2020, 1, 1), date(1999, 1, 1))

def test_subset_1(self):
a = TimeFrame.rangex("2020-01-01", "2020-02-01")
b = TimeFrame.rangex("2020-01-01", "2020-02-01")
c = TimeFrame.rangex("2020-01-01", "2020-01-21")
d = TimeFrame.rangex("2020-01-14", "2020-02-01")
e = TimeFrame.rangex("2020-01-14", "2020-01-21")
self.assertTrue(a.is_subset(b))
self.assertTrue(a.is_subset(c))
self.assertTrue(a.is_subset(d))
self.assertTrue(a.is_subset(e))

def test_subset_2(self):
a = TimeFrame.rangex("2020-01-01", "2020-02-01")
b = TimeFrame.rangex("2019-12-31", "2020-02-01")
c = TimeFrame.rangex("2020-01-01", "2020-09-21")
d = TimeFrame.rangex("2019-12-31", "2020-09-21")
e = TimeFrame.rangex("2019-01-01", "2019-02-01")
f = TimeFrame.rangex("2021-01-01", "2021-02-01")
self.assertFalse(a.is_subset(b))
self.assertFalse(a.is_subset(c))
self.assertFalse(a.is_subset(d))
self.assertFalse(a.is_subset(e))
self.assertFalse(a.is_subset(f))


class TestClock(unittest.TestCase):
def test_clock(self):
dim = SimDimensions.build(
Expand Down

0 comments on commit 8673f39

Please sign in to comment.