1use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use num_complex::Complex64;
7use runmat_builtins::{
8 Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
9 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10 CharArray, ClassDef, ComplexTensor, MethodDef, ObjectInstance, PropertyDef, Tensor, Value,
11};
12use runmat_macros::runtime_builtin;
13
14use crate::builtins::common::spec::{
15 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16 ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::common::tensor;
19use crate::builtins::control::type_resolvers::tf_type;
20use crate::{build_runtime_error, dispatcher, BuiltinResult, RuntimeError};
21
22const BUILTIN_NAME: &str = "tf";
23const TF_CLASS: &str = "tf";
24const DEFAULT_VARIABLE: &str = "s";
25const EPS: f64 = 1.0e-12;
26
27static TF_CLASS_REGISTERED: OnceLock<()> = OnceLock::new();
28
29const TF_OUTPUT_SYS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
30 name: "sys",
31 ty: BuiltinParamType::Any,
32 arity: BuiltinParamArity::Required,
33 default: None,
34 description: "SISO transfer-function object.",
35}];
36const TF_PARAM_NUMERATOR: BuiltinParamDescriptor = BuiltinParamDescriptor {
37 name: "numerator",
38 ty: BuiltinParamType::Any,
39 arity: BuiltinParamArity::Required,
40 default: None,
41 description: "Numerator coefficient vector.",
42};
43const TF_PARAM_DENOMINATOR: BuiltinParamDescriptor = BuiltinParamDescriptor {
44 name: "denominator",
45 ty: BuiltinParamType::Any,
46 arity: BuiltinParamArity::Required,
47 default: None,
48 description: "Denominator coefficient vector.",
49};
50const TF_INPUTS_NUM_DEN: [BuiltinParamDescriptor; 2] = [TF_PARAM_NUMERATOR, TF_PARAM_DENOMINATOR];
51const TF_INPUTS_NUM_DEN_TS: [BuiltinParamDescriptor; 3] = [
52 TF_PARAM_NUMERATOR,
53 TF_PARAM_DENOMINATOR,
54 BuiltinParamDescriptor {
55 name: "Ts",
56 ty: BuiltinParamType::NumericScalar,
57 arity: BuiltinParamArity::Optional,
58 default: Some("0.0"),
59 description: "Sample time (0 for continuous-time model).",
60 },
61];
62const TF_INPUTS_NUM_DEN_NAMEVALUE: [BuiltinParamDescriptor; 4] = [
63 TF_PARAM_NUMERATOR,
64 TF_PARAM_DENOMINATOR,
65 BuiltinParamDescriptor {
66 name: "name",
67 ty: BuiltinParamType::StringScalar,
68 arity: BuiltinParamArity::Variadic,
69 default: None,
70 description: "Option name ('Variable' or 'Ts').",
71 },
72 BuiltinParamDescriptor {
73 name: "value",
74 ty: BuiltinParamType::Any,
75 arity: BuiltinParamArity::Variadic,
76 default: None,
77 description: "Option value.",
78 },
79];
80const TF_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
81 BuiltinSignatureDescriptor {
82 label: "sys = tf(numerator, denominator)",
83 inputs: &TF_INPUTS_NUM_DEN,
84 outputs: &TF_OUTPUT_SYS,
85 },
86 BuiltinSignatureDescriptor {
87 label: "sys = tf(numerator, denominator, Ts)",
88 inputs: &TF_INPUTS_NUM_DEN_TS,
89 outputs: &TF_OUTPUT_SYS,
90 },
91 BuiltinSignatureDescriptor {
92 label: "sys = tf(numerator, denominator, \"Variable\", variableName)",
93 inputs: &TF_INPUTS_NUM_DEN_NAMEVALUE,
94 outputs: &TF_OUTPUT_SYS,
95 },
96 BuiltinSignatureDescriptor {
97 label: "sys = tf(numerator, denominator, name, value, ...)",
98 inputs: &TF_INPUTS_NUM_DEN_NAMEVALUE,
99 outputs: &TF_OUTPUT_SYS,
100 },
101];
102const TF_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
103 code: "RM.TF.INVALID_ARGUMENT",
104 identifier: Some("RunMat:tf:InvalidArgument"),
105 when: "Arguments do not match supported tf invocation forms.",
106 message: "tf: invalid argument",
107};
108const TF_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109 code: "RM.TF.INVALID_OPTION",
110 identifier: Some("RunMat:tf:InvalidOption"),
111 when: "A name/value option token is unsupported or malformed.",
112 message: "tf: invalid option",
113};
114const TF_ERROR_INVALID_SAMPLE_TIME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
115 code: "RM.TF.INVALID_SAMPLE_TIME",
116 identifier: Some("RunMat:tf:InvalidSampleTime"),
117 when: "Sample time is not a finite non-negative scalar.",
118 message: "tf: sample time must be a finite non-negative scalar",
119};
120const TF_ERROR_INVALID_VARIABLE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121 code: "RM.TF.INVALID_VARIABLE",
122 identifier: Some("RunMat:tf:InvalidVariable"),
123 when: "Variable option is not a supported control variable name.",
124 message: "tf: invalid Variable option",
125};
126const TF_ERROR_INVALID_COEFFICIENTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
127 code: "RM.TF.INVALID_COEFFICIENTS",
128 identifier: Some("RunMat:tf:InvalidCoefficients"),
129 when: "Numerator/denominator coefficients are not valid finite vectors.",
130 message: "tf: invalid coefficients",
131};
132const TF_ERROR_DENOMINATOR_INVALID: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
133 code: "RM.TF.DENOMINATOR_INVALID",
134 identifier: Some("RunMat:tf:DenominatorInvalid"),
135 when: "Denominator coefficient vector is empty or all zeros.",
136 message: "tf: invalid denominator coefficients",
137};
138const TF_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
139 code: "RM.TF.INTERNAL",
140 identifier: Some("RunMat:tf:Internal"),
141 when: "Internal tensor/object construction failed.",
142 message: "tf: internal error",
143};
144const TF_ERRORS: [BuiltinErrorDescriptor; 7] = [
145 TF_ERROR_INVALID_ARGUMENT,
146 TF_ERROR_INVALID_OPTION,
147 TF_ERROR_INVALID_SAMPLE_TIME,
148 TF_ERROR_INVALID_VARIABLE,
149 TF_ERROR_INVALID_COEFFICIENTS,
150 TF_ERROR_DENOMINATOR_INVALID,
151 TF_ERROR_INTERNAL,
152];
153pub const TF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
154 signatures: &TF_SIGNATURES,
155 output_mode: BuiltinOutputMode::Fixed,
156 completion_policy: BuiltinCompletionPolicy::Public,
157 errors: &TF_ERRORS,
158};
159
160#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::tf")]
161pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
162 name: "tf",
163 op_kind: GpuOpKind::Custom("transfer-function-constructor"),
164 supported_precisions: &[],
165 broadcast: BroadcastSemantics::None,
166 provider_hooks: &[],
167 constant_strategy: ConstantStrategy::InlineLiteral,
168 residency: ResidencyPolicy::GatherImmediately,
169 nan_mode: ReductionNaN::Include,
170 two_pass_threshold: None,
171 workgroup_size: None,
172 accepts_nan_mode: false,
173 notes: "Object construction runs on the host. gpuArray coefficient inputs are gathered before storing the transfer-function metadata.",
174};
175
176#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::tf")]
177pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
178 name: "tf",
179 shape: ShapeRequirements::Any,
180 constant_strategy: ConstantStrategy::InlineLiteral,
181 elementwise: None,
182 reduction: None,
183 emits_nan: false,
184 notes: "Transfer-function construction is metadata-only and terminates numeric fusion chains.",
185};
186
187fn tf_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
188 tf_error_with_message(error.message, error)
189}
190
191fn tf_error_with_detail(
192 error: &'static BuiltinErrorDescriptor,
193 detail: impl AsRef<str>,
194) -> RuntimeError {
195 tf_error_with_message(format!("{}: {}", error.message, detail.as_ref()), error)
196}
197
198fn tf_error_with_message(
199 message: impl Into<String>,
200 error: &'static BuiltinErrorDescriptor,
201) -> RuntimeError {
202 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
203 if let Some(identifier) = error.identifier {
204 builder = builder.with_identifier(identifier);
205 }
206 builder.build()
207}
208
209fn ensure_tf_class_registered() {
210 TF_CLASS_REGISTERED.get_or_init(|| {
211 let mut properties = HashMap::new();
212 for name in [
213 "Numerator",
214 "Denominator",
215 "Variable",
216 "Ts",
217 "InputDelay",
218 "OutputDelay",
219 ] {
220 properties.insert(
221 name.to_string(),
222 PropertyDef {
223 name: name.to_string(),
224 is_static: false,
225 is_constant: false,
226 is_dependent: false,
227 get_access: Access::Public,
228 set_access: Access::Public,
229 default_value: None,
230 },
231 );
232 }
233
234 let methods: HashMap<String, MethodDef> = HashMap::new();
235 runmat_builtins::register_class(ClassDef {
236 name: TF_CLASS.to_string(),
237 parent: None,
238 properties,
239 methods,
240 });
241 });
242}
243
244#[runtime_builtin(
245 name = "tf",
246 category = "control",
247 summary = "Create SISO transfer-function objects from numerator and denominator coefficients.",
248 keywords = "tf,transfer function,control system,filter,polynomial",
249 type_resolver(tf_type),
250 descriptor(crate::builtins::control::tf::TF_DESCRIPTOR),
251 builtin_path = "crate::builtins::control::tf"
252)]
253async fn tf_builtin(
254 numerator: Value,
255 denominator: Value,
256 rest: Vec<Value>,
257) -> BuiltinResult<Value> {
258 let options = TfOptions::parse(&rest)?;
259 let numerator = Coefficients::parse("numerator", numerator).await?;
260 let denominator = Coefficients::parse("denominator", denominator).await?;
261
262 if denominator.coeffs.is_empty() {
263 return Err(tf_error_with_detail(
264 &TF_ERROR_DENOMINATOR_INVALID,
265 "denominator coefficients cannot be empty",
266 ));
267 }
268 if denominator.is_all_zero() {
269 return Err(tf_error_with_detail(
270 &TF_ERROR_DENOMINATOR_INVALID,
271 "denominator coefficients must not all be zero",
272 ));
273 }
274
275 ensure_tf_class_registered();
276 let mut object = ObjectInstance::new(TF_CLASS.to_string());
277 object
278 .properties
279 .insert("Numerator".to_string(), numerator.into_row_value()?);
280 object
281 .properties
282 .insert("Denominator".to_string(), denominator.into_row_value()?);
283 object.properties.insert(
284 "Variable".to_string(),
285 Value::CharArray(CharArray::new_row(&options.variable)),
286 );
287 object
288 .properties
289 .insert("Ts".to_string(), Value::Num(options.sample_time));
290 object
291 .properties
292 .insert("InputDelay".to_string(), Value::Num(0.0));
293 object
294 .properties
295 .insert("OutputDelay".to_string(), Value::Num(0.0));
296 Ok(Value::Object(object))
297}
298
299#[derive(Clone)]
300struct TfOptions {
301 variable: String,
302 sample_time: f64,
303 variable_explicit: bool,
304}
305
306impl TfOptions {
307 fn parse(rest: &[Value]) -> BuiltinResult<Self> {
308 let mut options = Self {
309 variable: DEFAULT_VARIABLE.to_string(),
310 sample_time: 0.0,
311 variable_explicit: false,
312 };
313
314 match rest {
315 [] => {}
316 [sample_time] => {
317 options.sample_time = parse_sample_time(sample_time)?;
318 if options.sample_time > 0.0 {
319 options.variable = "z".to_string();
320 }
321 }
322 _ => {
323 if !rest.len().is_multiple_of(2) {
324 return Err(tf_error_with_detail(
325 &TF_ERROR_INVALID_ARGUMENT,
326 "optional arguments must be name-value pairs or a scalar sample time",
327 ));
328 }
329 let mut idx = 0;
330 while idx < rest.len() {
331 let name = scalar_text(&rest[idx], "option name")?;
332 let lowered = name.trim().to_ascii_lowercase();
333 let value = &rest[idx + 1];
334 match lowered.as_str() {
335 "variable" => {
336 options.variable = parse_variable(value)?;
337 options.variable_explicit = true;
338 }
339 "ts" | "sampletime" => options.sample_time = parse_sample_time(value)?,
340 _ => {
341 return Err(tf_error_with_detail(
342 &TF_ERROR_INVALID_OPTION,
343 format!("unsupported option '{name}'"),
344 ));
345 }
346 }
347 idx += 2;
348 }
349 if options.sample_time > 0.0 && !options.variable_explicit {
350 options.variable = "z".to_string();
351 }
352 }
353 }
354
355 Ok(options)
356 }
357}
358
359fn parse_sample_time(value: &Value) -> BuiltinResult<f64> {
360 let sample_time = match value {
361 Value::Num(n) => *n,
362 Value::Int(i) => i.to_f64(),
363 other => {
364 return Err(tf_error_with_detail(
365 &TF_ERROR_INVALID_SAMPLE_TIME,
366 format!("expected non-negative scalar, got {other:?}"),
367 ))
368 }
369 };
370 if !sample_time.is_finite() || sample_time < 0.0 {
371 return Err(tf_error(&TF_ERROR_INVALID_SAMPLE_TIME));
372 }
373 Ok(sample_time)
374}
375
376fn parse_variable(value: &Value) -> BuiltinResult<String> {
377 let variable = scalar_text(value, "Variable")?;
378 let variable = variable.trim();
379 match variable {
380 "s" | "p" | "z" | "q" | "z^-1" | "q^-1" => Ok(variable.to_string()),
381 _ => Err(tf_error_with_detail(
382 &TF_ERROR_INVALID_VARIABLE,
383 "must be one of 's', 'p', 'z', 'q', 'z^-1', or 'q^-1'",
384 )),
385 }
386}
387
388fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
389 match value {
390 Value::String(text) => Ok(text.clone()),
391 Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
392 Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
393 other => Err(tf_error_with_detail(
394 &TF_ERROR_INVALID_ARGUMENT,
395 format!("{context} must be a string scalar or character vector, got {other:?}"),
396 )),
397 }
398}
399
400#[derive(Clone)]
401struct Coefficients {
402 coeffs: Vec<Complex64>,
403}
404
405impl Coefficients {
406 async fn parse(label: &str, value: Value) -> BuiltinResult<Self> {
407 let gathered = dispatcher::gather_if_needed_async(&value).await?;
408 let coeffs = match gathered {
409 Value::Tensor(tensor) => {
410 ensure_vector_shape(label, &tensor.shape)?;
411 tensor
412 .data
413 .into_iter()
414 .map(|re| Complex64::new(re, 0.0))
415 .collect()
416 }
417 Value::ComplexTensor(tensor) => {
418 ensure_vector_shape(label, &tensor.shape)?;
419 tensor
420 .data
421 .into_iter()
422 .map(|(re, im)| Complex64::new(re, im))
423 .collect()
424 }
425 Value::LogicalArray(logical) => {
426 let tensor = tensor::logical_to_tensor(&logical).map_err(|err| {
427 tf_error_with_detail(
428 &TF_ERROR_INVALID_COEFFICIENTS,
429 format!("failed to convert logical array: {err}"),
430 )
431 })?;
432 ensure_vector_shape(label, &tensor.shape)?;
433 tensor
434 .data
435 .into_iter()
436 .map(|re| Complex64::new(re, 0.0))
437 .collect()
438 }
439 Value::Num(n) => vec![Complex64::new(n, 0.0)],
440 Value::Int(i) => vec![Complex64::new(i.to_f64(), 0.0)],
441 Value::Bool(b) => vec![Complex64::new(if b { 1.0 } else { 0.0 }, 0.0)],
442 Value::Complex(re, im) => vec![Complex64::new(re, im)],
443 other => {
444 return Err(tf_error_with_detail(
445 &TF_ERROR_INVALID_COEFFICIENTS,
446 format!("{label} must be a numeric coefficient vector, got {other:?}"),
447 ));
448 }
449 };
450
451 if coeffs.is_empty() {
452 return Err(tf_error_with_detail(
453 &TF_ERROR_INVALID_COEFFICIENTS,
454 format!("{label} coefficients cannot be empty"),
455 ));
456 }
457 for coeff in &coeffs {
458 if !coeff.re.is_finite() || !coeff.im.is_finite() {
459 return Err(tf_error_with_detail(
460 &TF_ERROR_INVALID_COEFFICIENTS,
461 format!("{label} coefficients must be finite"),
462 ));
463 }
464 }
465
466 Ok(Self { coeffs })
467 }
468
469 fn is_all_zero(&self) -> bool {
470 self.coeffs.iter().all(|coeff| coeff.norm() <= EPS)
471 }
472
473 fn into_row_value(self) -> BuiltinResult<Value> {
474 let len = self.coeffs.len();
475 if self.coeffs.iter().all(|coeff| coeff.im.abs() <= EPS) {
476 let data = self.coeffs.into_iter().map(|coeff| coeff.re).collect();
477 let tensor = Tensor::new(data, vec![1, len]).map_err(|err| {
478 tf_error_with_detail(&TF_ERROR_INTERNAL, format!("failed to build tensor: {err}"))
479 })?;
480 Ok(Value::Tensor(tensor))
481 } else {
482 let data = self
483 .coeffs
484 .into_iter()
485 .map(|coeff| (coeff.re, coeff.im))
486 .collect();
487 let tensor = ComplexTensor::new(data, vec![1, len]).map_err(|err| {
488 tf_error_with_detail(
489 &TF_ERROR_INTERNAL,
490 format!("failed to build complex tensor: {err}"),
491 )
492 })?;
493 Ok(Value::ComplexTensor(tensor))
494 }
495 }
496}
497
498fn ensure_vector_shape(label: &str, shape: &[usize]) -> BuiltinResult<()> {
499 let non_unit = shape.iter().copied().filter(|&dim| dim > 1).count();
500 if non_unit <= 1 {
501 Ok(())
502 } else {
503 Err(tf_error_with_detail(
504 &TF_ERROR_INVALID_COEFFICIENTS,
505 format!("{label} coefficients must be a vector"),
506 ))
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use futures::executor::block_on;
514 use runmat_builtins::IntValue;
515
516 fn run_tf(numerator: Value, denominator: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
517 block_on(tf_builtin(numerator, denominator, rest))
518 }
519
520 fn property<'a>(value: &'a Value, name: &str) -> &'a Value {
521 let Value::Object(object) = value else {
522 panic!("expected object, got {value:?}");
523 };
524 object
525 .properties
526 .get(name)
527 .unwrap_or_else(|| panic!("missing property {name}"))
528 }
529
530 #[test]
531 fn tf_descriptor_signatures_cover_core_forms() {
532 let labels: Vec<&str> = TF_DESCRIPTOR
533 .signatures
534 .iter()
535 .map(|sig| sig.label)
536 .collect();
537 assert!(labels.contains(&"sys = tf(numerator, denominator)"));
538 assert!(labels.contains(&"sys = tf(numerator, denominator, Ts)"));
539 assert!(labels.contains(&"sys = tf(numerator, denominator, \"Variable\", variableName)"));
540 assert!(labels.contains(&"sys = tf(numerator, denominator, name, value, ...)"));
541 }
542
543 #[test]
544 fn tf_constructs_continuous_siso_object() {
545 let sys = run_tf(
546 Value::Num(20.0),
547 Value::Tensor(Tensor::new(vec![1.0, 5.0], vec![1, 2]).unwrap()),
548 Vec::new(),
549 )
550 .expect("tf");
551
552 let Value::Object(object) = &sys else {
553 panic!("expected object");
554 };
555 assert_eq!(object.class_name, "tf");
556 assert_eq!(
557 property(&sys, "Variable"),
558 &Value::CharArray(CharArray::new_row("s"))
559 );
560 assert_eq!(property(&sys, "Ts"), &Value::Num(0.0));
561 match property(&sys, "Numerator") {
562 Value::Tensor(tensor) => {
563 assert_eq!(tensor.shape, vec![1, 1]);
564 assert_eq!(tensor.data, vec![20.0]);
565 }
566 other => panic!("expected numerator tensor, got {other:?}"),
567 }
568 match property(&sys, "Denominator") {
569 Value::Tensor(tensor) => {
570 assert_eq!(tensor.shape, vec![1, 2]);
571 assert_eq!(tensor.data, vec![1.0, 5.0]);
572 }
573 other => panic!("expected denominator tensor, got {other:?}"),
574 }
575 }
576
577 #[test]
578 fn tf_normalizes_column_coefficients_to_rows() {
579 let sys = run_tf(
580 Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap()),
581 Value::Tensor(Tensor::new(vec![1.0, 3.0, 2.0], vec![3, 1]).unwrap()),
582 Vec::new(),
583 )
584 .expect("tf");
585
586 match property(&sys, "Numerator") {
587 Value::Tensor(tensor) => {
588 assert_eq!(tensor.shape, vec![1, 2]);
589 assert_eq!(tensor.data, vec![1.0, 2.0]);
590 }
591 other => panic!("expected numerator tensor, got {other:?}"),
592 }
593 match property(&sys, "Denominator") {
594 Value::Tensor(tensor) => {
595 assert_eq!(tensor.shape, vec![1, 3]);
596 assert_eq!(tensor.data, vec![1.0, 3.0, 2.0]);
597 }
598 other => panic!("expected denominator tensor, got {other:?}"),
599 }
600 }
601
602 #[test]
603 fn tf_accepts_discrete_sample_time() {
604 let sys = run_tf(
605 Value::Int(IntValue::I32(1)),
606 Value::Tensor(Tensor::new(vec![1.0, -0.5], vec![1, 2]).unwrap()),
607 vec![Value::Num(0.1)],
608 )
609 .expect("tf");
610
611 assert_eq!(
612 property(&sys, "Variable"),
613 &Value::CharArray(CharArray::new_row("z"))
614 );
615 assert_eq!(property(&sys, "Ts"), &Value::Num(0.1));
616 }
617
618 #[test]
619 fn tf_positional_zero_sample_time_remains_continuous() {
620 let sys = run_tf(
621 Value::Int(IntValue::I32(1)),
622 Value::Tensor(Tensor::new(vec![1.0, 5.0], vec![1, 2]).unwrap()),
623 vec![Value::Num(0.0)],
624 )
625 .expect("tf");
626
627 assert_eq!(
628 property(&sys, "Variable"),
629 &Value::CharArray(CharArray::new_row("s"))
630 );
631 assert_eq!(property(&sys, "Ts"), &Value::Num(0.0));
632 }
633
634 #[test]
635 fn tf_accepts_variable_name_value_option() {
636 let sys = run_tf(
637 Value::Num(1.0),
638 Value::Tensor(Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap()),
639 vec![Value::from("Variable"), Value::from("p")],
640 )
641 .expect("tf");
642
643 assert_eq!(
644 property(&sys, "Variable"),
645 &Value::CharArray(CharArray::new_row("p"))
646 );
647 }
648
649 #[test]
650 fn tf_explicit_continuous_variable_survives_positive_sample_time() {
651 let sys = run_tf(
652 Value::Num(1.0),
653 Value::Tensor(Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap()),
654 vec![
655 Value::from("Variable"),
656 Value::from("s"),
657 Value::from("Ts"),
658 Value::Num(0.5),
659 ],
660 )
661 .expect("tf");
662
663 assert_eq!(
664 property(&sys, "Variable"),
665 &Value::CharArray(CharArray::new_row("s"))
666 );
667 assert_eq!(property(&sys, "Ts"), &Value::Num(0.5));
668 }
669
670 #[test]
671 fn tf_rejects_zero_denominator() {
672 let err = run_tf(
673 Value::Num(1.0),
674 Value::Tensor(Tensor::new(vec![0.0, 0.0], vec![1, 2]).unwrap()),
675 Vec::new(),
676 )
677 .expect_err("zero denominator should fail");
678 assert!(err.message().contains("must not all be zero"));
679 assert_eq!(err.identifier(), TF_ERROR_DENOMINATOR_INVALID.identifier);
680 }
681
682 #[test]
683 fn tf_rejects_matrix_coefficients() {
684 let err = run_tf(
685 Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap()),
686 Value::Tensor(Tensor::new(vec![1.0, 5.0], vec![1, 2]).unwrap()),
687 Vec::new(),
688 )
689 .expect_err("matrix numerator should fail");
690 assert!(err
691 .message()
692 .contains("numerator coefficients must be a vector"));
693 assert_eq!(err.identifier(), TF_ERROR_INVALID_COEFFICIENTS.identifier);
694 }
695}