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