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