Skip to content

Commit da82917

Browse files
committed
Use Udelta and ufloat for uncertain durations
1 parent a43b775 commit da82917

File tree

5 files changed

+85
-24
lines changed

5 files changed

+85
-24
lines changed

src/undate/converters/calendars/gregorian.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ class GregorianDateConverter(BaseCalendarConverter):
1313
#: calendar
1414
calendar_name: str = "Gregorian"
1515

16-
#: known non-leap year
16+
#: arbitrary known non-leap year
1717
NON_LEAP_YEAR: int = 2022
18+
#: arbitrary known leap year
19+
LEAP_YEAR: int = 2024
1820

1921
def min_month(self) -> int:
2022
"""First month for the Gregorian calendar."""
@@ -38,6 +40,7 @@ def max_day(self, year: int, month: int) -> int:
3840
_, max_day = monthrange(year, month)
3941
else:
4042
# if year and month are unknown, return maximum possible
43+
# TODO: should this return a ufloat?
4144
max_day = 31
4245

4346
return max_day

src/undate/date.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,23 @@ def days(self) -> int:
3333

3434
@dataclass
3535
class Udelta:
36+
"""An uncertain timedelta, for durations where the number of days is uncertain.
37+
Initialize with a list of possible day durations as integers, which are used
38+
to calculate a value for duration in :attr:`days` as an
39+
instance of :class:`uncertainties.ufloat`.
40+
"""
41+
42+
# NOTE: we will probably need other timedelta-like logic here besides days...
43+
44+
#: number of days, as an instance of :class:`uncertainties.ufloat`
3645
days: ufloat
37-
# def __init__(self, deltadays: ufloat):
38-
# self.days = deltadays
46+
47+
def __init__(self, *days: int):
48+
min_days = min(days)
49+
max_days = max(days)
50+
half_diff = (max_days - min_days) / 2
51+
midpoint = min_days + half_diff
52+
self.days = ufloat(midpoint, half_diff)
3953

4054

4155
#: timedelta for single day

src/undate/undate.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from typing import Dict, Optional, Union
1919

2020
from undate.converters.base import BaseDateConverter
21-
from undate.date import ONE_DAY, ONE_MONTH_MAX, Date, DatePrecision, Timedelta
21+
from undate.date import ONE_DAY, ONE_MONTH_MAX, Date, DatePrecision, Timedelta, Udelta
2222

2323

2424
class Calendar(StrEnum):
@@ -420,13 +420,14 @@ def _get_date_part(self, part: str) -> Optional[str]:
420420
value = self.initial_values.get(part)
421421
return str(value) if value else None
422422

423-
def duration(self) -> Timedelta:
423+
def duration(self) -> Timedelta | Udelta:
424424
"""What is the duration of this date?
425425
Calculate based on earliest and latest date within range,
426426
taking into account the precision of the date even if not all
427427
parts of the date are known. Note that durations are inclusive
428428
(i.e., a closed interval) and include both the earliest and latest
429-
date rather than the difference between them."""
429+
date rather than the difference between them. Returns a :class:`undate.date.Timedelta` when
430+
possible, and an :class:`undate.date.Udelta` when the duration is uncertain."""
430431

431432
# if precision is a single day, duration is one day
432433
# no matter when it is or what else is known
@@ -437,24 +438,48 @@ def duration(self) -> Timedelta:
437438
# calculate month duration within a single year (not min/max)
438439
if self.precision == DatePrecision.MONTH:
439440
latest = self.latest
441+
# if year is unknown, calculate month duration in
442+
# leap year and non-leap year, in case length varies
440443
if not self.known_year:
441-
# if year is unknown, calculate month duration in
442-
# a single year
443-
latest = Date(self.earliest.year, self.latest.month, self.latest.day)
444+
# TODO: should leap-year specific logic shift to the calendars,
445+
# since it works differently depending on the calendar?
446+
possible_years = [
447+
self.calendar_converter.LEAP_YEAR,
448+
self.calendar_converter.NON_LEAP_YEAR,
449+
]
450+
# TODO: what about partially known years like 191X ?
451+
else:
452+
# otherwise, get possible durations for all possible months
453+
# for a known year
454+
possible_years = [self.earliest.year]
455+
456+
# for every possible month and year, get max days for that month,
457+
possible_max_days = set()
458+
# appease mypy, which says month values could be None here
459+
if self.earliest.month is not None and self.latest.month is not None:
460+
for possible_month in range(self.earliest.month, self.latest.month + 1):
461+
for year in possible_years:
462+
possible_max_days.add(
463+
self.calendar_converter.max_day(year, possible_month)
464+
)
465+
466+
# if there is more than one possible value for month length,
467+
# whether due to leap year / non-leap year or ambiguous month,
468+
# return a uncertain delta
469+
if len(possible_max_days) > 1:
470+
return Udelta(*possible_max_days)
471+
472+
# otherwise, calculate timedelta normally
473+
max_day = list(possible_max_days)[0]
474+
latest = Date(self.earliest.year, self.earliest.month, max_day)
444475

445-
# TODO: calculate duration for a leap year and a non-leap year,
446-
# then return a udelta if they vary
447-
# TODO: how does this logic work for other calendars?
448-
449-
# latest = datetime.date(
450-
# self.earliest.year, self.latest.month, self.latest.day
451-
# )
452476
delta = latest - self.earliest + ONE_DAY
453477
# month duration can't ever be more than 31 days
454478
# (could we ever know if it's smaller?)
455479

456480
# if granularity == month but not known month, duration = 31
457481
if delta.astype(int) > 31:
482+
# FIXME: this depends on calendar!
458483
return ONE_MONTH_MAX
459484
return delta
460485

tests/test_date.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ def test_days(self):
8383

8484
class TestUdelta:
8585
def test_init(self):
86+
# february in an unknown year in Gregorian calendar could be 28 or 29 days
8687
february_days = ufloat(28.5, 0.5) # 28 or 29
87-
udelt = Udelta(february_days)
88-
assert udelt.days == february_days
88+
udelt = Udelta(28, 29)
89+
# two ufloat values don't actually compare as equal, due to the variance
90+
assert udelt != february_days
91+
# so inspect the expected values
92+
assert udelt.days.nominal_value == 28.5
93+
assert udelt.days.std_dev == 0.5

tests/test_undate.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from datetime import date
22

33
import pytest
4-
from uncertainties import ufloat
54

65
from undate import Undate, UndateInterval, Calendar
76
from undate.converters.base import BaseCalendarConverter
8-
from undate.date import DatePrecision, Timedelta
7+
from undate.date import DatePrecision, Timedelta, Udelta
98

109

1110
class TestUndate:
@@ -385,10 +384,25 @@ def test_partiallyknown_duration(self):
385384
assert Undate(month=6).duration().days == 30
386385
# partially known month
387386
# 1X = October, November, or December = 30 or 31 days
388-
assert Undate(year=1900, month="1X").duration().days == ufloat(30.5, 0.5)
389-
# what about february?
390-
# could vary with leap years; either 28 or 29 days
391-
assert Undate(month=2).duration().days == ufloat(28.5, 0.5)
387+
# should return a Udelta object
388+
unknown_month_duration = Undate(year=1900, month="1X").duration()
389+
assert isinstance(unknown_month_duration, Udelta)
390+
assert unknown_month_duration.days.nominal_value == 30.5
391+
assert unknown_month_duration.days.std_dev == 0.5
392+
393+
# completely unknown month should also return a Udelta object
394+
unknown_month_duration = Undate(year=1900, month="XX").duration()
395+
assert isinstance(unknown_month_duration, Udelta)
396+
# possible range is 28 to 31 days
397+
assert unknown_month_duration.days.nominal_value == 29.5
398+
assert unknown_month_duration.days.std_dev == 1.5
399+
400+
# the number of days in feburary of an unknow year is uncertain, since
401+
# it could vary with leap years; either 28 or 29 days
402+
feb_duration = Undate(month=2).duration()
403+
assert isinstance(feb_duration, Udelta)
404+
assert feb_duration.days.nominal_value == 28.5
405+
assert feb_duration.days.std_dev == 0.5
392406

393407
def test_known_year(self):
394408
assert Undate(2022).known_year is True

0 commit comments

Comments
 (0)