1use num_complex::Complex64;
4use runmat_builtins::{
5 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7};
8use runmat_builtins::{
9 BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
10 BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
11 BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
12};
13use runmat_macros::runtime_builtin;
14use runmat_value::{Tensor, Value};
15
16use crate::builtins::common::spec::{
17 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
18 ReductionNaN, ResidencyPolicy, ShapeRequirements,
19};
20use crate::builtins::control::tf_model::{output_complex_column, TfModel, EPS, SS_CLASS, TF_CLASS};
21use crate::builtins::control::type_resolvers::pzmap_type;
22use crate::builtins::plotting::style::{parse_line_style_args, LineStyleParseOptions};
23use crate::{BuiltinResult, RuntimeError};
24
25const BUILTIN_NAME: &str = "pzmap";
26
27const PZMAP_OUTPUT_P: BuiltinParamDescriptor = BuiltinParamDescriptor {
28 name: "p",
29 ty: BuiltinParamType::Any,
30 arity: BuiltinParamArity::Required,
31 default: None,
32 description: "Poles of the SISO tf or ss model as a column vector.",
33};
34const PZMAP_OUTPUT_Z: BuiltinParamDescriptor = BuiltinParamDescriptor {
35 name: "z",
36 ty: BuiltinParamType::Any,
37 arity: BuiltinParamArity::Required,
38 default: None,
39 description: "Zeros of the SISO tf or ss model as a column vector.",
40};
41const PZMAP_INPUT_SYS: BuiltinParamDescriptor = BuiltinParamDescriptor {
42 name: "sys",
43 ty: BuiltinParamType::Any,
44 arity: BuiltinParamArity::Required,
45 default: None,
46 description: "SISO tf or ss model.",
47};
48const PZMAP_OUTPUTS_P: [BuiltinParamDescriptor; 1] = [PZMAP_OUTPUT_P];
49const PZMAP_OUTPUTS_P_Z: [BuiltinParamDescriptor; 2] = [PZMAP_OUTPUT_P, PZMAP_OUTPUT_Z];
50const PZMAP_INPUTS_SYS: [BuiltinParamDescriptor; 1] = [PZMAP_INPUT_SYS];
51const PZMAP_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
52 BuiltinSignatureDescriptor {
53 label: "pzmap(sys)",
54 inputs: &PZMAP_INPUTS_SYS,
55 outputs: &[],
56 },
57 BuiltinSignatureDescriptor {
58 label: "p = pzmap(sys)",
59 inputs: &PZMAP_INPUTS_SYS,
60 outputs: &PZMAP_OUTPUTS_P,
61 },
62 BuiltinSignatureDescriptor {
63 label: "[p,z] = pzmap(sys)",
64 inputs: &PZMAP_INPUTS_SYS,
65 outputs: &PZMAP_OUTPUTS_P_Z,
66 },
67];
68const PZMAP_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
69 code: "RM.PZMAP.INVALID_ARGUMENT",
70 identifier: Some("RunMat:pzmap:InvalidArgument"),
71 when: "Inputs do not match supported pzmap invocation forms.",
72 message: "pzmap: invalid argument",
73};
74const PZMAP_ERROR_INVALID_MODEL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
75 code: "RM.PZMAP.INVALID_MODEL",
76 identifier: Some("RunMat:pzmap:InvalidModel"),
77 when: "Input system is not a valid SISO tf object.",
78 message: "pzmap: invalid model",
79};
80const PZMAP_ERROR_UNSUPPORTED_MODEL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
81 code: "RM.PZMAP.UNSUPPORTED_MODEL",
82 identifier: Some("RunMat:pzmap:UnsupportedModel"),
83 when: "Model form is not supported by the current implementation.",
84 message: "pzmap: unsupported model",
85};
86const PZMAP_ERROR_PLOT_FAILED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
87 code: "RM.PZMAP.PLOT_FAILED",
88 identifier: Some("RunMat:pzmap:PlotFailed"),
89 when: "Statement-form plotting failed for reasons other than known nonfatal setup conditions.",
90 message: "pzmap: plotting failed",
91};
92const PZMAP_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
93 code: "RM.PZMAP.INTERNAL",
94 identifier: Some("RunMat:pzmap:Internal"),
95 when: "Pole-zero extraction or output construction failed.",
96 message: "pzmap: internal error",
97};
98const PZMAP_ERRORS: [BuiltinErrorDescriptor; 5] = [
99 PZMAP_ERROR_INVALID_ARGUMENT,
100 PZMAP_ERROR_INVALID_MODEL,
101 PZMAP_ERROR_UNSUPPORTED_MODEL,
102 PZMAP_ERROR_PLOT_FAILED,
103 PZMAP_ERROR_INTERNAL,
104];
105pub const PZMAP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
106 signatures: &PZMAP_SIGNATURES,
107 output_mode: BuiltinOutputMode::ByRequestedOutputCount,
108 completion_policy: BuiltinCompletionPolicy::Public,
109 errors: &PZMAP_ERRORS,
110};
111const PZMAP_INTEGER_SYS: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
112 name: "sys",
113 classes: &[],
114 availability: BuiltinIntegerInputAvailability::Rejected,
115 scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
116 notes: "pzmap accepts a dynamic-system object. Integer coefficient support belongs to the tf/ss constructors and does not make an integer array a valid sys input.",
117}];
118pub const PZMAP_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
119 [BuiltinIntegerCapabilityDescriptor {
120 form: "[p,z] = pzmap(integer_sys)",
121 inputs: &PZMAP_INTEGER_SYS,
122 computation_domain: BuiltinIntegerComputationDomain::Structural,
123 output_class: BuiltinIntegerOutputClassRule::NotApplicable,
124 overflow: BuiltinIntegerOverflowRule::NotApplicable,
125 backend: BuiltinIntegerBackendRule::HostOnly,
126 overload: BuiltinIntegerOverloadKind::Multiple,
127 notes: "Integer input is inapplicable at the dynamic-system object boundary and rejects before provider access. Model coefficients have already crossed their constructor's model-numeric boundary.",
128 }];
129
130#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::pzmap")]
131pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
132 name: "pzmap",
133 op_kind: GpuOpKind::Custom("control-pole-zero-map"),
134 supported_precisions: &[],
135 broadcast: BroadcastSemantics::None,
136 provider_hooks: &[],
137 constant_strategy: ConstantStrategy::InlineLiteral,
138 residency: ResidencyPolicy::GatherImmediately,
139 nan_mode: ReductionNaN::Include,
140 two_pass_threshold: None,
141 workgroup_size: None,
142 accepts_nan_mode: false,
143 notes: "pzmap computes roots from host-side transfer-function metadata and plots through host rendering.",
144};
145
146#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::pzmap")]
147pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
148 name: "pzmap",
149 shape: ShapeRequirements::Any,
150 constant_strategy: ConstantStrategy::InlineLiteral,
151 elementwise: None,
152 reduction: None,
153 emits_nan: false,
154 notes: "pzmap is model analysis and plotting; it terminates numeric fusion chains.",
155};
156
157#[runtime_builtin(
158 name = "pzmap",
159 category = "control",
160 summary = "Plot or return poles and zeros of SISO transfer-function and state-space models.",
161 keywords = "pzmap,pole zero map,poles,zeros,control system,transfer function,state space,tf,ss",
162 sink = true,
163 suppress_auto_output = true,
164 type_resolver(pzmap_type),
165 descriptor(crate::builtins::control::pzmap::PZMAP_DESCRIPTOR),
166 integer_capabilities(crate::builtins::control::pzmap::PZMAP_INTEGER_CAPABILITIES),
167 builtin_path = "crate::builtins::control::pzmap"
168)]
169async fn pzmap_builtin(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
170 if is_plot_form_call() {
171 plot_pole_zero_map_statement(sys, rest).await?;
172 return Ok(Value::OutputList(Vec::new()));
173 }
174
175 if !rest.is_empty() {
176 return Err(pzmap_error(
177 "pzmap: output forms support exactly one system",
178 &PZMAP_ERROR_INVALID_ARGUMENT,
179 ));
180 }
181
182 let eval = PoleZeroMap::from_value_async(sys).await?;
183 if let Some(out_count) = crate::output_count::current_output_count() {
184 if out_count == 0 {
185 render_pole_zero_map(&eval, None, 0).await?;
186 return Ok(Value::OutputList(Vec::new()));
187 }
188 return match out_count {
189 1 => Ok(Value::OutputList(vec![eval.poles_value()?])),
190 2 => Ok(Value::OutputList(eval.outputs()?)),
191 _ => Err(pzmap_error(
192 "pzmap: too many output arguments",
193 &PZMAP_ERROR_INVALID_ARGUMENT,
194 )),
195 };
196 }
197
198 eval.poles_value()
199}
200
201fn is_plot_form_call() -> bool {
202 matches!(crate::output_count::current_output_count(), Some(0))
203 || (crate::output_context::requested_output_count() == Some(0)
204 && crate::output_count::current_output_count().is_none())
205}
206
207async fn plot_pole_zero_map_statement(first_sys: Value, rest: Vec<Value>) -> BuiltinResult<()> {
208 let mut systems = vec![(first_sys, None)];
209 for arg in rest {
210 let gathered = crate::dispatcher::gather_if_needed_async(&arg).await?;
211 if is_plot_style_arg(&gathered) {
212 if let Some((_, style)) = systems.last_mut() {
213 if style.is_some() {
214 return Err(pzmap_error(
215 "pzmap: only one style argument is supported per system",
216 &PZMAP_ERROR_INVALID_ARGUMENT,
217 ));
218 }
219 *style = Some(gathered);
220 continue;
221 }
222 }
223 if is_dynamic_model_object(&gathered) {
224 systems.push((gathered, None));
225 continue;
226 }
227 return Err(pzmap_error(
228 "pzmap: statement-form plots accept one or more tf or ss systems with optional styles",
229 &PZMAP_ERROR_INVALID_ARGUMENT,
230 ));
231 }
232
233 let mut args = Vec::new();
234 for (idx, (system, style)) in systems.into_iter().enumerate() {
235 let eval = PoleZeroMap::from_value_async(system).await?;
236 push_pole_zero_map_series(&mut args, &eval, style.as_ref(), idx)?;
237 }
238 render_pole_zero_map_args(args).await
239}
240
241fn is_dynamic_model_object(value: &Value) -> bool {
242 matches!(value, Value::Object(object) if object.is_class(TF_CLASS) || object.is_class(SS_CLASS))
243}
244
245fn is_plot_style_arg(value: &Value) -> bool {
246 matches!(
247 value,
248 Value::String(_) | Value::StringArray(_) | Value::CharArray(_)
249 )
250}
251
252#[derive(Clone, Debug)]
253struct PoleZeroMap {
254 poles: Vec<Complex64>,
255 zeros: Vec<Complex64>,
256}
257
258impl PoleZeroMap {
259 async fn from_value_async(value: Value) -> BuiltinResult<Self> {
260 let gathered = crate::dispatcher::gather_if_needed_async(&value).await?;
261 let model = match gathered {
262 Value::Object(object) if object.is_class(TF_CLASS) => {
263 TfModel::from_value(Value::Object(object), BUILTIN_NAME)?
264 }
265 Value::Object(object) if object.is_class(SS_CLASS) => {
266 super::rlocus::ss_object_to_tf(&object).map_err(map_rlocus_ss_error)?
267 }
268 Value::Object(object) => {
269 return Err(pzmap_error(
270 format!(
271 "pzmap: unsupported model class '{}'; supported classes are tf and ss",
272 object.class_name
273 ),
274 &PZMAP_ERROR_UNSUPPORTED_MODEL,
275 ));
276 }
277 other => {
278 return Err(pzmap_error(
279 format!("pzmap: expected a tf or ss object, got {other:?}"),
280 &PZMAP_ERROR_INVALID_MODEL,
281 ));
282 }
283 };
284 if model.input_delay.abs() > EPS || model.output_delay.abs() > EPS {
285 return Err(pzmap_error(
286 "pzmap: transfer functions with input or output delays are not supported",
287 &PZMAP_ERROR_UNSUPPORTED_MODEL,
288 ));
289 }
290 Ok(Self {
291 poles: model.poles().map_err(map_model_error)?,
292 zeros: model.zeros().map_err(map_model_error)?,
293 })
294 }
295
296 fn outputs(&self) -> BuiltinResult<Vec<Value>> {
297 Ok(vec![self.poles_value()?, self.zeros_value()?])
298 }
299
300 fn poles_value(&self) -> BuiltinResult<Value> {
301 output_complex_column(self.poles.clone(), BUILTIN_NAME)
302 }
303
304 fn zeros_value(&self) -> BuiltinResult<Value> {
305 output_complex_column(self.zeros.clone(), BUILTIN_NAME)
306 }
307}
308
309async fn render_pole_zero_map(
310 eval: &PoleZeroMap,
311 style: Option<&Value>,
312 series_index: usize,
313) -> BuiltinResult<()> {
314 let mut args = Vec::new();
315 push_pole_zero_map_series(&mut args, eval, style, series_index)?;
316 render_pole_zero_map_args(args).await
317}
318
319fn push_pole_zero_map_series(
320 args: &mut Vec<Value>,
321 eval: &PoleZeroMap,
322 style: Option<&Value>,
323 series_index: usize,
324) -> BuiltinResult<()> {
325 let color = style_color(style, series_index)?;
326 push_marker_series(args, &eval.poles, color, 'x')?;
327 push_marker_series(args, &eval.zeros, color, 'o')?;
328 Ok(())
329}
330
331async fn render_pole_zero_map_args(args: Vec<Value>) -> BuiltinResult<()> {
332 if args.is_empty() {
333 return Ok(());
334 }
335 if let Err(err) = crate::call_builtin_async("plot", &args).await {
336 if super::is_nonfatal_plot_setup_error(&err) {
337 return Ok(());
338 }
339 return Err(pzmap_error(
340 format!("pzmap: plotting failed: {}", err.message()),
341 &PZMAP_ERROR_PLOT_FAILED,
342 ));
343 }
344 let _ = crate::call_builtin_async("title", &[Value::from("Pole-Zero Map")]).await;
345 let _ = crate::call_builtin_async("xlabel", &[Value::from("Real Axis")]).await;
346 let _ = crate::call_builtin_async("ylabel", &[Value::from("Imaginary Axis")]).await;
347 let _ = crate::call_builtin_async("grid", &[Value::from("on")]).await;
348 Ok(())
349}
350
351fn push_marker_series(
352 args: &mut Vec<Value>,
353 values: &[Complex64],
354 color: glam::Vec4,
355 marker: char,
356) -> BuiltinResult<()> {
357 let mut x = Vec::new();
358 let mut y = Vec::new();
359 for value in values {
360 if value.re.is_finite() && value.im.is_finite() {
361 x.push(value.re);
362 y.push(value.im);
363 }
364 }
365 if !x.is_empty() {
366 args.push(column_tensor(x)?);
367 args.push(column_tensor(y)?);
368 args.push(Value::from(marker.to_string()));
369 args.push(Value::from("Color"));
370 args.push(color_value(color)?);
371 }
372 Ok(())
373}
374
375fn style_color(style: Option<&Value>, series_index: usize) -> BuiltinResult<glam::Vec4> {
376 if let Some(style) = style {
377 let parsed = parse_line_style_args(
378 std::slice::from_ref(style),
379 &LineStyleParseOptions::generic(BUILTIN_NAME),
380 )
381 .map_err(|err| pzmap_error(err.message().to_string(), &PZMAP_ERROR_INVALID_ARGUMENT))?;
382 if parsed.color_explicit {
383 return Ok(parsed.appearance.color);
384 }
385 }
386 Ok(crate::builtins::plotting::state::line_color_for_series_index(series_index))
387}
388
389fn color_value(color: glam::Vec4) -> BuiltinResult<Value> {
390 Tensor::new(
391 vec![color.x as f64, color.y as f64, color.z as f64],
392 vec![1, 3],
393 )
394 .map(Value::Tensor)
395 .map_err(|err| {
396 pzmap_error(
397 format!("pzmap: failed to build color value: {err}"),
398 &PZMAP_ERROR_INTERNAL,
399 )
400 })
401}
402
403fn column_tensor(data: Vec<f64>) -> BuiltinResult<Value> {
404 let rows = data.len();
405 Tensor::new(data, vec![rows, 1])
406 .map(Value::Tensor)
407 .map_err(|err| {
408 pzmap_error(
409 format!("pzmap: failed to build plot vector: {err}"),
410 &PZMAP_ERROR_INTERNAL,
411 )
412 })
413}
414
415fn map_model_error(err: RuntimeError) -> RuntimeError {
416 pzmap_error(err.message().to_string(), &PZMAP_ERROR_INTERNAL)
417}
418
419fn map_rlocus_ss_error(err: RuntimeError) -> RuntimeError {
420 let message = err.message().replace("rlocus", BUILTIN_NAME);
421 let descriptor = match err.identifier() {
422 Some("RunMat:rlocus:UnsupportedModel") => &PZMAP_ERROR_UNSUPPORTED_MODEL,
423 Some("RunMat:rlocus:Internal") => &PZMAP_ERROR_INTERNAL,
424 Some("RunMat:rlocus:InvalidArgument") => &PZMAP_ERROR_INVALID_ARGUMENT,
425 _ => &PZMAP_ERROR_INVALID_MODEL,
426 };
427 pzmap_error(message, descriptor)
428}
429
430fn pzmap_error(message: impl Into<String>, error: &'static BuiltinErrorDescriptor) -> RuntimeError {
431 let mut builder = crate::build_runtime_error(message).with_builtin(BUILTIN_NAME);
432 if let Some(identifier) = error.identifier {
433 builder = builder.with_identifier(identifier);
434 }
435 builder.build()
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use futures::executor::block_on;
442
443 fn tf(num: Vec<f64>, den: Vec<f64>) -> Value {
444 block_on(crate::call_builtin_async(
445 "tf",
446 &[
447 Value::Tensor(Tensor::new(num.clone(), vec![1, num.len()]).unwrap()),
448 Value::Tensor(Tensor::new(den.clone(), vec![1, den.len()]).unwrap()),
449 ],
450 ))
451 .expect("tf")
452 }
453
454 fn ss(a: Value, b: Value, c: Value, d: Value) -> Value {
455 block_on(crate::call_builtin_async("ss", &[a, b, c, d])).expect("ss")
456 }
457
458 fn run_pzmap(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
459 block_on(pzmap_builtin(sys, rest))
460 }
461
462 fn tensor(value: &Value) -> &Tensor {
463 match value {
464 Value::Tensor(tensor) => tensor,
465 other => panic!("expected tensor, got {other:?}"),
466 }
467 }
468
469 #[test]
470 fn descriptor_signatures_cover_output_forms() {
471 let labels = PZMAP_DESCRIPTOR
472 .signatures
473 .iter()
474 .map(|sig| sig.label)
475 .collect::<Vec<_>>();
476 assert!(labels.contains(&"pzmap(sys)"));
477 assert!(labels.contains(&"p = pzmap(sys)"));
478 assert!(labels.contains(&"[p,z] = pzmap(sys)"));
479 assert_eq!(PZMAP_INTEGER_CAPABILITIES.len(), 1);
480 assert_eq!(
481 PZMAP_INTEGER_CAPABILITIES[0].inputs[0].availability,
482 BuiltinIntegerInputAvailability::Rejected
483 );
484 }
485
486 #[test]
487 fn two_output_call_returns_poles_and_zeros() {
488 let sys = tf(vec![1.0, 3.0, 2.0], vec![1.0, 4.0]);
489 let _guard = crate::output_count::push_output_count(Some(2));
490 let result = run_pzmap(sys, Vec::new()).expect("pzmap");
491 let Value::OutputList(outputs) = result else {
492 panic!("expected output list");
493 };
494 assert_eq!(outputs.len(), 2);
495
496 let poles = tensor(&outputs[0]);
497 assert_eq!(poles.shape, vec![1, 1]);
498 assert!((poles.materialize_f64()[0] + 4.0).abs() < 1.0e-8);
499
500 let zeros = tensor(&outputs[1]);
501 assert_eq!(zeros.shape, vec![2, 1]);
502 assert!(zeros
503 .materialize_f64()
504 .iter()
505 .any(|z| (*z + 1.0).abs() < 1.0e-8));
506 assert!(zeros
507 .materialize_f64()
508 .iter()
509 .any(|z| (*z + 2.0).abs() < 1.0e-8));
510 }
511
512 #[test]
513 fn complex_poles_are_returned_as_complex_column() {
514 let sys = tf(vec![1.0], vec![1.0, 0.0, 1.0]);
515 let _guard = crate::output_count::push_output_count(Some(2));
516 let result = run_pzmap(sys, Vec::new()).expect("pzmap");
517 let Value::OutputList(outputs) = result else {
518 panic!("expected output list");
519 };
520 let Value::ComplexTensor(poles) = &outputs[0] else {
521 panic!("expected complex poles");
522 };
523 assert_eq!(poles.shape, vec![2, 1]);
524 assert!(poles
525 .materialize_f64()
526 .iter()
527 .all(|(re, _)| re.abs() < 1.0e-8));
528 assert!(poles
529 .materialize_f64()
530 .iter()
531 .any(|(_, im)| (*im - 1.0).abs() < 1.0e-8));
532 assert!(poles
533 .materialize_f64()
534 .iter()
535 .any(|(_, im)| (*im + 1.0).abs() < 1.0e-8));
536 assert_eq!(tensor(&outputs[1]).shape, vec![0, 1]);
537 }
538
539 #[test]
540 fn statement_form_plots_without_error() {
541 let sys = tf(vec![1.0, 2.0], vec![1.0, 3.0, 4.0]);
542 let _guard = crate::output_count::push_output_count(Some(0));
543 let result = run_pzmap(sys, Vec::new()).expect("pzmap");
544 assert!(matches!(result, Value::OutputList(outputs) if outputs.is_empty()));
545 }
546
547 #[test]
548 fn statement_form_accepts_multiple_systems() {
549 let first = tf(vec![1.0], vec![1.0, 1.0]);
550 let second = tf(vec![1.0, 2.0], vec![1.0, 3.0, 4.0]);
551 let _guard = crate::output_count::push_output_count(Some(0));
552 let result = run_pzmap(first, vec![second]).expect("pzmap");
553 assert!(matches!(result, Value::OutputList(outputs) if outputs.is_empty()));
554 }
555
556 #[test]
557 fn statement_form_accepts_styles_per_system() {
558 let _ = crate::builtins::plotting::clear_figure(None);
559 let first = tf(vec![1.0, 2.0], vec![1.0, 3.0]);
560 let second = tf(vec![1.0, 4.0], vec![1.0, 5.0]);
561 let _guard = crate::output_count::push_output_count(Some(0));
562 let result =
563 run_pzmap(first, vec![Value::from("r"), second, Value::from("b--")]).expect("pzmap");
564 assert!(matches!(result, Value::OutputList(outputs) if outputs.is_empty()));
565 }
566
567 #[test]
568 fn marker_series_pair_poles_and_zeros_with_same_color() {
569 let eval = PoleZeroMap {
570 poles: vec![Complex64::new(-1.0, 0.0)],
571 zeros: vec![Complex64::new(-2.0, 0.0)],
572 };
573 let mut args = Vec::new();
574 push_pole_zero_map_series(&mut args, &eval, Some(&Value::from("r--")), 0)
575 .expect("series args");
576 assert_eq!(args.len(), 10);
577 assert_eq!(args[2], Value::from("x"));
578 assert_eq!(args[3], Value::from("Color"));
579 assert_eq!(args[7], Value::from("o"));
580 assert_eq!(args[8], Value::from("Color"));
581 match (&args[4], &args[9]) {
582 (Value::Tensor(pole_color), Value::Tensor(zero_color)) => {
583 assert_eq!(pole_color.materialize_f64(), zero_color.materialize_f64());
584 }
585 other => panic!("expected color tensors, got {other:?}"),
586 }
587 }
588
589 #[test]
590 fn statement_form_rejects_invalid_style_tokens() {
591 let sys = tf(vec![1.0], vec![1.0, 1.0]);
592 let _guard = crate::output_count::push_output_count(Some(0));
593 let err = run_pzmap(sys, vec![Value::from("foo")]).expect_err("invalid style");
594 assert!(err.message().contains("unrecognised style token"));
595 assert_eq!(err.identifier(), PZMAP_ERROR_INVALID_ARGUMENT.identifier);
596 }
597
598 #[test]
599 fn state_space_model_returns_poles_and_zeros() {
600 let sys = ss(
601 Value::Num(-1.0),
602 Value::Num(1.0),
603 Value::Num(1.0),
604 Value::Num(0.0),
605 );
606 let _guard = crate::output_count::push_output_count(Some(2));
607 let result = run_pzmap(sys, Vec::new()).expect("pzmap");
608 let Value::OutputList(outputs) = result else {
609 panic!("expected output list");
610 };
611 assert_eq!(outputs.len(), 2);
612 assert_eq!(tensor(&outputs[0]).materialize_f64(), vec![-1.0]);
613 assert_eq!(tensor(&outputs[1]).shape, vec![0, 1]);
614 }
615
616 #[test]
617 fn non_model_input_uses_pzmap_invalid_model_identifier() {
618 let err = run_pzmap(Value::Num(1.0), Vec::new()).expect_err("should fail");
619 assert!(err.message().contains("expected a tf or ss object"));
620 assert_eq!(err.identifier(), PZMAP_ERROR_INVALID_MODEL.identifier);
621 }
622
623 #[test]
624 fn output_form_rejects_extra_arguments() {
625 let sys = tf(vec![1.0], vec![1.0, 1.0]);
626 let _guard = crate::output_count::push_output_count(Some(2));
627 let err = run_pzmap(sys, vec![Value::Num(1.0)]).expect_err("should fail");
628 assert!(err.message().contains("exactly one system"));
629 assert_eq!(err.identifier(), PZMAP_ERROR_INVALID_ARGUMENT.identifier);
630 }
631
632 #[test]
633 fn too_many_outputs_are_rejected() {
634 let sys = tf(vec![1.0], vec![1.0, 1.0]);
635 let _guard = crate::output_count::push_output_count(Some(3));
636 let err = run_pzmap(sys, Vec::new()).expect_err("should fail");
637 assert!(err.message().contains("too many output arguments"));
638 assert_eq!(err.identifier(), PZMAP_ERROR_INVALID_ARGUMENT.identifier);
639 }
640}