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