1use 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}