1use runmat_types::MemberAccess;
2use std::collections::HashMap;
3
4use runmat_builtins::{
5 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
6 BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
7 BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
8 BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
9 BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
10 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11};
12use runmat_value::{CharArray, ObjectInstance, StringArray, Tensor, Value};
13
14use crate::builtins::common::tensor;
15use crate::{
16 build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError, OBJECT_INDEX_MEMBER,
17 OBJECT_INDEX_PAREN, OBJECT_SUBSASGN_METHOD, OBJECT_SUBSREF_METHOD,
18};
19
20const BUILTIN_NAME: &str = "duration";
21const DURATION_CLASS: &str = "duration";
22const DAYS_FIELD: &str = "__days";
23const FORMAT_FIELD: &str = "Format";
24pub(crate) const DEFAULT_DURATION_FORMAT: &str = "hh:mm:ss";
25const SECONDS_PER_DAY: f64 = 86_400.0;
26
27const DURATION_SHORT_COMPONENT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
28 id: "duration-short-component-form",
29 mode: BuiltinExtensionMode::RunMatOnly,
30 description:
31 "duration with one hour component or two hour/minute components is a RunMat extension",
32 error_identifier: Some("RunMat:compatibility:DurationShortComponentFormExtension"),
33};
34const DURATION_GPU_INPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
35 id: "duration-gpu-input",
36 mode: BuiltinExtensionMode::RunMatOnly,
37 description: "duration with resident numeric input is a RunMat extension",
38 error_identifier: Some("RunMat:compatibility:DurationGpuInputExtension"),
39};
40const DURATION_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
41 DURATION_SHORT_COMPONENT_EXTENSION,
42 DURATION_GPU_INPUT_EXTENSION,
43];
44const DURATION_INTEGER_COMPONENT_INPUTS: [BuiltinIntegerInputCapability; 4] = [
45 BuiltinIntegerInputCapability {
46 name: "H",
47 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
48 availability: BuiltinIntegerInputAvailability::Documented,
49 scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
50 notes: "Numeric hour arrays can be scalar-expanded; values enter the duration floating representation.",
51 },
52 BuiltinIntegerInputCapability {
53 name: "MI",
54 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
55 availability: BuiltinIntegerInputAvailability::Documented,
56 scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
57 notes: "Numeric minute arrays can be scalar-expanded; nonscalars must match the other component sizes.",
58 },
59 BuiltinIntegerInputCapability {
60 name: "S",
61 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
62 availability: BuiltinIntegerInputAvailability::Documented,
63 scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
64 notes: "Numeric second arrays can be scalar-expanded; nonscalars must match the other component sizes.",
65 },
66 BuiltinIntegerInputCapability {
67 name: "MS",
68 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
69 availability: BuiltinIntegerInputAvailability::Documented,
70 scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
71 notes: "The optional fourth component contributes milliseconds and follows the same scalar-expansion rule.",
72 },
73];
74const DURATION_INTEGER_MATRIX_INPUT: [BuiltinIntegerInputCapability; 1] =
75 [BuiltinIntegerInputCapability {
76 name: "X",
77 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
78 availability: BuiltinIntegerInputAvailability::Documented,
79 scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
80 notes: "X must be a numeric matrix with exactly three columns ordered as hours, minutes, and seconds.",
81 }];
82pub const DURATION_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
83 BuiltinIntegerCapabilityDescriptor {
84 form: "D = duration(integer_H, integer_MI, integer_S, integer_MS?)",
85 inputs: &DURATION_INTEGER_COMPONENT_INPUTS,
86 computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
87 output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
88 overflow: BuiltinIntegerOverflowRule::NotApplicable,
89 backend: BuiltinIntegerBackendRule::GatherFallback,
90 overload: BuiltinIntegerOverloadKind::Multiple,
91 notes: "All numeric classes share MATLAB scalar expansion and produce a host duration object backed by binary64 day counts; resident inputs are a separately gated RunMat extension and gather before construction.",
92 },
93 BuiltinIntegerCapabilityDescriptor {
94 form: "D = duration(integer_X)",
95 inputs: &DURATION_INTEGER_MATRIX_INPUT,
96 computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
97 output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
98 overflow: BuiltinIntegerOverflowRule::NotApplicable,
99 backend: BuiltinIntegerBackendRule::GatherFallback,
100 overload: BuiltinIntegerOverloadKind::Multiple,
101 notes: "Each row of the N-by-3 matrix creates one duration; output is an N-by-1 host duration array.",
102 },
103];
104
105static DURATION_CLASS_REGISTERED: crate::class_registry::ClassRegistration =
106 crate::class_registry::ClassRegistration::new(DURATION_CLASS);
107
108const DURATION_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109 code: "RM.DURATION.INVALID_ARGUMENT",
110 identifier: Some("RunMat:duration:InvalidArgument"),
111 when: "Arguments or option grammar do not match supported duration forms.",
112 message: "duration: invalid argument",
113};
114const DURATION_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
115 code: "RM.DURATION.INVALID_INPUT",
116 identifier: Some("RunMat:duration:InvalidInput"),
117 when: "Input values cannot be converted/broadcast/formatted to a valid duration result.",
118 message: "duration: invalid input",
119};
120const DURATION_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121 code: "RM.DURATION.INTERNAL",
122 identifier: Some("RunMat:duration:Internal"),
123 when: "Internal duration state or indexing/evaluation failed unexpectedly.",
124 message: "duration: internal operation failed",
125};
126const DURATION_ERRORS: [BuiltinErrorDescriptor; 3] = [
127 DURATION_ERROR_INVALID_ARGUMENT,
128 DURATION_ERROR_INVALID_INPUT,
129 DURATION_ERROR_INTERNAL,
130];
131
132const OUT_DURATION: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
133 name: "t",
134 ty: BuiltinParamType::Any,
135 arity: BuiltinParamArity::Required,
136 default: None,
137 description: "Duration object result.",
138}];
139const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
140 name: "out",
141 ty: BuiltinParamType::Any,
142 arity: BuiltinParamArity::Required,
143 default: None,
144 description: "Method result.",
145}];
146const DURATION_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
147 name: "args",
148 ty: BuiltinParamType::Any,
149 arity: BuiltinParamArity::Variadic,
150 default: None,
151 description: "Duration constructor arguments.",
152}];
153const DURATION_FOUR_COMPONENT_INPUTS: [BuiltinParamDescriptor; 4] = [
154 BuiltinParamDescriptor {
155 name: "hours",
156 ty: BuiltinParamType::NumericArray,
157 arity: BuiltinParamArity::Required,
158 default: None,
159 description: "Hour component.",
160 },
161 BuiltinParamDescriptor {
162 name: "minutes",
163 ty: BuiltinParamType::NumericArray,
164 arity: BuiltinParamArity::Required,
165 default: None,
166 description: "Minute component.",
167 },
168 BuiltinParamDescriptor {
169 name: "seconds",
170 ty: BuiltinParamType::NumericArray,
171 arity: BuiltinParamArity::Required,
172 default: None,
173 description: "Second component.",
174 },
175 BuiltinParamDescriptor {
176 name: "milliseconds",
177 ty: BuiltinParamType::NumericArray,
178 arity: BuiltinParamArity::Required,
179 default: None,
180 description: "Millisecond component.",
181 },
182];
183const DURATION_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
184 BuiltinParamDescriptor {
185 name: "lhs",
186 ty: BuiltinParamType::Any,
187 arity: BuiltinParamArity::Required,
188 default: None,
189 description: "Left duration operand.",
190 },
191 BuiltinParamDescriptor {
192 name: "rhs",
193 ty: BuiltinParamType::Any,
194 arity: BuiltinParamArity::Required,
195 default: None,
196 description: "Right duration/datetime operand.",
197 },
198];
199const DURATION_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
200 BuiltinParamDescriptor {
201 name: "obj",
202 ty: BuiltinParamType::Any,
203 arity: BuiltinParamArity::Required,
204 default: None,
205 description: "Duration receiver object.",
206 },
207 BuiltinParamDescriptor {
208 name: "kind",
209 ty: BuiltinParamType::StringScalar,
210 arity: BuiltinParamArity::Required,
211 default: None,
212 description: "Indexing kind token.",
213 },
214 BuiltinParamDescriptor {
215 name: "payload",
216 ty: BuiltinParamType::Any,
217 arity: BuiltinParamArity::Required,
218 default: None,
219 description: "Index/member payload.",
220 },
221];
222const DURATION_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
223 BuiltinParamDescriptor {
224 name: "obj",
225 ty: BuiltinParamType::Any,
226 arity: BuiltinParamArity::Required,
227 default: None,
228 description: "Duration receiver object.",
229 },
230 BuiltinParamDescriptor {
231 name: "kind",
232 ty: BuiltinParamType::StringScalar,
233 arity: BuiltinParamArity::Required,
234 default: None,
235 description: "Indexing kind token.",
236 },
237 BuiltinParamDescriptor {
238 name: "payload",
239 ty: BuiltinParamType::Any,
240 arity: BuiltinParamArity::Required,
241 default: None,
242 description: "Index/member payload.",
243 },
244 BuiltinParamDescriptor {
245 name: "rhs",
246 ty: BuiltinParamType::Any,
247 arity: BuiltinParamArity::Required,
248 default: None,
249 description: "Assigned value.",
250 },
251];
252
253const DURATION_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
254 BuiltinSignatureDescriptor {
255 label: "t = duration(X)",
256 inputs: &[BuiltinParamDescriptor {
257 name: "X",
258 ty: BuiltinParamType::NumericArray,
259 arity: BuiltinParamArity::Required,
260 default: None,
261 description: "N-by-3 matrix of hour, minute, and second components.",
262 }],
263 outputs: &OUT_DURATION,
264 },
265 BuiltinSignatureDescriptor {
266 label: "t = duration(hours, minutes)",
267 inputs: &[
268 BuiltinParamDescriptor {
269 name: "hours",
270 ty: BuiltinParamType::NumericArray,
271 arity: BuiltinParamArity::Required,
272 default: None,
273 description: "Hour component.",
274 },
275 BuiltinParamDescriptor {
276 name: "minutes",
277 ty: BuiltinParamType::NumericArray,
278 arity: BuiltinParamArity::Required,
279 default: None,
280 description: "Minute component.",
281 },
282 ],
283 outputs: &OUT_DURATION,
284 },
285 BuiltinSignatureDescriptor {
286 label: "t = duration(hours, minutes, seconds)",
287 inputs: &[
288 BuiltinParamDescriptor {
289 name: "hours",
290 ty: BuiltinParamType::NumericArray,
291 arity: BuiltinParamArity::Required,
292 default: None,
293 description: "Hour component.",
294 },
295 BuiltinParamDescriptor {
296 name: "minutes",
297 ty: BuiltinParamType::NumericArray,
298 arity: BuiltinParamArity::Required,
299 default: None,
300 description: "Minute component.",
301 },
302 BuiltinParamDescriptor {
303 name: "seconds",
304 ty: BuiltinParamType::NumericArray,
305 arity: BuiltinParamArity::Required,
306 default: None,
307 description: "Second component.",
308 },
309 ],
310 outputs: &OUT_DURATION,
311 },
312 BuiltinSignatureDescriptor {
313 label: "t = duration(hours, minutes, seconds, milliseconds)",
314 inputs: &DURATION_FOUR_COMPONENT_INPUTS,
315 outputs: &OUT_DURATION,
316 },
317 BuiltinSignatureDescriptor {
318 label: "t = duration(___, \"Format\", format)",
319 inputs: &DURATION_ARGS_ONLY,
320 outputs: &OUT_DURATION,
321 },
322 BuiltinSignatureDescriptor {
323 label: "t = duration(___, Name, Value, ...)",
324 inputs: &DURATION_ARGS_ONLY,
325 outputs: &OUT_DURATION,
326 },
327];
328const DURATION_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
329 label: "out = duration.subsref(obj, kind, payload)",
330 inputs: &DURATION_SUBSREF_INPUTS,
331 outputs: &OUT_ANY,
332}];
333const DURATION_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
334 [BuiltinSignatureDescriptor {
335 label: "out = duration.subsasgn(obj, kind, payload, rhs)",
336 inputs: &DURATION_SUBSASGN_INPUTS,
337 outputs: &OUT_ANY,
338 }];
339const DURATION_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
340 label: "out = duration.op(lhs, rhs)",
341 inputs: &DURATION_BINARY_INPUTS,
342 outputs: &OUT_ANY,
343}];
344
345pub const DURATION_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
346 signatures: &DURATION_SIGNATURES,
347 output_mode: BuiltinOutputMode::Fixed,
348 completion_policy: BuiltinCompletionPolicy::Public,
349 errors: &DURATION_ERRORS,
350};
351pub const DURATION_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
352 signatures: &DURATION_SUBSREF_SIGNATURES,
353 output_mode: BuiltinOutputMode::Fixed,
354 completion_policy: BuiltinCompletionPolicy::MethodOnly,
355 errors: &DURATION_ERRORS,
356};
357pub const DURATION_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
358 signatures: &DURATION_SUBSASGN_SIGNATURES,
359 output_mode: BuiltinOutputMode::Fixed,
360 completion_policy: BuiltinCompletionPolicy::MethodOnly,
361 errors: &DURATION_ERRORS,
362};
363pub const DURATION_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
364 signatures: &DURATION_BINARY_SIGNATURES,
365 output_mode: BuiltinOutputMode::Fixed,
366 completion_policy: BuiltinCompletionPolicy::MethodOnly,
367 errors: &DURATION_ERRORS,
368};
369
370fn duration_error(message: impl Into<String>) -> RuntimeError {
371 build_runtime_error(message)
372 .with_builtin(BUILTIN_NAME)
373 .build()
374}
375
376fn ensure_duration_class_registered() {
377 DURATION_CLASS_REGISTERED.ensure(|| {
378 let mut properties = HashMap::new();
379 properties.insert(
380 FORMAT_FIELD.to_string(),
381 crate::class_registry::RuntimeProperty {
382 name: FORMAT_FIELD.to_string(),
383 is_static: false,
384 is_constant: false,
385 is_dependent: false,
386 get_access: MemberAccess::Public,
387 set_access: MemberAccess::Public,
388 default_value: Some(Value::String(DEFAULT_DURATION_FORMAT.to_string())),
389 },
390 );
391
392 let mut methods = HashMap::new();
393 for name in [
394 OBJECT_SUBSREF_METHOD,
395 OBJECT_SUBSASGN_METHOD,
396 "plus",
397 "minus",
398 "eq",
399 "ne",
400 "lt",
401 "le",
402 "gt",
403 "ge",
404 ] {
405 methods.insert(
406 name.to_string(),
407 crate::class_registry::RuntimeMethod {
408 name: name.to_string(),
409 is_static: false,
410 is_abstract: false,
411 is_sealed: false,
412 access: MemberAccess::Public,
413 function_name: format!("{DURATION_CLASS}.{name}"),
414 implicit_class_argument: None,
415 },
416 );
417 }
418
419 crate::class_registry::register_class(crate::class_registry::RuntimeClass {
420 name: DURATION_CLASS.to_string(),
421 parent: None,
422 properties,
423 methods,
424 });
425 });
426}
427
428pub fn is_duration_object(value: &Value) -> bool {
429 matches!(value, Value::Object(obj) if obj.is_class(DURATION_CLASS))
430}
431
432async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
433 let mut out = Vec::with_capacity(args.len());
434 for arg in args {
435 out.push(
436 gather_if_needed_async(arg)
437 .await
438 .map_err(|err| duration_error(format!("duration: {}", err.message())))?,
439 );
440 }
441 Ok(out)
442}
443
444fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
445 match value {
446 Value::String(text) => Ok(text.clone()),
447 Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
448 Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
449 _ => Err(duration_error(format!(
450 "duration: {context} must be a string scalar or character vector"
451 ))),
452 }
453}
454
455fn parse_trailing_format(args: &[Value]) -> BuiltinResult<(usize, Option<String>)> {
456 let mut positional_end = args.len();
457 let mut format = None;
458
459 while positional_end >= 2 {
460 let name = match scalar_text(&args[positional_end - 2], "option name") {
461 Ok(text) => text,
462 Err(_) => break,
463 };
464 if !name.trim().eq_ignore_ascii_case("format") {
465 break;
466 }
467 format = Some(scalar_text(&args[positional_end - 1], "Format option")?);
468 positional_end -= 2;
469 }
470
471 Ok((positional_end, format))
472}
473
474fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
475 tensor::value_into_tensor_for(context, value)
476 .map_err(|message| duration_error(format!("duration: {message}")))
477}
478
479fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
480 let tensor = tensor_from_numeric(value, context)?;
481 let shape = tensor::default_shape_for(&tensor.shape, tensor.len());
482 let values = tensor::tensor_into_values_f64(tensor);
483 Tensor::new(values, shape).map_err(|err| duration_error(format!("duration: {err}")))
484}
485
486fn format_for_object(obj: &ObjectInstance) -> String {
487 match obj.properties.get(FORMAT_FIELD) {
488 Some(Value::String(text)) => text.clone(),
489 Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
490 Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
491 _ => DEFAULT_DURATION_FORMAT.to_string(),
492 }
493}
494
495pub(crate) fn duration_tensor_from_duration_value(value: &Value) -> BuiltinResult<Tensor> {
496 match value {
497 Value::Object(obj) if obj.is_class(DURATION_CLASS) => {
498 match obj.properties.get(DAYS_FIELD) {
499 Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
500 Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
501 .map_err(|err| duration_error(format!("duration: {err}"))),
502 Some(other) => Err(duration_error(format!(
503 "duration: invalid internal day storage {other:?}"
504 ))),
505 None => Err(duration_error("duration: missing internal day storage")),
506 }
507 }
508 _ => Err(duration_error("duration: expected a duration value")),
509 }
510}
511
512pub(crate) fn duration_format_from_value(value: &Value) -> String {
513 match value {
514 Value::Object(obj) if obj.is_class(DURATION_CLASS) => format_for_object(obj),
515 _ => DEFAULT_DURATION_FORMAT.to_string(),
516 }
517}
518
519pub(crate) fn duration_object_from_days_tensor(
520 days: Tensor,
521 format: impl Into<String>,
522) -> BuiltinResult<Value> {
523 ensure_duration_class_registered();
524 let mut object = ObjectInstance::new(DURATION_CLASS.to_string());
525 object
526 .properties
527 .insert(DAYS_FIELD.to_string(), Value::Tensor(days));
528 object
529 .properties
530 .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
531 Ok(Value::Object(object))
532}
533
534fn duration_object_from_days(
535 days: Vec<f64>,
536 shape: Vec<usize>,
537 format: impl Into<String>,
538) -> BuiltinResult<Value> {
539 let tensor =
540 Tensor::new(days, shape).map_err(|err| duration_error(format!("duration: {err}")))?;
541 duration_object_from_days_tensor(tensor, format)
542}
543
544async fn duration_unit_value(
545 value: Value,
546 unit_name: &str,
547 days_per_unit: f64,
548) -> BuiltinResult<Value> {
549 let value = gather_if_needed_async(&value)
550 .await
551 .map_err(|err| duration_error(format!("{unit_name}: {}", err.message())))?;
552 if is_duration_object(&value) {
553 let days = duration_tensor_from_duration_value(&value)?;
554 let day_values = tensor::tensor_values_f64_cow(&days);
555 let data = day_values
556 .iter()
557 .map(|day| day / days_per_unit)
558 .collect::<Vec<_>>();
559 return if data.len() == 1 {
560 Ok(Value::Num(data[0]))
561 } else {
562 Ok(Value::Tensor(
563 Tensor::new(
564 data,
565 tensor::default_shape_for(&days.shape, day_values.len()),
566 )
567 .map_err(|err| duration_error(format!("{unit_name}: {err}")))?,
568 ))
569 };
570 }
571 let numeric = component_tensor(value, unit_name)?;
572 let shape = tensor::default_shape_for(&numeric.shape, numeric.len());
573 let values = tensor::tensor_into_values_f64(numeric);
574 let days = values
575 .iter()
576 .map(|value| {
577 if !value.is_finite() {
578 Err(duration_error(format!(
579 "{unit_name}: values must be finite"
580 )))
581 } else {
582 let days = value * days_per_unit;
583 if days.is_finite() {
584 Ok(days)
585 } else {
586 Err(duration_error(format!(
587 "{unit_name}: resulting duration is outside supported range"
588 )))
589 }
590 }
591 })
592 .collect::<BuiltinResult<Vec<_>>>()?;
593 duration_object_from_days(days, shape, DEFAULT_DURATION_FORMAT)
594}
595
596fn broadcast_component_data(
597 arrays: &[Tensor],
598 labels: &[&str],
599) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
600 let mut target_shape = vec![1, 1];
601 let mut target_len = 1usize;
602
603 for array in arrays {
604 let len = array.len();
605 if len > 1 {
606 let shape = tensor::default_shape_for(&array.shape, len);
607 if target_len == 1 {
608 target_len = len;
609 target_shape = shape;
610 } else if len != target_len || shape != target_shape {
611 return Err(duration_error(
612 "duration: non-scalar component inputs must have matching sizes",
613 ));
614 }
615 }
616 }
617
618 let mut broadcasted = Vec::with_capacity(arrays.len());
619 for (idx, array) in arrays.iter().enumerate() {
620 let values = array
621 .as_f64_slice()
622 .expect("duration components are normalized to double storage");
623 if values.len() == 1 {
624 broadcasted.push(vec![values[0]; target_len]);
625 } else if values.len() == target_len {
626 broadcasted.push(values.to_vec());
627 } else {
628 return Err(duration_error(format!(
629 "duration: {} input size does not match the other components",
630 labels[idx]
631 )));
632 }
633 }
634
635 Ok((broadcasted, target_shape))
636}
637
638fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
639 let labels = ["hours", "minutes", "seconds", "milliseconds"];
640 let mut arrays = Vec::with_capacity(args.len());
641 for (idx, arg) in args.into_iter().enumerate() {
642 arrays.push(component_tensor(arg, labels[idx])?);
643 }
644 while arrays.len() < 4 {
645 arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
646 }
647
648 let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
649 let len = broadcasted[0].len();
650 let mut days = Vec::with_capacity(len);
651 for idx in 0..len {
652 let total_seconds = broadcasted[0][idx] * 3600.0
653 + broadcasted[1][idx] * 60.0
654 + broadcasted[2][idx]
655 + broadcasted[3][idx] / 1000.0;
656 days.push(total_seconds / SECONDS_PER_DAY);
657 }
658
659 duration_object_from_days(
660 days,
661 shape,
662 format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
663 )
664}
665
666fn is_public_duration_matrix(value: &Value) -> bool {
667 match value {
668 Value::Tensor(tensor) => tensor.shape.len() <= 2 && tensor.cols() == 3,
669 Value::GpuTensor(handle) => {
670 handle.shape.len() <= 2 && handle.shape.get(1).copied().unwrap_or(1) == 3
671 }
672 _ => false,
673 }
674}
675
676fn is_numeric_duration_input(value: &Value) -> bool {
677 matches!(
678 value,
679 Value::Num(_) | Value::Int(_) | Value::Tensor(_) | Value::GpuTensor(_)
680 )
681}
682
683fn build_from_matrix(value: Value, format: Option<String>) -> BuiltinResult<Value> {
684 let matrix = component_tensor(value, "X")?;
685 if matrix.shape.len() > 2 || matrix.cols() != 3 {
686 return Err(duration_error(
687 "duration: X must be a numeric matrix with exactly three columns",
688 ));
689 }
690 let rows = matrix.rows();
691 let values = matrix
692 .as_f64_slice()
693 .expect("duration matrix is normalized to double storage");
694 let mut days = Vec::with_capacity(rows);
695 for row in 0..rows {
696 let total_seconds =
697 values[row] * 3600.0 + values[row + rows] * 60.0 + values[row + 2 * rows];
698 days.push(total_seconds / SECONDS_PER_DAY);
699 }
700 duration_object_from_days(
701 days,
702 vec![rows, 1],
703 format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
704 )
705}
706
707fn format_seconds_field(seconds: f64) -> String {
708 let whole = seconds.floor();
709 let fractional = seconds - whole;
710 if fractional.abs() <= 1e-9 {
711 format!("{:02}", whole as i64)
712 } else {
713 let mut text = format!("{:06.3}", seconds);
714 while text.contains('.') && text.ends_with('0') {
715 text.pop();
716 }
717 if text.ends_with('.') {
718 text.pop();
719 }
720 text
721 }
722}
723
724fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
725 if days.is_nan() {
726 return Ok("NaN".to_string());
727 }
728 if days == f64::INFINITY {
729 return Ok("Inf".to_string());
730 }
731 if days == f64::NEG_INFINITY {
732 return Ok("-Inf".to_string());
733 }
734
735 let total_seconds = days * SECONDS_PER_DAY;
736 let sign = if total_seconds < 0.0 { "-" } else { "" };
737 let total_seconds = total_seconds.abs();
738 let total_hours = (total_seconds / 3600.0).floor();
739 let total_minutes = (total_seconds / 60.0).floor();
740 let hours = total_hours as i64;
741 let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
742 let seconds_component =
743 total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
744
745 let rendered = match format {
746 "hh:mm:ss" => format!(
747 "{sign}{hours:02}:{minutes_component:02}:{}",
748 format_seconds_field(seconds_component)
749 ),
750 "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
751 "mm:ss" => format!(
752 "{sign}{:02}:{}",
753 total_minutes as i64,
754 format_seconds_field(total_seconds - total_minutes * 60.0)
755 ),
756 "s" | "ss" => {
757 let mut text = format!("{:.3}", total_seconds);
758 while text.contains('.') && text.ends_with('0') {
759 text.pop();
760 }
761 if text.ends_with('.') {
762 text.pop();
763 }
764 format!("{sign}{text}")
765 }
766 other => {
767 return Err(duration_error(format!(
768 "duration: unsupported Format value '{other}'"
769 )))
770 }
771 };
772
773 Ok(rendered)
774}
775
776pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
777 let Value::Object(obj) = value else {
778 return Ok(None);
779 };
780 if !obj.is_class(DURATION_CLASS) {
781 return Ok(None);
782 }
783 let days = duration_tensor_from_duration_value(value)?;
784 let format = format_for_object(obj);
785 let day_values = tensor::tensor_values_f64_cow(&days);
786 let mut strings = Vec::with_capacity(day_values.len());
787 for value in day_values.iter() {
788 strings.push(format_duration_value(*value, &format)?);
789 }
790 let shape = tensor::default_shape_for(&days.shape, day_values.len());
791 let array = StringArray::new(strings, shape)
792 .map_err(|err| duration_error(format!("duration: {err}")))?;
793 Ok(Some(array))
794}
795
796pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
797 let Some(array) = duration_string_array(value)? else {
798 return Ok(None);
799 };
800 if array.data.len() == 1 {
801 return Ok(Some(array.data[0].clone()));
802 }
803
804 let rows = array.rows;
805 let cols = array.cols;
806 let mut widths = vec![0usize; cols];
807 for col in 0..cols {
808 for row in 0..rows {
809 let idx = row + col * rows;
810 widths[col] = widths[col].max(array.data[idx].len());
811 }
812 }
813
814 let mut lines = Vec::with_capacity(rows);
815 for row in 0..rows {
816 let mut line = String::new();
817 for col in 0..cols {
818 if col > 0 {
819 line.push_str(" ");
820 }
821 let idx = row + col * rows;
822 let text = &array.data[idx];
823 line.push_str(text);
824 let padding = widths[col].saturating_sub(text.len());
825 if padding > 0 {
826 line.push_str(&" ".repeat(padding));
827 }
828 }
829 lines.push(line);
830 }
831
832 Ok(Some(lines.join("\n")))
833}
834
835pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
836 let Value::Object(obj) = value else {
837 return Ok(None);
838 };
839 if !obj.is_class(DURATION_CLASS) {
840 return Ok(None);
841 }
842 let days = duration_tensor_from_duration_value(value)?;
843 let len = days.len();
844 if len == 1 {
845 return duration_display_text(value);
846 }
847 let shape = tensor::default_shape_for(&days.shape, len);
848 Ok(Some(format!(
849 "[{} duration]",
850 shape
851 .iter()
852 .map(|dim| dim.to_string())
853 .collect::<Vec<_>>()
854 .join("x")
855 )))
856}
857
858pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
859 let Some(array) = duration_string_array(value)? else {
860 return Ok(None);
861 };
862 let width = array.data.iter().map(String::len).max().unwrap_or(0);
863 let rows = array.data.len();
864 let mut data = vec![' '; rows * width];
865 for (row, text) in array.data.iter().enumerate() {
866 for (col, ch) in text.chars().enumerate() {
867 data[row * width + col] = ch;
868 }
869 }
870 let out = CharArray::new(data, rows, width)
871 .map_err(|err| duration_error(format!("duration: {err}")))?;
872 Ok(Some(out))
873}
874
875fn compare_duration(
876 lhs: Value,
877 rhs: Value,
878 op: &str,
879 cmp: impl Fn(f64, f64) -> bool,
880) -> BuiltinResult<Value> {
881 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
882 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
883 let (left, right, shape) =
884 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
885 let out = left
886 .iter()
887 .zip(right.iter())
888 .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
889 .collect::<Vec<_>>();
890 if out.len() == 1 {
891 Ok(Value::Num(out[0]))
892 } else {
893 Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
894 duration_error(format!("duration: {err}"))
895 })?))
896 }
897}
898
899async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
900 let Value::Object(object) = obj else {
901 return Err(duration_error(
902 "duration.subsref: receiver must be a duration object",
903 ));
904 };
905 let format = format_for_object(&object);
906 let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
907
908 let Value::Cell(cell) = payload else {
909 return Err(duration_error(
910 "duration.subsref: indexing payload must be a cell array",
911 ));
912 };
913 if cell.data.is_empty() {
914 return duration_object_from_days_tensor(days, format);
915 }
916 if cell.data.len() != 1 {
917 return Err(duration_error(
918 "duration.subsref: only linear duration indexing is currently supported",
919 ));
920 }
921 let selector = cell.data[0].clone();
922 let selector = match selector {
923 Value::Tensor(tensor) => tensor,
924 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
925 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
926 Value::Int(value) => {
927 Tensor::new_integer(runmat_value::IntegerStorage::from_scalar(value), vec![1, 1])
928 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?
929 }
930 Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
931 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
932 other => {
933 return Err(duration_error(format!(
934 "duration.subsref: unsupported index value {other:?}"
935 )))
936 }
937 };
938 let indexed =
939 crate::perform_indexing(&Value::Tensor(days), &tensor::tensor_values_f64(&selector))
940 .await
941 .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
942 let indexed_days = match indexed {
943 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
944 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
945 Value::Tensor(tensor) => tensor,
946 other => {
947 return Err(duration_error(format!(
948 "duration.subsref: unexpected indexing result {other:?}"
949 )))
950 }
951 };
952 duration_object_from_days_tensor(indexed_days, format)
953}
954
955#[runmat_macros::runtime_builtin(
956 name = "duration",
957 descriptor(crate::builtins::duration::DURATION_DESCRIPTOR),
958 builtin_path = "crate::builtins::duration",
959 category = "datetime",
960 summary = "Create duration arrays from hour, minute, and second components.",
961 keywords = "duration,time span,elapsed time,Format",
962 related = "datetime,string,char,disp",
963 examples = "t = duration(1, 30, 45);",
964 extensions(DURATION_EXTENSIONS),
965 integer_capabilities(DURATION_INTEGER_CAPABILITIES)
966)]
967async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
968 ensure_duration_class_registered();
969 let (raw_positional_end, _) = parse_trailing_format(&args)?;
970 let raw_positional = &args[..raw_positional_end];
971 if args.iter().any(crate::value_contains_gpu) {
972 crate::compatibility::ensure_builtin_extension_enabled(
973 &DURATION_GPU_INPUT_EXTENSION,
974 BUILTIN_NAME,
975 )?;
976 }
977 let short_numeric_form = match raw_positional {
978 [value] => is_numeric_duration_input(value) && !is_public_duration_matrix(value),
979 [first, second] => is_numeric_duration_input(first) && is_numeric_duration_input(second),
980 _ => false,
981 };
982 if short_numeric_form {
983 crate::compatibility::ensure_builtin_extension_enabled(
984 &DURATION_SHORT_COMPONENT_EXTENSION,
985 BUILTIN_NAME,
986 )?;
987 }
988 let args = gather_args(&args).await?;
989 let (positional_end, format) = parse_trailing_format(&args)?;
990 let mut positional = args[..positional_end].to_vec();
991
992 match positional.len() {
993 1 if is_public_duration_matrix(&positional[0]) => {
994 build_from_matrix(positional.remove(0), format)
995 }
996 1..=4 if positional.iter().all(is_numeric_duration_input) => {
997 build_from_components(positional, format)
998 }
999 _ => Err(duration_error(
1000 "duration: unsupported argument pattern; use X or H/MI/S/MS numeric inputs",
1001 )),
1002 }
1003}
1004
1005#[runmat_macros::runtime_builtin(
1006 name = "days",
1007 builtin_path = "crate::builtins::duration",
1008 category = "datetime",
1009 summary = "Create duration values from days or convert duration values to day counts.",
1010 keywords = "days,duration,datetime"
1011)]
1012async fn days_builtin(value: Value) -> crate::BuiltinResult<Value> {
1013 duration_unit_value(value, "days", 1.0).await
1014}
1015
1016#[runmat_macros::runtime_builtin(
1017 name = "hours",
1018 builtin_path = "crate::builtins::duration",
1019 category = "datetime",
1020 summary = "Create duration values from hours or convert duration values to hour counts.",
1021 keywords = "hours,duration,datetime"
1022)]
1023async fn hours_builtin(value: Value) -> crate::BuiltinResult<Value> {
1024 duration_unit_value(value, "hours", 1.0 / 24.0).await
1025}
1026
1027#[runmat_macros::runtime_builtin(
1028 name = "minutes",
1029 builtin_path = "crate::builtins::duration",
1030 category = "datetime",
1031 summary = "Create duration values from minutes or convert duration values to minute counts.",
1032 keywords = "minutes,duration,datetime"
1033)]
1034async fn minutes_builtin(value: Value) -> crate::BuiltinResult<Value> {
1035 duration_unit_value(value, "minutes", 1.0 / (24.0 * 60.0)).await
1036}
1037
1038#[runmat_macros::runtime_builtin(
1039 name = "seconds",
1040 builtin_path = "crate::builtins::duration",
1041 category = "datetime",
1042 summary = "Create duration values from seconds or convert duration values to second counts.",
1043 keywords = "seconds,duration,datetime"
1044)]
1045async fn seconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
1046 duration_unit_value(value, "seconds", 1.0 / SECONDS_PER_DAY).await
1047}
1048
1049#[runmat_macros::runtime_builtin(
1050 name = "milliseconds",
1051 builtin_path = "crate::builtins::duration",
1052 category = "datetime",
1053 summary = "Create duration values from milliseconds or convert duration values to millisecond counts.",
1054 keywords = "milliseconds,duration,datetime"
1055)]
1056async fn milliseconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
1057 duration_unit_value(value, "milliseconds", 1.0 / (SECONDS_PER_DAY * 1000.0)).await
1058}
1059
1060#[runmat_macros::runtime_builtin(
1061 name = "years",
1062 builtin_path = "crate::builtins::duration",
1063 category = "datetime",
1064 summary = "Create fixed-length duration values from years or convert durations to fixed-length years.",
1065 keywords = "years,duration,datetime"
1066)]
1067async fn years_builtin(value: Value) -> crate::BuiltinResult<Value> {
1068 duration_unit_value(value, "years", 365.2425).await
1069}
1070
1071#[runmat_macros::runtime_builtin(
1072 name = "isduration",
1073 builtin_path = "crate::builtins::duration",
1074 category = "datetime",
1075 summary = "Return true for duration values.",
1076 keywords = "isduration,duration,predicate"
1077)]
1078fn isduration_builtin(value: Value) -> crate::BuiltinResult<Value> {
1079 Ok(Value::Bool(is_duration_object(&value)))
1080}
1081
1082#[runmat_macros::runtime_builtin(
1083 name = "duration.subsref",
1084 descriptor(crate::builtins::duration::DURATION_SUBSREF_DESCRIPTOR),
1085 builtin_path = "crate::builtins::duration"
1086)]
1087async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
1088 match kind.as_str() {
1089 OBJECT_INDEX_PAREN => duration_indexing(obj, payload).await,
1090 OBJECT_INDEX_MEMBER => {
1091 let Value::Object(object) = obj else {
1092 return Err(duration_error(
1093 "duration.subsref: receiver must be a duration object",
1094 ));
1095 };
1096 let field = scalar_text(&payload, "field selector")?;
1097 match field.as_str() {
1098 FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
1099 _ => Err(duration_error(format!(
1100 "duration.subsref: unsupported duration property '{field}'"
1101 ))),
1102 }
1103 }
1104 other => Err(duration_error(format!(
1105 "duration.subsref: unsupported indexing kind '{other}'"
1106 ))),
1107 }
1108}
1109
1110#[runmat_macros::runtime_builtin(
1111 name = "duration.subsasgn",
1112 descriptor(crate::builtins::duration::DURATION_SUBSASGN_DESCRIPTOR),
1113 builtin_path = "crate::builtins::duration"
1114)]
1115async fn duration_subsasgn(
1116 obj: Value,
1117 kind: String,
1118 payload: Value,
1119 rhs: Value,
1120) -> crate::BuiltinResult<Value> {
1121 let Value::Object(mut object) = obj else {
1122 return Err(duration_error(
1123 "duration.subsasgn: receiver must be a duration object",
1124 ));
1125 };
1126 match kind.as_str() {
1127 OBJECT_INDEX_MEMBER => {
1128 let field = scalar_text(&payload, "field selector")?;
1129 match field.as_str() {
1130 FORMAT_FIELD => {
1131 let text = scalar_text(&rhs, "Format value")?;
1132 object
1133 .properties
1134 .insert(FORMAT_FIELD.to_string(), Value::String(text));
1135 Ok(Value::Object(object))
1136 }
1137 _ => Err(duration_error(format!(
1138 "duration.subsasgn: unsupported duration property '{field}'"
1139 ))),
1140 }
1141 }
1142 _ => Err(duration_error(format!(
1143 "duration.subsasgn: unsupported indexing kind '{kind}'"
1144 ))),
1145 }
1146}
1147
1148#[runmat_macros::runtime_builtin(
1149 name = "duration.eq",
1150 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1151 builtin_path = "crate::builtins::duration"
1152)]
1153async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1154 compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
1155}
1156
1157#[runmat_macros::runtime_builtin(
1158 name = "duration.ne",
1159 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1160 builtin_path = "crate::builtins::duration"
1161)]
1162async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1163 compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
1164}
1165
1166#[runmat_macros::runtime_builtin(
1167 name = "duration.lt",
1168 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1169 builtin_path = "crate::builtins::duration"
1170)]
1171async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1172 compare_duration(lhs, rhs, "lt", |a, b| a < b)
1173}
1174
1175#[runmat_macros::runtime_builtin(
1176 name = "duration.le",
1177 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1178 builtin_path = "crate::builtins::duration"
1179)]
1180async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1181 compare_duration(lhs, rhs, "le", |a, b| a <= b)
1182}
1183
1184#[runmat_macros::runtime_builtin(
1185 name = "duration.gt",
1186 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1187 builtin_path = "crate::builtins::duration"
1188)]
1189async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1190 compare_duration(lhs, rhs, "gt", |a, b| a > b)
1191}
1192
1193#[runmat_macros::runtime_builtin(
1194 name = "duration.ge",
1195 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1196 builtin_path = "crate::builtins::duration"
1197)]
1198async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1199 compare_duration(lhs, rhs, "ge", |a, b| a >= b)
1200}
1201
1202#[runmat_macros::runtime_builtin(
1203 name = "duration.plus",
1204 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1205 builtin_path = "crate::builtins::duration"
1206)]
1207async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1208 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1209 if crate::builtins::datetime::is_datetime_object(&rhs) {
1210 let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
1211 let (left, right, shape) =
1212 tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
1213 let serials = left
1214 .iter()
1215 .zip(right.iter())
1216 .map(|(a, b)| a + b)
1217 .collect::<Vec<_>>();
1218 let tensor =
1219 Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
1220 return crate::builtins::datetime::datetime_object_from_serial_tensor(
1221 tensor,
1222 crate::builtins::datetime::datetime_format_from_value(&rhs),
1223 );
1224 }
1225
1226 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1227 let (left, right, shape) =
1228 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
1229 let days = left
1230 .iter()
1231 .zip(right.iter())
1232 .map(|(a, b)| a + b)
1233 .collect::<Vec<_>>();
1234 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1235}
1236
1237#[runmat_macros::runtime_builtin(
1238 name = "duration.minus",
1239 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1240 builtin_path = "crate::builtins::duration"
1241)]
1242async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1243 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1244 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1245 let (left, right, shape) =
1246 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
1247 let days = left
1248 .iter()
1249 .zip(right.iter())
1250 .map(|(a, b)| a - b)
1251 .collect::<Vec<_>>();
1252 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 fn run_duration(args: Vec<Value>) -> Value {
1260 futures::executor::block_on(duration_builtin(args)).expect("duration")
1261 }
1262
1263 fn integer_tensor(storage: runmat_value::IntegerStorage, shape: Vec<usize>) -> Value {
1264 Value::Tensor(Tensor::new_integer(storage, shape).expect("integer tensor"))
1265 }
1266
1267 #[test]
1268 fn duration_descriptor_signatures_cover_constructor_and_methods() {
1269 let labels: Vec<&str> = DURATION_DESCRIPTOR
1270 .signatures
1271 .iter()
1272 .map(|sig| sig.label)
1273 .collect();
1274 assert!(labels.contains(&"t = duration(X)"));
1275 assert!(labels.contains(&"t = duration(hours, minutes, seconds)"));
1276 assert!(labels.contains(&"t = duration(hours, minutes, seconds, milliseconds)"));
1277 assert!(labels.contains(&"t = duration(___, \"Format\", format)"));
1278
1279 let four_component = DURATION_DESCRIPTOR
1280 .signatures
1281 .iter()
1282 .find(|signature| {
1283 signature.label == "t = duration(hours, minutes, seconds, milliseconds)"
1284 })
1285 .expect("four-component duration signature");
1286 assert_eq!(
1287 four_component
1288 .inputs
1289 .iter()
1290 .map(|input| input.name)
1291 .collect::<Vec<_>>(),
1292 ["hours", "minutes", "seconds", "milliseconds"]
1293 );
1294 assert!(four_component.inputs.iter().all(|input| {
1295 matches!(input.ty, BuiltinParamType::NumericArray)
1296 && matches!(input.arity, BuiltinParamArity::Required)
1297 }));
1298
1299 assert_eq!(
1300 DURATION_SUBSREF_DESCRIPTOR.signatures[0].label,
1301 "out = duration.subsref(obj, kind, payload)"
1302 );
1303 assert_eq!(
1304 DURATION_BINARY_DESCRIPTOR.signatures[0].label,
1305 "out = duration.op(lhs, rhs)"
1306 );
1307 }
1308
1309 #[test]
1310 fn duration_builds_from_components() {
1311 let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
1312 let rendered = duration_display_text(&value)
1313 .expect("display")
1314 .expect("duration text");
1315 assert_eq!(rendered, "01:30:45");
1316 }
1317
1318 #[test]
1319 fn duration_formats_arrays() {
1320 let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
1321 let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
1322 let value = run_duration(vec![hours, minutes, Value::Num(0.0)]);
1323 let rendered = duration_display_text(&value)
1324 .expect("display")
1325 .expect("duration text");
1326 assert!(rendered.contains("01:15:00"));
1327 assert!(rendered.contains("02:45:00"));
1328 }
1329
1330 #[test]
1331 fn duration_typed_integer_components_cross_double_boundary_exactly() {
1332 let hours = integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 2]), vec![1, 2]);
1333 let minutes = integer_tensor(runmat_value::IntegerStorage::U16(vec![15, 45]), vec![1, 2]);
1334 let seconds = integer_tensor(runmat_value::IntegerStorage::I16(vec![0, 30]), vec![1, 2]);
1335 let value = run_duration(vec![hours, minutes, seconds]);
1336 let rendered = duration_display_text(&value)
1337 .expect("display")
1338 .expect("duration text");
1339 assert!(rendered.contains("01:15:00"));
1340 assert!(rendered.contains("02:45:30"));
1341 }
1342
1343 #[test]
1344 fn duration_integer_matrix_form_supports_all_classes_and_returns_column() {
1345 let storages = [
1346 runmat_value::IntegerStorage::I8(vec![1, 2, 15, 45, 0, 30]),
1347 runmat_value::IntegerStorage::I16(vec![1, 2, 15, 45, 0, 30]),
1348 runmat_value::IntegerStorage::I32(vec![1, 2, 15, 45, 0, 30]),
1349 runmat_value::IntegerStorage::I64(vec![1, 2, 15, 45, 0, 30]),
1350 runmat_value::IntegerStorage::U8(vec![1, 2, 15, 45, 0, 30]),
1351 runmat_value::IntegerStorage::U16(vec![1, 2, 15, 45, 0, 30]),
1352 runmat_value::IntegerStorage::U32(vec![1, 2, 15, 45, 0, 30]),
1353 runmat_value::IntegerStorage::U64(vec![1, 2, 15, 45, 0, 30]),
1354 ];
1355 for storage in storages {
1356 let value = run_duration(vec![integer_tensor(storage, vec![2, 3])]);
1357 let days = duration_tensor_from_duration_value(&value).expect("duration days");
1358 assert_eq!(days.shape, vec![2, 1]);
1359 let rendered = duration_display_text(&value)
1360 .expect("display")
1361 .expect("duration text");
1362 assert!(rendered.contains("01:15:00"));
1363 assert!(rendered.contains("02:45:30"));
1364 }
1365 }
1366
1367 #[test]
1368 fn duration_four_component_form_adds_milliseconds() {
1369 let value = run_duration(vec![
1370 Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap()),
1371 Value::Num(0.0),
1372 Value::Num(1.0),
1373 Value::Int(runmat_value::IntValue::U16(250)),
1374 ]);
1375 let days = duration_tensor_from_duration_value(&value).expect("duration days");
1376 assert_eq!(days.shape, vec![1, 2]);
1377 let seconds: Vec<f64> = days
1378 .materialize_f64()
1379 .into_iter()
1380 .map(|days| days * SECONDS_PER_DAY)
1381 .collect();
1382 assert!((seconds[0] - 1.25).abs() < 1.0e-12);
1383 assert!((seconds[1] - 3601.25).abs() < 1.0e-9);
1384 }
1385
1386 #[test]
1387 fn duration_public_components_preserve_nan_and_infinity() {
1388 let value = run_duration(vec![Value::Num(f64::NAN), Value::Num(0.0), Value::Num(0.0)]);
1389 assert!(duration_tensor_from_duration_value(&value)
1390 .unwrap()
1391 .materialize_f64()[0]
1392 .is_nan());
1393 for infinite in [f64::INFINITY, f64::NEG_INFINITY] {
1394 let value = run_duration(vec![Value::Num(infinite), Value::Num(0.0), Value::Num(0.0)]);
1395 assert_eq!(
1396 duration_tensor_from_duration_value(&value)
1397 .unwrap()
1398 .materialize_f64()[0],
1399 infinite
1400 );
1401 let expected = if infinite.is_sign_negative() {
1402 "-Inf"
1403 } else {
1404 "Inf"
1405 };
1406 assert_eq!(
1407 duration_display_text(&value).expect("display"),
1408 Some(expected.to_string())
1409 );
1410 assert_eq!(
1411 duration_string_array(&value)
1412 .expect("string conversion")
1413 .expect("duration string array")
1414 .data,
1415 vec![expected.to_string()]
1416 );
1417 let chars = duration_char_array(&value)
1418 .expect("char conversion")
1419 .expect("duration char array");
1420 assert_eq!(chars.data.iter().collect::<String>(), expected);
1421 }
1422 }
1423
1424 #[test]
1425 fn duration_short_numeric_forms_are_extension_gated_but_matrix_is_public() {
1426 let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1427 let error = futures::executor::block_on(duration_builtin(vec![Value::Num(1.0)]))
1428 .expect_err("one-component hour extension");
1429 assert_eq!(
1430 error.identifier(),
1431 DURATION_SHORT_COMPONENT_EXTENSION.error_identifier
1432 );
1433 let matrix = integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 30, 0]), vec![1, 3]);
1434 futures::executor::block_on(duration_builtin(vec![matrix]))
1435 .expect("documented matrix form remains public");
1436 drop(strict);
1437
1438 let extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1439 futures::executor::block_on(duration_builtin(vec![Value::Num(1.0)]))
1440 .expect("one-component extension in RunMat mode");
1441 futures::executor::block_on(duration_builtin(vec![Value::Num(1.0), Value::Num(30.0)]))
1442 .expect("two-component extension in RunMat mode");
1443 drop(extensions);
1444 }
1445
1446 #[test]
1447 fn duration_gpu_extension_rejects_before_provider_access() {
1448 let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1449 let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
1450 shape: vec![1, 3],
1451 device_id: 0,
1452 buffer_id: 9_399_002,
1453 descriptor: Default::default(),
1454 });
1455 let error = futures::executor::block_on(duration_builtin(vec![resident]))
1456 .expect_err("GPU extension gate");
1457 assert_eq!(
1458 error.identifier(),
1459 DURATION_GPU_INPUT_EXTENSION.error_identifier
1460 );
1461 drop(strict);
1462 }
1463
1464 #[test]
1465 fn duration_missing_days_render_without_error() {
1466 let value = duration_object_from_days_tensor(
1467 Tensor::new(vec![f64::NAN], vec![1, 1]).unwrap(),
1468 DEFAULT_DURATION_FORMAT,
1469 )
1470 .expect("duration object");
1471 let rendered = duration_string_array(&value)
1472 .expect("string array")
1473 .expect("duration strings");
1474 assert_eq!(rendered.data, vec!["NaN".to_string()]);
1475 assert_eq!(
1476 duration_display_text(&value).expect("display"),
1477 Some("NaN".to_string())
1478 );
1479 }
1480
1481 #[test]
1482 fn duration_unit_helpers_create_and_convert_values() {
1483 let one_day = futures::executor::block_on(days_builtin(Value::Num(1.0))).expect("days");
1484 assert!(is_duration_object(&one_day));
1485 let as_hours = futures::executor::block_on(hours_builtin(one_day.clone())).expect("hours");
1486 assert_eq!(as_hours, Value::Num(24.0));
1487 let as_minutes =
1488 futures::executor::block_on(minutes_builtin(one_day.clone())).expect("minutes");
1489 assert_eq!(as_minutes, Value::Num(1440.0));
1490 let as_seconds =
1491 futures::executor::block_on(seconds_builtin(one_day.clone())).expect("seconds");
1492 assert_eq!(as_seconds, Value::Num(86_400.0));
1493 let as_millis =
1494 futures::executor::block_on(milliseconds_builtin(one_day.clone())).expect("millis");
1495 assert_eq!(as_millis, Value::Num(86_400_000.0));
1496
1497 let two_hours = futures::executor::block_on(hours_builtin(Value::Num(2.0))).expect("hours");
1498 let rendered = duration_display_text(&two_hours)
1499 .expect("display")
1500 .expect("duration text");
1501 assert_eq!(rendered, "02:00:00");
1502
1503 let year = futures::executor::block_on(years_builtin(Value::Num(1.0))).expect("years");
1504 let year_days = duration_tensor_from_duration_value(&year).expect("duration tensor");
1505 assert!((tensor::tensor_value_f64(&year_days, 0) - 365.2425).abs() < 1e-9);
1506 assert_eq!(
1507 isduration_builtin(year).expect("isduration"),
1508 Value::Bool(true)
1509 );
1510 assert_eq!(
1511 isduration_builtin(Value::Num(1.0)).expect("isduration"),
1512 Value::Bool(false)
1513 );
1514 assert!(futures::executor::block_on(years_builtin(Value::Num(f64::MAX))).is_err());
1515 }
1516
1517 #[test]
1518 fn duration_unit_helpers_read_typed_integer_days_exactly() {
1519 let days = Tensor::new_integer(runmat_value::IntegerStorage::I16(vec![1, 2]), vec![1, 2])
1520 .expect("integer tensor");
1521 let value = duration_object_from_days_tensor(days, DEFAULT_DURATION_FORMAT)
1522 .expect("duration object");
1523
1524 let hours = futures::executor::block_on(hours_builtin(value.clone())).expect("hours");
1525 assert_eq!(
1526 hours,
1527 Value::Tensor(Tensor::new(vec![24.0, 48.0], vec![1, 2]).unwrap())
1528 );
1529
1530 let rendered = duration_display_text(&value)
1531 .expect("display")
1532 .expect("duration text");
1533 assert!(rendered.contains("24:00:00"));
1534 assert!(rendered.contains("48:00:00"));
1535 assert_eq!(
1536 duration_summary(&value).expect("summary"),
1537 Some("[1x2 duration]".to_string())
1538 );
1539 }
1540
1541 #[test]
1542 fn duration_supports_format_assignment_and_indexing() {
1543 let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0), Value::Num(0.0)]);
1544 let updated = futures::executor::block_on(duration_subsasgn(
1545 value.clone(),
1546 ".".to_string(),
1547 Value::String(FORMAT_FIELD.to_string()),
1548 Value::String("hh:mm".to_string()),
1549 ))
1550 .expect("subsasgn");
1551 let rendered = duration_display_text(&updated)
1552 .expect("display")
1553 .expect("duration text");
1554 assert_eq!(rendered, "01:05");
1555
1556 let array = run_duration(vec![
1557 Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1558 Value::Num(0.0),
1559 Value::Num(0.0),
1560 ]);
1561 let payload =
1562 Value::Cell(runmat_value::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1563 let indexed =
1564 futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1565 .expect("subsref");
1566 let text = duration_display_text(&indexed)
1567 .expect("display")
1568 .expect("duration text");
1569 assert_eq!(text, "02:00:00");
1570 }
1571
1572 #[test]
1573 fn duration_typed_integer_index_selectors_are_exact() {
1574 let array = run_duration(vec![
1575 integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 2]), vec![1, 2]),
1576 Value::Num(0.0),
1577 Value::Num(0.0),
1578 ]);
1579 let payload = Value::Cell(
1580 runmat_value::CellArray::new(
1581 vec![integer_tensor(
1582 runmat_value::IntegerStorage::U64(vec![2]),
1583 vec![1, 1],
1584 )],
1585 1,
1586 1,
1587 )
1588 .unwrap(),
1589 );
1590 let indexed =
1591 futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1592 .expect("subsref");
1593 let text = duration_display_text(&indexed)
1594 .expect("display")
1595 .expect("duration text");
1596 assert_eq!(text, "02:00:00");
1597 }
1598}