1use nalgebra::DMatrix;
4use num_complex::Complex64;
5use runmat_builtins::{ComplexTensor, ObjectInstance, Tensor, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::spec::{
9 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10 ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12use crate::builtins::control::type_resolvers::step_type;
13use crate::{build_runtime_error, BuiltinResult, RuntimeError};
14
15const BUILTIN_NAME: &str = "step";
16const EPS: f64 = 1.0e-12;
17const DEFAULT_SAMPLES: usize = 101;
18const MAX_DISCRETE_SAMPLES: usize = 1_000_000;
19
20#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::control::step")]
21pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
22 name: "step",
23 op_kind: GpuOpKind::Custom("control-step-response"),
24 supported_precisions: &[],
25 broadcast: BroadcastSemantics::None,
26 provider_hooks: &[],
27 constant_strategy: ConstantStrategy::InlineLiteral,
28 residency: ResidencyPolicy::GatherImmediately,
29 nan_mode: ReductionNaN::Include,
30 two_pass_threshold: None,
31 workgroup_size: None,
32 accepts_nan_mode: false,
33 notes: "Step-response simulation runs on the host from transfer-function metadata.",
34};
35
36#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::control::step")]
37pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
38 name: "step",
39 shape: ShapeRequirements::Any,
40 constant_strategy: ConstantStrategy::InlineLiteral,
41 elementwise: None,
42 reduction: None,
43 emits_nan: false,
44 notes: "step simulates a dynamic system and terminates numeric fusion chains.",
45};
46
47fn step_error(message: impl Into<String>) -> RuntimeError {
48 build_runtime_error(message)
49 .with_builtin(BUILTIN_NAME)
50 .build()
51}
52
53#[runtime_builtin(
54 name = "step",
55 category = "control",
56 summary = "Compute or plot the step response of a SISO transfer-function model.",
57 keywords = "step,response,control system,transfer function,tf",
58 sink = true,
59 suppress_auto_output = true,
60 type_resolver(step_type),
61 builtin_path = "crate::builtins::control::step"
62)]
63async fn step_builtin(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
64 if rest.len() > 1 {
65 return Err(step_error(
66 "step: expected step(sys), step(sys, tFinal), or step(sys, t)",
67 ));
68 }
69
70 let sys = crate::gather_if_needed_async(&sys).await?;
71 let mut rest_host = Vec::with_capacity(rest.len());
72 for arg in &rest {
73 rest_host.push(crate::gather_if_needed_async(arg).await?);
74 }
75 let model = TransferFunction::from_value(sys)?;
76 let time = TimeSpec::parse(rest_host.first(), model.sample_time)?;
77 let eval = evaluate_step(&model, time)?;
78
79 if crate::output_context::requested_output_count() == Some(0)
80 && crate::output_count::current_output_count().is_none()
81 {
82 plot_response(&eval).await?;
83 return Ok(Value::OutputList(Vec::new()));
84 }
85
86 if let Some(out_count) = crate::output_count::current_output_count() {
87 if out_count == 0 {
88 plot_response(&eval).await?;
89 return Ok(Value::OutputList(Vec::new()));
90 }
91 if out_count == 1 {
92 return Ok(Value::OutputList(vec![eval.y_value()?]));
93 }
94 return Ok(crate::output_count::output_list_with_padding(
95 out_count,
96 eval.outputs()?,
97 ));
98 }
99
100 eval.y_value()
101}
102
103#[derive(Clone, Debug)]
104struct TransferFunction {
105 numerator: Vec<f64>,
106 denominator: Vec<f64>,
107 sample_time: f64,
108}
109
110impl TransferFunction {
111 fn from_value(value: Value) -> BuiltinResult<Self> {
112 let Value::Object(object) = value else {
113 return Err(step_error("step: expected a tf object"));
114 };
115 if !object.is_class("tf") {
116 return Err(step_error(format!(
117 "step: expected a tf object, got {}",
118 object.class_name
119 )));
120 }
121
122 let numerator = property_coefficients(&object, "Numerator")?;
123 let denominator = property_coefficients(&object, "Denominator")?;
124 let sample_time = property_scalar(&object, "Ts")?;
125 if !sample_time.is_finite() || sample_time < 0.0 {
126 return Err(step_error(
127 "step: tf sample time must be finite and non-negative",
128 ));
129 }
130
131 let numerator = trim_leading_zeros(numerator);
132 let denominator = trim_leading_zeros(denominator);
133 if denominator.is_empty() {
134 return Err(step_error("step: denominator coefficients cannot be empty"));
135 }
136 if denominator[0].abs() <= EPS {
137 return Err(step_error(
138 "step: leading denominator coefficient must be non-zero",
139 ));
140 }
141 if numerator.len().saturating_sub(1) > denominator.len().saturating_sub(1) {
142 return Err(step_error(
143 "step: improper transfer functions are not supported yet",
144 ));
145 }
146
147 Ok(Self {
148 numerator,
149 denominator,
150 sample_time,
151 })
152 }
153
154 fn normalized(&self) -> (Vec<f64>, Vec<f64>) {
155 let leading = self.denominator[0];
156 let den = self
157 .denominator
158 .iter()
159 .map(|value| value / leading)
160 .collect::<Vec<_>>();
161 let num = self
162 .numerator
163 .iter()
164 .map(|value| value / leading)
165 .collect::<Vec<_>>();
166 (num, den)
167 }
168}
169
170fn property_coefficients(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
171 let value = object
172 .properties
173 .get(name)
174 .ok_or_else(|| step_error(format!("step: tf object is missing {name}")))?;
175 match value {
176 Value::Tensor(tensor) => Ok(tensor.data.clone()),
177 Value::ComplexTensor(tensor) => real_complex_coefficients(tensor, name),
178 Value::Num(n) => Ok(vec![*n]),
179 Value::Int(i) => Ok(vec![i.to_f64()]),
180 other => Err(step_error(format!(
181 "step: tf {name} coefficients must be numeric, got {other:?}"
182 ))),
183 }
184}
185
186fn real_complex_coefficients(tensor: &ComplexTensor, name: &str) -> BuiltinResult<Vec<f64>> {
187 let mut out = Vec::with_capacity(tensor.data.len());
188 for &(re, im) in &tensor.data {
189 if im.abs() > EPS {
190 return Err(step_error(format!(
191 "step: complex tf {name} coefficients are not supported yet"
192 )));
193 }
194 out.push(re);
195 }
196 Ok(out)
197}
198
199fn property_scalar(object: &ObjectInstance, name: &str) -> BuiltinResult<f64> {
200 let value = object
201 .properties
202 .get(name)
203 .ok_or_else(|| step_error(format!("step: tf object is missing {name}")))?;
204 match value {
205 Value::Num(n) => Ok(*n),
206 Value::Int(i) => Ok(i.to_f64()),
207 other => Err(step_error(format!(
208 "step: tf {name} property must be a scalar, got {other:?}"
209 ))),
210 }
211}
212
213#[derive(Clone, Debug)]
214enum TimeSpec {
215 Auto,
216 FinalTime(f64),
217 Vector(Vec<f64>),
218}
219
220impl TimeSpec {
221 fn parse(value: Option<&Value>, sample_time: f64) -> BuiltinResult<Self> {
222 let Some(value) = value else {
223 return Ok(Self::Auto);
224 };
225 match value {
226 Value::Num(n) => Self::final_time(*n),
227 Value::Int(i) => Self::final_time(i.to_f64()),
228 Value::Tensor(tensor) => {
229 ensure_time_vector_shape(&tensor.shape)?;
230 if tensor.data.len() == 1 {
231 return Self::final_time(tensor.data[0]);
232 }
233 Self::vector(tensor.data.clone(), sample_time)
234 }
235 other => Err(step_error(format!(
236 "step: time input must be a scalar final time or numeric vector, got {other:?}"
237 ))),
238 }
239 }
240
241 fn final_time(value: f64) -> BuiltinResult<Self> {
242 if !value.is_finite() || value <= 0.0 {
243 return Err(step_error(
244 "step: final time must be a positive finite scalar",
245 ));
246 }
247 Ok(Self::FinalTime(value))
248 }
249
250 fn vector(values: Vec<f64>, sample_time: f64) -> BuiltinResult<Self> {
251 validate_time_vector(&values)?;
252 if sample_time > 0.0 {
253 for &t in &values {
254 let k = (t / sample_time).round();
255 if (t - k * sample_time).abs() > 1.0e-8 * sample_time.max(1.0) {
256 return Err(step_error(
257 "step: discrete-time sample vector must align with the model sample time",
258 ));
259 }
260 }
261 }
262 Ok(Self::Vector(values))
263 }
264}
265
266fn ensure_time_vector_shape(shape: &[usize]) -> BuiltinResult<()> {
267 let non_unit = shape.iter().copied().filter(|&dim| dim > 1).count();
268 if non_unit <= 1 {
269 Ok(())
270 } else {
271 Err(step_error("step: time input must be a vector"))
272 }
273}
274
275fn validate_time_vector(values: &[f64]) -> BuiltinResult<()> {
276 if values.is_empty() {
277 return Err(step_error("step: time vector must not be empty"));
278 }
279 let mut previous = None;
280 for &value in values {
281 if !value.is_finite() || value < 0.0 {
282 return Err(step_error(
283 "step: time vector values must be finite and non-negative",
284 ));
285 }
286 if let Some(prev) = previous {
287 if value < prev {
288 return Err(step_error("step: time vector must be nondecreasing"));
289 }
290 }
291 previous = Some(value);
292 }
293 Ok(())
294}
295
296#[derive(Clone, Debug)]
297struct StepEval {
298 y: Vec<f64>,
299 t: Vec<f64>,
300}
301
302impl StepEval {
303 fn y_value(&self) -> BuiltinResult<Value> {
304 column_tensor(self.y.clone())
305 }
306
307 fn t_value(&self) -> BuiltinResult<Value> {
308 column_tensor(self.t.clone())
309 }
310
311 fn outputs(&self) -> BuiltinResult<Vec<Value>> {
312 Ok(vec![self.y_value()?, self.t_value()?])
313 }
314}
315
316fn evaluate_step(model: &TransferFunction, time: TimeSpec) -> BuiltinResult<StepEval> {
317 if model.sample_time > 0.0 {
318 evaluate_discrete_step(model, time)
319 } else {
320 evaluate_continuous_step(model, time)
321 }
322}
323
324fn evaluate_continuous_step(model: &TransferFunction, time: TimeSpec) -> BuiltinResult<StepEval> {
325 let t = continuous_time_vector(model, time)?;
326 let (num, den) = model.normalized();
327 let response = continuous_response(&num, &den, &t)?;
328 Ok(StepEval { y: response, t })
329}
330
331fn continuous_time_vector(model: &TransferFunction, time: TimeSpec) -> BuiltinResult<Vec<f64>> {
332 match time {
333 TimeSpec::Auto => Ok(linspace(0.0, automatic_final_time(model), DEFAULT_SAMPLES)),
334 TimeSpec::FinalTime(final_time) => Ok(linspace(0.0, final_time, DEFAULT_SAMPLES)),
335 TimeSpec::Vector(values) => Ok(values),
336 }
337}
338
339fn continuous_response(num: &[f64], den: &[f64], t: &[f64]) -> BuiltinResult<Vec<f64>> {
340 let order = den.len() - 1;
341 if order == 0 {
342 let gain = num.last().copied().unwrap_or(0.0) / den[0];
343 return Ok(vec![gain; t.len()]);
344 }
345
346 let mut padded_num = vec![0.0; order + 1 - num.len()];
347 padded_num.extend_from_slice(num);
348 let direct = padded_num[0];
349 let a = &den[1..];
350 let mut c = Vec::with_capacity(order);
351 for state_idx in 0..order {
352 let coeff_idx = order - state_idx;
353 c.push(padded_num[coeff_idx] - direct * den[coeff_idx]);
354 }
355
356 let mut state = vec![0.0; order];
357 let mut current_t = 0.0;
358 let mut response = Vec::with_capacity(t.len());
359 for &target_t in t {
360 if target_t > current_t {
361 integrate_to(&mut state, a, current_t, target_t);
362 current_t = target_t;
363 }
364 response.push(dot(&c, &state) + direct);
365 }
366 Ok(response)
367}
368
369fn integrate_to(state: &mut [f64], a: &[f64], start: f64, end: f64) {
370 let duration = end - start;
371 if duration <= 0.0 {
372 return;
373 }
374 let steps = ((duration / 0.01).ceil() as usize).clamp(1, 10_000);
375 let h = duration / steps as f64;
376 for _ in 0..steps {
377 rk4_step(state, a, h);
378 }
379}
380
381fn rk4_step(state: &mut [f64], a: &[f64], h: f64) {
382 let k1 = derivative(state, a);
383 let s2 = add_scaled(state, &k1, h * 0.5);
384 let k2 = derivative(&s2, a);
385 let s3 = add_scaled(state, &k2, h * 0.5);
386 let k3 = derivative(&s3, a);
387 let s4 = add_scaled(state, &k3, h);
388 let k4 = derivative(&s4, a);
389 for idx in 0..state.len() {
390 state[idx] += h * (k1[idx] + 2.0 * k2[idx] + 2.0 * k3[idx] + k4[idx]) / 6.0;
391 }
392}
393
394fn derivative(state: &[f64], a: &[f64]) -> Vec<f64> {
395 let order = state.len();
396 let mut dx = vec![0.0; order];
397 if order > 1 {
398 dx[..(order - 1)].copy_from_slice(&state[1..order]);
399 }
400 let mut last = 1.0;
401 for state_idx in 0..order {
402 let coeff = a[order - 1 - state_idx];
403 last -= coeff * state[state_idx];
404 }
405 dx[order - 1] = last;
406 dx
407}
408
409fn add_scaled(state: &[f64], delta: &[f64], scale: f64) -> Vec<f64> {
410 state
411 .iter()
412 .zip(delta)
413 .map(|(value, delta)| value + scale * delta)
414 .collect()
415}
416
417fn evaluate_discrete_step(model: &TransferFunction, time: TimeSpec) -> BuiltinResult<StepEval> {
418 let t = discrete_time_vector(model.sample_time, time)?;
419 if t.len() > MAX_DISCRETE_SAMPLES {
420 return Err(step_error(format!(
421 "step: discrete response would require more than {MAX_DISCRETE_SAMPLES} samples"
422 )));
423 }
424 let sample_indices = t
425 .iter()
426 .map(|&value| checked_discrete_sample_index(model.sample_time, value))
427 .collect::<BuiltinResult<Vec<_>>>()?;
428 let max_k = sample_indices.iter().copied().max().unwrap_or(0);
429 let count = max_k
430 .checked_add(1)
431 .ok_or_else(|| step_error("step: discrete sample index exceeds platform limits"))?;
432 let (num, den) = model.normalized();
433 let all_y = discrete_response(&num, &den, count)?;
434 let y = sample_indices
435 .into_iter()
436 .map(|idx| {
437 all_y
438 .get(idx)
439 .copied()
440 .ok_or_else(|| step_error("step: discrete sample index exceeds response length"))
441 })
442 .collect::<BuiltinResult<Vec<_>>>()?;
443 Ok(StepEval { y, t })
444}
445
446fn discrete_time_vector(sample_time: f64, time: TimeSpec) -> BuiltinResult<Vec<f64>> {
447 match time {
448 TimeSpec::Auto => Ok((0..DEFAULT_SAMPLES)
449 .map(|idx| idx as f64 * sample_time)
450 .collect()),
451 TimeSpec::FinalTime(final_time) => {
452 let steps = checked_discrete_sample_steps(sample_time, final_time)?;
453 Ok((0..=steps).map(|idx| idx as f64 * sample_time).collect())
454 }
455 TimeSpec::Vector(values) => Ok(values),
456 }
457}
458
459fn checked_discrete_sample_steps(sample_time: f64, final_time: f64) -> BuiltinResult<usize> {
460 let steps = (final_time / sample_time).floor();
461 if !steps.is_finite() || steps < 0.0 || steps > usize::MAX as f64 {
462 return Err(step_error(
463 "step: discrete sample count exceeds platform limits",
464 ));
465 }
466 if steps >= MAX_DISCRETE_SAMPLES as f64 {
467 return Err(step_error(format!(
468 "step: discrete response would require more than {MAX_DISCRETE_SAMPLES} samples"
469 )));
470 }
471 Ok(steps as usize)
472}
473
474fn checked_discrete_sample_index(sample_time: f64, time: f64) -> BuiltinResult<usize> {
475 let index = (time / sample_time).round();
476 if !index.is_finite() || index < 0.0 || index > usize::MAX as f64 {
477 return Err(step_error(
478 "step: discrete sample index exceeds platform limits",
479 ));
480 }
481 if index >= MAX_DISCRETE_SAMPLES as f64 {
482 return Err(step_error(format!(
483 "step: discrete response would require more than {MAX_DISCRETE_SAMPLES} samples"
484 )));
485 }
486 Ok(index as usize)
487}
488
489fn discrete_response(num: &[f64], den: &[f64], count: usize) -> BuiltinResult<Vec<f64>> {
490 let order = den.len() - 1;
491 let mut padded_num = vec![0.0; order + 1 - num.len()];
492 padded_num.extend_from_slice(num);
493 let mut y = vec![0.0; count];
494 for k in 0..count {
495 let mut value = 0.0;
496 for (idx, &coeff) in padded_num.iter().enumerate() {
497 if k >= idx {
498 value += coeff;
499 }
500 }
501 for idx in 1..den.len() {
502 if k >= idx {
503 value -= den[idx] * y[k - idx];
504 }
505 }
506 y[k] = value;
507 }
508 Ok(y)
509}
510
511async fn plot_response(eval: &StepEval) -> BuiltinResult<()> {
512 let t = eval.t_value()?;
513 let y = eval.y_value()?;
514 if let Err(err) = crate::call_builtin_async("plot", &[t, y]).await {
515 if is_nonfatal_plot_setup_error(&err) {
516 return Ok(());
517 }
518 return Err(err);
519 }
520 let _ = crate::call_builtin_async("title", &[Value::from("Step Response")]).await;
521 let _ = crate::call_builtin_async("xlabel", &[Value::from("Time")]).await;
522 let _ = crate::call_builtin_async("ylabel", &[Value::from("Amplitude")]).await;
523 Ok(())
524}
525
526fn is_nonfatal_plot_setup_error(err: &RuntimeError) -> bool {
527 let lower = err.to_string().to_ascii_lowercase();
528 lower.contains("plotting is unavailable")
529 || lower.contains("non-main thread")
530 || lower.contains("interactive plotting failed")
531 || lower.contains("eventloop can't be recreated")
532}
533
534fn automatic_final_time(model: &TransferFunction) -> f64 {
535 let (_, den) = model.normalized();
536 let poles = polynomial_roots(&den).unwrap_or_default();
537 let slowest_decay = poles
538 .iter()
539 .filter_map(|pole| if pole.re < -EPS { Some(-pole.re) } else { None })
540 .fold(f64::INFINITY, f64::min);
541 if slowest_decay.is_finite() && slowest_decay > EPS {
542 (5.0 / slowest_decay).clamp(1.0, 100.0)
543 } else {
544 10.0
545 }
546}
547
548fn polynomial_roots(coeffs: &[f64]) -> BuiltinResult<Vec<Complex64>> {
549 let trimmed = trim_leading_zeros(coeffs.to_vec());
550 if trimmed.len() <= 1 {
551 return Ok(Vec::new());
552 }
553 if trimmed.len() == 2 {
554 return Ok(vec![Complex64::new(-trimmed[1] / trimmed[0], 0.0)]);
555 }
556 let degree = trimmed.len() - 1;
557 let leading = trimmed[0];
558 let mut companion = DMatrix::<Complex64>::zeros(degree, degree);
559 for row in 1..degree {
560 companion[(row, row - 1)] = Complex64::new(1.0, 0.0);
561 }
562 for (idx, coeff) in trimmed.iter().enumerate().skip(1) {
563 companion[(0, idx - 1)] = Complex64::new(-coeff / leading, 0.0);
564 }
565 let eigenvalues = companion
566 .eigenvalues()
567 .ok_or_else(|| step_error("step: failed to compute transfer-function poles"))?;
568 Ok(eigenvalues.iter().copied().collect())
569}
570
571fn trim_leading_zeros(coeffs: Vec<f64>) -> Vec<f64> {
572 let first_nonzero = coeffs
573 .iter()
574 .position(|value| value.abs() > EPS)
575 .unwrap_or(coeffs.len());
576 coeffs[first_nonzero..].to_vec()
577}
578
579fn linspace(start: f64, end: f64, count: usize) -> Vec<f64> {
580 if count <= 1 {
581 return vec![end];
582 }
583 let step = (end - start) / (count - 1) as f64;
584 (0..count).map(|idx| start + idx as f64 * step).collect()
585}
586
587fn dot(lhs: &[f64], rhs: &[f64]) -> f64 {
588 lhs.iter().zip(rhs).map(|(a, b)| a * b).sum()
589}
590
591fn column_tensor(data: Vec<f64>) -> BuiltinResult<Value> {
592 let rows = data.len();
593 let tensor =
594 Tensor::new(data, vec![rows, 1]).map_err(|err| step_error(format!("step: {err}")))?;
595 Ok(Value::Tensor(tensor))
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use futures::executor::block_on;
602 use runmat_builtins::{CharArray, ObjectInstance};
603
604 fn tf_object(num: Vec<f64>, den: Vec<f64>, sample_time: f64) -> Value {
605 let mut object = ObjectInstance::new("tf".to_string());
606 object.properties.insert(
607 "Numerator".to_string(),
608 Value::Tensor(Tensor::new(num.clone(), vec![1, num.len()]).unwrap()),
609 );
610 object.properties.insert(
611 "Denominator".to_string(),
612 Value::Tensor(Tensor::new(den.clone(), vec![1, den.len()]).unwrap()),
613 );
614 object.properties.insert(
615 "Variable".to_string(),
616 Value::CharArray(CharArray::new_row(if sample_time > 0.0 {
617 "z"
618 } else {
619 "s"
620 })),
621 );
622 object
623 .properties
624 .insert("Ts".to_string(), Value::Num(sample_time));
625 Value::Object(object)
626 }
627
628 fn run_step(sys: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
629 block_on(step_builtin(sys, rest))
630 }
631
632 fn tensor_data(value: Value) -> Vec<f64> {
633 match value {
634 Value::Tensor(tensor) => tensor.data,
635 other => panic!("expected tensor, got {other:?}"),
636 }
637 }
638
639 #[test]
640 fn first_order_continuous_response_matches_closed_form_for_explicit_time() {
641 let sys = tf_object(vec![1.0], vec![1.0, 1.0], 0.0);
642 let time = Value::Tensor(Tensor::new(vec![0.0, 0.5, 1.0, 2.0], vec![1, 4]).unwrap());
643 let y = tensor_data(run_step(sys, vec![time]).expect("step"));
644 for (actual, t) in y.iter().zip([0.0_f64, 0.5, 1.0, 2.0]) {
645 let expected = 1.0 - (-t).exp();
646 assert!(
647 (actual - expected).abs() < 1.0e-5,
648 "t={t} actual={actual} expected={expected}"
649 );
650 }
651 }
652
653 #[test]
654 fn multi_output_returns_y_then_time() {
655 let sys = tf_object(vec![1.0], vec![1.0, 1.0], 0.0);
656 let _guard = crate::output_count::push_output_count(Some(2));
657 let time = Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![2, 1]).unwrap());
658 let result = run_step(sys, vec![time]).expect("step");
659 let Value::OutputList(outputs) = result else {
660 panic!("expected output list");
661 };
662 assert_eq!(outputs.len(), 2);
663 assert_eq!(tensor_data(outputs[1].clone()), vec![0.0, 1.0]);
664 let y = tensor_data(outputs[0].clone());
665 assert!((y[1] - (1.0 - (-1.0_f64).exp())).abs() < 1.0e-5);
666 }
667
668 #[test]
669 fn single_requested_output_returns_only_response() {
670 let sys = tf_object(vec![1.0], vec![1.0, 1.0], 0.0);
671 let _guard = crate::output_count::push_output_count(Some(1));
672 let time = Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap());
673 let result = run_step(sys, vec![time]).expect("step");
674 let Value::OutputList(outputs) = result else {
675 panic!("expected output list");
676 };
677 assert_eq!(outputs.len(), 1);
678 let y = tensor_data(outputs[0].clone());
679 assert_eq!(y.len(), 2);
680 assert!((y[1] - (1.0 - (-1.0_f64).exp())).abs() < 1.0e-5);
681 }
682
683 #[test]
684 fn scalar_final_time_generates_column_time_vector_ending_at_final_time() {
685 let sys = tf_object(vec![1.0], vec![1.0, 1.0], 0.0);
686 let _guard = crate::output_count::push_output_count(Some(2));
687 let result = run_step(sys, vec![Value::Num(5.0)]).expect("step");
688 let Value::OutputList(outputs) = result else {
689 panic!("expected output list");
690 };
691 let t = tensor_data(outputs[1].clone());
692 assert_eq!(t.len(), DEFAULT_SAMPLES);
693 assert_eq!(t[0], 0.0);
694 assert!((t[t.len() - 1] - 5.0).abs() < 1.0e-12);
695 }
696
697 #[test]
698 fn discrete_response_uses_sample_time_grid() {
699 let sys = tf_object(vec![1.0], vec![1.0, -0.5], 0.1);
700 let time = Value::Tensor(Tensor::new(vec![0.0, 0.1, 0.2], vec![1, 3]).unwrap());
701 let y = tensor_data(run_step(sys, vec![time]).expect("step"));
702 assert_eq!(y, vec![0.0, 1.0, 1.5]);
703 }
704
705 #[test]
706 fn discrete_final_time_rejects_excessive_sample_count() {
707 let sys = tf_object(vec![1.0], vec![1.0, -0.5], 1.0e-6);
708 let err = run_step(sys, vec![Value::Num(2.0)]).expect_err("should fail");
709 assert!(err.message().contains("more than 1000000 samples"));
710 }
711
712 #[test]
713 fn discrete_time_vector_rejects_excessive_sample_index() {
714 let sys = tf_object(vec![1.0], vec![1.0, -0.5], 1.0);
715 let time =
716 Value::Tensor(Tensor::new(vec![0.0, MAX_DISCRETE_SAMPLES as f64], vec![1, 2]).unwrap());
717 let err = run_step(sys, vec![time]).expect_err("should fail");
718 assert!(err.message().contains("more than 1000000 samples"));
719 }
720
721 #[test]
722 fn rejects_non_tf_input() {
723 let err = run_step(Value::Num(1.0), Vec::new()).expect_err("expected error");
724 assert!(err.message().contains("expected a tf object"));
725 }
726}