Skip to main content

runmat_runtime/builtins/control/
rlocus.rs

1//! Root-locus analysis for SISO transfer-function models.
2
3use nalgebra::{DMatrix, DVector};
4use num_complex::Complex64;
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
7    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
8    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
9    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
10    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12};
13use runmat_macros::runtime_builtin;
14use runmat_value::{ComplexTensor, ObjectInstance, Tensor, Value};
15
16use crate::builtins::common::{
17    spec::{
18        BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19        ReductionNaN, ResidencyPolicy, ShapeRequirements,
20    },
21    tensor,
22};
23use crate::builtins::control::tf_model::{poly_eval, polynomial_roots, scalar_f64, TfModel, EPS};
24use crate::builtins::control::type_resolvers::rlocus_type;
25use crate::{BuiltinResult, RuntimeError};
26
27const BUILTIN_NAME: &str = "rlocus";
28const DEFAULT_GAIN_POINTS: usize = 121;
29const DEFAULT_GAIN_DECADES: f64 = 4.0;
30
31const RLOCUS_OUTPUT_R: BuiltinParamDescriptor = BuiltinParamDescriptor {
32    name: "r",
33    ty: BuiltinParamType::Any,
34    arity: BuiltinParamArity::Required,
35    default: None,
36    description: "Closed-loop pole locations as a branches-by-gains matrix.",
37};
38const RLOCUS_OUTPUT_K: BuiltinParamDescriptor = BuiltinParamDescriptor {
39    name: "k",
40    ty: BuiltinParamType::NumericArray,
41    arity: BuiltinParamArity::Required,
42    default: None,
43    description: "Gain vector used to compute the root locus.",
44};
45const RLOCUS_INPUT_SYS: BuiltinParamDescriptor = BuiltinParamDescriptor {
46    name: "sys",
47    ty: BuiltinParamType::Any,
48    arity: BuiltinParamArity::Required,
49    default: None,
50    description: "SISO tf or ss model.",
51};
52const RLOCUS_INPUT_K: BuiltinParamDescriptor = BuiltinParamDescriptor {
53    name: "k",
54    ty: BuiltinParamType::NumericArray,
55    arity: BuiltinParamArity::Optional,
56    default: None,
57    description: "Finite nonnegative gain vector.",
58};
59const RLOCUS_OUTPUTS_R: [BuiltinParamDescriptor; 1] = [RLOCUS_OUTPUT_R];
60const RLOCUS_OUTPUTS_R_K: [BuiltinParamDescriptor; 2] = [RLOCUS_OUTPUT_R, RLOCUS_OUTPUT_K];
61const RLOCUS_INPUTS_SYS: [BuiltinParamDescriptor; 1] = [RLOCUS_INPUT_SYS];
62const RLOCUS_INPUTS_SYS_K: [BuiltinParamDescriptor; 2] = [RLOCUS_INPUT_SYS, RLOCUS_INPUT_K];
63const RLOCUS_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
64    BuiltinSignatureDescriptor {
65        label: "r = rlocus(sys)",
66        inputs: &RLOCUS_INPUTS_SYS,
67        outputs: &RLOCUS_OUTPUTS_R,
68    },
69    BuiltinSignatureDescriptor {
70        label: "r = rlocus(sys, k)",
71        inputs: &RLOCUS_INPUTS_SYS_K,
72        outputs: &RLOCUS_OUTPUTS_R,
73    },
74    BuiltinSignatureDescriptor {
75        label: "[r,k] = rlocus(sys)",
76        inputs: &RLOCUS_INPUTS_SYS,
77        outputs: &RLOCUS_OUTPUTS_R_K,
78    },
79    BuiltinSignatureDescriptor {
80        label: "[r,k] = rlocus(sys, k)",
81        inputs: &RLOCUS_INPUTS_SYS_K,
82        outputs: &RLOCUS_OUTPUTS_R_K,
83    },
84];
85const RLOCUS_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
86    code: "RM.RLOCUS.INVALID_ARGUMENT",
87    identifier: Some("RunMat:rlocus:InvalidArgument"),
88    when: "Inputs do not match supported rlocus invocation forms.",
89    message: "rlocus: invalid argument",
90};
91const RLOCUS_ERROR_INVALID_MODEL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
92    code: "RM.RLOCUS.INVALID_MODEL",
93    identifier: Some("RunMat:rlocus:InvalidModel"),
94    when: "Input system is not a valid SISO tf or ss object.",
95    message: "rlocus: invalid model",
96};
97const RLOCUS_ERROR_UNSUPPORTED_MODEL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
98    code: "RM.RLOCUS.UNSUPPORTED_MODEL",
99    identifier: Some("RunMat:rlocus:UnsupportedModel"),
100    when: "Model form is not supported by the current implementation.",
101    message: "rlocus: unsupported model",
102};
103const RLOCUS_ERROR_PLOT_FAILED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
104    code: "RM.RLOCUS.PLOT_FAILED",
105    identifier: Some("RunMat:rlocus:PlotFailed"),
106    when: "Statement-form plotting failed for reasons other than known nonfatal setup conditions.",
107    message: "rlocus: plotting failed",
108};
109const RLOCUS_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
110    code: "RM.RLOCUS.INTERNAL",
111    identifier: Some("RunMat:rlocus:Internal"),
112    when: "Root-locus computation or output construction failed.",
113    message: "rlocus: internal error",
114};
115const RLOCUS_ERRORS: [BuiltinErrorDescriptor; 5] = [
116    RLOCUS_ERROR_INVALID_ARGUMENT,
117    RLOCUS_ERROR_INVALID_MODEL,
118    RLOCUS_ERROR_UNSUPPORTED_MODEL,
119    RLOCUS_ERROR_PLOT_FAILED,
120    RLOCUS_ERROR_INTERNAL,
121];
122pub const RLOCUS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
123    signatures: &RLOCUS_SIGNATURES,
124    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
125    completion_policy: BuiltinCompletionPolicy::Public,
126    errors: &RLOCUS_ERRORS,
127};
128const RLOCUS_INTEGER_GAIN_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
129    id: "rlocus-integer-gain",
130    mode: BuiltinExtensionMode::RunMatOnly,
131    description: "rlocus accepts a typed-integer gain vector as a RunMat extension",
132    error_identifier: Some("RunMat:compatibility:RlocusIntegerGainExtension"),
133};
134const RLOCUS_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [RLOCUS_INTEGER_GAIN_EXTENSION];
135const RLOCUS_INTEGER_GAIN_INPUTS: [BuiltinIntegerInputCapability; 1] =
136    [BuiltinIntegerInputCapability {
137        name: "k",
138        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
139        availability: BuiltinIntegerInputAvailability::RunMatOnly,
140        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
141        notes: "The public gain surface does not document typed integer classes; RunMat mode admits them only through a checked binary64 root-locus boundary.",
142    }];
143pub const RLOCUS_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
144    [BuiltinIntegerCapabilityDescriptor {
145        form: "r = rlocus(sys, integer_k)",
146        inputs: &RLOCUS_INTEGER_GAIN_INPUTS,
147        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
148        output_class: BuiltinIntegerOutputClassRule::Double,
149        overflow: BuiltinIntegerOverflowRule::Error,
150        backend: BuiltinIntegerBackendRule::GatherFallback,
151        overload: BuiltinIntegerOverloadKind::FunctionSpecific,
152        notes: "Gain values are gated and checked before any provider lookup, then intentionally enter double polynomial-root computation; returned gains are double.",
153    }];
154
155#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::rlocus")]
156pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
157    name: "rlocus",
158    op_kind: GpuOpKind::Custom("control-root-locus"),
159    supported_precisions: &[],
160    broadcast: BroadcastSemantics::None,
161    provider_hooks: &[],
162    constant_strategy: ConstantStrategy::InlineLiteral,
163    residency: ResidencyPolicy::GatherImmediately,
164    nan_mode: ReductionNaN::Include,
165    two_pass_threshold: None,
166    workgroup_size: None,
167    accepts_nan_mode: false,
168    notes:
169        "rlocus computes closed-loop polynomial roots on the host from transfer-function metadata.",
170};
171
172#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::rlocus")]
173pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
174    name: "rlocus",
175    shape: ShapeRequirements::Any,
176    constant_strategy: ConstantStrategy::InlineLiteral,
177    elementwise: None,
178    reduction: None,
179    emits_nan: false,
180    notes: "rlocus is model analysis and plotting; it terminates numeric fusion chains.",
181};
182
183fn rlocus_error(
184    message: impl Into<String>,
185    error: &'static BuiltinErrorDescriptor,
186) -> RuntimeError {
187    let mut builder = crate::build_runtime_error(message).with_builtin(BUILTIN_NAME);
188    if let Some(identifier) = error.identifier {
189        builder = builder.with_identifier(identifier);
190    }
191    builder.build()
192}
193
194#[runtime_builtin(
195    name = "rlocus",
196    category = "control",
197    summary = "Compute or plot root loci of SISO transfer-function models.",
198    keywords = "rlocus,root locus,control system,transfer function,tf",
199    sink = true,
200    suppress_auto_output = true,
201    type_resolver(rlocus_type),
202    descriptor(crate::builtins::control::rlocus::RLOCUS_DESCRIPTOR),
203    extensions(RLOCUS_EXTENSIONS),
204    integer_capabilities(RLOCUS_INTEGER_CAPABILITIES),
205    builtin_path = "crate::builtins::control::rlocus"
206)]
207async fn rlocus_builtin(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
208    if crate::builtins::common::validation::value_has_native_integer_class(&sys)
209        || matches!(sys, Value::GpuTensor(_))
210    {
211        return Err(rlocus_error(
212            "rlocus: expected a SISO dynamic system model",
213            &RLOCUS_ERROR_INVALID_MODEL,
214        ));
215    }
216    for value in &rest {
217        if crate::builtins::common::validation::value_has_native_integer_class(value) {
218            crate::builtins::common::validation::ensure_runmat_integer_f64_boundary(
219                value,
220                &RLOCUS_INTEGER_GAIN_EXTENSION,
221                BUILTIN_NAME,
222                "gain",
223            )
224            .await?;
225        }
226    }
227    if is_statement_form_call() {
228        plot_root_locus_statement(sys, rest).await?;
229        return Ok(Value::OutputList(Vec::new()));
230    }
231
232    if rest.len() > 1 {
233        return Err(rlocus_error(
234            "rlocus: expected rlocus(sys) or rlocus(sys, k)",
235            &RLOCUS_ERROR_INVALID_ARGUMENT,
236        ));
237    }
238
239    let model = DynamicModel::from_value_async(sys).await?;
240    let gains = match rest.first() {
241        Some(value) => Some(parse_gain_arg(value).await?),
242        None => None,
243    };
244    let eval = RootLocus::compute(&model.tf, gains)?;
245
246    if crate::output_context::requested_output_count() == Some(0)
247        && crate::output_count::current_output_count().is_none()
248    {
249        render_root_locus_plot(&eval, None).await?;
250        return Ok(Value::OutputList(Vec::new()));
251    }
252
253    if let Some(out_count) = crate::output_count::current_output_count() {
254        if out_count == 0 {
255            render_root_locus_plot(&eval, None).await?;
256            return Ok(Value::OutputList(Vec::new()));
257        }
258        if out_count == 1 {
259            return Ok(Value::OutputList(vec![eval.roots_value()?]));
260        }
261        return Ok(crate::output_count::output_list_with_padding(
262            out_count,
263            eval.outputs()?,
264        ));
265    }
266
267    eval.roots_value()
268}
269
270fn is_statement_form_call() -> bool {
271    matches!(crate::output_count::current_output_count(), Some(0))
272        || (crate::output_context::requested_output_count() == Some(0)
273            && crate::output_count::current_output_count().is_none())
274}
275
276async fn plot_root_locus_statement(first_sys: Value, rest: Vec<Value>) -> BuiltinResult<()> {
277    let mut systems = vec![(first_sys, None)];
278    let mut gains = None;
279
280    for arg in rest {
281        let gathered = crate::dispatcher::gather_if_needed_async(&arg).await?;
282        if is_plot_style_arg(&gathered) {
283            if let Some((_, style)) = systems.last_mut() {
284                if style.is_some() {
285                    return Err(rlocus_error(
286                        "rlocus: only one style argument is supported per system",
287                        &RLOCUS_ERROR_INVALID_ARGUMENT,
288                    ));
289                }
290                *style = Some(gathered);
291                continue;
292            }
293        }
294        if is_dynamic_model_object(&gathered) {
295            if gains.is_some() {
296                return Err(rlocus_error(
297                    "rlocus: gain vector must follow all systems in statement-form plots",
298                    &RLOCUS_ERROR_INVALID_ARGUMENT,
299                ));
300            }
301            systems.push((gathered, None));
302            continue;
303        }
304        if is_numeric_vector_like(&gathered) && gains.is_none() {
305            gains = Some(real_gain_vector(gathered)?);
306            continue;
307        }
308        return Err(rlocus_error(
309            "rlocus: unsupported statement-form plot argument",
310            &RLOCUS_ERROR_INVALID_ARGUMENT,
311        ));
312    }
313
314    let mut first = true;
315    let mut hold_guard = HoldOffGuard::new();
316    for (system, style) in systems {
317        let model = DynamicModel::from_value_async(system).await?;
318        let eval = RootLocus::compute(&model.tf, gains.clone())?;
319        render_root_locus_plot(&eval, style.as_ref()).await?;
320        if first {
321            first = false;
322            let _ = crate::call_builtin_async("hold", &[Value::from("on")]).await;
323            hold_guard.arm();
324        }
325    }
326    Ok(())
327}
328
329struct HoldOffGuard {
330    armed: bool,
331    previous_hold_enabled: bool,
332}
333
334impl HoldOffGuard {
335    fn new() -> Self {
336        Self {
337            armed: false,
338            previous_hold_enabled: crate::builtins::plotting::state::current_hold_enabled(),
339        }
340    }
341
342    fn arm(&mut self) {
343        self.armed = true;
344    }
345}
346
347impl Drop for HoldOffGuard {
348    fn drop(&mut self) {
349        if self.armed {
350            let mode = if self.previous_hold_enabled {
351                crate::builtins::plotting::HoldMode::On
352            } else {
353                crate::builtins::plotting::HoldMode::Off
354            };
355            crate::builtins::plotting::set_hold(mode);
356        }
357    }
358}
359
360fn is_dynamic_model_object(value: &Value) -> bool {
361    matches!(value, Value::Object(object) if object.is_class("tf") || object.is_class("ss"))
362}
363
364fn is_plot_style_arg(value: &Value) -> bool {
365    matches!(
366        value,
367        Value::String(_) | Value::StringArray(_) | Value::CharArray(_)
368    )
369}
370
371fn is_numeric_vector_like(value: &Value) -> bool {
372    match value {
373        Value::Num(_) | Value::Int(_) | Value::Bool(_) | Value::Complex(_, _) => true,
374        Value::Tensor(tensor) => is_vector_shape(&tensor.shape),
375        Value::ComplexTensor(tensor) => is_vector_shape(&tensor.shape),
376        Value::LogicalArray(logical) => is_vector_shape(&logical.shape),
377        _ => false,
378    }
379}
380
381async fn parse_gain_arg(value: &Value) -> BuiltinResult<Vec<f64>> {
382    let gathered = crate::dispatcher::gather_if_needed_async(value).await?;
383    real_gain_vector(gathered)
384}
385
386fn real_gain_vector(value: Value) -> BuiltinResult<Vec<f64>> {
387    let gains = match value {
388        Value::Num(n) => vec![n],
389        Value::Int(i) => vec![i.to_f64()],
390        Value::Bool(b) => vec![if b { 1.0 } else { 0.0 }],
391        Value::Complex(re, im) if im.abs() <= EPS => vec![re],
392        Value::Tensor(tensor) => {
393            ensure_vector_shape(&tensor.shape)?;
394            tensor::tensor_values_f64(&tensor)
395        }
396        Value::ComplexTensor(tensor) => {
397            ensure_vector_shape(&tensor.shape)?;
398            tensor
399                .materialize_f64()
400                .into_iter()
401                .map(|(re, im)| {
402                    if im.abs() <= EPS {
403                        Ok(re)
404                    } else {
405                        Err(rlocus_error(
406                            "rlocus: gain vector must be real",
407                            &RLOCUS_ERROR_INVALID_ARGUMENT,
408                        ))
409                    }
410                })
411                .collect::<BuiltinResult<Vec<_>>>()?
412        }
413        Value::LogicalArray(logical) => {
414            ensure_vector_shape(&logical.shape)?;
415            logical
416                .data
417                .into_iter()
418                .map(|value| if value == 0 { 0.0 } else { 1.0 })
419                .collect()
420        }
421        other => {
422            return Err(rlocus_error(
423                format!("rlocus: k must be a real numeric vector, got {other:?}"),
424                &RLOCUS_ERROR_INVALID_ARGUMENT,
425            ));
426        }
427    };
428    validate_gains(gains)
429}
430
431fn ensure_vector_shape(shape: &[usize]) -> BuiltinResult<()> {
432    if is_vector_shape(shape) {
433        Ok(())
434    } else {
435        Err(rlocus_error(
436            "rlocus: k must be a vector",
437            &RLOCUS_ERROR_INVALID_ARGUMENT,
438        ))
439    }
440}
441
442fn is_vector_shape(shape: &[usize]) -> bool {
443    shape.iter().copied().filter(|&dim| dim > 1).count() <= 1
444}
445
446fn validate_gains(gains: Vec<f64>) -> BuiltinResult<Vec<f64>> {
447    if gains.is_empty() {
448        return Err(rlocus_error(
449            "rlocus: k must not be empty",
450            &RLOCUS_ERROR_INVALID_ARGUMENT,
451        ));
452    }
453    if gains.iter().any(|gain| !gain.is_finite()) {
454        return Err(rlocus_error(
455            "rlocus: k values must be finite",
456            &RLOCUS_ERROR_INVALID_ARGUMENT,
457        ));
458    }
459    if gains.iter().any(|gain| *gain < 0.0) {
460        return Err(rlocus_error(
461            "rlocus: k values must be nonnegative",
462            &RLOCUS_ERROR_INVALID_ARGUMENT,
463        ));
464    }
465    Ok(gains)
466}
467
468#[derive(Clone, Debug)]
469struct RootLocus {
470    gains: Vec<f64>,
471    roots: Vec<Complex64>,
472    branches: usize,
473    poles: Vec<Complex64>,
474    zeros: Vec<Complex64>,
475}
476
477#[derive(Clone, Debug)]
478struct DynamicModel {
479    tf: TfModel,
480}
481
482impl DynamicModel {
483    async fn from_value_async(value: Value) -> BuiltinResult<Self> {
484        let gathered = crate::dispatcher::gather_if_needed_async(&value).await?;
485        let Value::Object(object) = gathered else {
486            return Err(rlocus_error(
487                "rlocus: expected a SISO dynamic system model",
488                &RLOCUS_ERROR_INVALID_MODEL,
489            ));
490        };
491        if object.is_class("tf") {
492            return Ok(Self {
493                tf: TfModel::from_value(Value::Object(object), BUILTIN_NAME)?,
494            });
495        }
496        if object.is_class("ss") {
497            return Ok(Self {
498                tf: ss_object_to_tf(&object)?,
499            });
500        }
501        Err(rlocus_error(
502            format!(
503                "rlocus: unsupported model class '{}'; expected SISO tf or ss",
504                object.class_name
505            ),
506            &RLOCUS_ERROR_UNSUPPORTED_MODEL,
507        ))
508    }
509}
510
511impl RootLocus {
512    fn compute(model: &TfModel, gains: Option<Vec<f64>>) -> BuiltinResult<Self> {
513        ensure_supported_model(model)?;
514        let gains = gains.unwrap_or_else(|| default_gains(model));
515        let branches = characteristic_branch_count(model);
516        let poles = polynomial_roots(&model.denominator, BUILTIN_NAME)?;
517        let zeros = if max_norm(&model.numerator) <= EPS {
518            Vec::new()
519        } else {
520            polynomial_roots(&model.numerator, BUILTIN_NAME)?
521        };
522
523        let mut previous: Option<Vec<Complex64>> = None;
524        let mut roots = Vec::with_capacity(branches.saturating_mul(gains.len()));
525        for gain in &gains {
526            let mut column = roots_for_gain(model, *gain, branches)?;
527            column = match previous.as_ref() {
528                Some(prev) => track_roots(prev, column),
529                None => sort_roots(column),
530            };
531            previous = Some(column.clone());
532            roots.extend(column);
533        }
534
535        Ok(Self {
536            gains,
537            roots,
538            branches,
539            poles,
540            zeros,
541        })
542    }
543
544    fn outputs(&self) -> BuiltinResult<Vec<Value>> {
545        Ok(vec![self.roots_value()?, self.gains_value()?])
546    }
547
548    fn roots_value(&self) -> BuiltinResult<Value> {
549        let shape = vec![self.branches, self.gains.len()];
550        if self.roots.iter().all(|root| root.im.abs() <= EPS) {
551            let data = self.roots.iter().map(|root| root.re).collect::<Vec<_>>();
552            Tensor::new(data, shape).map(Value::Tensor).map_err(|err| {
553                rlocus_error(
554                    format!("rlocus: failed to build root matrix: {err}"),
555                    &RLOCUS_ERROR_INTERNAL,
556                )
557            })
558        } else {
559            let data = self
560                .roots
561                .iter()
562                .map(|root| (root.re, root.im))
563                .collect::<Vec<_>>();
564            ComplexTensor::new(data, shape)
565                .map(Value::ComplexTensor)
566                .map_err(|err| {
567                    rlocus_error(
568                        format!("rlocus: failed to build complex root matrix: {err}"),
569                        &RLOCUS_ERROR_INTERNAL,
570                    )
571                })
572        }
573    }
574
575    fn gains_value(&self) -> BuiltinResult<Value> {
576        Tensor::new(self.gains.clone(), vec![1, self.gains.len()])
577            .map(Value::Tensor)
578            .map_err(|err| {
579                rlocus_error(
580                    format!("rlocus: failed to build gain vector: {err}"),
581                    &RLOCUS_ERROR_INTERNAL,
582                )
583            })
584    }
585
586    fn root(&self, branch: usize, gain_index: usize) -> Complex64 {
587        self.roots[branch + gain_index * self.branches]
588    }
589}
590
591fn ensure_supported_model(model: &TfModel) -> BuiltinResult<()> {
592    if model.input_delay.abs() > EPS || model.output_delay.abs() > EPS {
593        return Err(rlocus_error(
594            "rlocus: transfer functions with input or output delays are not supported",
595            &RLOCUS_ERROR_UNSUPPORTED_MODEL,
596        ));
597    }
598    Ok(())
599}
600
601pub(crate) fn ss_object_to_tf(object: &ObjectInstance) -> BuiltinResult<TfModel> {
602    let a = matrix_property(object, "A")?;
603    let b = matrix_property(object, "B")?;
604    let c = matrix_property(object, "C")?;
605    let d = matrix_property(object, "D")?;
606    let sample_time = scalar_property(object, "Ts")?;
607    if !sample_time.is_finite() || sample_time < 0.0 {
608        return Err(rlocus_error(
609            "rlocus: ss sample time must be a finite nonnegative scalar",
610            &RLOCUS_ERROR_INVALID_MODEL,
611        ));
612    }
613    ensure_zero_delay_property(object, "InputDelay")?;
614    ensure_zero_delay_property(object, "OutputDelay")?;
615    validate_ss_dimensions(&a, &b, &c, &d)?;
616
617    let denominator = characteristic_polynomial_from_matrix(&a)?;
618    let numerator = state_space_numerator(&a, &b, &c, d[(0, 0)], &denominator)?;
619    let numerator = trim_leading_complex_zeros(clean_coefficients(numerator));
620    let denominator = trim_leading_complex_zeros(clean_coefficients(denominator));
621    if denominator.is_empty() || denominator[0].norm() <= EPS {
622        return Err(rlocus_error(
623            "rlocus: ss model produced an invalid denominator polynomial",
624            &RLOCUS_ERROR_INTERNAL,
625        ));
626    }
627    ensure_finite_coefficients("Numerator", &numerator)?;
628    ensure_finite_coefficients("Denominator", &denominator)?;
629
630    Ok(TfModel {
631        numerator,
632        denominator,
633        variable: if sample_time > 0.0 { "z" } else { "s" }.to_string(),
634        sample_time,
635        input_delay: 0.0,
636        output_delay: 0.0,
637    })
638}
639
640fn property<'a>(object: &'a ObjectInstance, name: &str) -> BuiltinResult<&'a Value> {
641    object.properties.get(name).ok_or_else(|| {
642        rlocus_error(
643            format!("rlocus: model object is missing {name} property"),
644            &RLOCUS_ERROR_INVALID_MODEL,
645        )
646    })
647}
648
649fn matrix_property(object: &ObjectInstance, name: &str) -> BuiltinResult<DMatrix<Complex64>> {
650    let value = property(object, name)?;
651    match value {
652        Value::Tensor(tensor) => {
653            ensure_matrix_shape(name, &tensor.shape)?;
654            let data = tensor::tensor_values_f64(tensor)
655                .into_iter()
656                .map(|re| Complex64::new(re, 0.0))
657                .collect::<Vec<_>>();
658            ensure_finite_coefficients(name, &data)?;
659            Ok(DMatrix::from_column_slice(tensor.rows, tensor.cols, &data))
660        }
661        Value::ComplexTensor(tensor) => {
662            ensure_matrix_shape(name, &tensor.shape)?;
663            let data = tensor::complex_tensor_values_complex64(tensor);
664            ensure_finite_coefficients(name, &data)?;
665            Ok(DMatrix::from_column_slice(tensor.rows, tensor.cols, &data))
666        }
667        Value::LogicalArray(logical) => {
668            ensure_matrix_shape(name, &logical.shape)?;
669            let (rows, cols) = rows_cols(&logical.shape);
670            let data = logical
671                .data
672                .iter()
673                .map(|&value| Complex64::new(if value == 0 { 0.0 } else { 1.0 }, 0.0))
674                .collect::<Vec<_>>();
675            Ok(DMatrix::from_column_slice(rows, cols, &data))
676        }
677        Value::Num(n) => scalar_matrix(*n, 0.0, name),
678        Value::Int(i) => scalar_matrix(i.to_f64(), 0.0, name),
679        Value::Bool(b) => scalar_matrix(if *b { 1.0 } else { 0.0 }, 0.0, name),
680        Value::Complex(re, im) => scalar_matrix(*re, *im, name),
681        other => Err(rlocus_error(
682            format!("rlocus: ss {name} must be a finite numeric matrix, got {other:?}"),
683            &RLOCUS_ERROR_INVALID_MODEL,
684        )),
685    }
686}
687
688fn scalar_matrix(re: f64, im: f64, name: &str) -> BuiltinResult<DMatrix<Complex64>> {
689    let value = Complex64::new(re, im);
690    ensure_finite_coefficients(name, &[value])?;
691    Ok(DMatrix::from_element(1, 1, value))
692}
693
694fn ensure_matrix_shape(name: &str, shape: &[usize]) -> BuiltinResult<()> {
695    if shape.len() <= 2 {
696        Ok(())
697    } else {
698        Err(rlocus_error(
699            format!("rlocus: ss {name} must be a 2-D matrix, got shape {shape:?}"),
700            &RLOCUS_ERROR_INVALID_MODEL,
701        ))
702    }
703}
704
705fn rows_cols(shape: &[usize]) -> (usize, usize) {
706    if shape.len() >= 2 {
707        (shape[0], shape[1])
708    } else if shape.len() == 1 {
709        (1, shape[0])
710    } else {
711        (0, 0)
712    }
713}
714
715fn scalar_property(object: &ObjectInstance, name: &str) -> BuiltinResult<f64> {
716    let value = scalar_f64(property(object, name)?, name, BUILTIN_NAME)?;
717    if value.is_finite() {
718        Ok(value)
719    } else {
720        Err(rlocus_error(
721            format!("rlocus: ss {name} must be finite"),
722            &RLOCUS_ERROR_INVALID_MODEL,
723        ))
724    }
725}
726
727fn ensure_zero_delay_property(object: &ObjectInstance, name: &str) -> BuiltinResult<()> {
728    let values = numeric_values(property(object, name)?, name)?;
729    if values.iter().any(|value| value.norm() > EPS) {
730        return Err(rlocus_error(
731            "rlocus: ss models with input or output delays are not supported",
732            &RLOCUS_ERROR_UNSUPPORTED_MODEL,
733        ));
734    }
735    Ok(())
736}
737
738fn numeric_values(value: &Value, name: &str) -> BuiltinResult<Vec<Complex64>> {
739    let values = match value {
740        Value::Num(n) => vec![Complex64::new(*n, 0.0)],
741        Value::Int(i) => vec![Complex64::new(i.to_f64(), 0.0)],
742        Value::Bool(b) => vec![Complex64::new(if *b { 1.0 } else { 0.0 }, 0.0)],
743        Value::Complex(re, im) => vec![Complex64::new(*re, *im)],
744        Value::Tensor(tensor) => tensor::tensor_values_f64(tensor)
745            .into_iter()
746            .map(|re| Complex64::new(re, 0.0))
747            .collect(),
748        Value::ComplexTensor(tensor) => tensor::complex_tensor_values_complex64(tensor),
749        Value::LogicalArray(logical) => logical
750            .data
751            .iter()
752            .map(|&value| Complex64::new(if value == 0 { 0.0 } else { 1.0 }, 0.0))
753            .collect(),
754        other => {
755            return Err(rlocus_error(
756                format!("rlocus: ss {name} must be numeric, got {other:?}"),
757                &RLOCUS_ERROR_INVALID_MODEL,
758            ));
759        }
760    };
761    ensure_finite_coefficients(name, &values)?;
762    Ok(values)
763}
764
765fn validate_ss_dimensions(
766    a: &DMatrix<Complex64>,
767    b: &DMatrix<Complex64>,
768    c: &DMatrix<Complex64>,
769    d: &DMatrix<Complex64>,
770) -> BuiltinResult<()> {
771    if a.nrows() != a.ncols() {
772        return Err(rlocus_error(
773            format!(
774                "rlocus: ss A must be square, got {}x{}",
775                a.nrows(),
776                a.ncols()
777            ),
778            &RLOCUS_ERROR_INVALID_MODEL,
779        ));
780    }
781    let states = a.nrows();
782    if b.nrows() != states {
783        return Err(rlocus_error(
784            format!(
785                "rlocus: ss B must have {states} rows to match A, got {}x{}",
786                b.nrows(),
787                b.ncols()
788            ),
789            &RLOCUS_ERROR_INVALID_MODEL,
790        ));
791    }
792    if c.ncols() != states {
793        return Err(rlocus_error(
794            format!(
795                "rlocus: ss C must have {states} columns to match A, got {}x{}",
796                c.nrows(),
797                c.ncols()
798            ),
799            &RLOCUS_ERROR_INVALID_MODEL,
800        ));
801    }
802    if d.nrows() != c.nrows() || d.ncols() != b.ncols() {
803        return Err(rlocus_error(
804            format!(
805                "rlocus: ss D must have shape {}x{} to match C outputs and B inputs, got {}x{}",
806                c.nrows(),
807                b.ncols(),
808                d.nrows(),
809                d.ncols()
810            ),
811            &RLOCUS_ERROR_INVALID_MODEL,
812        ));
813    }
814    if b.ncols() != 1 || c.nrows() != 1 || d.nrows() != 1 || d.ncols() != 1 {
815        return Err(rlocus_error(
816            "rlocus: only SISO ss models are supported",
817            &RLOCUS_ERROR_UNSUPPORTED_MODEL,
818        ));
819    }
820    Ok(())
821}
822
823fn characteristic_polynomial_from_matrix(a: &DMatrix<Complex64>) -> BuiltinResult<Vec<Complex64>> {
824    let n = a.nrows();
825    if n == 0 {
826        return Ok(vec![Complex64::new(1.0, 0.0)]);
827    }
828    let mut coeffs = Vec::with_capacity(n + 1);
829    coeffs.push(Complex64::new(1.0, 0.0));
830    let mut b = DMatrix::<Complex64>::identity(n, n);
831    for k in 1..=n {
832        let ab = a * &b;
833        let trace = (0..n).fold(Complex64::new(0.0, 0.0), |acc, idx| acc + ab[(idx, idx)]);
834        let coeff = -trace / Complex64::new(k as f64, 0.0);
835        coeffs.push(coeff);
836        let mut next = ab;
837        for idx in 0..n {
838            next[(idx, idx)] += coeff;
839        }
840        b = next;
841    }
842    let coeffs = clean_coefficients(coeffs);
843    ensure_finite_coefficients("ss characteristic polynomial", &coeffs)?;
844    Ok(coeffs)
845}
846
847fn state_space_numerator(
848    a: &DMatrix<Complex64>,
849    b: &DMatrix<Complex64>,
850    c: &DMatrix<Complex64>,
851    d: Complex64,
852    denominator: &[Complex64],
853) -> BuiltinResult<Vec<Complex64>> {
854    let degree = a.nrows();
855    if degree == 0 {
856        return Ok(vec![d]);
857    }
858
859    let mut points = Vec::with_capacity(degree + 1);
860    let mut values = Vec::with_capacity(degree + 1);
861    for point in interpolation_candidates(degree) {
862        let Some(response) = state_space_response_at(a, b, c, d, point) else {
863            continue;
864        };
865        points.push(point);
866        values.push(response * poly_eval(denominator, point));
867        if points.len() == degree + 1 {
868            break;
869        }
870    }
871    if points.len() != degree + 1 {
872        return Err(rlocus_error(
873            "rlocus: failed to find enough nonsingular interpolation points for ss model",
874            &RLOCUS_ERROR_INTERNAL,
875        ));
876    }
877
878    let mut vandermonde = DMatrix::<Complex64>::zeros(degree + 1, degree + 1);
879    for (row, point) in points.iter().enumerate() {
880        for col in 0..=degree {
881            vandermonde[(row, col)] = point.powu((degree - col) as u32);
882        }
883    }
884    let rhs = DVector::<Complex64>::from_vec(values);
885    let coeffs = vandermonde.lu().solve(&rhs).ok_or_else(|| {
886        rlocus_error(
887            "rlocus: failed to solve ss numerator interpolation system",
888            &RLOCUS_ERROR_INTERNAL,
889        )
890    })?;
891    let coeffs = clean_coefficients(coeffs.iter().copied().collect());
892    ensure_finite_coefficients("ss numerator polynomial", &coeffs)?;
893    Ok(coeffs)
894}
895
896fn interpolation_candidates(degree: usize) -> Vec<Complex64> {
897    let needed = degree + 1;
898    let mut points = Vec::with_capacity(needed * 6);
899    for radius_idx in 0..4 {
900        let radius = 0.75 + radius_idx as f64;
901        for idx in 0..needed {
902            let angle = std::f64::consts::TAU * ((idx as f64 + 0.37) / needed as f64);
903            points.push(Complex64::from_polar(radius, angle));
904        }
905    }
906    for idx in 1..=(needed * 2) {
907        let value = idx as f64;
908        points.push(Complex64::new(value, 0.0));
909        points.push(Complex64::new(-value, 0.0));
910    }
911    points
912}
913
914fn state_space_response_at(
915    a: &DMatrix<Complex64>,
916    b: &DMatrix<Complex64>,
917    c: &DMatrix<Complex64>,
918    d: Complex64,
919    point: Complex64,
920) -> Option<Complex64> {
921    let n = a.nrows();
922    if n == 0 {
923        return Some(d);
924    }
925    let mut system = -a.clone();
926    for idx in 0..n {
927        system[(idx, idx)] += point;
928    }
929    let state = system.lu().solve(b)?;
930    let output = c * state;
931    Some(d + output[(0, 0)])
932}
933
934fn characteristic_branch_count(model: &TfModel) -> usize {
935    model
936        .denominator
937        .len()
938        .max(model.numerator.len())
939        .saturating_sub(1)
940}
941
942fn roots_for_gain(model: &TfModel, gain: f64, branches: usize) -> BuiltinResult<Vec<Complex64>> {
943    let polynomial = characteristic_polynomial(model, gain);
944    let mut roots = polynomial_roots(&polynomial, BUILTIN_NAME)?;
945    if roots.len() > branches {
946        return Err(rlocus_error(
947            "rlocus: root calculation returned more roots than characteristic branches",
948            &RLOCUS_ERROR_INTERNAL,
949        ));
950    }
951    roots.resize(branches, Complex64::new(f64::INFINITY, 0.0));
952    Ok(roots)
953}
954
955fn characteristic_polynomial(model: &TfModel, gain: f64) -> Vec<Complex64> {
956    let len = model.denominator.len().max(model.numerator.len()).max(1);
957    let mut out = vec![Complex64::new(0.0, 0.0); len];
958    let denominator_offset = len - model.denominator.len();
959    for (idx, coeff) in model.denominator.iter().enumerate() {
960        out[denominator_offset + idx] += *coeff;
961    }
962    let numerator_offset = len - model.numerator.len();
963    let gain = Complex64::new(gain, 0.0);
964    for (idx, coeff) in model.numerator.iter().enumerate() {
965        out[numerator_offset + idx] += gain * *coeff;
966    }
967    out
968}
969
970fn default_gains(model: &TfModel) -> Vec<f64> {
971    let numerator_scale = max_norm(&model.numerator);
972    if numerator_scale <= EPS {
973        return vec![0.0];
974    }
975    let denominator_scale = max_norm(&model.denominator).max(1.0);
976    let center = (denominator_scale / numerator_scale).clamp(1.0e-9, 1.0e9);
977    let start = center.log10() - DEFAULT_GAIN_DECADES;
978    let stop = center.log10() + DEFAULT_GAIN_DECADES;
979
980    let mut gains = Vec::with_capacity(DEFAULT_GAIN_POINTS + 16);
981    gains.push(0.0);
982    if DEFAULT_GAIN_POINTS == 1 {
983        gains.push(center);
984        return gains;
985    }
986    for idx in 0..DEFAULT_GAIN_POINTS {
987        let fraction = idx as f64 / (DEFAULT_GAIN_POINTS - 1) as f64;
988        let gain = 10.0_f64.powf(start + fraction * (stop - start));
989        if gain.is_finite() && gain > 0.0 {
990            gains.push(gain);
991        }
992    }
993    for gain in critical_gains(model) {
994        push_gain_window(&mut gains, gain);
995    }
996    sort_and_dedup_gains(gains)
997}
998
999fn max_norm(coeffs: &[Complex64]) -> f64 {
1000    coeffs
1001        .iter()
1002        .map(|coeff| coeff.norm())
1003        .fold(0.0_f64, f64::max)
1004}
1005
1006fn critical_gains(model: &TfModel) -> Vec<f64> {
1007    let den_derivative = poly_derivative(&model.denominator);
1008    let num_derivative = poly_derivative(&model.numerator);
1009    let equation = poly_sub(
1010        &poly_mul(&den_derivative, &model.numerator),
1011        &poly_mul(&model.denominator, &num_derivative),
1012    );
1013    let Ok(points) = polynomial_roots(&equation, BUILTIN_NAME) else {
1014        return Vec::new();
1015    };
1016    let mut gains = Vec::new();
1017    for point in points {
1018        if point.im.abs() > 1.0e-7 {
1019            continue;
1020        }
1021        let numerator = poly_eval(&model.numerator, point);
1022        if numerator.norm() <= EPS {
1023            continue;
1024        }
1025        let gain = -poly_eval(&model.denominator, point) / numerator;
1026        if gain.im.abs() <= 1.0e-7 && gain.re.is_finite() && gain.re > 0.0 {
1027            gains.push(gain.re);
1028        }
1029    }
1030    gains
1031}
1032
1033fn push_gain_window(gains: &mut Vec<f64>, gain: f64) {
1034    for factor in [0.9, 0.99, 1.0, 1.01, 1.1] {
1035        let candidate = gain * factor;
1036        if candidate.is_finite() && candidate > 0.0 {
1037            gains.push(candidate);
1038        }
1039    }
1040}
1041
1042fn sort_and_dedup_gains(mut gains: Vec<f64>) -> Vec<f64> {
1043    gains.sort_by(f64::total_cmp);
1044    gains.dedup_by(|a, b| (*a - *b).abs() <= 1.0e-10 * a.abs().max(b.abs()).max(1.0));
1045    gains
1046}
1047
1048fn poly_derivative(coeffs: &[Complex64]) -> Vec<Complex64> {
1049    if coeffs.len() <= 1 {
1050        return vec![Complex64::new(0.0, 0.0)];
1051    }
1052    let degree = coeffs.len() - 1;
1053    coeffs
1054        .iter()
1055        .take(degree)
1056        .enumerate()
1057        .map(|(idx, coeff)| *coeff * Complex64::new((degree - idx) as f64, 0.0))
1058        .collect()
1059}
1060
1061fn poly_mul(left: &[Complex64], right: &[Complex64]) -> Vec<Complex64> {
1062    if left.is_empty() || right.is_empty() {
1063        return Vec::new();
1064    }
1065    let mut out = vec![Complex64::new(0.0, 0.0); left.len() + right.len() - 1];
1066    for (i, lhs) in left.iter().enumerate() {
1067        for (j, rhs) in right.iter().enumerate() {
1068            out[i + j] += *lhs * *rhs;
1069        }
1070    }
1071    clean_coefficients(out)
1072}
1073
1074fn poly_sub(left: &[Complex64], right: &[Complex64]) -> Vec<Complex64> {
1075    let len = left.len().max(right.len());
1076    let mut out = vec![Complex64::new(0.0, 0.0); len];
1077    let left_offset = len - left.len();
1078    for (idx, coeff) in left.iter().enumerate() {
1079        out[left_offset + idx] += *coeff;
1080    }
1081    let right_offset = len - right.len();
1082    for (idx, coeff) in right.iter().enumerate() {
1083        out[right_offset + idx] -= *coeff;
1084    }
1085    clean_coefficients(out)
1086}
1087
1088fn clean_coefficients(mut coeffs: Vec<Complex64>) -> Vec<Complex64> {
1089    let scale = max_norm(&coeffs).max(1.0);
1090    let tol = scale * 1.0e-10;
1091    for coeff in &mut coeffs {
1092        if coeff.re.abs() <= tol {
1093            coeff.re = 0.0;
1094        }
1095        if coeff.im.abs() <= tol {
1096            coeff.im = 0.0;
1097        }
1098    }
1099    coeffs
1100}
1101
1102fn trim_leading_complex_zeros(coeffs: Vec<Complex64>) -> Vec<Complex64> {
1103    let first_nonzero = coeffs
1104        .iter()
1105        .position(|value| value.norm() > EPS)
1106        .unwrap_or(coeffs.len());
1107    coeffs[first_nonzero..].to_vec()
1108}
1109
1110fn ensure_finite_coefficients(label: &str, coeffs: &[Complex64]) -> BuiltinResult<()> {
1111    if coeffs
1112        .iter()
1113        .any(|value| !value.re.is_finite() || !value.im.is_finite())
1114    {
1115        return Err(rlocus_error(
1116            format!("rlocus: {label} values must be finite"),
1117            &RLOCUS_ERROR_INVALID_MODEL,
1118        ));
1119    }
1120    Ok(())
1121}
1122
1123fn sort_roots(mut roots: Vec<Complex64>) -> Vec<Complex64> {
1124    roots.sort_by(|a, b| a.re.total_cmp(&b.re).then(a.im.total_cmp(&b.im)));
1125    roots
1126}
1127
1128fn track_roots(previous: &[Complex64], roots: Vec<Complex64>) -> Vec<Complex64> {
1129    if previous.len() != roots.len() {
1130        return sort_roots(roots);
1131    }
1132    let mut assigned = vec![false; roots.len()];
1133    let mut ordered = Vec::with_capacity(roots.len());
1134    for prev in previous {
1135        let mut best_idx = None;
1136        let mut best_distance = f64::INFINITY;
1137        for (idx, root) in roots.iter().enumerate() {
1138            if assigned[idx] {
1139                continue;
1140            }
1141            let Some(distance) = root_distance2(*prev, *root) else {
1142                continue;
1143            };
1144            if distance < best_distance {
1145                best_distance = distance;
1146                best_idx = Some(idx);
1147            }
1148        }
1149        let idx = best_idx
1150            .or_else(|| assigned.iter().position(|used| !*used))
1151            .unwrap_or(0);
1152        assigned[idx] = true;
1153        ordered.push(roots[idx]);
1154    }
1155    ordered
1156}
1157
1158fn root_distance2(a: Complex64, b: Complex64) -> Option<f64> {
1159    if !a.re.is_finite() || !a.im.is_finite() || !b.re.is_finite() || !b.im.is_finite() {
1160        return None;
1161    }
1162    let dr = a.re - b.re;
1163    let di = a.im - b.im;
1164    Some(dr * dr + di * di)
1165}
1166
1167async fn render_root_locus_plot(eval: &RootLocus, style: Option<&Value>) -> BuiltinResult<()> {
1168    let mut args = Vec::new();
1169    for branch in 0..eval.branches {
1170        let mut x = Vec::with_capacity(eval.gains.len());
1171        let mut y = Vec::with_capacity(eval.gains.len());
1172        for gain_idx in 0..eval.gains.len() {
1173            let root = eval.root(branch, gain_idx);
1174            if root.re.is_finite() && root.im.is_finite() {
1175                x.push(root.re);
1176                y.push(root.im);
1177            }
1178        }
1179        if x.is_empty() {
1180            continue;
1181        }
1182        args.push(column_tensor(x)?);
1183        args.push(column_tensor(y)?);
1184        if let Some(style) = style {
1185            args.push(style.clone());
1186        }
1187    }
1188    push_marker_series(&mut args, &eval.poles, "x")?;
1189    push_marker_series(&mut args, &eval.zeros, "o")?;
1190
1191    if args.is_empty() {
1192        return Ok(());
1193    }
1194    if let Err(err) = crate::call_builtin_async("plot", &args).await {
1195        if super::is_nonfatal_plot_setup_error(&err) {
1196            return Ok(());
1197        }
1198        return Err(rlocus_error(
1199            format!("rlocus: plotting failed: {}", err.message()),
1200            &RLOCUS_ERROR_PLOT_FAILED,
1201        ));
1202    }
1203    let _ = crate::call_builtin_async("title", &[Value::from("Root Locus")]).await;
1204    let _ = crate::call_builtin_async("xlabel", &[Value::from("Real Axis")]).await;
1205    let _ = crate::call_builtin_async("ylabel", &[Value::from("Imaginary Axis")]).await;
1206    let _ = crate::call_builtin_async("grid", &[Value::from("on")]).await;
1207    Ok(())
1208}
1209
1210fn push_marker_series(
1211    args: &mut Vec<Value>,
1212    values: &[Complex64],
1213    marker: &str,
1214) -> BuiltinResult<()> {
1215    let mut x = Vec::new();
1216    let mut y = Vec::new();
1217    for value in values {
1218        if value.re.is_finite() && value.im.is_finite() {
1219            x.push(value.re);
1220            y.push(value.im);
1221        }
1222    }
1223    if !x.is_empty() {
1224        args.push(column_tensor(x)?);
1225        args.push(column_tensor(y)?);
1226        args.push(Value::from(marker));
1227    }
1228    Ok(())
1229}
1230
1231fn column_tensor(data: Vec<f64>) -> BuiltinResult<Value> {
1232    let rows = data.len();
1233    Tensor::new(data, vec![rows, 1])
1234        .map(Value::Tensor)
1235        .map_err(|err| {
1236            rlocus_error(
1237                format!("rlocus: failed to build plot vector: {err}"),
1238                &RLOCUS_ERROR_INTERNAL,
1239            )
1240        })
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246    use futures::executor::block_on;
1247    use runmat_value::{IntegerComplexStorage, IntegerStorage};
1248
1249    fn tf(num: Vec<f64>, den: Vec<f64>) -> Value {
1250        block_on(crate::call_builtin_async(
1251            "tf",
1252            &[
1253                Value::Tensor(Tensor::new(num.clone(), vec![1, num.len()]).unwrap()),
1254                Value::Tensor(Tensor::new(den.clone(), vec![1, den.len()]).unwrap()),
1255            ],
1256        ))
1257        .expect("tf")
1258    }
1259
1260    fn discrete_tf(num: Vec<f64>, den: Vec<f64>, sample_time: f64) -> Value {
1261        block_on(crate::call_builtin_async(
1262            "tf",
1263            &[
1264                Value::Tensor(Tensor::new(num.clone(), vec![1, num.len()]).unwrap()),
1265                Value::Tensor(Tensor::new(den.clone(), vec![1, den.len()]).unwrap()),
1266                Value::Num(sample_time),
1267            ],
1268        ))
1269        .expect("tf")
1270    }
1271
1272    fn ss(a: Value, b: Value, c: Value, d: Value) -> Value {
1273        block_on(crate::call_builtin_async("ss", &[a, b, c, d])).expect("ss")
1274    }
1275
1276    fn run_rlocus(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1277        block_on(rlocus_builtin(sys, rest))
1278    }
1279
1280    fn tensor(value: &Value) -> &Tensor {
1281        match value {
1282            Value::Tensor(tensor) => tensor,
1283            other => panic!("expected tensor, got {other:?}"),
1284        }
1285    }
1286
1287    #[test]
1288    fn numeric_values_reads_typed_integer_storage_exactly() {
1289        let tensor = Tensor::new_integer(IntegerStorage::I16(vec![-1, 0, 2]), vec![1, 3])
1290            .expect("typed integer tensor");
1291
1292        assert_eq!(
1293            numeric_values(&Value::Tensor(tensor), "A").expect("numeric values"),
1294            vec![
1295                Complex64::new(-1.0, 0.0),
1296                Complex64::new(0.0, 0.0),
1297                Complex64::new(2.0, 0.0),
1298            ]
1299        );
1300    }
1301
1302    #[test]
1303    fn numeric_values_reads_complex_integer_storage_exactly() {
1304        let storage = IntegerComplexStorage::new(
1305            IntegerStorage::I16(vec![1, -2]),
1306            IntegerStorage::I16(vec![3, -4]),
1307        )
1308        .expect("complex integer storage");
1309        let tensor = ComplexTensor::new_integer(storage, vec![1, 2]).unwrap();
1310
1311        assert_eq!(
1312            numeric_values(&Value::ComplexTensor(tensor), "A").expect("numeric values"),
1313            vec![Complex64::new(1.0, 3.0), Complex64::new(-2.0, -4.0)]
1314        );
1315    }
1316
1317    #[test]
1318    fn matrix_property_reads_typed_integer_storage_exactly() {
1319        let matrix = Tensor::new_integer(IntegerStorage::I16(vec![1, 3, 2, 4]), vec![2, 2])
1320            .expect("typed integer matrix");
1321        let mut object = ObjectInstance::new("ss".to_string());
1322        object
1323            .properties
1324            .insert("A".to_string(), Value::Tensor(matrix));
1325
1326        let parsed = matrix_property(&object, "A").expect("matrix property");
1327
1328        assert_eq!(parsed[(0, 0)], Complex64::new(1.0, 0.0));
1329        assert_eq!(parsed[(1, 0)], Complex64::new(3.0, 0.0));
1330        assert_eq!(parsed[(0, 1)], Complex64::new(2.0, 0.0));
1331        assert_eq!(parsed[(1, 1)], Complex64::new(4.0, 0.0));
1332    }
1333
1334    #[test]
1335    fn matrix_property_reads_complex_integer_storage_exactly() {
1336        let storage = IntegerComplexStorage::new(
1337            IntegerStorage::I16(vec![1, 3, 2, 4]),
1338            IntegerStorage::I16(vec![-1, -3, -2, -4]),
1339        )
1340        .expect("complex integer storage");
1341        let matrix = ComplexTensor::new_integer(storage, vec![2, 2]).unwrap();
1342        let mut object = ObjectInstance::new("ss".to_string());
1343        object
1344            .properties
1345            .insert("A".to_string(), Value::ComplexTensor(matrix));
1346
1347        let parsed = matrix_property(&object, "A").expect("matrix property");
1348
1349        assert_eq!(parsed[(0, 0)], Complex64::new(1.0, -1.0));
1350        assert_eq!(parsed[(1, 0)], Complex64::new(3.0, -3.0));
1351        assert_eq!(parsed[(0, 1)], Complex64::new(2.0, -2.0));
1352        assert_eq!(parsed[(1, 1)], Complex64::new(4.0, -4.0));
1353    }
1354
1355    #[test]
1356    fn descriptor_signatures_cover_output_forms() {
1357        let labels = RLOCUS_DESCRIPTOR
1358            .signatures
1359            .iter()
1360            .map(|sig| sig.label)
1361            .collect::<Vec<_>>();
1362        assert!(labels.contains(&"r = rlocus(sys)"));
1363        assert!(labels.contains(&"r = rlocus(sys, k)"));
1364        assert!(labels.contains(&"[r,k] = rlocus(sys)"));
1365        assert!(labels.contains(&"[r,k] = rlocus(sys, k)"));
1366    }
1367
1368    #[test]
1369    fn explicit_gain_returns_closed_loop_roots_and_gains() {
1370        let sys = tf(vec![1.0], vec![1.0, 1.0]);
1371        let gains = Value::Tensor(Tensor::new(vec![0.0, 1.0, 3.0], vec![1, 3]).unwrap());
1372        let _guard = crate::output_count::push_output_count(Some(2));
1373        let result = run_rlocus(sys, vec![gains]).expect("rlocus");
1374        let Value::OutputList(outputs) = result else {
1375            panic!("expected output list");
1376        };
1377        assert_eq!(outputs.len(), 2);
1378
1379        let roots = tensor(&outputs[0]);
1380        assert_eq!(roots.shape, vec![1, 3]);
1381        assert_eq!(roots.materialize_f64(), vec![-1.0, -2.0, -4.0]);
1382
1383        let gains = tensor(&outputs[1]);
1384        assert_eq!(gains.shape, vec![1, 3]);
1385        assert_eq!(gains.materialize_f64(), vec![0.0, 1.0, 3.0]);
1386    }
1387
1388    #[test]
1389    fn explicit_typed_integer_gain_returns_closed_loop_roots_and_gains() {
1390        let _compatibility = crate::compatibility::push_runmat_extensions_enabled(true);
1391        let sys = tf(vec![1.0], vec![1.0, 1.0]);
1392        let gains = Value::Tensor(
1393            Tensor::new_integer(IntegerStorage::U16(vec![0, 1, 3]), vec![1, 3]).unwrap(),
1394        );
1395        let _guard = crate::output_count::push_output_count(Some(2));
1396        let result = run_rlocus(sys, vec![gains]).expect("rlocus");
1397        let Value::OutputList(outputs) = result else {
1398            panic!("expected output list");
1399        };
1400
1401        let roots = tensor(&outputs[0]);
1402        assert_eq!(roots.shape, vec![1, 3]);
1403        assert_eq!(roots.materialize_f64(), vec![-1.0, -2.0, -4.0]);
1404
1405        let gains = tensor(&outputs[1]);
1406        assert_eq!(gains.shape, vec![1, 3]);
1407        assert_eq!(gains.materialize_f64(), vec![0.0, 1.0, 3.0]);
1408        assert!(gains.integer_storage().is_none());
1409    }
1410
1411    #[test]
1412    fn multi_branch_matrix_uses_branch_rows_and_gain_columns() {
1413        let sys = tf(vec![1.0, 0.0], vec![1.0, 3.0, 2.0]);
1414        let gains = Value::Tensor(Tensor::new(vec![0.0, 1.0, 2.0], vec![1, 3]).unwrap());
1415        let result = run_rlocus(sys, vec![gains]).expect("rlocus");
1416        let roots = tensor(&result);
1417        assert_eq!(roots.shape, vec![2, 3]);
1418
1419        for (gain_idx, gain) in [0.0_f64, 1.0, 2.0].iter().enumerate() {
1420            let column = &roots.materialize_f64()[gain_idx * 2..gain_idx * 2 + 2];
1421            for root in column {
1422                let residual = root * root + (3.0 + gain) * root + 2.0;
1423                assert!(
1424                    residual.abs() < 1.0e-8,
1425                    "gain={gain} root={root} residual={residual}"
1426                );
1427            }
1428        }
1429    }
1430
1431    #[test]
1432    fn state_space_siso_matches_transfer_function_root_locus() {
1433        let sys = ss(
1434            Value::Num(-1.0),
1435            Value::Num(1.0),
1436            Value::Num(1.0),
1437            Value::Num(0.0),
1438        );
1439        let gains = Value::Tensor(Tensor::new(vec![0.0, 1.0, 3.0], vec![1, 3]).unwrap());
1440        let result = run_rlocus(sys, vec![gains]).expect("rlocus");
1441        let roots = tensor(&result);
1442        assert_eq!(roots.shape, vec![1, 3]);
1443        for (actual, expected) in roots.materialize_f64().iter().zip([-1.0, -2.0, -4.0]) {
1444            assert!((actual - expected).abs() < 1.0e-8);
1445        }
1446    }
1447
1448    #[test]
1449    fn state_space_mimo_is_rejected_with_rlocus_identifier() {
1450        let sys = ss(
1451            Value::Num(-1.0),
1452            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1453            Value::Tensor(Tensor::new(vec![3.0, 4.0], vec![2, 1]).unwrap()),
1454            Value::Tensor(Tensor::new(vec![0.0, 0.0, 0.0, 0.0], vec![2, 2]).unwrap()),
1455        );
1456        let err = run_rlocus(sys, Vec::new()).expect_err("MIMO ss should fail");
1457        assert!(err.message().contains("only SISO ss models"));
1458        assert_eq!(err.identifier(), RLOCUS_ERROR_UNSUPPORTED_MODEL.identifier);
1459    }
1460
1461    #[test]
1462    fn non_model_input_uses_rlocus_invalid_model_identifier() {
1463        let err = run_rlocus(Value::Num(1.0), Vec::new()).expect_err("should fail");
1464        assert!(err.message().contains("dynamic system model"));
1465        assert_eq!(err.identifier(), RLOCUS_ERROR_INVALID_MODEL.identifier);
1466    }
1467
1468    #[test]
1469    fn complex_root_matrix_is_returned_when_branches_are_complex() {
1470        let sys = tf(vec![1.0], vec![1.0, 2.0, 2.0]);
1471        let gains = Value::Tensor(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
1472        let result = run_rlocus(sys, vec![gains]).expect("rlocus");
1473        let Value::ComplexTensor(roots) = result else {
1474            panic!("expected complex root matrix");
1475        };
1476        assert_eq!(roots.shape, vec![2, 1]);
1477        assert!(roots
1478            .materialize_f64()
1479            .iter()
1480            .any(|(re, im)| (*re + 1.0).abs() < 1.0e-8 && (*im - 1.0).abs() < 1.0e-8));
1481        assert!(roots
1482            .materialize_f64()
1483            .iter()
1484            .any(|(re, im)| (*re + 1.0).abs() < 1.0e-8 && (*im + 1.0).abs() < 1.0e-8));
1485    }
1486
1487    #[test]
1488    fn discrete_system_uses_closed_loop_polynomial_in_z() {
1489        let sys = discrete_tf(vec![1.0], vec![1.0, -0.5], 0.1);
1490        let gains = Value::Tensor(Tensor::new(vec![0.5], vec![1, 1]).unwrap());
1491        let roots = run_rlocus(sys, vec![gains]).expect("rlocus");
1492        let roots = tensor(&roots);
1493        assert_eq!(roots.shape, vec![1, 1]);
1494        assert!(roots.materialize_f64()[0].abs() < 1.0e-12);
1495    }
1496
1497    #[test]
1498    fn statement_form_plots_without_error() {
1499        let sys = tf(vec![1.0, 2.0], vec![1.0, 3.0, 4.0]);
1500        let _guard = crate::output_count::push_output_count(Some(0));
1501        let result = run_rlocus(sys, Vec::new()).expect("rlocus");
1502        assert!(matches!(result, Value::OutputList(outputs) if outputs.is_empty()));
1503    }
1504
1505    #[test]
1506    fn statement_form_turns_hold_off_when_later_system_fails() {
1507        let _plot_guard = crate::builtins::plotting::tests::lock_plot_registry();
1508        crate::builtins::plotting::tests::ensure_plot_test_env();
1509        crate::builtins::plotting::reset_hold_state_for_run();
1510        let _ = crate::builtins::plotting::clear_figure(None);
1511
1512        let sys = tf(vec![1.0, 2.0], vec![1.0, 3.0, 4.0]);
1513        let malformed_tf = Value::Object(ObjectInstance::new("tf".to_string()));
1514        let _output_guard = crate::output_count::push_output_count(Some(0));
1515
1516        let err = run_rlocus(sys, vec![malformed_tf]).expect_err("second system should fail");
1517        assert!(err.message().contains("missing"));
1518        assert!(!crate::builtins::plotting::state::current_hold_enabled());
1519    }
1520
1521    #[test]
1522    fn statement_form_restores_existing_hold_on_when_later_system_fails() {
1523        let _plot_guard = crate::builtins::plotting::tests::lock_plot_registry();
1524        crate::builtins::plotting::tests::ensure_plot_test_env();
1525        crate::builtins::plotting::reset_hold_state_for_run();
1526        let _ = crate::builtins::plotting::clear_figure(None);
1527        crate::builtins::plotting::set_hold(crate::builtins::plotting::HoldMode::On);
1528
1529        let sys = tf(vec![1.0, 2.0], vec![1.0, 3.0, 4.0]);
1530        let malformed_tf = Value::Object(ObjectInstance::new("tf".to_string()));
1531        let _output_guard = crate::output_count::push_output_count(Some(0));
1532
1533        let err = run_rlocus(sys, vec![malformed_tf]).expect_err("second system should fail");
1534        assert!(err.message().contains("missing"));
1535        assert!(crate::builtins::plotting::state::current_hold_enabled());
1536        crate::builtins::plotting::reset_hold_state_for_run();
1537    }
1538
1539    #[test]
1540    fn rejects_negative_gain() {
1541        let sys = tf(vec![1.0], vec![1.0, 1.0]);
1542        let err = run_rlocus(sys, vec![Value::Num(-1.0)]).expect_err("should fail");
1543        assert!(err.message().contains("nonnegative"));
1544        assert_eq!(err.identifier(), RLOCUS_ERROR_INVALID_ARGUMENT.identifier);
1545    }
1546}