Skip to main content

runmat_runtime/builtins/control/
tf.rs

1//! MATLAB-compatible `tf` transfer-function constructor for RunMat.
2
3use 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}