Skip to main content

runmat_runtime/builtins/control/
tf_model.rs

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