Skip to main content

runmat_runtime/builtins/control/
pzmap.rs

1//! Pole-zero map plotting and extraction for SISO transfer-function and state-space models.
2
3use 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}