diff --git a/examples/datetime_human_tests.nbt b/examples/datetime_human_tests.nbt new file mode 100644 index 00000000..6c5c740f --- /dev/null +++ b/examples/datetime_human_tests.nbt @@ -0,0 +1,38 @@ +assert((0 second // human) == "0 seconds") +assert((1 second // human) == "1 second") +assert((5 second // human) == "5 seconds") +assert((1.5 second // human) == "1.500 seconds") + +assert((60 seconds // human) == "1 minute") +assert((73 seconds // human) == "1 minute + 13 seconds") +assert((120 seconds // human) == "2 minutes") +assert((60.1 seconds // human) == "1 minute + 0.100 seconds") +assert((1 minute // human) == "1 minute") +assert((1.25 minute // human) == "1 minute + 15 seconds") +assert((2.5 minute // human) == "2 minutes + 30 seconds") + +assert((1 hour // human) == "1 hour") +assert((1.5 hour // human) == "1 hour + 30 minutes") +assert((2 hour // human) == "2 hours") +assert((1 hour + 1 sec // human) == "1 hour + 1 second") + +assert((1 day // human) == "1 day") +assert((1.37 day // human) == "1 day + 8 hours + 52 minutes + 48 seconds") + +assert((1 week // human) == "7 days") +assert((1.5 weeks // human) == "10 days + 12 hours") +assert((2 weeks // human) == "14 days") + +assert((1 sidereal_day // human) == "23 hours + 56 minutes + 4.090500 seconds") + +assert((10000 days // human) == "10000 days") +assert((50 million days // human) == "50_000_000 days") + +assert((1e12 days // human) == "1_000_000_000_000 days") +assert((1e15 days // human) == "1.0e+15 days") + +assert((1 ms // human) == "0.001 seconds") +assert((1 µs // human) == "0.000001 seconds") +assert((1 ns // human) == "0.000000001 seconds") +assert((1234 ns // human) == "0.000001234 seconds") +assert((1s + 1234 ns // human) == "1.000001234 seconds") diff --git a/examples/format_time.nbt b/examples/format_time.nbt deleted file mode 100644 index b8e44b92..00000000 --- a/examples/format_time.nbt +++ /dev/null @@ -1,9 +0,0 @@ -let time = 17.47 hours - -let num_seconds = mod(time -> seconds, 60 seconds) -let num_minutes = mod(time - num_seconds, 60 minutes) -> minutes // floor -let num_hours = floor((time - num_minutes - num_seconds) / 1 hour) × hour -> hours - -assert_eq(num_hours + num_minutes + num_seconds -> s, time -> s, 1ms) - -print("{num_hours/h}:{num_minutes/min}:{num_seconds/s}") diff --git a/numbat/modules/datetime/human.nbt b/numbat/modules/datetime/human.nbt new file mode 100644 index 00000000..dd7e9a43 --- /dev/null +++ b/numbat/modules/datetime/human.nbt @@ -0,0 +1,36 @@ +use core::functions +use core::strings +use units::si +use datetime::functions + +fn _human_num_days(time: Time) -> Scalar = floor(time / day) + +fn _human_join(a: String, b: String) -> String = + if str_slice(a, 0, 2) == "0 " then b else if str_slice(b, 0, 2) == "0 " then a else "{a} + {b}" + +fn _remove_plural_suffix(str: String) -> String = + if str_slice(str, 0, 2) == "1 " then str_slice(str, 0, str_length(str) - 1) else str + +fn _human_seconds(dt: DateTime) -> String = + _remove_plural_suffix(format_datetime("%-S%.f seconds", dt)) + +fn _human_minutes(dt: DateTime) -> String = + _remove_plural_suffix(format_datetime("%-M minutes", dt)) + +fn _human_hours(dt: DateTime) -> String = + _remove_plural_suffix(format_datetime("%-H hours", dt)) + +fn _human_days(num_days: Scalar) -> String = + _remove_plural_suffix("{num_days} days") + +fn _human_readable_duration(time: Time, dt: DateTime, num_days: Scalar) -> String = + _human_join(_human_join(_human_join(_human_days(_human_num_days(time)), _human_hours(dt)), _human_minutes(dt)), _human_seconds(dt)) + +# Implementation details: +# we skip hours/minutes/seconds for durations larger than 1000 days because: +# (a) we run into floating point precision problems at the nanosecond level at this point +# (b) for much larger numbers, we can't convert to DateTimes anymore +fn human(time: Time) = + if _human_num_days(time) > 1000 + then "{_human_num_days(time)} days" + else _human_readable_duration(time, parse_datetime("0001-01-01T00:00:00Z") + time, _human_num_days(time)) diff --git a/numbat/modules/prelude.nbt b/numbat/modules/prelude.nbt index b2e83cd1..9a5f78f6 100644 --- a/numbat/modules/prelude.nbt +++ b/numbat/modules/prelude.nbt @@ -30,3 +30,4 @@ use physics::constants use physics::temperature_conversion use datetime::functions +use datetime::human diff --git a/numbat/src/bytecode_interpreter.rs b/numbat/src/bytecode_interpreter.rs index 7bbd7b05..ff3a73bf 100644 --- a/numbat/src/bytecode_interpreter.rs +++ b/numbat/src/bytecode_interpreter.rs @@ -130,8 +130,8 @@ impl BytecodeInterpreter { Op::DiffDateTime } else { match operator { - BinaryOperator::Add => Op::AddDateTime, - BinaryOperator::Sub => Op::SubDateTime, + BinaryOperator::Add => Op::AddToDateTime, + BinaryOperator::Sub => Op::SubFromDateTime, BinaryOperator::ConvertTo => Op::ConvertDateTime, _ => unreachable!("{operator:?} is not valid with a DateTime"), // should be unreachable, because the typechecker will error first } diff --git a/numbat/src/interpreter.rs b/numbat/src/interpreter.rs index b06d786c..2b04964e 100644 --- a/numbat/src/interpreter.rs +++ b/numbat/src/interpreter.rs @@ -41,6 +41,10 @@ pub enum RuntimeError { DateParsingError(chrono::ParseError), #[error("Unknown timezone: {0}")] UnknownTimezone(String), + #[error("Exceeded maximum size for time durations")] + DurationOutOfRange, + #[error("DateTime out of range")] + DateTimeOutOfRange, } #[derive(Debug, PartialEq, Eq)] diff --git a/numbat/src/vm.rs b/numbat/src/vm.rs index b56d4446..103af07c 100644 --- a/numbat/src/vm.rs +++ b/numbat/src/vm.rs @@ -73,9 +73,9 @@ pub enum Op { LogicalNeg, /// Similar to Add, but has DateTime on the LHS and a quantity on the RHS - AddDateTime, + AddToDateTime, /// Similar to Sub, but has DateTime on the LHS and a quantity on the RHS - SubDateTime, + SubFromDateTime, /// Computes the difference between two DateTimes DiffDateTime, /// Converts a DateTime value to another timezone @@ -122,9 +122,9 @@ impl Op { Op::Negate | Op::Factorial | Op::Add - | Op::AddDateTime + | Op::AddToDateTime | Op::Subtract - | Op::SubDateTime + | Op::SubFromDateTime | Op::DiffDateTime | Op::ConvertDateTime | Op::Multiply @@ -157,9 +157,9 @@ impl Op { Op::Negate => "Negate", Op::Factorial => "Factorial", Op::Add => "Add", - Op::AddDateTime => "AddDateTime", + Op::AddToDateTime => "AddDateTime", Op::Subtract => "Subtract", - Op::SubDateTime => "SubDateTime", + Op::SubFromDateTime => "SubDateTime", Op::DiffDateTime => "DiffDateTime", Op::ConvertDateTime => "ConvertDateTime", Op::Multiply => "Multiply", @@ -649,7 +649,7 @@ impl Vm { }; self.push_quantity(result.map_err(RuntimeError::QuantityError)?); } - op @ (Op::AddDateTime | Op::SubDateTime) => { + op @ (Op::AddToDateTime | Op::SubFromDateTime) => { let rhs = self.pop_quantity(); let lhs = self.pop_datetime(); @@ -657,15 +657,20 @@ impl Vm { let base = rhs.to_base_unit_representation(); let seconds_f = base.unsafe_value().to_f64(); - let duration = chrono::Duration::seconds(seconds_f.trunc() as i64) + let duration = chrono::Duration::try_seconds(seconds_f.trunc() as i64) + .ok_or(RuntimeError::DurationOutOfRange)? + chrono::Duration::nanoseconds( (seconds_f.fract() * 1_000_000_000f64).round() as i64, ); self.push(Value::DateTime( match op { - Op::AddDateTime => lhs + duration, - Op::SubDateTime => lhs - duration, + Op::AddToDateTime => lhs + .checked_add_signed(duration) + .ok_or(RuntimeError::DateTimeOutOfRange)?, + Op::SubFromDateTime => lhs + .checked_sub_signed(duration) + .ok_or(RuntimeError::DateTimeOutOfRange)?, _ => unreachable!(), }, chrono::Local::now().offset().fix(),