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};
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}