7
$\begingroup$

A number of inconsistencies using Range with DateObject when they are stepped through using (time) Quantity.

This Q relates to Range, Quantity and DateObjects, as DateRange syntax and options are not as readable and further functionalities that are supposed to be interoperable (otherwise, why does Range support DateObject at all?)

Range[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 1}],Quantity[1, "Month"]]

enter image description here

where's Jul 1? It's included by moving Range start point to Mar 1:

Range[DateObject[{2016, 3, 1}], DateObject[{2016, 7, 1}], 
 Quantity[1, "Month"]]

enter image description here

Similarly for end-of-month, one would hope `Quantity[1,"Month"] is consistent with the mixed-base calendars rather than just adding 30 days:

Range[DateObject[{2016, 3, 31}], DateObject[{2016, 6, 30}], 
 Quantity[1, "Month"]]

enter image description here

Where's Jun 30? Again, can be seen by moving starting point, here to Apr 30 but returns May 30, rather than May 31

Range[DateObject[{2016, 4, 30}], DateObject[{2016, 6, 30}], 
 Quantity[1, "Month"]]

enter image description here

Even if the correct end-of-months (except for, again, missing endpoint) is obtained from certain starting points, eg

Range[DateObject[{2015, 12, 31}], DateObject[{2016, 6, 30}], 
 Quantity[1, "Month"]]

enter image description here

$\endgroup$
6
  • 5
    $\begingroup$ Probably DateRange is more appropriate here. $\endgroup$
    – swish
    Commented Jun 25, 2017 at 17:31
  • 1
    $\begingroup$ @swish, I'm aware of DateRange, but its syntax and options are not as readable. In any case this Q regards Range and Quantity. Wolfram has built a set of functionalities that are supposed to be interoperable. $\endgroup$ Commented Jun 25, 2017 at 20:13
  • $\begingroup$ @alancalvitti Can you explain how DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 1}], "Month"] is not as readable as Range[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 1}], Quantity[1, "Month"]]? Not only are the parameters near identical but you can make them identical by using Quantity[1, "Month"] in DateRange. It is the exact same parameter list; with the added benefit that DateRange returns the expected list of dates. $\endgroup$
    – Edmund
    Commented Jun 26, 2017 at 9:22
  • $\begingroup$ @Edmund, a colleague had provided me an example using DateRange with more parameters. I should reword to say "less orthogonal" rather than less readable, in that if Range worked as intended, DateRange - a more restricted function - could be deprecated. $\endgroup$ Commented Jun 26, 2017 at 16:14
  • $\begingroup$ @alancalvitti I would say that Range does work as intended and so does DateRange. $\endgroup$
    – Edmund
    Commented Jun 26, 2017 at 16:38

3 Answers 3

7
$\begingroup$

Summary

Range applied to non-integers can be reduced to an expression involving an integral range -- i.e. Range[min, max, di] is notionally equivalent to

min + di * Range[0, Floor[(max - min) / di]]

A problem arises when the min and max are dates and di is a duration. In min + di * ..., the duration di * ... is evaluated from the base date min and entails exact month lengths. On the contrary, (max - min) / di resolves to a ratio of two durations with no base date for reference. In this case, di is interpreted using a "generic" month length of 365/12 days. It is the mismatch of these two interpretations of di that gives rise to an unexpected number of entries in the result.

Furthermore, when a duration of n months is added to a date, the computation attempts to preserve the day number of the initial date if possible. This is why the generated dates do not always fall at month-end even though the initial date does.

Work-around

DateRange operates upon dates as dates-proper (as @swish notes in a comment). It respects actual month lengths instead of generic ones and will produce the expected results:

DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 1}], "Month"]

(* DateObject[{2016, 2, 1}], ..., DateObject[{2016, 7, 1}] *)

It can also pin the generated dates to month-end, if desired:

DateRange[DateObject[{2015, 12, 31}], DateObject[{2016, 6, 30}], "EndOfMonth"]

(* {DateObject[{2015, 12, 31}], ..., DateObject[{2016, 5, 31}], ...} *)

Analysis

A trace of the original expression is very lengthy, but we can inspect certain substeps to see what is going on:

trace screenshot

We can see a subexpression which is performing the interval division (max - min) / di, which ultimately resolves to:

Quantity[151, "Day"] * Quantity[1, Power["Months", -1]]

(* 1812 / 365 *)

Simple arithmetic operations like this can be applied to date objects, but it entails a conversion to a common unit. The trace does not reveal the choice made by Mathematica, but all choices lead to the same result:

UnitConvert[Quantity[151, "Day"], #]& /@ {"Days", "Months", "Years"}

(* {Quantity[151, "Days"], Quantity[1812/365, "Months"], Quantity[151/365, "Years"]} *)

UnitConvert[Quantity[1, "Month"], #]& /@ {"Days", "Months", "Years"}

(* {Quantity[365/12, "Days"], Quantity[1, "Months"], Quantity[1/12, "Years"]} *)

% / %%

(* {365/1812, 365/1812, 365/1812} *)

So when Mathematica attempts to convert these interval sizes into a common unit, it is using a generic duration for a month, i.e. 365/12 days. It is this generic month length that ultimately causes the behaviour under discussion.

The floor of this interval division value is 4, and indeed the trace contains another subexpression which is generating a list of five dates (i.e. initial value plus four more), namely:

{ DateObject[{2016, 2, 1}]
, DateObject[{2016, 2, 1}] + Quantity[1, "Month"]
, DateObject[{2016, 2, 1}] + 2 * Quantity[1, "Month"]
, DateObject[{2016, 2, 1}] + 3 * Quantity[1, "Month"]
, DateObject[{2016, 2, 1}] + 4 * Quantity[1, "Month"]
}

date list

Once again, the trace shows evidence that a generic algorithm is being used to create the range.

Now that we have seen how Range generates its result, we should be able to get a list with six entries just by adding enough days to the interval so that the floor-plus-one is six instead of five:

{##, #2 - #1, N[(#2 - #1) / Quantity[1, "Month"]]}&[
  DateObject[{2016, 2, 1}], DateObject[{2016, 7, #}]]& /@
 {1, 2, 3} // Grid

interval list

The rollover point is July 3:

Range[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 2}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 6, 1}] *)

Range[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 3}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 7, 1}] *)

DateRange does not suffer from these problems because it uses an algorithm that is specifically designed to work upon (and only upon) date objects:

DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 2}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 7, 1}] *)

DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 3}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 7, 1}] *)

DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 7, 31}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 7, 1}] *)

DateRange[DateObject[{2016, 2, 1}], DateObject[{2016, 8, 1}], Quantity[1, "Months"]]

(* {DateObject[{2016, 2, 1}], ..., DateObject[{2016, 8, 1}] *)

Month Ends

The final two expressions in the question show that adding a duration of one or more months to a month-end date does not always yield a month-end result. More directly:

DateObject[{2015, 3, 31}] + Quantity[2, "Months"]

(* DateObject[{2015, 5, 31}] *)

yet:

DateObject[{2015, 4, 30}] + Quantity[1, "Months"]

(* DateObject[{2015, 5, 30}] *)

In fact, such additions attempt to preserve the day number of the initial date, only changing it if the day does not exist in the month:

DateObject[{2015, 9, 30}] + Quantity[1, "Month"] * Range[0, 11]

dates

Notice how all dates fall on the 30th, except for February which only has 29 days.

DateRange can be used to force all dates to lie at month-end:

DateRange[DateObject[{2015, 9, 30}], DateObject[{2016, 8, 31}], "EndOfMonth"]

dates

Design Choice?

This behaviour appears to be a design choice made by WRI. Had Range been written to use exact date arithmetic instead of generic period sizes, there would be no need for DateRange to exist. But this would also mean that we would lose the ability, for example, to expressly choose between preserving the day number or pinning to month-end when adding durations to dates.

The Range documentation is virtually silent about being able to operate upon dates. It states that "the arguments to Range need not be integers" and an example shows that Quantity objects are acceptable. It may very well be that the ability to apply Range to dates is merely a side-effect of its ability to operate upon Quantity objects. The design consequences in the context of dates might not have been explicitly considered.

$\endgroup$
2
  • $\begingroup$ Does "It is because of this generic length (365/12 days) that we see the results that are obtained" explain my last example? Range[DateObject[{2015, 12, 31}], DateObject[{2016, 6, 30}], Quantity[1, "Month"]] $\endgroup$ Commented Jun 26, 2017 at 3:37
  • $\begingroup$ @alancalvitti No. The differing behaviour between your last two examples is due to the date arithmetic expression mentioned in the main body of the response. Specifically, adding months using expressions of the form date + n * Quantity[1, "Months"] will preserve the day number as far as possible. To force the calculation use of month-ends, we need to resort to DatePlus or DateRange using "EndOfMonth" as the unit. Later I will update my response to mention this explicitly. $\endgroup$
    – WReach
    Commented Jun 26, 2017 at 14:54
5
$\begingroup$

If you must use Range with DateObject instead of DateRange I suggest using dates that are of the same granularity as the Range increment.

DateObject[{yyyy, mm}] will give a DateObject of month granularity. You can also specify the granularity with the second parameter; DateObject[{yyyy, mm}, "Month"].

dates = Range[DateObject[{2016, 2}], DateObject[{2016, 7}], Quantity[1, "Month"]]

Mathematica graphics

Once you have your range of months you can adjust them with the Date & Time functions.

DatePlus[dates, "EndOfMonth"]

Mathematica graphics

Hope this helps.

$\endgroup$
3
  • $\begingroup$ Edmund, increments and granularity are dictated by business needs, not by what's convenient. By the way, can get the same answer as above by doing Range on 1st of month, and then subtracting Quantity[1,"Days"]. $\endgroup$ Commented Jun 26, 2017 at 3:40
  • $\begingroup$ @alancalvitti You can also set complete dates to monthly granularity, DateObject[{2016,2,1},"Month"]. I am just detailing how you need to use Range with DateObjects. If you must do it than the about method will work. $\endgroup$
    – Edmund
    Commented Jun 26, 2017 at 9:15
  • $\begingroup$ in revising this (11.3), DatePlus[dates, "EndOfMonth"] is not returning the last date of the month, but rather seems to leave the input dates invariant. Can you replicate? $\endgroup$ Commented Jul 20, 2018 at 14:12
2
$\begingroup$

Just gonna share my thoughts on the implementation of Range. I've came up with two naive ways to define it:

range1[from_, to_, step_] := Most@NestWhileList[# + step &, from, # <= to &]
range2[from_, to_, step_] := NestList[# + step &, from, Floor[(to - from)/step]]

And it seems the real Range works more like range2, and DateRange like range1. As the date difference returns number of days, the rounding in range2 pretty much explains its behaviour.

$\endgroup$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.