Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calendar arithmetic for DateTimes #517

Merged
merged 1 commit into from
Aug 1, 2024
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
43 changes: 27 additions & 16 deletions book/src/date-and-time.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
# Date and time

Numbat supports date and time handling based on the [proleptic Gregorian calendar](https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar),
which is the (usual) Gregorian calendar extended to dates before its introduction in 1582. Julian calendar dates are currently not supported.
which is the (usual) Gregorian calendar extended to dates before its introduction in 1582.

A few examples of useful operations that can be performed on dates and times:

```nbt
# Which date is 40 days from now?
now() + 40 days

# Which date was 1 million seconds ago?
now() - 1 million seconds

# How many days are left until September 1st?
date("2024-11-01") - today() -> days

Expand All @@ -21,13 +15,22 @@ now() -> tz("Asia/Kathmandu") # use tab completion to find time zone names
# What is the local time when it is 2024-11-01 12:30:00 in Australia?
datetime("2024-11-01 12:30:00 Australia/Sydney") -> local

# Which date was 1 million seconds ago?
now() - 1 million seconds

# Which date is 40 days from now?
calendar_add(now(), 40 days)

# Which weekday was the 1st day of this century?
date("2000-01-01") -> weekday

# What is the current UNIX timestamp?
now() -> unixtime

# What is the date corresponding to the UNIX timestamp 1707568901?
# What is the date corresponding to a given UNIX timestamp?
from_unixtime(1707568901)

# How long are one million seconds in days, hours, minutes, seconds
# How long are one million seconds in days, hours, minutes, seconds?
1 million seconds -> human
```

Expand All @@ -44,12 +47,16 @@ The following operations are supported for `DateTime` objects:

<div class="warning">

**Warning**: You can add `years` or `months` to a given date (`now() + 3 months`), but note that the result might not be what you expect.
The unit `year` is defined as the *average* length of a year (a [tropical year](https://en.wikipedia.org/wiki/Tropical_year), to be precise), and
`month` is defined as the *average* length of a month (1/12 of a `year`). So this does not take into account the actual length of the months or the leap years.
However, note that adding or subtracting "one year" or "one month" is not a well-defined operation anyway. For example, what should "one month after March 31st"
be? April 30th or May 1st? If your answer is April 30th, then what is "one month after March 30th"? If your answer is May 1st, then what is "one month after
April 1st"?
**Warning**: You can directly add `days`, `months` and `years` to a given date (`now() + 3 months`), but note that the result might not be what you expect.
The unit `day` is defined as having a length of 24 hours. But due to daylight
saving time, days can be shorter or longer than that. A `month` is defined
as 1/12 of a `year`, but calendar months have varying lengths. And a `year`
is defined as the average length of a
[tropical](https://en.wikipedia.org/wiki/Tropical_year) year. But a calendar
year can have 365 or 366 days, depending on whether it is a leap year or not.

If you want to take all of these factors into account, you should use the `calendar_add`/`calendar_sub` functions instead of directly adding or
subtracting `days`, `months`, or `years`.

</div>

Expand All @@ -68,7 +75,11 @@ The following functions are available for date and time handling:
- `get_local_timezone() -> String`: Returns the users local timezone
- `unixtime(dt: DateTime) -> Scalar`: Converts a `DateTime` to a UNIX timestamp.
- `from_unixtime(ut: Scalar) -> DateTime`: Converts a UNIX timestamp to a `DateTime` object.
- `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds
- `calendar_add(dt: DateTime, span: Time)`: Add a span of time to a `DateTime` object, taking proper calendar arithmetic into accound.
- `calendar_sub(dt: DateTime, span: Time)`: Subtract a span of time from a `DateTime` object, taking proper calendar arithmetic into accound.
- `weekday(dt: DateTime) -> String`: Returns the weekday of a `DateTime` object as a string.
- `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds.
- `julian_date(dt: DateTime) -> Scalar`: Convert a `DateTime` to a [Julian date](https://en.wikipedia.org/wiki/Julian_day).

## Date time formats

Expand Down
21 changes: 21 additions & 0 deletions book/src/list-functions-datetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ Parses a string (time only) into a `DateTime` object.
fn time(input: String) -> DateTime
```

### `calendar_add`
Adds the given time span to a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.

```nbt
fn calendar_add(dt: DateTime, span: Time) -> DateTime
```

### `calendar_sub`
Subtract the given time span from a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.

```nbt
fn calendar_sub(dt: DateTime, span: Time) -> DateTime
```

### `weekday`
Get the day of the week from a given `DateTime`.

```nbt
fn weekday(dt: DateTime) -> String
```

### `julian_date` (Julian date)
Convert a `DateTime` to a Julian date, the number of days since the origin of the Julian date system (noon on November 24, 4714 BC in the proleptic Gregorian calendar).
More information [here](https://en.wikipedia.org/wiki/Julian_day).
Expand Down
27 changes: 27 additions & 0 deletions examples/tests/datetime.nbt
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ assert_eq(from_unixtime(1658346725), dt_unixtime_1)



# Calendar arithmetic

let dt_start = datetime("2024-03-30 12:00:00 Europe/Berlin") # one day before DST starts

# If we simply add "1 day == 24 hours", we end up at 13:00 on the next day:
assert_eq(dt_start + 1 day, datetime("2024-03-31 13:00:00 Europe/Berlin"))

# If we use DST-aware calendar arithmetic, we end up at 12:00 on the next day:
assert_eq(calendar_add(dt_start, 1 day), datetime("2024-03-31 12:00:00 Europe/Berlin"))
assert_eq(calendar_add(dt_start, 2 days), datetime("2024-04-01 12:00:00 Europe/Berlin"))

assert_eq(calendar_add(dt_start, 3 months), datetime("2024-06-30 12:00:00 Europe/Berlin"))
assert_eq(calendar_add(dt_start, 12 months), datetime("2025-03-30 12:00:00 Europe/Berlin"))

assert_eq(calendar_add(dt_start, 10 years), datetime("2034-03-30 12:00:00 Europe/Berlin"))

assert_eq(calendar_add(dt_start, 1 second), datetime("2024-03-30 12:00:01 Europe/Berlin"))
assert_eq(calendar_add(dt_start, 1 minute), datetime("2024-03-30 12:01:00 Europe/Berlin"))
assert_eq(calendar_add(dt_start, 1 hour), datetime("2024-03-30 13:00:00 Europe/Berlin"))



# Weekday

assert_eq(date("2024-08-01") -> weekday, "Thursday")


# Julian date

let dt_jd = datetime("2013-01-01 00:30:00 UTC")
Expand Down
25 changes: 25 additions & 0 deletions numbat/modules/datetime/functions.nbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use core::strings
use core::quantities
use units::si
use units::time

@description("Returns the current date and time.")
fn now() -> DateTime
Expand Down Expand Up @@ -43,6 +45,29 @@ fn date(input: String) -> DateTime =
fn time(input: String) -> DateTime =
datetime("{_today_str()} {input}")

fn _add_days(dt: DateTime, n_days: Scalar) -> DateTime
fn _add_months(dt: DateTime, n_months: Scalar) -> DateTime
fn _add_years(dt: DateTime, n_years: Scalar) -> DateTime

@description("Adds the given time span to a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.")
fn calendar_add(dt: DateTime, span: Time) -> DateTime =
if unit_of(span) == days
then _add_days(dt, span / days)
else if unit_of(span) == months
then _add_months(dt, span / months)
else if unit_of(span) == years
then _add_years(dt, span / years)
else if unit_of(span) == seconds || unit_of(span) == minutes || unit_of(span) == hours
then dt + span
else error("calendar_add: Unsupported unit: {unit_of(span)}")

@description("Subtract the given time span from a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.")
fn calendar_sub(dt: DateTime, span: Time) -> DateTime =
calendar_add(dt, -span)

@description("Get the day of the week from a given `DateTime`.")
fn weekday(dt: DateTime) -> String = format_datetime("%A", dt)

@name("Julian date")
@description("Convert a `DateTime` to a Julian date, the number of days since the origin of the Julian date system (noon on November 24, 4714 BC in the proleptic Gregorian calendar).")
@url("https://en.wikipedia.org/wiki/Julian_day")
Expand Down
39 changes: 39 additions & 0 deletions numbat/src/ffi/datetime.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use jiff::Span;
use jiff::Timestamp;
use jiff::Zoned;
use num_traits::ToPrimitive;

use super::macros::*;
use super::Args;
Expand Down Expand Up @@ -67,3 +69,40 @@ pub fn from_unixtime(mut args: Args) -> Result<Value> {

return_datetime!(dt)
}

fn calendar_add(
mut args: Args,
unit_name: &str,
to_span: fn(i64) -> std::result::Result<Span, jiff::Error>,
) -> Result<Value> {
let dt = datetime_arg!(args);
let n = quantity_arg!(args).unsafe_value().to_f64();

if n.fract() != 0.0 {
return Err(RuntimeError::UserError(format!(
"calendar_add: requires an integer number of {unit_name}s"
)));
}

let n_i64 = n.to_i64().ok_or_else(|| {
RuntimeError::UserError(format!("calendar:add: number of {unit_name}s is too large",))
})?;

let output = dt
.checked_add(to_span(n_i64).map_err(|_| RuntimeError::DurationOutOfRange)?)
.map_err(|_| RuntimeError::DateTimeOutOfRange)?;

return_datetime!(output)
}

pub fn _add_days(args: Args) -> Result<Value> {
calendar_add(args, "day", |n| Span::new().try_days(n))
}

pub fn _add_months(args: Args) -> Result<Value> {
calendar_add(args, "month", |n| Span::new().try_months(n))
}

pub fn _add_years(args: Args) -> Result<Value> {
calendar_add(args, "year", |n| Span::new().try_years(n))
}
4 changes: 4 additions & 0 deletions numbat/src/ffi/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
insert_function!(unixtime, 1..=1);
insert_function!(from_unixtime, 1..=1);

insert_function!(_add_days, 2..=2);
insert_function!(_add_months, 2..=2);
insert_function!(_add_years, 2..=2);

// Currency
insert_function!(exchange_rate, 1..=1);

Expand Down
Loading