Skip to main content

runmat_runtime/builtins/control/
tf_model.rs

1//! Shared SISO transfer-function object parsing, construction, and algebra.
2
3use std::cell::Cell;
4use std::collections::HashMap;
5
6use nalgebra::DMatrix;
7use num_complex::Complex64;
8use runmat_builtins::{
9    Access, CharArray, ClassDef, ComplexTensor, MethodDef, ObjectInstance, PropertyDef, Tensor,
10    Value,
11};
12
13use crate::builtins::common::tensor;
14use crate::{build_runtime_error, dispatcher, BuiltinResult, RuntimeError};
15
16pub const TF_CLASS: &str = "tf";
17pub const SS_CLASS: &str = "ss";
18pub const DEFAULT_CONTINUOUS_VARIABLE: &str = "s";
19pub const DEFAULT_DISCRETE_VARIABLE: &str = "z";
20pub const EPS: f64 = 1.0e-12;
21
22thread_local! {
23    static TF_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
24}
25
26#[derive(Clone, Debug)]
27pub struct TfModel {
28    pub numerator: Vec<Complex64>,
29    pub denominator: Vec<Complex64>,
30    pub variable: String,
31    pub sample_time: f64,
32    pub input_delay: f64,
33    pub output_delay: f64,
34}
35
36#[derive(Clone, Debug)]
37pub struct RealTfModel {
38    pub numerator: Vec<f64>,
39    pub denominator: Vec<f64>,
40    pub sample_time: f64,
41    pub input_delay: f64,
42    pub output_delay: f64,
43}
44
45#[derive(Clone, Debug)]
46pub struct TfOptions {
47    pub variable: String,
48    pub sample_time: f64,
49}
50
51impl Default for TfOptions {
52    fn default() -> Self {
53        Self {
54            variable: DEFAULT_CONTINUOUS_VARIABLE.to_string(),
55            sample_time: 0.0,
56        }
57    }
58}
59
60pub fn control_error(
61    builtin: &'static str,
62    identifier: &'static str,
63    message: impl Into<String>,
64) -> RuntimeError {
65    build_runtime_error(message)
66        .with_builtin(builtin)
67        .with_identifier(identifier)
68        .build()
69}
70
71pub fn ensure_tf_class_registered() {
72    TF_CLASS_REGISTERED.with(|registered| {
73        if registered.get() {
74            return;
75        }
76        let mut properties = HashMap::new();
77        for name in [
78            "Numerator",
79            "Denominator",
80            "Variable",
81            "Ts",
82            "InputDelay",
83            "OutputDelay",
84        ] {
85            properties.insert(
86                name.to_string(),
87                PropertyDef {
88                    name: name.to_string(),
89                    is_static: false,
90                    is_constant: false,
91                    is_dependent: false,
92                    get_access: Access::Public,
93                    set_access: Access::Public,
94                    default_value: None,
95                },
96            );
97        }
98
99        let mut methods = HashMap::new();
100        for method_name in [
101            "plus", "minus", "uplus", "uminus", "times", "mtimes", "rdivide", "mrdivide",
102            "ldivide", "mldivide", "power", "mpower",
103        ] {
104            methods.insert(
105                method_name.to_string(),
106                MethodDef {
107                    name: method_name.to_string(),
108                    is_static: false,
109                    is_abstract: false,
110                    is_sealed: false,
111                    access: Access::Public,
112                    function_name: format!("{TF_CLASS}.{method_name}"),
113                    implicit_class_argument: None,
114                },
115            );
116        }
117
118        runmat_builtins::register_class(ClassDef {
119            name: TF_CLASS.to_string(),
120            parent: None,
121            properties,
122            methods,
123        });
124        registered.set(true);
125    });
126}
127
128impl TfModel {
129    pub fn new(
130        numerator: Vec<Complex64>,
131        denominator: Vec<Complex64>,
132        options: TfOptions,
133    ) -> BuiltinResult<Self> {
134        Self::with_delays(numerator, denominator, options, 0.0, 0.0)
135    }
136
137    pub fn with_delays(
138        numerator: Vec<Complex64>,
139        denominator: Vec<Complex64>,
140        options: TfOptions,
141        input_delay: f64,
142        output_delay: f64,
143    ) -> BuiltinResult<Self> {
144        validate_coefficients("numerator", &numerator, "tf")?;
145        validate_coefficients("denominator", &denominator, "tf")?;
146        if all_zero(&denominator) {
147            return Err(control_error(
148                "tf",
149                "RunMat:tf:DenominatorInvalid",
150                "tf: invalid denominator coefficients: denominator coefficients must not all be zero",
151            ));
152        }
153        let variable = validate_variable(&options.variable, "tf")?;
154        validate_sample_time(options.sample_time, "tf")?;
155        validate_variable_domain(&variable, options.sample_time, "tf")?;
156        validate_delay(input_delay, "InputDelay", "tf")?;
157        validate_delay(output_delay, "OutputDelay", "tf")?;
158        Ok(Self {
159            numerator: trim_leading_complex_zeros(numerator),
160            denominator: trim_leading_complex_zeros(denominator),
161            variable,
162            sample_time: options.sample_time,
163            input_delay,
164            output_delay,
165        })
166    }
167
168    pub fn continuous_variable(variable: impl Into<String>) -> BuiltinResult<Self> {
169        let variable = variable.into();
170        Self::new(
171            vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
172            vec![Complex64::new(1.0, 0.0)],
173            TfOptions {
174                variable,
175                sample_time: 0.0,
176            },
177        )
178    }
179
180    pub fn discrete_variable(variable: impl Into<String>, sample_time: f64) -> BuiltinResult<Self> {
181        Self::new(
182            vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
183            vec![Complex64::new(1.0, 0.0)],
184            TfOptions {
185                variable: variable.into(),
186                sample_time,
187            },
188        )
189    }
190
191    pub fn scalar(value: Complex64, options: TfOptions) -> BuiltinResult<Self> {
192        Self::new(vec![value], vec![Complex64::new(1.0, 0.0)], options)
193    }
194
195    pub async fn from_value_async(value: Value, builtin: &'static str) -> BuiltinResult<Self> {
196        let gathered = dispatcher::gather_if_needed_async(&value).await?;
197        Self::from_value(gathered, builtin)
198    }
199
200    pub fn from_value(value: Value, builtin: &'static str) -> BuiltinResult<Self> {
201        let Value::Object(object) = value else {
202            return Err(control_error(
203                builtin,
204                invalid_model_identifier(builtin),
205                format!("{builtin}: expected a tf object"),
206            ));
207        };
208        if !object.is_class(TF_CLASS) {
209            return Err(control_error(
210                builtin,
211                unsupported_model_identifier(builtin),
212                format!(
213                    "{builtin}: unsupported model class '{}'; only SISO tf objects are supported",
214                    object.class_name
215                ),
216            ));
217        }
218
219        let numerator = coefficients_from_property(&object, "Numerator", builtin)?;
220        let denominator = coefficients_from_property(&object, "Denominator", builtin)?;
221        let sample_time = scalar_property(&object, "Ts", builtin)?;
222        let input_delay = scalar_property(&object, "InputDelay", builtin)?;
223        let output_delay = scalar_property(&object, "OutputDelay", builtin)?;
224        validate_sample_time(sample_time, builtin)?;
225        validate_delay(input_delay, "InputDelay", builtin)?;
226        validate_delay(output_delay, "OutputDelay", builtin)?;
227        if all_zero(&denominator) {
228            return Err(control_error(
229                builtin,
230                invalid_model_identifier(builtin),
231                format!("{builtin}: denominator coefficients must not all be zero"),
232            ));
233        }
234        let variable = match object.properties.get("Variable") {
235            Some(value) => validate_variable(&scalar_text(value, "Variable", builtin)?, builtin)?,
236            None => {
237                if sample_time > 0.0 {
238                    DEFAULT_DISCRETE_VARIABLE.to_string()
239                } else {
240                    DEFAULT_CONTINUOUS_VARIABLE.to_string()
241                }
242            }
243        };
244        validate_variable_domain(&variable, sample_time, builtin)?;
245        Ok(Self {
246            numerator: trim_leading_complex_zeros(numerator),
247            denominator: trim_leading_complex_zeros(denominator),
248            variable,
249            sample_time,
250            input_delay,
251            output_delay,
252        })
253    }
254
255    pub fn to_value(&self, builtin: &'static str) -> BuiltinResult<Value> {
256        ensure_tf_class_registered();
257        let mut object = ObjectInstance::new(TF_CLASS.to_string());
258        object.properties.insert(
259            "Numerator".to_string(),
260            coefficient_value(&self.numerator, builtin)?,
261        );
262        object.properties.insert(
263            "Denominator".to_string(),
264            coefficient_value(&self.denominator, builtin)?,
265        );
266        object.properties.insert(
267            "Variable".to_string(),
268            Value::CharArray(CharArray::new_row(&self.variable)),
269        );
270        object
271            .properties
272            .insert("Ts".to_string(), Value::Num(self.sample_time));
273        object
274            .properties
275            .insert("InputDelay".to_string(), Value::Num(self.input_delay));
276        object
277            .properties
278            .insert("OutputDelay".to_string(), Value::Num(self.output_delay));
279        Ok(Value::Object(object))
280    }
281
282    pub fn to_real(&self, builtin: &'static str) -> BuiltinResult<RealTfModel> {
283        let numerator = real_coefficients(&self.numerator, "Numerator", builtin)?;
284        let denominator = real_coefficients(&self.denominator, "Denominator", builtin)?;
285        Ok(RealTfModel {
286            numerator,
287            denominator,
288            sample_time: self.sample_time,
289            input_delay: self.input_delay,
290            output_delay: self.output_delay,
291        })
292    }
293
294    pub fn is_discrete(&self) -> bool {
295        self.sample_time > 0.0
296    }
297
298    pub fn normalized(&self) -> BuiltinResult<Self> {
299        let leading = *self.denominator.first().ok_or_else(|| {
300            control_error(
301                "tf",
302                "RunMat:tf:DenominatorInvalid",
303                "tf: denominator coefficients cannot be empty",
304            )
305        })?;
306        if leading.norm() <= EPS {
307            return Err(control_error(
308                "tf",
309                "RunMat:tf:DenominatorInvalid",
310                "tf: leading denominator coefficient must be non-zero",
311            ));
312        }
313        let mut out = self.clone();
314        out.numerator = out.numerator.iter().map(|value| *value / leading).collect();
315        out.denominator = out
316            .denominator
317            .iter()
318            .map(|value| *value / leading)
319            .collect();
320        Ok(out)
321    }
322
323    pub fn add(&self, rhs: &Self) -> BuiltinResult<Self> {
324        self.ensure_arithmetic_compatible(rhs, "plus")?;
325        let numerator = poly_add(
326            &poly_mul(&self.numerator, &rhs.denominator),
327            &poly_mul(&rhs.numerator, &self.denominator),
328        );
329        let denominator = poly_mul(&self.denominator, &rhs.denominator);
330        self.with_new_coefficients(numerator, denominator)
331    }
332
333    pub fn sub(&self, rhs: &Self) -> BuiltinResult<Self> {
334        self.ensure_arithmetic_compatible(rhs, "minus")?;
335        let numerator = poly_sub(
336            &poly_mul(&self.numerator, &rhs.denominator),
337            &poly_mul(&rhs.numerator, &self.denominator),
338        );
339        let denominator = poly_mul(&self.denominator, &rhs.denominator);
340        self.with_new_coefficients(numerator, denominator)
341    }
342
343    pub fn neg(&self) -> BuiltinResult<Self> {
344        self.with_new_coefficients(
345            poly_scale(&self.numerator, -Complex64::new(1.0, 0.0)),
346            self.denominator.clone(),
347        )
348    }
349
350    pub fn mul(&self, rhs: &Self) -> BuiltinResult<Self> {
351        self.ensure_arithmetic_compatible(rhs, "mtimes")?;
352        self.with_new_coefficients(
353            poly_mul(&self.numerator, &rhs.numerator),
354            poly_mul(&self.denominator, &rhs.denominator),
355        )
356    }
357
358    pub fn div(&self, rhs: &Self) -> BuiltinResult<Self> {
359        self.ensure_arithmetic_compatible(rhs, "mrdivide")?;
360        if all_zero(&rhs.numerator) {
361            return Err(control_error(
362                "tf",
363                "RunMat:tf:DivideByZero",
364                "tf: cannot divide by a zero transfer function",
365            ));
366        }
367        self.with_new_coefficients(
368            poly_mul(&self.numerator, &rhs.denominator),
369            poly_mul(&self.denominator, &rhs.numerator),
370        )
371    }
372
373    pub fn powi(&self, exponent: i64) -> BuiltinResult<Self> {
374        let one = Self::scalar(
375            Complex64::new(1.0, 0.0),
376            TfOptions {
377                variable: self.variable.clone(),
378                sample_time: self.sample_time,
379            },
380        )?;
381        if exponent == 0 {
382            return Ok(one);
383        }
384        let mut base = self.clone();
385        let mut exp = exponent;
386        if exp < 0 {
387            base = one.div(&base)?;
388            exp = exp.checked_neg().ok_or_else(|| {
389                control_error(
390                    "tf",
391                    "RunMat:tf:InvalidExponent",
392                    "tf: exponent magnitude is too large",
393                )
394            })?;
395        }
396        let mut result = one;
397        while exp > 0 {
398            if exp & 1 == 1 {
399                result = result.mul(&base)?;
400            }
401            exp >>= 1;
402            if exp > 0 {
403                base = base.mul(&base)?;
404            }
405        }
406        Ok(result)
407    }
408
409    pub fn poles(&self) -> BuiltinResult<Vec<Complex64>> {
410        polynomial_roots(&self.denominator, "pole")
411    }
412
413    pub fn zeros(&self) -> BuiltinResult<Vec<Complex64>> {
414        polynomial_roots(&self.numerator, "zero")
415    }
416
417    pub fn dc_gain(&self) -> BuiltinResult<Complex64> {
418        let point = if self.is_discrete() {
419            Complex64::new(1.0, 0.0)
420        } else {
421            Complex64::new(0.0, 0.0)
422        };
423        let num = poly_eval(&self.numerator, point);
424        let den = poly_eval(&self.denominator, point);
425        if den.norm() <= EPS {
426            if num.norm() <= EPS {
427                Ok(Complex64::new(f64::NAN, f64::NAN))
428            } else {
429                Ok(num / Complex64::new(0.0, 0.0))
430            }
431        } else {
432            Ok(num / den)
433        }
434    }
435
436    pub fn is_stable(&self) -> BuiltinResult<bool> {
437        let poles = self.poles()?;
438        if self.is_discrete() {
439            Ok(poles.iter().all(|pole| pole.norm() < 1.0 - EPS))
440        } else {
441            Ok(poles.iter().all(|pole| pole.re < -EPS))
442        }
443    }
444
445    pub fn feedback(&self, rhs: &Self, sign: f64) -> BuiltinResult<Self> {
446        if sign != -1.0 && sign != 1.0 {
447            return Err(control_error(
448                "feedback",
449                "RunMat:feedback:InvalidSign",
450                "feedback: sign must be -1 or +1",
451            ));
452        }
453        self.ensure_arithmetic_compatible(rhs, "feedback")?;
454        let numerator = poly_mul(&self.numerator, &rhs.denominator);
455        let base_denominator = poly_mul(&self.denominator, &rhs.denominator);
456        let loop_numerator = poly_mul(&self.numerator, &rhs.numerator);
457        let denominator = if sign < 0.0 {
458            poly_add(&base_denominator, &loop_numerator)
459        } else {
460            poly_sub(&base_denominator, &loop_numerator)
461        };
462        self.with_new_coefficients(numerator, denominator)
463    }
464
465    fn ensure_arithmetic_compatible(&self, rhs: &Self, op: &'static str) -> BuiltinResult<()> {
466        if (self.sample_time - rhs.sample_time).abs() > EPS {
467            return Err(control_error(
468                "tf",
469                "RunMat:tf:SampleTimeMismatch",
470                format!("tf.{op}: sample times must match"),
471            ));
472        }
473        if self.variable != rhs.variable {
474            return Err(control_error(
475                "tf",
476                "RunMat:tf:VariableMismatch",
477                format!("tf.{op}: transfer-function variables must match"),
478            ));
479        }
480        if self.input_delay.abs() > EPS
481            || self.output_delay.abs() > EPS
482            || rhs.input_delay.abs() > EPS
483            || rhs.output_delay.abs() > EPS
484        {
485            return Err(control_error(
486                "tf",
487                "RunMat:tf:UnsupportedDelay",
488                format!("tf.{op}: input and output delays are not supported in arithmetic"),
489            ));
490        }
491        Ok(())
492    }
493
494    fn with_new_coefficients(
495        &self,
496        numerator: Vec<Complex64>,
497        denominator: Vec<Complex64>,
498    ) -> BuiltinResult<Self> {
499        Self::with_delays(
500            numerator,
501            denominator,
502            TfOptions {
503                variable: self.variable.clone(),
504                sample_time: self.sample_time,
505            },
506            self.input_delay,
507            self.output_delay,
508        )
509    }
510}
511
512impl RealTfModel {
513    pub fn normalized(&self, builtin: &'static str) -> BuiltinResult<(Vec<f64>, Vec<f64>)> {
514        let den = trim_leading_real_zeros(self.denominator.clone());
515        if den.is_empty() || den[0].abs() <= EPS {
516            return Err(control_error(
517                builtin,
518                invalid_model_identifier(builtin),
519                format!("{builtin}: leading denominator coefficient must be non-zero"),
520            ));
521        }
522        let num = trim_leading_real_zeros(self.numerator.clone());
523        let leading = den[0];
524        Ok((
525            num.iter().map(|value| value / leading).collect(),
526            den.iter().map(|value| value / leading).collect(),
527        ))
528    }
529
530    pub fn ensure_zero_delays(&self, builtin: &'static str) -> BuiltinResult<()> {
531        if self.input_delay.abs() > EPS || self.output_delay.abs() > EPS {
532            return Err(control_error(
533                builtin,
534                unsupported_model_identifier(builtin),
535                format!(
536                    "{builtin}: transfer functions with input or output delays are not supported"
537                ),
538            ));
539        }
540        Ok(())
541    }
542}
543
544pub async fn parse_coefficients(
545    label: &str,
546    value: Value,
547    builtin: &'static str,
548) -> BuiltinResult<Vec<Complex64>> {
549    let gathered = dispatcher::gather_if_needed_async(&value).await?;
550    let coeffs = match gathered {
551        Value::Tensor(tensor) => {
552            ensure_vector_shape(label, &tensor.shape, builtin)?;
553            tensor
554                .data
555                .into_iter()
556                .map(|re| Complex64::new(re, 0.0))
557                .collect()
558        }
559        Value::ComplexTensor(tensor) => {
560            ensure_vector_shape(label, &tensor.shape, builtin)?;
561            tensor
562                .data
563                .into_iter()
564                .map(|(re, im)| Complex64::new(re, im))
565                .collect()
566        }
567        Value::LogicalArray(logical) => {
568            let tensor = tensor::logical_to_tensor(&logical).map_err(|err| {
569                control_error(
570                    builtin,
571                    invalid_coefficients_identifier(builtin),
572                    format!("{builtin}: failed to convert logical array: {err}"),
573                )
574            })?;
575            ensure_vector_shape(label, &tensor.shape, builtin)?;
576            tensor
577                .data
578                .into_iter()
579                .map(|re| Complex64::new(re, 0.0))
580                .collect()
581        }
582        Value::Num(n) => vec![Complex64::new(n, 0.0)],
583        Value::Int(i) => vec![Complex64::new(i.to_f64(), 0.0)],
584        Value::Bool(b) => vec![Complex64::new(if b { 1.0 } else { 0.0 }, 0.0)],
585        Value::Complex(re, im) => vec![Complex64::new(re, im)],
586        other => {
587            return Err(control_error(
588                builtin,
589                invalid_coefficients_identifier(builtin),
590                format!("{builtin}: {label} must be a numeric coefficient vector, got {other:?}"),
591            ));
592        }
593    };
594    validate_coefficients(label, &coeffs, builtin)?;
595    Ok(coeffs)
596}
597
598pub async fn value_to_model_with_reference(
599    value: Value,
600    reference: &TfModel,
601    builtin: &'static str,
602) -> BuiltinResult<TfModel> {
603    let gathered = dispatcher::gather_if_needed_async(&value).await?;
604    if matches!(gathered, Value::Object(_)) {
605        return TfModel::from_value(gathered, builtin);
606    }
607    let scalar = scalar_complex(&gathered, builtin)?;
608    TfModel::scalar(
609        scalar,
610        TfOptions {
611            variable: reference.variable.clone(),
612            sample_time: reference.sample_time,
613        },
614    )
615}
616
617pub async fn two_models_ordered(
618    lhs: Value,
619    rhs: Value,
620    builtin: &'static str,
621) -> BuiltinResult<(TfModel, TfModel)> {
622    let lhs = dispatcher::gather_if_needed_async(&lhs).await?;
623    let rhs = dispatcher::gather_if_needed_async(&rhs).await?;
624    match (lhs, rhs) {
625        (left @ Value::Object(_), right @ Value::Object(_)) => Ok((
626            TfModel::from_value(left, builtin)?,
627            TfModel::from_value(right, builtin)?,
628        )),
629        (left @ Value::Object(_), right) => {
630            let left_model = TfModel::from_value(left, builtin)?;
631            let right_model = value_to_model_with_reference(right, &left_model, builtin).await?;
632            Ok((left_model, right_model))
633        }
634        (left, right @ Value::Object(_)) => {
635            let right_model = TfModel::from_value(right, builtin)?;
636            let left_model = value_to_model_with_reference(left, &right_model, builtin).await?;
637            Ok((left_model, right_model))
638        }
639        (left, right) => {
640            let options = TfOptions::default();
641            Ok((
642                TfModel::scalar(scalar_complex(&left, builtin)?, options.clone())?,
643                TfModel::scalar(scalar_complex(&right, builtin)?, options)?,
644            ))
645        }
646    }
647}
648
649pub fn scalar_text(value: &Value, context: &str, builtin: &'static str) -> BuiltinResult<String> {
650    match value {
651        Value::String(text) => Ok(text.clone()),
652        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
653        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
654        other => Err(control_error(
655            builtin,
656            invalid_argument_identifier(builtin),
657            format!(
658                "{builtin}: {context} must be a string scalar or character vector, got {other:?}"
659            ),
660        )),
661    }
662}
663
664pub fn scalar_f64(value: &Value, context: &str, builtin: &'static str) -> BuiltinResult<f64> {
665    match value {
666        Value::Num(n) => Ok(*n),
667        Value::Int(i) => Ok(i.to_f64()),
668        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
669        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
670        Value::LogicalArray(logical) if logical.data.len() == 1 => {
671            Ok(if logical.data[0] == 0 { 0.0 } else { 1.0 })
672        }
673        other => Err(control_error(
674            builtin,
675            invalid_argument_identifier(builtin),
676            format!("{builtin}: {context} must be a real scalar, got {other:?}"),
677        )),
678    }
679}
680
681pub fn scalar_complex(value: &Value, builtin: &'static str) -> BuiltinResult<Complex64> {
682    match value {
683        Value::Num(n) => Ok(Complex64::new(*n, 0.0)),
684        Value::Int(i) => Ok(Complex64::new(i.to_f64(), 0.0)),
685        Value::Bool(b) => Ok(Complex64::new(if *b { 1.0 } else { 0.0 }, 0.0)),
686        Value::Complex(re, im) => Ok(Complex64::new(*re, *im)),
687        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(Complex64::new(tensor.data[0], 0.0)),
688        Value::ComplexTensor(tensor) if tensor.data.len() == 1 => {
689            let (re, im) = tensor.data[0];
690            Ok(Complex64::new(re, im))
691        }
692        Value::LogicalArray(logical) if logical.data.len() == 1 => Ok(Complex64::new(
693            if logical.data[0] == 0 { 0.0 } else { 1.0 },
694            0.0,
695        )),
696        other => Err(control_error(
697            builtin,
698            invalid_argument_identifier(builtin),
699            format!("{builtin}: expected a scalar numeric value or tf object, got {other:?}"),
700        )),
701    }
702}
703
704pub fn validate_variable(variable: &str, builtin: &'static str) -> BuiltinResult<String> {
705    let variable = variable.trim();
706    match variable {
707        "s" | "p" | "z" | "q" | "z^-1" | "q^-1" => Ok(variable.to_string()),
708        _ => Err(control_error(
709            builtin,
710            "RunMat:tf:InvalidVariable",
711            "tf: invalid Variable option: must be one of 's', 'p', 'z', 'q', 'z^-1', or 'q^-1'",
712        )),
713    }
714}
715
716pub fn is_discrete_variable(variable: &str) -> bool {
717    matches!(variable.trim(), "z" | "q" | "z^-1" | "q^-1")
718}
719
720pub fn validate_variable_domain(
721    variable: &str,
722    sample_time: f64,
723    builtin: &'static str,
724) -> BuiltinResult<()> {
725    let discrete_variable = is_discrete_variable(variable);
726    if discrete_variable && sample_time <= 0.0 {
727        return Err(control_error(
728            builtin,
729            "RunMat:tf:InvalidSampleTime",
730            format!(
731                "{builtin}: discrete transfer-function variables require a positive sample time"
732            ),
733        ));
734    }
735    if !discrete_variable && sample_time > 0.0 {
736        return Err(control_error(
737            builtin,
738            "RunMat:tf:InvalidVariable",
739            format!("{builtin}: continuous transfer-function variables require Ts = 0"),
740        ));
741    }
742    Ok(())
743}
744
745pub fn validate_sample_time(sample_time: f64, builtin: &'static str) -> BuiltinResult<()> {
746    if !sample_time.is_finite() || sample_time < 0.0 {
747        return Err(control_error(
748            builtin,
749            invalid_sample_time_identifier(builtin),
750            format!("{builtin}: sample time must be a finite non-negative scalar"),
751        ));
752    }
753    Ok(())
754}
755
756pub fn output_complex_scalar(value: Complex64) -> Value {
757    if value.im.abs() <= EPS {
758        Value::Num(value.re)
759    } else {
760        Value::Complex(value.re, value.im)
761    }
762}
763
764pub fn output_complex_column(
765    values: Vec<Complex64>,
766    builtin: &'static str,
767) -> BuiltinResult<Value> {
768    let rows = values.len();
769    if values.iter().all(|value| value.im.abs() <= EPS) {
770        let data = values.into_iter().map(|value| value.re).collect::<Vec<_>>();
771        Ok(Value::Tensor(Tensor::new(data, vec![rows, 1]).map_err(
772            |err| {
773                control_error(
774                    builtin,
775                    internal_identifier(builtin),
776                    format!("{builtin}: failed to build output tensor: {err}"),
777                )
778            },
779        )?))
780    } else {
781        let data = values
782            .into_iter()
783            .map(|value| (value.re, value.im))
784            .collect::<Vec<_>>();
785        Ok(Value::ComplexTensor(
786            ComplexTensor::new(data, vec![rows, 1]).map_err(|err| {
787                control_error(
788                    builtin,
789                    internal_identifier(builtin),
790                    format!("{builtin}: failed to build complex output tensor: {err}"),
791                )
792            })?,
793        ))
794    }
795}
796
797pub fn ss_poles_from_object(
798    object: &ObjectInstance,
799    builtin: &'static str,
800) -> BuiltinResult<(Vec<Complex64>, f64)> {
801    let a = ss_state_matrix_property(object, "A", builtin)?;
802    let sample_time = scalar_property(object, "Ts", builtin)?;
803    validate_sample_time(sample_time, builtin)?;
804    let eigenvalues = a.eigenvalues().ok_or_else(|| {
805        control_error(
806            builtin,
807            internal_identifier(builtin),
808            format!("{builtin}: failed to compute state matrix eigenvalues"),
809        )
810    })?;
811    Ok((eigenvalues.iter().copied().collect(), sample_time))
812}
813
814fn ss_state_matrix_property(
815    object: &ObjectInstance,
816    name: &'static str,
817    builtin: &'static str,
818) -> BuiltinResult<DMatrix<Complex64>> {
819    let value = object.properties.get(name).ok_or_else(|| {
820        control_error(
821            builtin,
822            invalid_model_identifier(builtin),
823            format!("{builtin}: ss object is missing {name}"),
824        )
825    })?;
826    let tensor = match value {
827        Value::Tensor(tensor) => tensor.clone(),
828        Value::Num(n) => Tensor::new(vec![*n], vec![1, 1]).map_err(|err| {
829            control_error(
830                builtin,
831                internal_identifier(builtin),
832                format!("{builtin}: failed to build scalar matrix: {err}"),
833            )
834        })?,
835        Value::Int(i) => Tensor::new(vec![i.to_f64()], vec![1, 1]).map_err(|err| {
836            control_error(
837                builtin,
838                internal_identifier(builtin),
839                format!("{builtin}: failed to build scalar matrix: {err}"),
840            )
841        })?,
842        other => {
843            return Err(control_error(
844                builtin,
845                unsupported_model_identifier(builtin),
846                format!("{builtin}: ss {name} must be a finite real matrix, got {other:?}"),
847            ));
848        }
849    };
850    if tensor.shape.len() > 2 || tensor.rows != tensor.cols {
851        return Err(control_error(
852            builtin,
853            invalid_model_identifier(builtin),
854            format!(
855                "{builtin}: ss {name} must be square, got {:?}",
856                tensor.shape
857            ),
858        ));
859    }
860    if tensor.data.iter().any(|value| !value.is_finite()) {
861        return Err(control_error(
862            builtin,
863            unsupported_model_identifier(builtin),
864            format!("{builtin}: ss {name} must contain only finite real values"),
865        ));
866    }
867    let mut matrix = DMatrix::<Complex64>::zeros(tensor.rows, tensor.cols);
868    for col in 0..tensor.cols {
869        for row in 0..tensor.rows {
870            matrix[(row, col)] = Complex64::new(tensor.data[row + col * tensor.rows], 0.0);
871        }
872    }
873    Ok(matrix)
874}
875
876pub fn polynomial_roots(
877    coeffs: &[Complex64],
878    builtin: &'static str,
879) -> BuiltinResult<Vec<Complex64>> {
880    let trimmed = trim_leading_complex_zeros(coeffs.to_vec());
881    if trimmed.len() <= 1 {
882        return Ok(Vec::new());
883    }
884    if trimmed.len() == 2 {
885        return Ok(vec![-trimmed[1] / trimmed[0]]);
886    }
887    let degree = trimmed.len() - 1;
888    let leading = trimmed[0];
889    if leading.norm() <= EPS {
890        return Err(control_error(
891            builtin,
892            invalid_model_identifier(builtin),
893            format!("{builtin}: leading polynomial coefficient must be non-zero"),
894        ));
895    }
896    let mut companion = DMatrix::<Complex64>::zeros(degree, degree);
897    for row in 1..degree {
898        companion[(row, row - 1)] = Complex64::new(1.0, 0.0);
899    }
900    for (idx, coeff) in trimmed.iter().enumerate().skip(1) {
901        companion[(0, idx - 1)] = -*coeff / leading;
902    }
903    let eigenvalues = companion.eigenvalues().ok_or_else(|| {
904        control_error(
905            builtin,
906            internal_identifier(builtin),
907            format!("{builtin}: failed to compute polynomial roots"),
908        )
909    })?;
910    Ok(eigenvalues.iter().copied().collect())
911}
912
913pub fn poly_eval(coeffs: &[Complex64], x: Complex64) -> Complex64 {
914    coeffs
915        .iter()
916        .fold(Complex64::new(0.0, 0.0), |acc, coeff| acc * x + *coeff)
917}
918
919pub fn trim_leading_real_zeros(coeffs: Vec<f64>) -> Vec<f64> {
920    let first_nonzero = coeffs
921        .iter()
922        .position(|value| value.abs() > EPS)
923        .unwrap_or(coeffs.len());
924    if first_nonzero == coeffs.len() {
925        return vec![0.0];
926    }
927    coeffs[first_nonzero..].to_vec()
928}
929
930fn coefficients_from_property(
931    object: &ObjectInstance,
932    name: &str,
933    builtin: &'static str,
934) -> BuiltinResult<Vec<Complex64>> {
935    let value = object.properties.get(name).ok_or_else(|| {
936        control_error(
937            builtin,
938            invalid_model_identifier(builtin),
939            format!("{builtin}: tf object is missing {name}"),
940        )
941    })?;
942    match value {
943        Value::Tensor(tensor) => {
944            ensure_vector_shape(name, &tensor.shape, builtin)?;
945            Ok(tensor
946                .data
947                .iter()
948                .map(|value| Complex64::new(*value, 0.0))
949                .collect())
950        }
951        Value::ComplexTensor(tensor) => {
952            ensure_vector_shape(name, &tensor.shape, builtin)?;
953            Ok(tensor
954                .data
955                .iter()
956                .map(|(re, im)| Complex64::new(*re, *im))
957                .collect())
958        }
959        Value::Num(n) => Ok(vec![Complex64::new(*n, 0.0)]),
960        Value::Int(i) => Ok(vec![Complex64::new(i.to_f64(), 0.0)]),
961        Value::Bool(b) => Ok(vec![Complex64::new(if *b { 1.0 } else { 0.0 }, 0.0)]),
962        other => Err(control_error(
963            builtin,
964            invalid_model_identifier(builtin),
965            format!("{builtin}: tf {name} coefficients must be numeric, got {other:?}"),
966        )),
967    }
968}
969
970fn coefficient_value(coeffs: &[Complex64], builtin: &'static str) -> BuiltinResult<Value> {
971    let len = coeffs.len();
972    if coeffs.iter().all(|coeff| coeff.im.abs() <= EPS) {
973        let data = coeffs.iter().map(|coeff| coeff.re).collect::<Vec<_>>();
974        let tensor = Tensor::new(data, vec![1, len]).map_err(|err| {
975            control_error(
976                builtin,
977                internal_identifier(builtin),
978                format!("{builtin}: failed to build coefficient tensor: {err}"),
979            )
980        })?;
981        Ok(Value::Tensor(tensor))
982    } else {
983        let data = coeffs
984            .iter()
985            .map(|coeff| (coeff.re, coeff.im))
986            .collect::<Vec<_>>();
987        let tensor = ComplexTensor::new(data, vec![1, len]).map_err(|err| {
988            control_error(
989                builtin,
990                internal_identifier(builtin),
991                format!("{builtin}: failed to build complex coefficient tensor: {err}"),
992            )
993        })?;
994        Ok(Value::ComplexTensor(tensor))
995    }
996}
997
998fn property<'a>(
999    object: &'a ObjectInstance,
1000    name: &str,
1001    builtin: &'static str,
1002) -> BuiltinResult<&'a Value> {
1003    object.properties.get(name).ok_or_else(|| {
1004        control_error(
1005            builtin,
1006            invalid_model_identifier(builtin),
1007            format!("{builtin}: tf object is missing {name} property"),
1008        )
1009    })
1010}
1011
1012fn scalar_property(
1013    object: &ObjectInstance,
1014    name: &str,
1015    builtin: &'static str,
1016) -> BuiltinResult<f64> {
1017    scalar_f64(property(object, name, builtin)?, name, builtin)
1018}
1019
1020fn validate_delay(value: f64, label: &str, builtin: &'static str) -> BuiltinResult<()> {
1021    if !value.is_finite() || value < 0.0 {
1022        return Err(control_error(
1023            builtin,
1024            invalid_model_identifier(builtin),
1025            format!("{builtin}: {label} must be a finite non-negative scalar"),
1026        ));
1027    }
1028    Ok(())
1029}
1030
1031fn validate_coefficients(
1032    label: &str,
1033    coeffs: &[Complex64],
1034    builtin: &'static str,
1035) -> BuiltinResult<()> {
1036    if coeffs.is_empty() {
1037        return Err(control_error(
1038            builtin,
1039            invalid_coefficients_identifier(builtin),
1040            format!("{builtin}: {label} coefficients cannot be empty"),
1041        ));
1042    }
1043    for coeff in coeffs {
1044        if !coeff.re.is_finite() || !coeff.im.is_finite() {
1045            return Err(control_error(
1046                builtin,
1047                invalid_coefficients_identifier(builtin),
1048                format!("{builtin}: {label} coefficients must be finite"),
1049            ));
1050        }
1051    }
1052    Ok(())
1053}
1054
1055fn real_coefficients(
1056    coeffs: &[Complex64],
1057    label: &str,
1058    builtin: &'static str,
1059) -> BuiltinResult<Vec<f64>> {
1060    let mut out = Vec::with_capacity(coeffs.len());
1061    for coeff in coeffs {
1062        if coeff.im.abs() > EPS {
1063            return Err(control_error(
1064                builtin,
1065                unsupported_model_identifier(builtin),
1066                format!("{builtin}: complex tf {label} coefficients are not supported"),
1067            ));
1068        }
1069        out.push(coeff.re);
1070    }
1071    Ok(out)
1072}
1073
1074fn ensure_vector_shape(label: &str, shape: &[usize], builtin: &'static str) -> BuiltinResult<()> {
1075    let non_unit = shape.iter().copied().filter(|&dim| dim > 1).count();
1076    if non_unit <= 1 {
1077        Ok(())
1078    } else {
1079        Err(control_error(
1080            builtin,
1081            invalid_coefficients_identifier(builtin),
1082            format!("{builtin}: {label} coefficients must be a vector"),
1083        ))
1084    }
1085}
1086
1087fn trim_leading_complex_zeros(coeffs: Vec<Complex64>) -> Vec<Complex64> {
1088    let first_nonzero = coeffs
1089        .iter()
1090        .position(|value| value.norm() > EPS)
1091        .unwrap_or(coeffs.len());
1092    let trimmed = coeffs[first_nonzero..].to_vec();
1093    if trimmed.is_empty() {
1094        vec![Complex64::new(0.0, 0.0)]
1095    } else {
1096        trimmed
1097    }
1098}
1099
1100fn all_zero(coeffs: &[Complex64]) -> bool {
1101    coeffs.iter().all(|coeff| coeff.norm() <= EPS)
1102}
1103
1104fn poly_add(lhs: &[Complex64], rhs: &[Complex64]) -> Vec<Complex64> {
1105    let len = lhs.len().max(rhs.len());
1106    let mut out = vec![Complex64::new(0.0, 0.0); len];
1107    for (idx, value) in lhs.iter().enumerate() {
1108        out[len - lhs.len() + idx] += *value;
1109    }
1110    for (idx, value) in rhs.iter().enumerate() {
1111        out[len - rhs.len() + idx] += *value;
1112    }
1113    trim_leading_complex_zeros(out)
1114}
1115
1116fn poly_sub(lhs: &[Complex64], rhs: &[Complex64]) -> Vec<Complex64> {
1117    poly_add(lhs, &poly_scale(rhs, -Complex64::new(1.0, 0.0)))
1118}
1119
1120fn poly_scale(coeffs: &[Complex64], scale: Complex64) -> Vec<Complex64> {
1121    trim_leading_complex_zeros(coeffs.iter().map(|value| *value * scale).collect())
1122}
1123
1124fn poly_mul(lhs: &[Complex64], rhs: &[Complex64]) -> Vec<Complex64> {
1125    if all_zero(lhs) || all_zero(rhs) {
1126        return vec![Complex64::new(0.0, 0.0)];
1127    }
1128    let mut out = vec![Complex64::new(0.0, 0.0); lhs.len() + rhs.len() - 1];
1129    for (i, a) in lhs.iter().enumerate() {
1130        for (j, b) in rhs.iter().enumerate() {
1131            out[i + j] += *a * *b;
1132        }
1133    }
1134    trim_leading_complex_zeros(out)
1135}
1136
1137fn invalid_argument_identifier(builtin: &str) -> &'static str {
1138    match builtin {
1139        "feedback" => "RunMat:feedback:InvalidArgument",
1140        "stepinfo" => "RunMat:stepinfo:InvalidArgument",
1141        "dcgain" => "RunMat:dcgain:InvalidArgument",
1142        "pole" => "RunMat:pole:InvalidArgument",
1143        "zero" => "RunMat:zero:InvalidArgument",
1144        "damp" => "RunMat:damp:InvalidModel",
1145        "rlocus" => "RunMat:rlocus:InvalidArgument",
1146        "isstable" => "RunMat:isstable:InvalidArgument",
1147        _ => "RunMat:tf:InvalidArgument",
1148    }
1149}
1150
1151fn invalid_coefficients_identifier(builtin: &str) -> &'static str {
1152    match builtin {
1153        "feedback" => "RunMat:feedback:InvalidModel",
1154        "stepinfo" => "RunMat:stepinfo:InvalidData",
1155        "dcgain" => "RunMat:dcgain:InvalidModel",
1156        "pole" => "RunMat:pole:InvalidModel",
1157        "zero" => "RunMat:zero:InvalidModel",
1158        "damp" => "RunMat:damp:InvalidModel",
1159        "rlocus" => "RunMat:rlocus:InvalidModel",
1160        "isstable" => "RunMat:isstable:InvalidModel",
1161        _ => "RunMat:tf:InvalidCoefficients",
1162    }
1163}
1164
1165fn invalid_sample_time_identifier(builtin: &str) -> &'static str {
1166    match builtin {
1167        "feedback" => "RunMat:feedback:InvalidSampleTime",
1168        "stepinfo" => "RunMat:stepinfo:InvalidArgument",
1169        "dcgain" => "RunMat:dcgain:InvalidModel",
1170        "pole" => "RunMat:pole:InvalidModel",
1171        "zero" => "RunMat:zero:InvalidModel",
1172        "damp" => "RunMat:damp:InvalidModel",
1173        "rlocus" => "RunMat:rlocus:InvalidModel",
1174        "isstable" => "RunMat:isstable:InvalidModel",
1175        _ => "RunMat:tf:InvalidSampleTime",
1176    }
1177}
1178
1179fn invalid_model_identifier(builtin: &str) -> &'static str {
1180    match builtin {
1181        "feedback" => "RunMat:feedback:InvalidModel",
1182        "stepinfo" => "RunMat:stepinfo:InvalidSystem",
1183        "dcgain" => "RunMat:dcgain:InvalidModel",
1184        "pole" => "RunMat:pole:InvalidModel",
1185        "zero" => "RunMat:zero:InvalidModel",
1186        "damp" => "RunMat:damp:InvalidModel",
1187        "rlocus" => "RunMat:rlocus:InvalidModel",
1188        "isstable" => "RunMat:isstable:InvalidModel",
1189        _ => "RunMat:tf:InvalidModel",
1190    }
1191}
1192
1193fn unsupported_model_identifier(builtin: &str) -> &'static str {
1194    match builtin {
1195        "feedback" => "RunMat:feedback:UnsupportedModel",
1196        "stepinfo" => "RunMat:stepinfo:UnsupportedModel",
1197        "dcgain" => "RunMat:dcgain:UnsupportedModel",
1198        "pole" => "RunMat:pole:UnsupportedModel",
1199        "zero" => "RunMat:zero:UnsupportedModel",
1200        "damp" => "RunMat:damp:UnsupportedModel",
1201        "rlocus" => "RunMat:rlocus:UnsupportedModel",
1202        "isstable" => "RunMat:isstable:UnsupportedModel",
1203        _ => "RunMat:tf:UnsupportedModel",
1204    }
1205}
1206
1207fn internal_identifier(builtin: &str) -> &'static str {
1208    match builtin {
1209        "feedback" => "RunMat:feedback:Internal",
1210        "stepinfo" => "RunMat:stepinfo:Internal",
1211        "dcgain" => "RunMat:dcgain:Internal",
1212        "pole" => "RunMat:pole:Internal",
1213        "zero" => "RunMat:zero:Internal",
1214        "damp" => "RunMat:damp:Internal",
1215        "rlocus" => "RunMat:rlocus:Internal",
1216        "isstable" => "RunMat:isstable:Internal",
1217        _ => "RunMat:tf:Internal",
1218    }
1219}