Skip to main content

runmat_runtime/builtins/control/
ss.rs

1//! MATLAB-compatible `ss` state-space model constructor for RunMat.
2use runmat_types::MemberAccess;
3
4use runmat_builtins::{
5    BuiltinExtensionDescriptor, BuiltinExtensionMode, BuiltinIntegerBackendRule,
6    BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
7    BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
8    BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
9};
10use std::collections::HashMap;
11
12use runmat_builtins::{
13    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
14    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
15};
16use runmat_macros::runtime_builtin;
17use runmat_value::{CellArray, CharArray, ObjectInstance, Tensor, Value};
18
19use crate::builtins::common::{
20    spec::{
21        BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
22        ReductionNaN, ResidencyPolicy, ShapeRequirements,
23    },
24    tensor,
25};
26use crate::builtins::control::type_resolvers::ss_type;
27use crate::{build_runtime_error, dispatcher, BuiltinResult, RuntimeError};
28
29const BUILTIN_NAME: &str = "ss";
30const SS_CLASS: &str = "ss";
31
32static SS_CLASS_REGISTERED: crate::class_registry::ClassRegistration =
33    crate::class_registry::ClassRegistration::new(SS_CLASS);
34
35const SS_OUTPUT_SYS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
36    name: "sys",
37    ty: BuiltinParamType::Any,
38    arity: BuiltinParamArity::Required,
39    default: None,
40    description: "State-space model object.",
41}];
42const SS_PARAM_A: BuiltinParamDescriptor = BuiltinParamDescriptor {
43    name: "A",
44    ty: BuiltinParamType::NumericArray,
45    arity: BuiltinParamArity::Required,
46    default: None,
47    description: "State matrix with shape n-by-n.",
48};
49const SS_PARAM_B: BuiltinParamDescriptor = BuiltinParamDescriptor {
50    name: "B",
51    ty: BuiltinParamType::NumericArray,
52    arity: BuiltinParamArity::Required,
53    default: None,
54    description: "Input matrix with shape n-by-nu.",
55};
56const SS_PARAM_C: BuiltinParamDescriptor = BuiltinParamDescriptor {
57    name: "C",
58    ty: BuiltinParamType::NumericArray,
59    arity: BuiltinParamArity::Required,
60    default: None,
61    description: "Output matrix with shape ny-by-n.",
62};
63const SS_PARAM_D: BuiltinParamDescriptor = BuiltinParamDescriptor {
64    name: "D",
65    ty: BuiltinParamType::NumericArray,
66    arity: BuiltinParamArity::Required,
67    default: None,
68    description: "Feedthrough matrix with shape ny-by-nu.",
69};
70const SS_INPUTS_ABCD: [BuiltinParamDescriptor; 4] =
71    [SS_PARAM_A, SS_PARAM_B, SS_PARAM_C, SS_PARAM_D];
72const SS_INPUTS_ABCD_TS: [BuiltinParamDescriptor; 5] = [
73    SS_PARAM_A,
74    SS_PARAM_B,
75    SS_PARAM_C,
76    SS_PARAM_D,
77    BuiltinParamDescriptor {
78        name: "Ts",
79        ty: BuiltinParamType::NumericScalar,
80        arity: BuiltinParamArity::Optional,
81        default: Some("0.0"),
82        description: "Sample time (0 for continuous-time model).",
83    },
84];
85const SS_INPUTS_ABCD_NAMEVALUE: [BuiltinParamDescriptor; 6] = [
86    SS_PARAM_A,
87    SS_PARAM_B,
88    SS_PARAM_C,
89    SS_PARAM_D,
90    BuiltinParamDescriptor {
91        name: "name",
92        ty: BuiltinParamType::StringScalar,
93        arity: BuiltinParamArity::Variadic,
94        default: None,
95        description: "Option name ('Ts' or 'SampleTime').",
96    },
97    BuiltinParamDescriptor {
98        name: "value",
99        ty: BuiltinParamType::Any,
100        arity: BuiltinParamArity::Variadic,
101        default: None,
102        description: "Option value.",
103    },
104];
105const SS_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
106    BuiltinSignatureDescriptor {
107        label: "sys = ss(A, B, C, D)",
108        inputs: &SS_INPUTS_ABCD,
109        outputs: &SS_OUTPUT_SYS,
110    },
111    BuiltinSignatureDescriptor {
112        label: "sys = ss(A, B, C, D, Ts)",
113        inputs: &SS_INPUTS_ABCD_TS,
114        outputs: &SS_OUTPUT_SYS,
115    },
116    BuiltinSignatureDescriptor {
117        label: "sys = ss(A, B, C, D, \"Ts\", Ts)",
118        inputs: &SS_INPUTS_ABCD_NAMEVALUE,
119        outputs: &SS_OUTPUT_SYS,
120    },
121    BuiltinSignatureDescriptor {
122        label: "sys = ss(A, B, C, D, name, value, ...)",
123        inputs: &SS_INPUTS_ABCD_NAMEVALUE,
124        outputs: &SS_OUTPUT_SYS,
125    },
126];
127const SS_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128    code: "RM.SS.INVALID_ARGUMENT",
129    identifier: Some("RunMat:ss:InvalidArgument"),
130    when: "Arguments do not match supported ss invocation forms.",
131    message: "ss: invalid argument",
132};
133const SS_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
134    code: "RM.SS.INVALID_OPTION",
135    identifier: Some("RunMat:ss:InvalidOption"),
136    when: "A name/value option token is unsupported or malformed.",
137    message: "ss: invalid option",
138};
139const SS_ERROR_INVALID_SAMPLE_TIME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
140    code: "RM.SS.INVALID_SAMPLE_TIME",
141    identifier: Some("RunMat:ss:InvalidSampleTime"),
142    when: "Sample time is not a finite non-negative scalar.",
143    message: "ss: sample time must be a finite non-negative scalar",
144};
145const SS_ERROR_INVALID_DIMENSIONS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
146    code: "RM.SS.INVALID_DIMENSIONS",
147    identifier: Some("RunMat:ss:InvalidDimensions"),
148    when: "A, B, C, and D dimensions do not define a consistent state-space model.",
149    message: "ss: invalid state-space matrix dimensions",
150};
151const SS_ERROR_UNSUPPORTED_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
152    code: "RM.SS.UNSUPPORTED_INPUT",
153    identifier: Some("RunMat:ss:UnsupportedInput"),
154    when: "An input is complex, sparse, logical, or another unsupported model form.",
155    message: "ss: unsupported input",
156};
157const SS_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
158    code: "RM.SS.INTERNAL",
159    identifier: Some("RunMat:ss:Internal"),
160    when: "Internal tensor/object construction failed.",
161    message: "ss: internal error",
162};
163const SS_ERRORS: [BuiltinErrorDescriptor; 6] = [
164    SS_ERROR_INVALID_ARGUMENT,
165    SS_ERROR_INVALID_OPTION,
166    SS_ERROR_INVALID_SAMPLE_TIME,
167    SS_ERROR_INVALID_DIMENSIONS,
168    SS_ERROR_UNSUPPORTED_INPUT,
169    SS_ERROR_INTERNAL,
170];
171pub const SS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
172    signatures: &SS_SIGNATURES,
173    output_mode: BuiltinOutputMode::Fixed,
174    completion_policy: BuiltinCompletionPolicy::Public,
175    errors: &SS_ERRORS,
176};
177
178const SS_INTEGER_MATRIX_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
179    id: "ss-integer-matrix-input",
180    mode: BuiltinExtensionMode::RunMatOnly,
181    description: "ss with typed-integer state-space matrices is a RunMat extension",
182    error_identifier: Some("RunMat:compatibility:SsIntegerMatrixInputExtension"),
183};
184const SS_INTEGER_SAMPLE_TIME_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
185    id: "ss-integer-sample-time",
186    mode: BuiltinExtensionMode::RunMatOnly,
187    description: "ss with a typed-integer sample time is a RunMat extension",
188    error_identifier: Some("RunMat:compatibility:SsIntegerSampleTimeExtension"),
189};
190const SS_EXPLICIT_GPU_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
191    id: "ss-explicit-gpu-input",
192    mode: BuiltinExtensionMode::RunMatOnly,
193    description: "ss with explicit GPU input data is a RunMat extension",
194    error_identifier: Some("RunMat:compatibility:SsExplicitGpuInputExtension"),
195};
196pub const SS_EXTENSIONS: [BuiltinExtensionDescriptor; 3] = [
197    SS_INTEGER_MATRIX_EXTENSION,
198    SS_INTEGER_SAMPLE_TIME_EXTENSION,
199    SS_EXPLICIT_GPU_EXTENSION,
200];
201
202const SS_INTEGER_MATRIX_INPUTS: [BuiltinIntegerInputCapability; 4] = [
203    ss_integer_matrix_input("A"),
204    ss_integer_matrix_input("B"),
205    ss_integer_matrix_input("C"),
206    ss_integer_matrix_input("D"),
207];
208const SS_INTEGER_SAMPLE_TIME_INPUT: [BuiltinIntegerInputCapability; 1] =
209    [BuiltinIntegerInputCapability {
210        name: "Ts",
211        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
212        availability: BuiltinIntegerInputAvailability::RunMatOnly,
213        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
214        notes: "RunMat admits a typed scalar only when its exact value can enter the binary64 model metadata.",
215    }];
216const fn ss_integer_matrix_input(name: &'static str) -> BuiltinIntegerInputCapability {
217    BuiltinIntegerInputCapability {
218        name,
219        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
220        availability: BuiltinIntegerInputAvailability::RunMatOnly,
221        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
222        notes: "The documented public surface does not establish typed-integer storage; RunMat requires every value to be exact at the binary64 model boundary.",
223    }
224}
225pub const SS_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
226    BuiltinIntegerCapabilityDescriptor {
227        form: "sys = ss(integer_A, integer_B, integer_C, integer_D, ...)",
228        inputs: &SS_INTEGER_MATRIX_INPUTS,
229        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
230        output_class: BuiltinIntegerOutputClassRule::Double,
231        overflow: BuiltinIntegerOverflowRule::Error,
232        backend: BuiltinIntegerBackendRule::GatherFallback,
233        overload: BuiltinIntegerOverloadKind::Multiple,
234        notes: "Each typed matrix is an independently gated RunMat extension and is checked before conversion; the state-space object's numeric matrices are binary64.",
235    },
236    BuiltinIntegerCapabilityDescriptor {
237        form: "sys = ss(A, B, C, D, integer_Ts)",
238        inputs: &SS_INTEGER_SAMPLE_TIME_INPUT,
239        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
240        output_class: BuiltinIntegerOutputClassRule::Double,
241        overflow: BuiltinIntegerOverflowRule::Error,
242        backend: BuiltinIntegerBackendRule::GatherFallback,
243        overload: BuiltinIntegerOverloadKind::StructuralParameter,
244        notes: "Typed sample time is a gated RunMat extension stored as binary64 model metadata after an exact representability check; the public contract requires a numeric scalar without enumerating typed storage classes.",
245    },
246];
247
248#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::ss")]
249pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
250    name: "ss",
251    op_kind: GpuOpKind::Custom("state-space-model-constructor"),
252    supported_precisions: &[],
253    broadcast: BroadcastSemantics::None,
254    provider_hooks: &[],
255    constant_strategy: ConstantStrategy::InlineLiteral,
256    residency: ResidencyPolicy::GatherImmediately,
257    nan_mode: ReductionNaN::Include,
258    two_pass_threshold: None,
259    workgroup_size: None,
260    accepts_nan_mode: false,
261    notes: "Object construction runs on the host. gpuArray matrix inputs are gathered before validating and storing the state-space metadata.",
262};
263
264#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::ss")]
265pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
266    name: "ss",
267    shape: ShapeRequirements::Any,
268    constant_strategy: ConstantStrategy::InlineLiteral,
269    elementwise: None,
270    reduction: None,
271    emits_nan: false,
272    notes: "State-space construction is metadata-only and terminates numeric fusion chains.",
273};
274
275fn ss_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
276    ss_error_with_message(error.message, error)
277}
278
279fn ss_error_with_detail(
280    error: &'static BuiltinErrorDescriptor,
281    detail: impl AsRef<str>,
282) -> RuntimeError {
283    ss_error_with_message(format!("{}: {}", error.message, detail.as_ref()), error)
284}
285
286fn ss_error_with_message(
287    message: impl Into<String>,
288    error: &'static BuiltinErrorDescriptor,
289) -> RuntimeError {
290    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
291    if let Some(identifier) = error.identifier {
292        builder = builder.with_identifier(identifier);
293    }
294    builder.build()
295}
296
297fn ensure_ss_class_registered() {
298    SS_CLASS_REGISTERED.ensure(|| {
299        let mut properties = HashMap::new();
300        for name in [
301            "A",
302            "B",
303            "C",
304            "D",
305            "Ts",
306            "InputDelay",
307            "OutputDelay",
308            "StateName",
309            "InputName",
310            "OutputName",
311        ] {
312            properties.insert(
313                name.to_string(),
314                crate::class_registry::RuntimeProperty {
315                    name: name.to_string(),
316                    is_static: false,
317                    is_constant: false,
318                    is_dependent: false,
319                    get_access: MemberAccess::Public,
320                    set_access: MemberAccess::Public,
321                    default_value: None,
322                },
323            );
324        }
325
326        let methods: HashMap<String, crate::class_registry::RuntimeMethod> = HashMap::new();
327        crate::class_registry::register_class(crate::class_registry::RuntimeClass {
328            name: SS_CLASS.to_string(),
329            parent: None,
330            properties,
331            methods,
332        });
333    });
334}
335
336#[runtime_builtin(
337    name = "ss",
338    category = "control",
339    summary = "Create state-space model objects from A, B, C, and D matrices.",
340    keywords = "ss,state space,control system,model,matrices",
341    type_resolver(ss_type),
342    descriptor(crate::builtins::control::ss::SS_DESCRIPTOR),
343    extensions(crate::builtins::control::ss::SS_EXTENSIONS),
344    integer_capabilities(crate::builtins::control::ss::SS_INTEGER_CAPABILITIES),
345    builtin_path = "crate::builtins::control::ss"
346)]
347pub(crate) async fn ss_builtin(
348    a: Value,
349    b: Value,
350    c: Value,
351    d: Value,
352    rest: Vec<Value>,
353) -> BuiltinResult<Value> {
354    let matrices = [&a, &b, &c, &d];
355    if matrices
356        .iter()
357        .any(|value| crate::builtins::common::validation::value_contains_explicit_gpu(value))
358        || rest
359            .iter()
360            .any(crate::builtins::common::validation::value_contains_explicit_gpu)
361    {
362        crate::compatibility::ensure_builtin_extension_enabled(
363            &SS_EXPLICIT_GPU_EXTENSION,
364            BUILTIN_NAME,
365        )?;
366    }
367    for (value, role) in matrices.into_iter().zip(["A", "B", "C", "D"]) {
368        crate::builtins::common::validation::ensure_runmat_integer_f64_boundary(
369            value,
370            &SS_INTEGER_MATRIX_EXTENSION,
371            BUILTIN_NAME,
372            role,
373        )
374        .await?;
375    }
376    for value in &rest {
377        crate::builtins::common::validation::ensure_runmat_integer_f64_boundary(
378            value,
379            &SS_INTEGER_SAMPLE_TIME_EXTENSION,
380            BUILTIN_NAME,
381            "sample-time",
382        )
383        .await?;
384    }
385    let options = SsOptions::parse(&rest).await?;
386    let a = RealMatrix::parse("A", a).await?;
387    let b = RealMatrix::parse("B", b).await?;
388    let c = RealMatrix::parse("C", c).await?;
389    let d = RealMatrix::parse("D", d).await?;
390
391    validate_state_space_dimensions(&a, &b, &c, &d)?;
392
393    let state_count = a.rows;
394    let input_count = b.cols;
395    let output_count = c.rows;
396
397    ensure_ss_class_registered();
398    let mut object = ObjectInstance::new(SS_CLASS.to_string());
399    object.properties.insert("A".to_string(), a.into_value());
400    object.properties.insert("B".to_string(), b.into_value());
401    object.properties.insert("C".to_string(), c.into_value());
402    object.properties.insert("D".to_string(), d.into_value());
403    object
404        .properties
405        .insert("Ts".to_string(), Value::Num(options.sample_time));
406    object.properties.insert(
407        "InputDelay".to_string(),
408        zero_tensor_value(vec![input_count, 1])?,
409    );
410    object.properties.insert(
411        "OutputDelay".to_string(),
412        zero_tensor_value(vec![output_count, 1])?,
413    );
414    object.properties.insert(
415        "StateName".to_string(),
416        empty_name_cell_value(state_count, 1)?,
417    );
418    object.properties.insert(
419        "InputName".to_string(),
420        empty_name_cell_value(input_count, 1)?,
421    );
422    object.properties.insert(
423        "OutputName".to_string(),
424        empty_name_cell_value(output_count, 1)?,
425    );
426    Ok(Value::Object(object))
427}
428
429#[derive(Clone)]
430struct SsOptions {
431    sample_time: f64,
432}
433
434impl SsOptions {
435    async fn parse(rest: &[Value]) -> BuiltinResult<Self> {
436        let mut options = Self { sample_time: 0.0 };
437
438        match rest {
439            [] => {}
440            [sample_time] => options.sample_time = parse_sample_time(sample_time).await?,
441            _ => {
442                if !rest.len().is_multiple_of(2) {
443                    return Err(ss_error_with_detail(
444                        &SS_ERROR_INVALID_ARGUMENT,
445                        "optional arguments must be name-value pairs or a scalar sample time",
446                    ));
447                }
448                let mut idx = 0;
449                while idx < rest.len() {
450                    let name = scalar_text(&rest[idx], "option name")?;
451                    let lowered = name.trim().to_ascii_lowercase();
452                    let value = &rest[idx + 1];
453                    match lowered.as_str() {
454                        "ts" | "sampletime" => {
455                            options.sample_time = parse_sample_time(value).await?
456                        }
457                        _ => {
458                            return Err(ss_error_with_detail(
459                                &SS_ERROR_INVALID_OPTION,
460                                format!("unsupported option '{name}'"),
461                            ));
462                        }
463                    }
464                    idx += 2;
465                }
466            }
467        }
468
469        Ok(options)
470    }
471}
472
473async fn parse_sample_time(value: &Value) -> BuiltinResult<f64> {
474    let gathered = dispatcher::gather_if_needed_async(value).await?;
475    let sample_time = match &gathered {
476        Value::Num(n) => *n,
477        Value::Int(i) => i.to_f64(),
478        Value::Tensor(tensor) if tensor::is_scalar_tensor(tensor) => {
479            tensor::tensor_value_f64(tensor, 0)
480        }
481        other => {
482            return Err(ss_error_with_detail(
483                &SS_ERROR_INVALID_SAMPLE_TIME,
484                format!("expected non-negative scalar, got {other:?}"),
485            ))
486        }
487    };
488    if !sample_time.is_finite() || sample_time < 0.0 {
489        return Err(ss_error(&SS_ERROR_INVALID_SAMPLE_TIME));
490    }
491    Ok(sample_time)
492}
493
494fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
495    match value {
496        Value::String(text) => Ok(text.clone()),
497        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
498        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
499        other => Err(ss_error_with_detail(
500            &SS_ERROR_INVALID_ARGUMENT,
501            format!("{context} must be a string scalar or character vector, got {other:?}"),
502        )),
503    }
504}
505
506#[derive(Clone)]
507struct RealMatrix {
508    tensor: Tensor,
509    rows: usize,
510    cols: usize,
511}
512
513impl RealMatrix {
514    async fn parse(label: &str, value: Value) -> BuiltinResult<Self> {
515        let gathered = dispatcher::gather_if_needed_async(&value).await?;
516        let tensor = match gathered {
517            Value::Tensor(tensor) => tensor::integer_tensor_to_f64(tensor).map_err(|err| {
518                ss_error_with_detail(
519                    &SS_ERROR_INTERNAL,
520                    format!("failed to normalize {label}: {err}"),
521                )
522            })?,
523            Value::Num(n) => Tensor::new(vec![n], vec![1, 1]).map_err(|err| {
524                ss_error_with_detail(&SS_ERROR_INTERNAL, format!("failed to build tensor: {err}"))
525            })?,
526            Value::Int(i) => Tensor::new(vec![i.to_f64()], vec![1, 1]).map_err(|err| {
527                ss_error_with_detail(&SS_ERROR_INTERNAL, format!("failed to build tensor: {err}"))
528            })?,
529            Value::Complex(_, _) | Value::ComplexTensor(_) => {
530                return Err(ss_error_with_detail(
531                    &SS_ERROR_UNSUPPORTED_INPUT,
532                    format!(
533                        "{label} must be finite real numeric data; complex input is unsupported"
534                    ),
535                ));
536            }
537            other => {
538                return Err(ss_error_with_detail(
539                    &SS_ERROR_UNSUPPORTED_INPUT,
540                    format!("{label} must be a finite real numeric matrix, got {other:?}"),
541                ));
542            }
543        };
544
545        if tensor.shape.len() > 2 {
546            return Err(ss_error_with_detail(
547                &SS_ERROR_INVALID_DIMENSIONS,
548                format!("{label} must be a 2-D matrix, got shape {:?}", tensor.shape),
549            ));
550        }
551        let values = tensor::tensor_values_f64_cow(&tensor);
552        if values.iter().any(|value| !value.is_finite()) {
553            return Err(ss_error_with_detail(
554                &SS_ERROR_UNSUPPORTED_INPUT,
555                format!("{label} must contain only finite real values"),
556            ));
557        }
558
559        Ok(Self {
560            rows: tensor.rows,
561            cols: tensor.cols,
562            tensor,
563        })
564    }
565
566    fn into_value(self) -> Value {
567        Value::Tensor(self.tensor)
568    }
569}
570
571fn validate_state_space_dimensions(
572    a: &RealMatrix,
573    b: &RealMatrix,
574    c: &RealMatrix,
575    d: &RealMatrix,
576) -> BuiltinResult<()> {
577    if a.rows != a.cols {
578        return Err(ss_error_with_detail(
579            &SS_ERROR_INVALID_DIMENSIONS,
580            format!("A must be square, got {}x{}", a.rows, a.cols),
581        ));
582    }
583
584    let state_count = a.rows;
585    if b.rows != state_count {
586        return Err(ss_error_with_detail(
587            &SS_ERROR_INVALID_DIMENSIONS,
588            format!(
589                "B must have {} rows to match A, got {}x{}",
590                state_count, b.rows, b.cols
591            ),
592        ));
593    }
594    if c.cols != state_count {
595        return Err(ss_error_with_detail(
596            &SS_ERROR_INVALID_DIMENSIONS,
597            format!(
598                "C must have {} columns to match A, got {}x{}",
599                state_count, c.rows, c.cols
600            ),
601        ));
602    }
603    if d.rows != c.rows || d.cols != b.cols {
604        return Err(ss_error_with_detail(
605            &SS_ERROR_INVALID_DIMENSIONS,
606            format!(
607                "D must have shape {}x{} to match C outputs and B inputs, got {}x{}",
608                c.rows, b.cols, d.rows, d.cols
609            ),
610        ));
611    }
612
613    Ok(())
614}
615
616fn zero_tensor_value(shape: Vec<usize>) -> BuiltinResult<Value> {
617    let len = shape.iter().product();
618    Tensor::new(vec![0.0; len], shape)
619        .map(Value::Tensor)
620        .map_err(|err| {
621            ss_error_with_detail(&SS_ERROR_INTERNAL, format!("failed to build tensor: {err}"))
622        })
623}
624
625fn empty_name_cell_value(rows: usize, cols: usize) -> BuiltinResult<Value> {
626    let len = rows * cols;
627    let values = (0..len)
628        .map(|_| Value::CharArray(CharArray::new_row("")))
629        .collect();
630    CellArray::new(values, rows, cols)
631        .map(Value::Cell)
632        .map_err(|err| {
633            ss_error_with_detail(
634                &SS_ERROR_INTERNAL,
635                format!("failed to build cell array: {err}"),
636            )
637        })
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::builtins::common::test_support;
644    use futures::executor::block_on;
645    use runmat_value::{IntValue, IntegerStorage};
646
647    fn run_ss(a: Value, b: Value, c: Value, d: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
648        block_on(ss_builtin(a, b, c, d, rest))
649    }
650
651    fn property<'a>(value: &'a Value, name: &str) -> &'a Value {
652        let Value::Object(object) = value else {
653            panic!("expected object, got {value:?}");
654        };
655        object
656            .properties
657            .get(name)
658            .unwrap_or_else(|| panic!("missing property {name}"))
659    }
660
661    fn assert_tensor(value: &Value, shape: &[usize], data: &[f64]) {
662        match value {
663            Value::Tensor(tensor) => {
664                assert_eq!(tensor.shape, shape);
665                assert_eq!(tensor.materialize_f64(), data);
666            }
667            other => panic!("expected tensor, got {other:?}"),
668        }
669    }
670
671    #[test]
672    fn ss_descriptor_signatures_cover_core_forms() {
673        let labels: Vec<&str> = SS_DESCRIPTOR
674            .signatures
675            .iter()
676            .map(|sig| sig.label)
677            .collect();
678        assert!(labels.contains(&"sys = ss(A, B, C, D)"));
679        assert!(labels.contains(&"sys = ss(A, B, C, D, Ts)"));
680        assert!(labels.contains(&"sys = ss(A, B, C, D, \"Ts\", Ts)"));
681        assert!(labels.contains(&"sys = ss(A, B, C, D, name, value, ...)"));
682    }
683
684    #[test]
685    fn ss_constructs_continuous_state_space_object() {
686        let sys = run_ss(
687            Value::Tensor(Tensor::new(vec![0.0, -2.0, 1.0, -3.0], vec![2, 2]).unwrap()),
688            Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![2, 1]).unwrap()),
689            Value::Tensor(Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap()),
690            Value::Num(0.0),
691            Vec::new(),
692        )
693        .expect("ss");
694
695        let Value::Object(object) = &sys else {
696            panic!("expected object");
697        };
698        assert_eq!(object.class_name, "ss");
699        assert_eq!(property(&sys, "Ts"), &Value::Num(0.0));
700        assert_tensor(property(&sys, "A"), &[2, 2], &[0.0, -2.0, 1.0, -3.0]);
701        assert_tensor(property(&sys, "B"), &[2, 1], &[0.0, 1.0]);
702        assert_tensor(property(&sys, "C"), &[1, 2], &[1.0, 0.0]);
703        assert_tensor(property(&sys, "D"), &[1, 1], &[0.0]);
704        assert_tensor(property(&sys, "InputDelay"), &[1, 1], &[0.0]);
705        assert_tensor(property(&sys, "OutputDelay"), &[1, 1], &[0.0]);
706    }
707
708    #[test]
709    fn ss_preserves_matrix_orientation_for_mimo_systems() {
710        let sys = run_ss(
711            Value::Num(-1.0),
712            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
713            Value::Tensor(Tensor::new(vec![3.0, 4.0], vec![2, 1]).unwrap()),
714            Value::Tensor(Tensor::new(vec![0.0, 0.1, 0.2, 0.3], vec![2, 2]).unwrap()),
715            Vec::new(),
716        )
717        .expect("ss");
718
719        assert_tensor(property(&sys, "A"), &[1, 1], &[-1.0]);
720        assert_tensor(property(&sys, "B"), &[1, 2], &[1.0, 2.0]);
721        assert_tensor(property(&sys, "C"), &[2, 1], &[3.0, 4.0]);
722        assert_tensor(property(&sys, "D"), &[2, 2], &[0.0, 0.1, 0.2, 0.3]);
723        assert_tensor(property(&sys, "InputDelay"), &[2, 1], &[0.0, 0.0]);
724        assert_tensor(property(&sys, "OutputDelay"), &[2, 1], &[0.0, 0.0]);
725    }
726
727    #[test]
728    fn ss_accepts_discrete_sample_time() {
729        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
730        let sys = run_ss(
731            Value::Int(IntValue::I32(1)),
732            Value::Int(IntValue::I32(2)),
733            Value::Int(IntValue::I32(3)),
734            Value::Int(IntValue::I32(4)),
735            vec![Value::Num(0.25)],
736        )
737        .expect("ss");
738
739        assert_eq!(property(&sys, "Ts"), &Value::Num(0.25));
740    }
741
742    #[test]
743    fn ss_typed_integer_matrices_and_sample_time_cross_double_boundary_exactly() {
744        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
745        fn mirrorless_integer_tensor(storage: IntegerStorage, shape: Vec<usize>) -> Value {
746            let tensor = Tensor::new_integer(storage, shape).unwrap();
747            Value::Tensor(tensor)
748        }
749
750        let sys = run_ss(
751            mirrorless_integer_tensor(IntegerStorage::I16(vec![0, -2, 1, -3]), vec![2, 2]),
752            mirrorless_integer_tensor(IntegerStorage::U16(vec![0, 1]), vec![2, 1]),
753            mirrorless_integer_tensor(IntegerStorage::I8(vec![1, 0]), vec![1, 2]),
754            mirrorless_integer_tensor(IntegerStorage::U8(vec![0]), vec![1, 1]),
755            vec![Value::from("Ts"), {
756                let ts = Tensor::new_integer(IntegerStorage::U16(vec![1]), vec![1, 1]).unwrap();
757                Value::Tensor(ts)
758            }],
759        )
760        .expect("ss");
761
762        assert_tensor(property(&sys, "A"), &[2, 2], &[0.0, -2.0, 1.0, -3.0]);
763        assert_tensor(property(&sys, "B"), &[2, 1], &[0.0, 1.0]);
764        assert_tensor(property(&sys, "C"), &[1, 2], &[1.0, 0.0]);
765        assert_tensor(property(&sys, "D"), &[1, 1], &[0.0]);
766        assert_eq!(property(&sys, "Ts"), &Value::Num(1.0));
767        assert!(
768            matches!(property(&sys, "A"), Value::Tensor(tensor) if tensor.integer_storage().is_none())
769        );
770    }
771
772    #[test]
773    fn ss_gates_typed_integer_matrices_in_strict_mode() {
774        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
775        let error = run_ss(
776            Value::Int(IntValue::I32(1)),
777            Value::Num(2.0),
778            Value::Num(3.0),
779            Value::Num(4.0),
780            Vec::new(),
781        )
782        .expect_err("typed-integer ss matrices must be gated in strict mode");
783        assert_eq!(
784            error.identifier(),
785            Some("RunMat:compatibility:SsIntegerMatrixInputExtension")
786        );
787    }
788
789    #[test]
790    fn ss_rejects_integer_matrix_values_that_round_at_double_boundary() {
791        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
792        let error = run_ss(
793            Value::Int(IntValue::U64((1_u64 << 53) + 1)),
794            Value::Num(2.0),
795            Value::Num(3.0),
796            Value::Num(4.0),
797            Vec::new(),
798        )
799        .expect_err("inexact typed-integer ss matrices must not round silently");
800        assert!(error.message().contains("exactly representable as double"));
801    }
802
803    #[test]
804    fn ss_accepts_sample_time_name_value_options() {
805        let sys = run_ss(
806            Value::Num(1.0),
807            Value::Num(2.0),
808            Value::Num(3.0),
809            Value::Num(4.0),
810            vec![Value::from("SampleTime"), Value::Num(0.5)],
811        )
812        .expect("ss");
813
814        assert_eq!(property(&sys, "Ts"), &Value::Num(0.5));
815    }
816
817    #[test]
818    fn ss_rejects_nonsquare_a_matrix() {
819        let err = run_ss(
820            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
821            Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
822            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
823            Value::Tensor(Tensor::new(vec![0.0], vec![1, 1]).unwrap()),
824            Vec::new(),
825        )
826        .expect_err("nonsquare A should fail");
827        assert!(err.message().contains("A must be square"));
828        assert_eq!(err.identifier(), SS_ERROR_INVALID_DIMENSIONS.identifier);
829    }
830
831    #[test]
832    fn ss_rejects_b_row_mismatch() {
833        let err = run_ss(
834            Value::Tensor(Tensor::new(vec![1.0, 0.0, 0.0, 1.0], vec![2, 2]).unwrap()),
835            Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
836            Value::Tensor(Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap()),
837            Value::Tensor(Tensor::new(vec![0.0], vec![1, 1]).unwrap()),
838            Vec::new(),
839        )
840        .expect_err("B mismatch should fail");
841        assert!(err.message().contains("B must have 2 rows"));
842        assert_eq!(err.identifier(), SS_ERROR_INVALID_DIMENSIONS.identifier);
843    }
844
845    #[test]
846    fn ss_rejects_d_shape_mismatch() {
847        let err = run_ss(
848            Value::Tensor(Tensor::new(vec![1.0, 0.0, 0.0, 1.0], vec![2, 2]).unwrap()),
849            Value::Tensor(Tensor::new(vec![1.0, 0.0], vec![2, 1]).unwrap()),
850            Value::Tensor(Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap()),
851            Value::Tensor(Tensor::new(vec![0.0, 0.0], vec![1, 2]).unwrap()),
852            Vec::new(),
853        )
854        .expect_err("D mismatch should fail");
855        assert!(err.message().contains("D must have shape 1x1"));
856        assert_eq!(err.identifier(), SS_ERROR_INVALID_DIMENSIONS.identifier);
857    }
858
859    #[test]
860    fn ss_rejects_invalid_sample_time() {
861        let err = run_ss(
862            Value::Num(1.0),
863            Value::Num(1.0),
864            Value::Num(1.0),
865            Value::Num(0.0),
866            vec![Value::Num(-0.1)],
867        )
868        .expect_err("negative Ts should fail");
869        assert_eq!(err.identifier(), SS_ERROR_INVALID_SAMPLE_TIME.identifier);
870    }
871
872    #[test]
873    fn ss_rejects_complex_inputs() {
874        let err = run_ss(
875            Value::Complex(1.0, 1.0),
876            Value::Num(1.0),
877            Value::Num(1.0),
878            Value::Num(0.0),
879            Vec::new(),
880        )
881        .expect_err("complex A should fail");
882        assert!(err.message().contains("complex input is unsupported"));
883        assert_eq!(err.identifier(), SS_ERROR_UNSUPPORTED_INPUT.identifier);
884    }
885
886    #[test]
887    fn ss_gpu_matrix_input_gathers_to_host() {
888        test_support::with_test_provider(|provider| {
889            let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
890            let view = runmat_accelerate_api::HostTensorView {
891                data: &tensor.materialize_f64(),
892                shape: &tensor.shape,
893            };
894            let handle = provider.upload(&view).expect("upload");
895            let sys = run_ss(
896                Value::GpuTensor(handle),
897                Value::Num(2.0),
898                Value::Num(3.0),
899                Value::Num(4.0),
900                Vec::new(),
901            )
902            .expect("ss");
903
904            assert_tensor(property(&sys, "A"), &[1, 1], &[1.0]);
905        });
906    }
907}