Coverage for datesat / core.py: 59.2%

151 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-10 23:47 +0000

1""" 

2Core Date and Period classes for DateSAT. 

3 

4These classes represent the basic data structures used by all approaches. 

5The difference between approaches is in how these are converted to Z3 constraints, 

6not in the data representation itself. 

7""" 

8 

9import warnings 

10from datetime import date, timedelta 

11from dateutil.relativedelta import relativedelta 

12from z3 import * 

13 

14 

15class Date: 

16 """Date class with year/month/day representation.""" 

17 

18 def __init__(self, year: int, month: int, day: int, bounded: bool = True): 

19 """Initialize a Date with year, month, day components. 

20  

21 Args: 

22 year: Year component 

23 month: Month component 

24 day: Day component 

25 bounded: If True (default), validates that the date is within the allowed 

26 range [1900-03-01 to 2100-02-28]. If False, only validates 

27 calendar correctness but not range. 

28 """ 

29 self._year = year 

30 self._month = month 

31 self._day = day 

32 self._bounded = bounded 

33 self._validate() 

34 

35 @property 

36 def year(self) -> int: 

37 """Get the year component.""" 

38 return self._year 

39 

40 @property 

41 def month(self) -> int: 

42 """Get the month component.""" 

43 return self._month 

44 

45 @property 

46 def day(self) -> int: 

47 """Get the day component.""" 

48 return self._day 

49 

50 def _validate(self) -> None: 

51 """Validate that the date components are valid.""" 

52 # Validate input format first: all components must be integers (no bools allowed) 

53 date_components = (self._year, self._month, self._day) 

54 if not all(isinstance(v, int) and not isinstance(v, bool) for v in date_components): 

55 raise ValueError( 

56 "Invalid date format: year, month, and day must be integers" 

57 ) 

58 

59 # First, validate calendar correctness 

60 try: 

61 date(self._year, self._month, self._day) 

62 except ValueError as e: 

63 raise ValueError( 

64 f"Invalid date: {self._year}-{self._month:02d}-{self._day:02d}" 

65 ) from e 

66 

67 # Next, enforce allowed window [1900-03-01 - 2100-02-28] only if bounded 

68 if self._bounded: 68 ↛ exitline 68 didn't return from function '_validate' because the condition on line 68 was always true

69 min_allowed = (1900, 3, 1) 

70 max_allowed = (2100, 2, 28) 

71 

72 if date_components < min_allowed or date_components > max_allowed: 

73 raise ValueError( 

74 f"Date outside allowed range: {self._year}-{self._month:02d}-{self._day:02d} (allowed [1900-03-01..2100-02-28])" 

75 ) 

76 

77 def __str__(self) -> str: 

78 """Return a string representation of the Date.""" 

79 return f"Date({self.year}, {self.month}, {self.day})" 

80 

81 def __hash__(self) -> int: 

82 """Return hash value for Date object.""" 

83 return hash((self.year, self.month, self.day)) 

84 

85 def __eq__(self, other: "Date") -> bool: 

86 """Check if two dates are equal.""" 

87 if isinstance(other, Date): 87 ↛ 95line 87 didn't jump to line 95 because the condition on line 87 was always true

88 return ( 

89 self.year == other.year 

90 and self.month == other.month 

91 and self.day == other.day 

92 ) 

93 # Allow DateVar implementations to handle reflected comparison 

94 # Includes: symbolic DateVar, EvalDateVar (validation), EnumerationDateVar (baseline) 

95 other_cls = other.__class__ 

96 other_name = other_cls.__name__ 

97 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 

98 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

99 return NotImplemented 

100 raise TypeError(f"Cannot compare Date with {type(other)}") 

101 

102 def __ne__(self, other: "Date") -> bool: 

103 """Check if two dates are not equal.""" 

104 if isinstance(other, Date): 

105 return not self.__eq__(other) 

106 # Allow DateVar implementations to handle reflected comparison 

107 other_cls = other.__class__ 

108 other_name = other_cls.__name__ 

109 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 109 ↛ 111line 109 didn't jump to line 111 because the condition on line 109 was never true

110 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

111 return NotImplemented 

112 raise TypeError(f"Cannot compare Date with {type(other)}") 

113 

114 def __lt__(self, other: "Date"): 

115 """Check if this date is less than another.""" 

116 if isinstance(other, Date): 

117 return self.to_python_date() < other.to_python_date() 

118 # Allow DateVar implementations to handle reflected comparison 

119 other_cls = other.__class__ 

120 other_name = other_cls.__name__ 

121 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 

122 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

123 return NotImplemented 

124 raise TypeError(f"Cannot compare Date with {type(other)}") 

125 

126 def __le__(self, other: "Date"): 

127 """Check if this date is less than or equal to another.""" 

128 if isinstance(other, Date): 

129 return self.to_python_date() <= other.to_python_date() 

130 # Allow DateVar implementations to handle reflected comparison 

131 other_cls = other.__class__ 

132 other_name = other_cls.__name__ 

133 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 

134 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

135 return NotImplemented 

136 raise TypeError(f"Cannot compare Date with {type(other)}") 

137 

138 def __gt__(self, other: "Date"): 

139 """Check if this date is greater than another.""" 

140 if isinstance(other, Date): 

141 return self.to_python_date() > other.to_python_date() 

142 # Allow DateVar implementations to handle reflected comparison 

143 other_cls = other.__class__ 

144 other_name = other_cls.__name__ 

145 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 

146 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

147 return NotImplemented 

148 raise TypeError(f"Cannot compare Date with {type(other)}") 

149 

150 def __ge__(self, other: "Date"): 

151 """Check if this date is greater than or equal to another.""" 

152 if isinstance(other, Date): 

153 return self.to_python_date() >= other.to_python_date() 

154 # Allow DateVar implementations to handle reflected comparison 

155 other_cls = other.__class__ 

156 other_name = other_cls.__name__ 

157 if (other_name in ("DateVar", "EvalDateVar", "EnumerationDateVar") or 

158 (other_name == "DateVar" and getattr(other_cls, "__module__", "").startswith("datesat.symbolic"))): 

159 return NotImplemented 

160 raise TypeError(f"Cannot compare Date with {type(other)}") 

161 

162 def to_python_date(self) -> date: 

163 """Convert to Python date object.""" 

164 return date(self.year, self.month, self.day) 

165 

166 @classmethod 

167 def from_python_date(cls, d: date) -> "Date": 

168 """Create Date from Python date object.""" 

169 return cls(d.year, d.month, d.day) 

170 

171 def __add__(self, other: "Period"): 

172 """ 

173 Date + Period using Python's datetime library with relativedelta. 

174 This provides the same semantics as the symbolic DateVar operations. 

175  

176 Intermediate dates must be within the allowed range [1900-03-01, 2100-02-28]. 

177 If the result is outside this range, raises ValueError to ensure consistency 

178 with symbolic DateVar bounds enforcement (which makes constraints UNSAT). 

179 This error should be caught during constraint building and converted to an UNSAT constraint. 

180 """ 

181 if not isinstance(other, Period): 

182 raise TypeError(f"Cannot add {type(other)} to Date") 

183 

184 # Convert to Python date and use relativedelta for years/months/days 

185 py_date = self.to_python_date() 

186 result_date = py_date + relativedelta( 

187 years=other.years, months=other.months, days=other.days 

188 ) 

189 

190 # Try to convert back to Date (will validate the result and enforce bounds) 

191 # This ensures intermediate dates are bounded, consistent with symbolic DateVars 

192 # If out of bounds, ValueError is raised and should be caught during constraint building 

193 # to convert to an UNSAT constraint 

194 return Date.from_python_date(result_date) 

195 

196 def __sub__(self, other: "Period"): 

197 """ 

198 Date - Period implemented as Date + (-Period). 

199  

200 Intermediate dates must be within the allowed range [1900-03-01, 2100-02-28]. 

201 If the result is outside this range, raises ValueError to ensure consistency 

202 with symbolic DateVar bounds enforcement (which makes constraints UNSAT). 

203 """ 

204 if not isinstance(other, Period): 

205 raise TypeError(f"Cannot subtract {type(other)} from Date") 

206 

207 neg_period = Period(-other.years, -other.months, -other.days) 

208 return self.__add__(neg_period) 

209 

210 

211class Period: 

212 """Period class for representing time periods.""" 

213 

214 # Period bounds based on date range [1900-03-01 to 2100-02-28] 

215 # These match the allowed date range to ensure periods are semantically valid 

216 MAX_PERIOD_DAYS = 73048 # abs(EPOCH_DAYS_MAX - EPOCH_DAYS_MIN) = abs(36523 - (-36525)) 

217 MAX_PERIOD_YEARS = 200 # YEAR_MAX - YEAR_MIN = 2100 - 1900 

218 MAX_PERIOD_MONTHS = 2400 # MAX_PERIOD_YEARS * 12 = 200 * 12 

219 

220 def __init__(self, years: int, months: int, days: int): 

221 """Initialize a Period with years, months, days components.""" 

222 # Validate input format: exactly three integer components (no bools allowed) 

223 period_components = (years, months, days) 

224 if not all(isinstance(v, int) and not isinstance(v, bool) for v in period_components): 

225 raise ValueError( 

226 "Invalid Period format: years, months, and days must be integers" 

227 ) 

228 

229 # Validate period bounds: ensure periods fit within the allowed date range 

230 if abs(years) > self.MAX_PERIOD_YEARS: 

231 raise ValueError( 

232 f"Period years out of range: {years} (max ±{self.MAX_PERIOD_YEARS})" 

233 ) 

234 if abs(months) > self.MAX_PERIOD_MONTHS: 

235 raise ValueError( 

236 f"Period months out of range: {months} (max ±{self.MAX_PERIOD_MONTHS})" 

237 ) 

238 if abs(days) > self.MAX_PERIOD_DAYS: 

239 raise ValueError( 

240 f"Period days out of range: {days} (max ±{self.MAX_PERIOD_DAYS})" 

241 ) 

242 

243 self._years = years 

244 self._months = months 

245 self._days = days 

246 

247 @property 

248 def years(self) -> int: 

249 """Get the years component.""" 

250 return self._years 

251 

252 @property 

253 def months(self) -> int: 

254 """Get the months component.""" 

255 return self._months 

256 

257 @property 

258 def days(self) -> int: 

259 """Get the days component.""" 

260 return self._days 

261 

262 def __str__(self) -> str: 

263 return f"Period({self.years}, {self.months}, {self.days})" 

264 

265 def __hash__(self) -> int: 

266 """Return hash value for Period object.""" 

267 return hash((self.years, self.months, self.days)) 

268 

269 def __eq__(self, other: "Period") -> bool: 

270 raise TypeError(f"Cannot compare Period with {type(other)}") 

271 

272 def __ne__(self, other: "Period") -> bool: 

273 raise TypeError(f"Cannot compare Period with {type(other)}") 

274 

275 def __mul__(self, other: int) -> "Period": 

276 """Support Period * Int multiplication.""" 

277 if isinstance(other, int): 277 ↛ 284line 277 didn't jump to line 284 because the condition on line 277 was always true

278 return Period( 

279 self.years * other, 

280 self.months * other, 

281 self.days * other 

282 ) 

283 else: 

284 raise TypeError(f"Cannot multiply Period with {type(other)}") 

285 

286 def __rmul__(self, other: int) -> "Period": 

287 """Support Int * Period multiplication.""" 

288 if isinstance(other, int): 

289 return self.__mul__(other) 

290 else: 

291 raise TypeError(f"Cannot multiply Period with {type(other)}") 

292 

293 def __add__(self, other: "Period") -> "Period": 

294 """Support Period + Period addition.""" 

295 if isinstance(other, Period): 295 ↛ 303line 295 didn't jump to line 303 because the condition on line 295 was always true

296 return Period( 

297 self.years + other.years, 

298 self.months + other.months, 

299 self.days + other.days 

300 ) 

301 else: 

302 # Delegate to the right operand's __radd__ method 

303 return NotImplemented 

304 

305 def __sub__(self, other: "Period") -> "Period": 

306 """Support Period - Period subtraction.""" 

307 if isinstance(other, Period): 

308 return Period( 

309 self.years - other.years, 

310 self.months - other.months, 

311 self.days - other.days 

312 ) 

313 else: 

314 return NotImplemented