1use 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}