From 7108c3671c7b552263aa61d39bb9b0a22df3468f Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Wed, 22 Jul 2026 14:45:52 +0800 Subject: [PATCH 01/26] control: add InputDelay/OutputDelay/IODelay property and delay helpers Add __adjust_delay__ validation/expansion, store InputDelay/OutputDelay/ IODelay on @lti with get/set wiring, add hasdelay/totaldelay query helpers, and document the option keys on tf/ss/zpk. --- inst/@lti/__lti_group__.m | 22 +++++++++++++ inst/@lti/__lti_keys__.m | 6 ++++ inst/@lti/get.m | 6 ++++ inst/@lti/lti.m | 5 ++- inst/@lti/set.m | 9 ++++++ inst/@ss/ss.m | 21 ++++++++++++ inst/@tf/tf.m | 46 +++++++++++++++++++++++++++ inst/@zpk/zpk.m | 19 +++++++++++ inst/__adjust_delay__.m | 61 +++++++++++++++++++++++++++++++++++ inst/hasdelay.m | 66 ++++++++++++++++++++++++++++++++++++++ inst/totaldelay.m | 67 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 inst/__adjust_delay__.m create mode 100644 inst/hasdelay.m create mode 100644 inst/totaldelay.m diff --git a/inst/@lti/__lti_group__.m b/inst/@lti/__lti_group__.m index ec869ca6..52a62866 100644 --- a/inst/@lti/__lti_group__.m +++ b/inst/@lti/__lti_group__.m @@ -54,6 +54,10 @@ ## retlti.outgroup remains empty struct endif + retlti.indelay = zeros (numel (retlti.inname), 1); + retlti.outdelay = zeros (numel (retlti.outname), 1); + retlti.iodelay = zeros (numel (retlti.outname), numel (retlti.inname)); + if (lti1.tsam == lti2.tsam) retlti.tsam = lti1.tsam; elseif (lti1.tsam == -1 && lti2.tsam > 0) @@ -90,3 +94,21 @@ ret = cell2struct ([ca; cb], [fa; fb]); endfunction + + +%!test +%! h = [tf(1,[1 1]); tf(1,[1 2])]; +%! assert (totaldelay (h), zeros (2, 1)); + +%!test +%! h1 = [tf(1,[1 1]); tf(1,[1 2])]; +%! assert (hasdelay (h1), false); +%! h2 = [tf(1,[1 1]), tf(1,[1 2])]; +%! assert (hasdelay (h2), false); + +%!test +%! h = [tf(1,[1 1]); tf(1,[1 2])]; +%! [p, m] = size (h); +%! assert (size (h.InputDelay), [m, 1]); +%! assert (size (h.OutputDelay), [p, 1]); +%! assert (size (h.IODelay), [p, m]); diff --git a/inst/@lti/__lti_keys__.m b/inst/@lti/__lti_keys__.m index 5ffe42b3..8f0b9e79 100644 --- a/inst/@lti/__lti_keys__.m +++ b/inst/@lti/__lti_keys__.m @@ -28,6 +28,9 @@ ## cell vector of lti-specific keys keys = {"tsam"; + "inputdelay"; + "outputdelay"; + "iodelay"; "inname"; "outname"; "ingroup"; @@ -38,6 +41,9 @@ ## cell vector of lti-specific assignable values vals = {"scalar (sample time in seconds)"; + "m-by-1 real matrix (input delay: seconds for ct models, samples for dt models)"; + "p-by-1 real matrix (output delay: seconds for ct models, samples for dt models)"; + "p-by-m real matrix (I/O delay: seconds for ct models, samples for dt models)"; "m-by-1 cell vector of strings"; "p-by-1 cell vector of strings"; "struct with indices as fields"; diff --git a/inst/@lti/get.m b/inst/@lti/get.m index ecf7eb97..4919e122 100644 --- a/inst/@lti/get.m +++ b/inst/@lti/get.m @@ -56,6 +56,12 @@ val = sys.outgroup; case "tsam" val = sys.tsam; + case "inputdelay" + val = sys.indelay; + case "outputdelay" + val = sys.outdelay; + case "iodelay" + val = sys.iodelay; case "name" val = sys.name; case "notes" diff --git a/inst/@lti/lti.m b/inst/@lti/lti.m index 77133440..1025429a 100644 --- a/inst/@lti/lti.m +++ b/inst/@lti/lti.m @@ -38,7 +38,10 @@ "outgroup", struct (), "name", "", "notes", {{}}, - "userdata", []); + "userdata", [], + "indelay", zeros (m, 1), + "outdelay", zeros (p, 1), + "iodelay", zeros (p, m)); ltisys = class (ltisys, "lti"); diff --git a/inst/@lti/set.m b/inst/@lti/set.m index 24f8600f..8a762e20 100644 --- a/inst/@lti/set.m +++ b/inst/@lti/set.m @@ -82,6 +82,15 @@ error ("lti: set: invalid sampling time"); endif + case "inputdelay" + sys.indelay = __adjust_delay__ (val, [m, 1], isdt (sys), [class(sys), ": set"]); + + case "outputdelay" + sys.outdelay = __adjust_delay__ (val, [p, 1], isdt (sys), [class(sys), ": set"]); + + case "iodelay" + sys.iodelay = __adjust_delay__ (val, [p, m], isdt (sys), [class(sys), ": set"]); + case {"ingroup", "inputgroup"} if (isstruct (val) && all (size (val) == 1) ... && all (structfun (@(x) is_group_idx (x, m), val))) diff --git a/inst/@ss/ss.m b/inst/@ss/ss.m index 14eae56a..77e0d645 100644 --- a/inst/@ss/ss.m +++ b/inst/@ss/ss.m @@ -68,6 +68,21 @@ ## @item 'tsam' ## Sampling time. See 'Inputs' for details. ## +## @item 'InputDelay' +## m-by-1 real matrix (or scalar, which expands to all inputs). +## Delay at each input, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'OutputDelay' +## p-by-1 real matrix (or scalar, which expands to all outputs). +## Delay at each output, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'IODelay' +## p-by-m real matrix (or scalar, which expands to all input/output pairs). +## Delay from each input to each output, in seconds for continuous-time +## models or samples for discrete-time models. +## ## @item 'inname' ## The name of the input channels in @var{sys}. ## Cell vector of length m containing strings. @@ -295,3 +310,9 @@ %! %! assert (M, Me, 1e-6); +%!test # delay wiring smoke test for ss +%! s = ss (-1, 1, 1, 0, "InputDelay", 0.3, "OutputDelay", 0.2, "IODelay", 0.1); +%! assert (s.InputDelay, 0.3); +%! assert (s.OutputDelay, 0.2); +%! assert (s.IODelay, 0.1); + diff --git a/inst/@tf/tf.m b/inst/@tf/tf.m index fbc38269..9094eb3e 100644 --- a/inst/@tf/tf.m +++ b/inst/@tf/tf.m @@ -73,6 +73,21 @@ ## @item 'tsam' ## Sampling time. See 'Inputs' for details. ## +## @item 'InputDelay' +## m-by-1 real matrix (or scalar, which expands to all inputs). +## Delay at each input, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'OutputDelay' +## p-by-1 real matrix (or scalar, which expands to all outputs). +## Delay at each output, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'IODelay' +## p-by-m real matrix (or scalar, which expands to all input/output pairs). +## Delay from each input to each output, in seconds for continuous-time +## models or samples for discrete-time models. +## ## @item 'inname' ## The name of the input channels in @var{sys}. ## Cell vector of length m containing strings. @@ -237,3 +252,34 @@ endif endfunction + +%!test # IODelay via constructor +%! h = tf (10, [1 3 10], "IODelay", 0.25); +%! assert (h.IODelay, 0.25); +%! assert (h.InputDelay, 0); +%! assert (h.OutputDelay, 0); + +%!test # InputDelay via constructor +%! h = tf ([1 -1], [1 4 5], "InputDelay", 0.3); +%! assert (h.InputDelay, 0.3); + +%!test # OutputDelay via constructor +%! h = tf (10, [1 3 10], "OutputDelay", 0.25); +%! assert (h.OutputDelay, 0.25); + +%!test # scalar expansion to MIMO via dot-assignment +%! h = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! h.InputDelay = [0.1; 0.2]; +%! h.OutputDelay = [0.3; 0.4]; +%! assert (h.InputDelay, [0.1; 0.2]); +%! assert (h.OutputDelay, [0.3; 0.4]); + +%!test # discrete-time integer delay is accepted +%! h = tf (1, [1 -0.5], 0.1, "InputDelay", 3); +%! assert (h.InputDelay, 3); + +%!error +%! tf (1, [1 -0.5], 0.1, "InputDelay", 0.35); + +%!error +%! tf (10, [1 3 10], "IODelay", -0.1); diff --git a/inst/@zpk/zpk.m b/inst/@zpk/zpk.m index 89d736ff..6ff9edeb 100644 --- a/inst/@zpk/zpk.m +++ b/inst/@zpk/zpk.m @@ -53,6 +53,21 @@ ## String or cell array of strings specifying the names of the inputs. ## @item 'OutputName' ## String or cell array of strings specifying the names of the outputs. +## +## @item 'InputDelay' +## m-by-1 real matrix (or scalar, which expands to all inputs). +## Delay at each input, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'OutputDelay' +## p-by-1 real matrix (or scalar, which expands to all outputs). +## Delay at each output, in seconds for continuous-time models or +## samples for discrete-time models. +## +## @item 'IODelay' +## p-by-m real matrix (or scalar, which expands to all input/output pairs). +## Delay from each input to each output, in seconds for continuous-time +## models or samples for discrete-time models. ## @end table ## Type @code{set (zpk)} for further options. ## @@ -283,3 +298,7 @@ %! assert (z, -2, 1e-6); %! assert (p, -1, 1e-6); %! assert (k, 1/3, 1e-6); + +%!test # delay wiring smoke test for zpk +%! z = zpk ([], -1, 10, "InputDelay", 0.4); +%! assert (z.InputDelay, 0.4); diff --git a/inst/__adjust_delay__.m b/inst/__adjust_delay__.m new file mode 100644 index 00000000..9d303320 --- /dev/null +++ b/inst/__adjust_delay__.m @@ -0,0 +1,61 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## Check whether a delay value has the required size, expanding a scalar +## to the full size if necessary, and enforce non-negative, finite values +## (integer-valued for discrete-time models). Used by lti/set and __set__. + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function val = __adjust_delay__ (val, req_size, isdiscrete, caller = "lti: set") + + if (! is_real_matrix (val)) + error ("%s: delay value must be a real-valued matrix", caller); + endif + + if (isscalar (val)) + val = val * ones (req_size); + elseif (! isequal (size (val), req_size)) + error ("%s: delay matrix must have size %dx%d", caller, req_size(1), req_size(2)); + endif + + if (any (val(:) < 0) || ! all (isfinite (val(:)))) + error ("%s: delay values must be non-negative and finite", caller); + endif + + if (isdiscrete && any (val(:) != fix (val(:)))) + error ("%s: delay values must be integer-valued for discrete-time models", caller); + endif + +endfunction + + +%!assert (__adjust_delay__ (0.3, [1, 1], false), 0.3) +%!assert (__adjust_delay__ (0.3, [2, 1], false), [0.3; 0.3]) +%!assert (__adjust_delay__ ([0.1; 0.2], [2, 1], false), [0.1; 0.2]) +%!assert (__adjust_delay__ (0.25, [2, 2], false), [0.25, 0.25; 0.25, 0.25]) +%!assert (__adjust_delay__ (3, [1, 1], true), 3) +%!assert (__adjust_delay__ (0, [1, 1], true), 0) + +%!error (__adjust_delay__ (0.3, [1, 1], true)) +%!error (__adjust_delay__ (-0.1, [1, 1], false)) +%!error (__adjust_delay__ ([0.1, 0.2], [2, 1], false)) +%!error (__adjust_delay__ ("a", [1, 1], false)) +%!error (__adjust_delay__ (Inf, [1, 1], false)) diff --git a/inst/hasdelay.m b/inst/hasdelay.m new file mode 100644 index 00000000..ee280ef7 --- /dev/null +++ b/inst/hasdelay.m @@ -0,0 +1,66 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{bool} =} hasdelay (@var{sys}) +## Return true if @var{sys} has a nonzero @var{InputDelay}, @var{OutputDelay}, +## or @var{IODelay}. +## +## @strong{Inputs} +## @table @var +## @item sys +## @acronym{LTI} system. +## @end table +## +## @strong{Outputs} +## @table @var +## @item bool +## True if @var{sys} has any nonzero delay, false otherwise. +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function bool = hasdelay (sys) + + if (nargin != 1 || ! isa (sys, "lti")) + print_usage (); + endif + + [indelay, outdelay, iodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); + + bool = any (indelay(:) != 0) || any (outdelay(:) != 0) || any (iodelay(:) != 0); + +endfunction + + +%!test +%! h = tf (10, [1 3 10]); +%! assert (hasdelay (h), false); + +%!test +%! h = tf (10, [1 3 10], "IODelay", 0.25); +%! assert (hasdelay (h), true); + +%!test +%! h = tf ([1 -1], [1 4 5], "InputDelay", 0.3); +%! assert (hasdelay (h), true); + +%!error (hasdelay ()) +%!error (hasdelay (1)) diff --git a/inst/totaldelay.m b/inst/totaldelay.m new file mode 100644 index 00000000..252247be --- /dev/null +++ b/inst/totaldelay.m @@ -0,0 +1,67 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{delay} =} totaldelay (@var{sys}) +## Return the p-by-m matrix of total delay (in seconds for continuous-time +## models, samples for discrete-time models) from each input to each +## output, i.e. @code{IODelay + InputDelay + OutputDelay}. +## +## @strong{Inputs} +## @table @var +## @item sys +## @acronym{LTI} system. +## @end table +## +## @strong{Outputs} +## @table @var +## @item delay +## p-by-m real matrix. @code{delay(i,j)} is the total delay from +## input j to output i. +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function delay = totaldelay (sys) + + if (nargin != 1 || ! isa (sys, "lti")) + print_usage (); + endif + + [indelay, outdelay, iodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); + [p, m] = size (sys); + + delay = iodelay + repmat (outdelay, 1, m) + repmat (indelay.', p, 1); + +endfunction + + +%!test +%! h = tf (10, [1 3 10], "IODelay", 0.25); +%! assert (totaldelay (h), 0.25); + +%!test +%! h = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! h.InputDelay = [0.1; 0.2]; +%! h.OutputDelay = [0.3; 0.4]; +%! assert (totaldelay (h), [0.4, 0.5; 0.5, 0.6], 1e-10); + +%!error (totaldelay ()) +%!error (totaldelay (1)) From 8d5f37e934960bd00a3585b25ebc475b24b3230a Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Wed, 22 Jul 2026 17:41:56 +0800 Subject: [PATCH 02/26] control: compose InputDelay/OutputDelay/IODelay across series/parallel/append/feedback Propagate delay correctly through series, parallel, and append; error clearly when feedback operands carry nonzero delay (not yet supported at this point); fix delay-guard handling of numeric operands and 4-arg series found during review. --- inst/@lti/feedback.m | 12 ++++++++++++ inst/@lti/parallel.m | 33 +++++++++++++++++++++++++++++++++ inst/@lti/series.m | 44 ++++++++++++++++++++++++++++++++++++++++++++ inst/append.m | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/inst/@lti/feedback.m b/inst/@lti/feedback.m index a6932253..a87d195e 100644 --- a/inst/@lti/feedback.m +++ b/inst/@lti/feedback.m @@ -178,6 +178,10 @@ in_idx = 1 : m1; out_idx = 1 : p1; + if ((isa (sys1, "lti") && hasdelay (sys1)) || (isa (sys2, "lti") && hasdelay (sys2))) + error ("feedback: nonzero delays require internal delay support (not yet implemented)"); + endif + sys = __sys_group__ (sys1, sys2); sys = __sys_connect__ (sys, M); sys = __sys_prune__ (sys, out_idx, in_idx); @@ -276,3 +280,11 @@ %!assert (S1.b, S2.b, 1e-4); %!assert (S1.c, S2.c, 1e-4); %!assert (S1.d, S2.d, 1e-4); + + +%!test # no delay on either operand: existing behavior unaffected (regression) +%! s1 = tf (1, [1 1]); +%! s = feedback (s1); +%! assert (hasdelay (s), false); + +%!error feedback (tf (1, [1 1], "InputDelay", 0.1)) diff --git a/inst/@lti/parallel.m b/inst/@lti/parallel.m index 60d77cfc..c60a9f42 100644 --- a/inst/@lti/parallel.m +++ b/inst/@lti/parallel.m @@ -45,6 +45,17 @@ if (nargin == 2) sys = sys1 + sys2; + + if ((isa (sys1, "lti") && hasdelay (sys1)) || (isa (sys2, "lti") && hasdelay (sys2))) + d1 = totaldelay (sys1); + d2 = totaldelay (sys2); + + if (! isequal (d1, d2)) + error ("parallel: mismatched delays require internal delay support (not yet implemented)"); + endif + + sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", d1); + endif ## elseif (nargin == 6) ## TODO: implement "complicated" case sys = parallel (sys1, sys2, in1, in2, out1, out2) @@ -54,3 +65,25 @@ endif endfunction + + +%!test # matching delays: exact composition +%! s1 = tf (1, [1 1], "InputDelay", 0.1, "OutputDelay", 0.2); +%! s2 = tf (1, [1 2], "InputDelay", 0.1, "OutputDelay", 0.2); +%! s = parallel (s1, s2); +%! assert (s.InputDelay, 0, 1e-10); +%! assert (s.OutputDelay, 0, 1e-10); +%! assert (s.IODelay, 0.3, 1e-10); + +%!test # no delay on either operand: result has no delay (regression) +%! s1 = tf (1, [1 1]); +%! s2 = tf (1, [1 2]); +%! s = parallel (s1, s2); +%! assert (hasdelay (s), false); + +%!error parallel (tf (1, [1 1], "InputDelay", 0.1, "OutputDelay", 0.2), tf (1, [1 2], "InputDelay", 0.3, "OutputDelay", 0.4)) + +%!test # one operand is a plain numeric gain (no delay): should not error (regression) +%! s1 = tf (1, [1 1]); +%! s = parallel (s1, 2); +%! assert (hasdelay (s), false); diff --git a/inst/@lti/series.m b/inst/@lti/series.m index ea0b1ce5..29df97e9 100644 --- a/inst/@lti/series.m +++ b/inst/@lti/series.m @@ -59,10 +59,30 @@ if (nargin == 2) sys = sys2 * sys1; + + [p1, m1] = size (sys1); + [p2, m2] = size (sys2); + + if ((isa (sys1, "lti") && hasdelay (sys1)) || (isa (sys2, "lti") && hasdelay (sys2))) + if (p1 != 1 || m2 != 1) + error ("series: MIMO connections with delays are not yet supported (require internal delay support)"); + endif + + id1 = get (sys1, "inputdelay"); + od2 = get (sys2, "outputdelay"); + io_junction = get (sys1, "outputdelay") + get (sys2, "inputdelay") ... + + get (sys1, "iodelay") + get (sys2, "iodelay"); + + sys = set (sys, "InputDelay", id1, "OutputDelay", od2, "IODelay", io_junction); + endif elseif (nargin == 4) [p1, m1] = size (sys1); [p2, m2] = size (sys2); + if ((isa (sys1, "lti") && hasdelay (sys1)) || (isa (sys2, "lti") && hasdelay (sys2))) + error ("series: MIMO connections with delays are not yet supported (require internal delay support)"); + endif + if (! is_real_vector (out1)) error ("series: argument 3 (outputs1) invalid"); endif @@ -116,3 +136,27 @@ endif endfunction + + +%!test # single-channel cascade: exact delay composition +%! s1 = tf (1, [1 1], "InputDelay", 0.1, "OutputDelay", 0.2); +%! s2 = tf (1, [1 2], "InputDelay", 0.3, "OutputDelay", 0.4); +%! s = series (s1, s2); +%! assert (s.InputDelay, 0.1, 1e-10); +%! assert (s.OutputDelay, 0.4, 1e-10); +%! assert (s.IODelay, 0.5, 1e-10); + +%!test # no delay on either operand: result has no delay (regression) +%! s1 = tf (1, [1 1]); +%! s2 = tf (1, [1 2]); +%! s = series (s1, s2); +%! assert (hasdelay (s), false); + +%!error series (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [0.1;0.2]), tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]})) + +%!test # one operand is a plain numeric gain (no delay): should not error (regression) +%! s1 = tf (1, [1 1]); +%! s = series (s1, 2); +%! assert (hasdelay (s), false); + +%!error series (tf (1, [1 1], "InputDelay", 0.1), tf (1, [1 2]), [1], [1]) diff --git a/inst/append.m b/inst/append.m index 7f8e9d46..710b4ffb 100644 --- a/inst/append.m +++ b/inst/append.m @@ -33,4 +33,48 @@ sys = blkdiag (varargin{:}); + any_delay = any (cellfun (@(s) isa (s, "lti") && hasdelay (s), varargin)); + + if (any_delay) + indelay = cell (nargin, 1); + outdelay = cell (nargin, 1); + iodelay = cell (nargin, 1); + + for k = 1 : nargin + if (isa (varargin{k}, "lti")) + [indelay{k}, outdelay{k}, iodelay{k}] = get (varargin{k}, "inputdelay", "outputdelay", "iodelay"); + else + [p, m] = size (varargin{k}); + indelay{k} = zeros (m, 1); + outdelay{k} = zeros (p, 1); + iodelay{k} = zeros (p, m); + endif + endfor + + sys = set (sys, "InputDelay", vertcat (indelay{:}), ... + "OutputDelay", vertcat (outdelay{:}), ... + "IODelay", blkdiag (iodelay{:})); + endif + endfunction + + +%!test # 3-operand append: delays carry through per-block, no cross terms +%! s1 = tf (1, [1 1], "InputDelay", 0.1, "OutputDelay", 0.2); +%! s2 = tf (1, [1 2], "InputDelay", 0.3, "OutputDelay", 0.4); +%! s3 = tf (1, [1 3]); +%! s = append (s1, s2, s3); +%! assert (s.InputDelay, [0.1; 0.3; 0], 1e-10); +%! assert (s.OutputDelay, [0.2; 0.4; 0], 1e-10); +%! assert (s.IODelay, zeros (3, 3), 1e-10); + +%!test # no delay on any operand: result has no delay (regression) +%! s = append (tf (1, [1 1]), tf (1, [1 2])); +%! assert (hasdelay (s), false); + +%!test # mix of dynamic (with delay) and static-gain (numeric) operands +%! s1 = tf (1, [1 1], "InputDelay", 0.1, "OutputDelay", 0.2); +%! s = append (s1, eye (2)); +%! assert (s.InputDelay, [0.1; 0; 0], 1e-10); +%! assert (s.OutputDelay, [0.2; 0; 0], 1e-10); +%! assert (s.IODelay, zeros (3, 3), 1e-10); From d049bdca4066d52618a5fc79c190fec79ebc506b Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Wed, 22 Jul 2026 18:43:24 +0800 Subject: [PATCH 03/26] control: c2d/d2c delay handling and DelayModeling option Add absorbDelay for discrete tf/zpk models, convert InputDelay/ OutputDelay/IODelay between seconds and samples in c2d/d2c, approximate fractional SISO delays via thiran, add the c2dOptions constructor and wire DelayModeling into c2d (renaming 'truncate' to 'state'), absorb delay into extra dynamics for DelayModeling='state', and apply the stored delay's phase factor in freqresp/bode/bodemag/nyquist/nichols/ sigma/sensitivity. --- inst/@lti/c2d.m | 198 +++++++++++++++++++++++++++++++- inst/@lti/d2c.m | 31 ++++- inst/@lti/freqresp.m | 39 +++++++ inst/__apply_freqresp_delay__.m | 109 ++++++++++++++++++ inst/__frequency_response__.m | 4 + inst/absorbDelay.m | 151 ++++++++++++++++++++++++ inst/bode.m | 11 ++ inst/c2dOptions.m | 107 +++++++++++++++++ inst/sigma.m | 8 ++ 9 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 inst/__apply_freqresp_delay__.m create mode 100644 inst/absorbDelay.m create mode 100644 inst/c2dOptions.m diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index 2ccf17f4..3d0f6751 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -76,6 +76,19 @@ error ("c2d: second argument is not a valid sample time"); endif + delay_modeling = "delay"; + + if (isstruct (method)) + if (w0 != 0) + error ("c2d: cannot combine a c2dOptions struct with an explicit prewarp frequency argument"); + endif + + opt = method; + method = opt.Method; + w0 = opt.PrewarpFrequency; + delay_modeling = opt.DelayModeling; + endif + if (! ischar (method)) error ("c2d: third argument is not a string"); endif @@ -84,8 +97,76 @@ error ("c2d: fourth argument is not a valid pre-warping frequency"); endif - sys = __c2d__ (sys, tsam, lower (method), w0); - sys.tsam = tsam; + origsys = sys; + + if (hasdelay (origsys)) + [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); + + indelay_samples = indelay / tsam; + outdelay_samples = outdelay / tsam; + iodelay_samples = iodelay / tsam; + + all_samples = [indelay_samples(:); outdelay_samples(:); iodelay_samples(:)]; + + if (strcmp (delay_modeling, "state")) + % Delay must be zeroed before __c2d__ for the same recursive-re-entry + % reason documented below (ss() preserves delay fields), even though + % this branch never calls thiran -- otherwise a MIMO/recursive call + % into __c2d__ would re-enter this same "state" branch again. + sys_no_delay = set (origsys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + + % absorbDelay errors on ss -- route ss inputs through tf, absorb + % there, then convert back, since absorbDelay's per-channel math + % already handles a general IODelay matrix correctly. + is_ss_input = isa (origsys, "ss"); + + if (is_ss_input) + sys = tf (sys_no_delay); + else + sys = sys_no_delay; + endif + + sys = __c2d__ (sys, tsam, lower (method), w0); + sys.tsam = tsam; + sys = set (sys, "InputDelay", round (indelay_samples), ... + "OutputDelay", round (outdelay_samples), ... + "IODelay", round (iodelay_samples)); + sys = absorbDelay (sys); + + if (is_ss_input) + sys = ss (sys); + endif + elseif (any (abs (all_samples - round (all_samples)) > 1e-8)) + [p, m] = size (origsys); + + if (p != 1 || m != 1) + error ("c2d: fractional delays on MIMO systems are not yet supported (per-channel Thiran approximation is not yet implemented)"); + endif + + % __c2d__ recurses into this same function via ss() for the general + % case, and ss() does not strip delay fields -- so discretizing + % origsys directly here would re-enter this fractional branch a + % second time and double-apply the Thiran filter below. Delay must + % be zeroed before the __c2d__ call, not just after. + sys_no_delay = set (origsys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + sys = __c2d__ (sys_no_delay, tsam, lower (method), w0); + sys.tsam = tsam; + + total_delay = totaldelay (origsys); + filt = thiran (total_delay, tsam); + sys = sys * filt; + sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + else + sys = __c2d__ (origsys, tsam, lower (method), w0); + sys.tsam = tsam; + sys = set (sys, "InputDelay", round (indelay_samples), ... + "OutputDelay", round (outdelay_samples), ... + "IODelay", round (iodelay_samples)); + endif + else + sys = __c2d__ (origsys, tsam, lower (method), w0); + sys.tsam = tsam; + endif endfunction @@ -339,3 +420,116 @@ %!assert (Aexint, Aexint_exp, 1e-4); +%!test # exact integer-sample InputDelay survives c2d +%! sys = tf (1, [1 1], "InputDelay", 1.0); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! assert (dsys.InputDelay, 2); +%! assert (dsys.OutputDelay, 0); +%! assert (dsys.IODelay, 0); + +%!test # no delay: unaffected (regression) +%! sys = tf (1, [1 1]); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (hasdelay (dsys), false); + + +%!test # SISO tf fractional InputDelay: approximated via thiran +%! sys = tf (1, [1 1], "InputDelay", 1.33); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! assert (hasdelay (dsys), false); +%! sys_dyn = c2d (tf (1, [1 1]), 0.5, "zoh"); +%! filt = thiran (1.33, 0.5); +%! expected = sys_dyn * filt; +%! [numd, dend] = tfdata (dsys); +%! [nume, dene] = tfdata (expected); +%! assert (numd, nume, 1e-8); +%! assert (dend, dene, 1e-8); + +%!test # SISO zpk fractional OutputDelay: approximated via thiran +%! sys = zpk ([], -1, 1, "OutputDelay", 1.33); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! assert (hasdelay (dsys), false); +%! sys_dyn = c2d (zpk ([], -1, 1), 0.5, "zoh"); +%! filt = thiran (1.33, 0.5); +%! expected = sys_dyn * filt; +%! w = [0.1, 1, 5]; +%! assert (freqresp (dsys, w), freqresp (expected, w), 1e-8); + +%!error c2d (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [1.33;0]), 0.5, "zoh") + + +%!test # c2dOptions struct as 3rd argument, default DelayModeling +%! opt = c2dOptions (); +%! sys = tf (1, [1 1], "InputDelay", 1.0); +%! dsys = c2d (sys, 0.5, opt); +%! assert (dsys.InputDelay, 2); + +%!test # DelayModeling='state': fractional SISO delay rounds to integer samples, absorbed into extra dynamics +%! opt = c2dOptions ("DelayModeling", "state"); +%! sys = tf (1, [1 1], "InputDelay", 1.33); +%! dsys = c2d (sys, 0.5, opt); +%! assert (isdt (dsys), true); +%! assert (hasdelay (dsys), false); +%! rational_dsys = c2d (tf (1, [1 1]), 0.5, "zoh"); +%! k = round (1.33 / 0.5); +%! w = [0.1, 1, 5]; +%! expected = freqresp (rational_dsys, w) .* reshape (exp (-1i * w * 0.5 * k), 1, 1, []); +%! assert (freqresp (dsys, w), expected, 1e-8); + +%!test # DelayModeling='state': MIMO fractional delay succeeds (unlike 'delay' mode), absorbed per channel +%! opt = c2dOptions ("DelayModeling", "state"); +%! sys = tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [1.33;0]); +%! dsys = c2d (sys, 0.5, opt); +%! assert (hasdelay (dsys), false); +%! rational_dsys = c2d (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}), 0.5, "zoh"); +%! total = [round(1.33/0.5), 0; round(1.33/0.5), 0]; +%! w = [0.1, 1, 5]; +%! resp = freqresp (rational_dsys, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* exp (-1i * w * 0.5 * total(i,j)); +%! endfor +%! endfor +%! assert (freqresp (dsys, w), expected, 1e-8); + +%!test # DelayModeling='state': ss input absorbs InputDelay into extra dynamics +%! sys = ss (-1, 1, 1, 0, "InputDelay", 0.65); +%! opt = c2dOptions ("DelayModeling", "state"); +%! dsys = c2d (sys, 0.5, opt); +%! assert (isa (dsys, "ss"), true); +%! assert (isdt (dsys), true); +%! assert (hasdelay (dsys), false); +%! rational_dsys = c2d (ss (-1, 1, 1, 0), 0.5, "zoh"); +%! k = round (0.65 / 0.5); +%! w = [0.1, 1, 5]; +%! expected = freqresp (rational_dsys, w) .* reshape (exp (-1i * w * 0.5 * k), 1, 1, []); +%! assert (freqresp (dsys, w), expected, 1e-8); + +%!test # DelayModeling='state': ss input with per-channel IODelay matrix (MIMO) +%! A = [-1, 0; 0, -2]; +%! B = [1, 0; 0, 1]; +%! C = [1, 0; 0, 1]; +%! D = [0, 0; 0, 0]; +%! sys = ss (A, B, C, D); +%! sys = set (sys, "IODelay", [0.5, 0; 0, 1.0]); +%! opt = c2dOptions ("DelayModeling", "state"); +%! dsys = c2d (sys, 0.5, opt); +%! assert (hasdelay (dsys), false); +%! rational_dsys = c2d (ss (A, B, C, D), 0.5, "zoh"); +%! total = [round(0.5/0.5), 0; 0, round(1.0/0.5)]; +%! w = [0.1, 1, 5]; +%! resp = freqresp (rational_dsys, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* exp (-1i * w * 0.5 * total(i,j)); +%! endfor +%! endfor +%! assert (freqresp (dsys, w), expected, 1e-8); + +%!error c2d (tf (1, [1 1]), 0.5, c2dOptions (), 100) + diff --git a/inst/@lti/d2c.m b/inst/@lti/d2c.m index 84869611..ad9a4552 100644 --- a/inst/@lti/d2c.m +++ b/inst/@lti/d2c.m @@ -73,9 +73,19 @@ error ("d2c: third argument is not a valid pre-warping frequency"); endif - sys = __d2c__ (sys, sys.tsam, lower (method), w0); + origsys = sys; + tsam = sys.tsam; + sys = __d2c__ (sys, tsam, lower (method), w0); sys.tsam = 0; + if (hasdelay (origsys)) + [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); + + sys = set (sys, "InputDelay", indelay * tsam, ... + "OutputDelay", outdelay * tsam, ... + "IODelay", iodelay * tsam); + endif + endfunction @@ -146,3 +156,22 @@ %! Me = [A, B; C, D]; %! %!assert (Mo, Me, 1e-4); + + +%!test # integer sample delay converts to seconds correctly +%! sys = tf (1, [1 -0.5], 0.5, "InputDelay", 2); +%! csys = d2c (sys, "zoh"); +%! assert (isct (csys), true); +%! assert (csys.InputDelay, 1.0, 1e-10); +%! assert (csys.OutputDelay, 0, 1e-10); +%! assert (csys.IODelay, 0, 1e-10); + +%!test # no delay: unaffected (regression) +%! sys = tf (1, [1 -0.5], 0.5); +%! csys = d2c (sys, "zoh"); +%! assert (hasdelay (csys), false); + +%!test # round-trip: delay survives c2d then d2c +%! sys = tf (1, [1 1], "InputDelay", 1.0); +%! sys2 = d2c (c2d (sys, 0.5, "zoh"), "zoh"); +%! assert (sys2.InputDelay, 1.0, 1e-10); diff --git a/inst/@lti/freqresp.m b/inst/@lti/freqresp.m index cc87ba3f..77f3d0d0 100644 --- a/inst/@lti/freqresp.m +++ b/inst/@lti/freqresp.m @@ -53,5 +53,44 @@ endif H = __freqresp__ (sys, w); + H = __apply_freqresp_delay__ (sys, H, w, false); endfunction + +%!test # no-delay system: unaffected (regression) +%! sys = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = freqresp (sys, w); +%! rational_H = __freqresp__ (sys, w); +%! assert (H, rational_H); + +%!test # SISO continuous tf with InputDelay: freqresp applies the delay +%! sys = tf (1, [1, 1], "InputDelay", 0.3); +%! rational = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = freqresp (sys, w); +%! expected = freqresp (rational, w) .* reshape (exp (-1i * w * 0.3), 1, 1, []); +%! assert (H, expected, 1e-10); + +%!test # SISO discrete tf with integer InputDelay: freqresp applies the delay +%! sys = tf (1, [1, -0.5], 0.1, "InputDelay", 2); +%! rational = tf (1, [1, -0.5], 0.1); +%! w = [0.1, 1, 5]; +%! H = freqresp (sys, w); +%! expected = freqresp (rational, w) .* reshape (exp (-1i * w * 0.1 * 2), 1, 1, []); +%! assert (H, expected, 1e-10); + +%!test # MIMO tf with per-channel IODelay: freqresp applies each channel's own delay +%! sys = tf ({1, 1; 1, 1}, {[1, -0.5], [1, -0.6]; [1, -0.7], [1, -0.8]}, 0.1); +%! sys = set (sys, "IODelay", [1, 0; 0, 2]); +%! rational = tf ({1, 1; 1, 1}, {[1, -0.5], [1, -0.6]; [1, -0.7], [1, -0.8]}, 0.1); +%! total = [1, 0; 0, 2]; +%! w = [0.1, 1, 5]; +%! resp = freqresp (rational, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* exp (-1i * w * 0.1 * total(i,j)); +%! endfor +%! endfor +%! assert (freqresp (sys, w), expected, 1e-8); diff --git a/inst/__apply_freqresp_delay__.m b/inst/__apply_freqresp_delay__.m new file mode 100644 index 00000000..76722884 --- /dev/null +++ b/inst/__apply_freqresp_delay__.m @@ -0,0 +1,109 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function H = __apply_freqresp_delay__ (sys, H, w, cellflag) + + if (! hasdelay (sys)) + return; + endif + + total = totaldelay (sys); + + if (isdt (sys)) + tsam = abs (get (sys, "tsam")); + phase = @(wk) exp (-1i * wk * tsam * total); + else + phase = @(wk) exp (-1i * wk * total); + endif + + if (cellflag) + for k = 1 : numel (w) + H{k} = H{k} .* phase (w(k)); + endfor + else + for k = 1 : numel (w) + H(:,:,k) = H(:,:,k) .* phase (w(k)); + endfor + endif + +endfunction + + +%!test # no-delay system: exact no-op, cellflag=false +%! sys = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w); +%! H2 = __apply_freqresp_delay__ (sys, H, w, false); +%! assert (H2, H); + +%!test # no-delay system: exact no-op, cellflag=true +%! sys = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w, true); +%! H2 = __apply_freqresp_delay__ (sys, H, w, true); +%! assert (H2, H); + +%!test # SISO continuous tf with InputDelay, cellflag=false +%! sys = tf (1, [1, 1], "InputDelay", 0.3); +%! rational = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w); +%! H2 = __apply_freqresp_delay__ (sys, H, w, false); +%! expected = __freqresp__ (rational, w) .* reshape (exp (-1i * w * 0.3), 1, 1, []); +%! assert (H2, expected, 1e-10); + +%!test # SISO discrete tf with integer InputDelay (samples), cellflag=false +%! sys = tf (1, [1, -0.5], 0.1, "InputDelay", 2); +%! rational = tf (1, [1, -0.5], 0.1); +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w); +%! H2 = __apply_freqresp_delay__ (sys, H, w, false); +%! expected = __freqresp__ (rational, w) .* reshape (exp (-1i * w * 0.1 * 2), 1, 1, []); +%! assert (H2, expected, 1e-10); + +%!test # MIMO tf with per-channel IODelay matrix, cellflag=false +%! sys = tf ({1, 1; 1, 1}, {[1, -0.5], [1, -0.6]; [1, -0.7], [1, -0.8]}, 0.1); +%! sys = set (sys, "IODelay", [1, 0; 0, 2]); +%! rational = tf ({1, 1; 1, 1}, {[1, -0.5], [1, -0.6]; [1, -0.7], [1, -0.8]}, 0.1); +%! total = [1, 0; 0, 2]; +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w); +%! H2 = __apply_freqresp_delay__ (sys, H, w, false); +%! resp = __freqresp__ (rational, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* exp (-1i * w * 0.1 * total(i,j)); +%! endfor +%! endfor +%! assert (H2, expected, 1e-8); + +%!test # cellflag=true with delay: SISO continuous tf +%! sys = tf (1, [1, 1], "InputDelay", 0.3); +%! rational = tf (1, [1, 1]); +%! w = [0.1, 1, 5]; +%! H = __freqresp__ (sys, w, true); +%! H2 = __apply_freqresp_delay__ (sys, H, w, true); +%! rational_H = __freqresp__ (rational, w, true); +%! for k = 1:numel (w) +%! expected_k = rational_H{k} * exp (-1i * w(k) * 0.3); +%! assert (H2{k}, expected_k, 1e-10); +%! endfor diff --git a/inst/__frequency_response__.m b/inst/__frequency_response__.m index 87273fe4..a5b92784 100644 --- a/inst/__frequency_response__.m +++ b/inst/__frequency_response__.m @@ -111,10 +111,14 @@ ## compute frequency response H for all LTI models H_auto = cellfun (@__freqresp__, args(sys_idx), w_auto, {cellflag}, "uniformoutput", false); + H_auto = cellfun (@__apply_freqresp_delay__, args(sys_idx), H_auto, w_auto, ... + {cellflag}, "uniformoutput", false); if (auto) H = H_auto; else H = cellfun (@__freqresp__, args(sys_idx), w, {cellflag}, "uniformoutput", false); + H = cellfun (@__apply_freqresp_delay__, args(sys_idx), H, w, ... + {cellflag}, "uniformoutput", false); endif ## restore frequency vectors of FRD models in w diff --git a/inst/absorbDelay.m b/inst/absorbDelay.m new file mode 100644 index 00000000..98b283c6 --- /dev/null +++ b/inst/absorbDelay.m @@ -0,0 +1,151 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{sys} =} absorbDelay (@var{sys}) +## Absorb the delays of a discrete-time @var{tf} or @var{zpk} model into +## the denominator or poles by adding integrators at the origin. +## +## For continuous-time delays or @var{ss} models, an error is raised. +## +## @strong{Inputs} +## @table @var +## @item sys +## Discrete-time @var{tf} or @var{zpk} system with delays. +## @end table +## +## @strong{Outputs} +## @table @var +## @item sys +## Delay-free equivalent system (exact for discrete, integrators added). +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function sys = absorbDelay (sys) + + if (nargin != 1 || ! isa (sys, "lti")) + print_usage (); + endif + + if (! (isa (sys, "tf") || isa (sys, "zpk"))) + error ("absorbDelay: only tf and zpk models are supported (ss not yet implemented)"); + endif + + if (! hasdelay (sys)) + return; + endif + + if (isct (sys)) + error ("absorbDelay: continuous-time delays cannot be absorbed exactly; use pade() to approximate them first"); + endif + + total = totaldelay (sys); + origsys = sys; + + if (isa (sys, "zpk")) + [z, p, k, tsam] = zpkdata (sys); + [pr, pc] = size (p); + + for i = 1 : pr + for j = 1 : pc + n = total (i, j); + if (n > 0) + p{i,j} = [p{i,j}; zeros(n, 1)]; + endif + endfor + endfor + + sys = zpk (z, p, k, tsam); + else + [num, den, tsam] = tfdata (sys); + [pr, pc] = size (den); + + for i = 1 : pr + for j = 1 : pc + n = total (i, j); + if (n > 0) + den{i,j} = [den{i,j}, zeros(1, n)]; + endif + endfor + endfor + + sys = tf (num, den, tsam); + endif + + sys = set (sys, "lti", origsys); + sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + +endfunction + + +%!test # discrete SISO tf with InputDelay: exact equivalence via freqresp +%! # freqresp() does not yet apply a system's stored delay (that is a +%! # later, separate phase), so the expected response is built by hand: +%! # delay-free rational response times the z^-k phase factor. +%! sys = tf (1, [1 -0.5], 0.1, "InputDelay", 2); +%! sys2 = absorbDelay (sys); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! expected = freqresp (tf (1, [1 -0.5], 0.1), w) .* reshape (exp (-1i * w * 0.1 * 2), 1, 1, []); +%! assert (freqresp (sys2, w), expected, 1e-10); + +%!test # discrete MIMO tf with differing per-channel IODelay +%! sys = tf ({1, 1; 1, 1}, {[1 -0.5], [1 -0.6]; [1 -0.7], [1 -0.8]}, 0.1); +%! sys = set (sys, "IODelay", [1, 0; 0, 2]); +%! sys2 = absorbDelay (sys); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! rational = tf ({1, 1; 1, 1}, {[1 -0.5], [1 -0.6]; [1 -0.7], [1 -0.8]}, 0.1); +%! total = [1, 0; 0, 2]; +%! resp = freqresp (rational, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* exp (-1i * w * 0.1 * total(i,j)); +%! endfor +%! endfor +%! assert (freqresp (sys2, w), expected, 1e-8); + +%!test # zpk discrete with OutputDelay +%! sys = zpk ([], -0.5, 2, 0.1, "OutputDelay", 3); +%! sys2 = absorbDelay (sys); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! expected = freqresp (zpk ([], -0.5, 2, 0.1), w) .* reshape (exp (-1i * w * 0.1 * 3), 1, 1, []); +%! assert (freqresp (sys2, w), expected, 1e-10); + +%!test # no delay: no-op +%! sys = tf (1, [1 -0.5], 0.1); +%! sys2 = absorbDelay (sys); +%! [num1, den1] = tfdata (sys); +%! [num2, den2] = tfdata (sys2); +%! assert (num2, num1); +%! assert (den2, den1); +%! assert (hasdelay (sys2), false); + +%!test # continuous-time, no delay: no-op, no error +%! sys = tf (1, [1 1]); +%! sys2 = absorbDelay (sys); +%! assert (hasdelay (sys2), false); + +%!error absorbDelay (tf (1, [1 1], "InputDelay", 0.5)) + +%!error absorbDelay (ss (-1, 1, 1, 0, 0.1, "InputDelay", 1)) diff --git a/inst/bode.m b/inst/bode.m index 621e10c6..6363d515 100644 --- a/inst/bode.m +++ b/inst/bode.m @@ -287,3 +287,14 @@ %! G = tf (num, den); %! [~, pha] = bode (G); %! assert (pha(1), -88.828076660194938, 1e-9); + +%!test # delayed system: phase differs from delay-free counterpart by -w*delay +%! sys = tf (1, [1, 1], "InputDelay", 0.3); +%! rational = tf (1, [1, 1]); +%! w = [0.5, 2, 4]; +%! [mag1, pha1] = bode (sys, w); +%! [mag2, pha2] = bode (rational, w); +%! assert (mag1, mag2, 1e-10); +%! diff_rad = deg2rad (pha1(:) - pha2(:)); +%! expected_rad = -w(:) * 0.3; +%! assert (mod (diff_rad - expected_rad + pi, 2*pi) - pi, zeros (3, 1), 1e-8); diff --git a/inst/c2dOptions.m b/inst/c2dOptions.m new file mode 100644 index 00000000..a009fb4e --- /dev/null +++ b/inst/c2dOptions.m @@ -0,0 +1,107 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{opt} =} c2dOptions (@var{'key1'}, @var{value1}, @var{'key2'}, @var{value2}, @dots{}) +## Create c2d options struct @var{opt} from a number of key and value pairs. +## The struct contains configuration for continuous-to-discrete-time conversion. +## +## @strong{Properties} +## @table @var +## @item Method +## Discretization method (default: "zoh"). +## @item PrewarpFrequency +## Prewarping frequency (default: 0). +## @item DelayModeling +## How to handle delays: "delay" or "state" (default: "delay"). +## @end table +## +## @strong{Outputs} +## @table @var +## @item opt +## Struct with fields Method, PrewarpFrequency, DelayModeling. +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function opt = c2dOptions (varargin) + + opt = struct ("Method", "zoh", "PrewarpFrequency", 0, "DelayModeling", "delay"); + + if (rem (numel (varargin), 2)) + error ("c2dOptions: properties and values must come in pairs"); + endif + + for k = 1 : 2 : numel (varargin) + key = varargin{k}; + val = varargin{k+1}; + + switch (key) + case "Method" + if (! ischar (val)) + error ("c2dOptions: Method must be a string"); + endif + opt.Method = val; + + case "PrewarpFrequency" + if (! issample (val, 0)) + error ("c2dOptions: PrewarpFrequency must be a valid pre-warping frequency"); + endif + opt.PrewarpFrequency = val; + + case "DelayModeling" + val = lower (val); + if (! any (strcmp (val, {"delay", "state"}))) + error ("c2dOptions: DelayModeling must be 'delay' or 'state'"); + endif + opt.DelayModeling = val; + + otherwise + error ("c2dOptions: unknown property '%s'", key); + endswitch + endfor + +endfunction + + +%!test # defaults +%! opt = c2dOptions (); +%! assert (opt.Method, "zoh"); +%! assert (opt.PrewarpFrequency, 0); +%! assert (opt.DelayModeling, "delay"); + +%!test # override DelayModeling only +%! opt = c2dOptions ("DelayModeling", "state"); +%! assert (opt.DelayModeling, "state"); +%! assert (opt.Method, "zoh"); +%! assert (opt.PrewarpFrequency, 0); + +%!test # case-insensitive DelayModeling +%! opt = c2dOptions ("DelayModeling", "STATE"); +%! assert (opt.DelayModeling, "state"); + +%!test # override Method and PrewarpFrequency +%! opt = c2dOptions ("Method", "tustin", "PrewarpFrequency", 10); +%! assert (opt.Method, "tustin"); +%! assert (opt.PrewarpFrequency, 10); + +%!error c2dOptions ("DelayModeling", "bogus") +%!error c2dOptions ("NotAKey", 1) +%!error c2dOptions ("Method") diff --git a/inst/sigma.m b/inst/sigma.m index 048e70ab..f9eab25b 100644 --- a/inst/sigma.m +++ b/inst/sigma.m @@ -114,3 +114,11 @@ %! [sv_obs, w_obs] = sigma (ss (A, B, C, D), w); %!assert (sv_obs, sv_exp, 1e-4); %!assert (w_obs, w_exp, 1e-4); + +%!test # delayed SISO system: singular value (== magnitude for SISO) unaffected by delay +%! sys = tf (1, [1, 1], "InputDelay", 0.3); +%! rational = tf (1, [1, 1]); +%! w = [0.5, 2, 4]; +%! [sv1] = sigma (sys, w); +%! [sv2] = sigma (rational, w); +%! assert (sv1, sv2, 1e-10); From e292c851d72cc19dcfebd786bae3417d1e86c0a5 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Wed, 22 Jul 2026 22:15:46 +0800 Subject: [PATCH 04/26] control: apply stored delay in time-domain responses Add a time-response delay-shift helper and apply the stored delay's time shift in step/impulse/ramp/stepinfo/initial and via superposition in lsim. --- inst/__apply_timeresp_delay__.m | 59 +++++++++++ inst/__time_response__.m | 31 ++++++ inst/impulse.m | 24 +++++ inst/initial.m | 16 +++ inst/lsim.m | 181 +++++++++++++++++++++++++++----- inst/step.m | 10 ++ 6 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 inst/__apply_timeresp_delay__.m diff --git a/inst/__apply_timeresp_delay__.m b/inst/__apply_timeresp_delay__.m new file mode 100644 index 00000000..f7ad6afd --- /dev/null +++ b/inst/__apply_timeresp_delay__.m @@ -0,0 +1,59 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function y = __apply_timeresp_delay__ (y, delay) + + delay = round (delay); + + if (delay <= 0) + return; + endif + + n = rows (y); + + if (delay >= n) + y = zeros (size (y)); + else + y = [zeros(delay, columns (y)); y(1:end-delay, :)]; + endif + +endfunction + + +%!test # zero delay is a no-op +%! y = [1;2;3;4;5]; +%! assert (__apply_timeresp_delay__ (y, 0), y); + +%!test # integer-sample shift, zero-padded at start, truncated at end +%! y = [1;2;3;4;5]; +%! assert (__apply_timeresp_delay__ (y, 2), [0;0;1;2;3]); + +%!test # multi-column input shifts every column identically +%! y = [1 10; 2 20; 3 30; 4 40]; +%! assert (__apply_timeresp_delay__ (y, 1), [0 0; 1 10; 2 20; 3 30]); + +%!test # delay >= number of rows yields all-zero output of the same size +%! y = [1;2;3]; +%! assert (__apply_timeresp_delay__ (y, 5), zeros (3, 1)); + +%!test # floating-point noise on an already-integer delay is tolerated +%! y = [1;2;3;4]; +%! assert (__apply_timeresp_delay__ (y, 2 + 1e-10), [0;0;1;2]); diff --git a/inst/__time_response__.m b/inst/__time_response__.m index e1ac550f..16947a1a 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -172,12 +172,24 @@ sys_ct2dt = cellfun (@c2d, sys_ctss, dt(ct_idx), {response1}, "uniformoutput", false); sys_dt(ct_idx) = sys_ct2dt; + ## delay is already whole samples on sys_dt (c2d's default DelayModeling + ## rounds to samples at conversion time; already-discrete inputs are + ## documented in samples too), so every shift below is an exact + ## integer-sample array shift -- no interpolation needed. + delay_mat = cellfun (@totaldelay, sys_dt, "uniformoutput", false); + ## time vector: we have to consider the following cases: ## 1. ct system: last sample is tfinal (ensured by __sim_horizon__) ## 2. dt system ## a) nout > 0 (no plotting): last sample is less or equal tfinal ## b) nout > 0 (plotting): last sample is the first greater ## than tfinal (we need xlim([0,tfinal]) for the plot) + delay_ext = 0; + for i = 1:length (dt) + delay_ext = max (delay_ext, max (delay_mat{i}(:)) * dt{i}); + endfor + tfinal = tfinal + delay_ext; + if nout > 0 dt_extra = cell2mat (dt) .* ct_idx; else @@ -217,6 +229,25 @@ error ("time_response: invalid response type\n"); endswitch + ## apply each channel's total delay as a post-processing shift on y; + ## x (state trajectory) is intentionally left unshifted, see plan/spec. + for i = 1:n_sys + if (strcmp (response, "initial")) + outdelay = get (sys_dt{i}, "outputdelay"); + for row = 1:columns (y{i}) + y{i}(:, row) = __apply_timeresp_delay__ (y{i}(:, row), outdelay(row)); + endfor + else + p_i = size (y{i}, 2); + m_i = size (y{i}, 3); + for row = 1:p_i + for col = 1:m_i + y{i}(:, row, col) = __apply_timeresp_delay__ (y{i}(:, row, col), delay_mat{i}(row, col)); + endfor + endfor + endif + endfor + if (nout == 0) # display plot diff --git a/inst/impulse.m b/inst/impulse.m index e773f6b0..26355f10 100644 --- a/inst/impulse.m +++ b/inst/impulse.m @@ -149,3 +149,27 @@ %! impulse (G1,G2,1.5*G2,12); %! warning ("on", "Control:convert-to-state-space"); +%!test # MIMO IODelay: each (output,input) channel shifted independently +%! sys = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! sys = set (sys, "IODelay", [0.5, 0; 0, 1.0]); +%! sys_nodelay = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! ## fixed dt=0.05 is an exact divisor of both IODelay entries so that +%! ## c2d's default "delay" mode (which only rounds/absorbs whole-sample +%! ## delays and has no MIMO fractional-delay support) sees whole-sample +%! ## delays; an auto-chosen dt would not generally divide 0.5 s/1.0 s evenly. +%! [y, t] = impulse (sys, 8, 0.05); +%! [y0, t0] = impulse (sys_nodelay, t); +%! dt = t(2) - t(1); +%! total = [0.5, 0; 0, 1.0]; +%! for i = 1:2 +%! for j = 1:2 +%! k = round (total(i,j) / dt); +%! if (k == 0) +%! expected = y0(:, i, j); +%! else +%! expected = [zeros(k, 1); y0(1:end-k, i, j)]; +%! endif +%! assert (y(:, i, j), expected, 1e-6); +%! endfor +%! endfor + diff --git a/inst/initial.m b/inst/initial.m index 89f634c5..20b2ad51 100644 --- a/inst/initial.m +++ b/inst/initial.m @@ -204,3 +204,19 @@ %! %!assert (initial_c, initial_c_exp, 1e-4) %!assert (initial_d, initial_d_exp, 1e-4) + +%!test # OutputDelay shifts the free response; InputDelay/IODelay are inert (no input) +%! A = [-1 0; 0 -2]; +%! B = [1; 1]; +%! C = [1 0; 0 1]; +%! D = [0; 0]; +%! sys = ss (A, B, C, D, "OutputDelay", [0.4; 0], "InputDelay", 0.7); +%! sys_nodelay = ss (A, B, C, D); +%! x0 = [1; 2]; +%! [y, t] = initial (sys, x0, 5); +%! [y0, t0] = initial (sys_nodelay, x0, t); +%! dt = t(2) - t(1); +%! k = round (0.4 / dt); +%! expected_col1 = [zeros(k, 1); y0(1:end-k, 1)]; +%! assert (y(:, 1), expected_col1, 1e-6); +%! assert (y(:, 2), y0(:, 2), 1e-6); diff --git a/inst/lsim.m b/inst/lsim.m index f9d7fa79..ae8e6aaf 100644 --- a/inst/lsim.m +++ b/inst/lsim.m @@ -352,40 +352,104 @@ error ("lsim: input vector 'u' must have %d columns", m); endif - ## preallocate memory - y = zeros (urows, p); - x_arr = zeros (urows, n); - - ## initial conditions if (isempty (x0)) x0 = zeros (n, 1); elseif (n != length (x0) || ! is_real_vector (x0)) error ("lsim: 'x0' must be a vector with %d elements", n); endif - x = vec (x0); # make sure that x is a column vector + x0 = vec (x0); # make sure that x0 is a column vector - ## When discretization method was foh transform initial state into - ## the states representing the foh form. - ## The required matrix "Bd1" is stored by c2d in sys.userdata - if was_ct && (strcmp (method, "foh")) && (max (size (sys.userdata)) > 0) - x = x - sys.userdata * u(1,:)'; - endif + is_foh = was_ct && strcmp (method, "foh") && (max (size (sys.userdata)) > 0); + + if (! hasdelay (sys)) + + ## preallocate memory + y = zeros (urows, p); + x_arr = zeros (urows, n); + + x = x0; + + ## When discretization method was foh transform initial state into + ## the states representing the foh form. + ## The required matrix "Bd1" is stored by c2d in sys.userdata + if (is_foh) + x = x - sys.userdata * u(1,:)'; + endif + + ## simulation + for k = 1 : urows + y(k, :) = C * x + D * u(k, :).'; + x_arr(k, :) = x; + x = A * x + B * u(k, :).'; + endfor + + ## When discretization method was foh transform back from foh states + ## into original state + if (is_foh) + x_arr = x_arr + u * sys.userdata'; + endif + + else + + ## Delayed system: superposition of a zero-input term (from x0 alone, + ## only OutputDelay applies -- there is no input for InputDelay/IODelay + ## to act on) plus one zero-state term per input channel (from u(:,j) + ## alone, shifted by that channel's full totaldelay, which already + ## includes OutputDelay). Both terms use the same delay-free A,B,C,D, + ## so their states add linearly and are left unshifted (see plan/spec). + total = totaldelay (sys); # p-by-m, whole samples + outdelay = get (sys, "outputdelay"); # p-by-1, whole samples + + y = zeros (urows, p); + x_arr = zeros (urows, n); + + ## zero-input term: driven by x0 alone, with no associated input + ## sample, so no foh initial-state correction applies here -- the + ## foh correction for u(1,:) is fully accounted for by the per-column + ## offsets below (each column contributes its own -Bd1(:,j)*u(1,j) + ## piece); applying it here too would double-count it. + x = x0; + y_free = zeros (urows, p); + for k = 1 : urows + y_free(k, :) = C * x; + x_arr(k, :) = x; + x = A * x; + endfor + for row = 1 : p + y(:, row) += __apply_timeresp_delay__ (y_free(:, row), outdelay(row)); + endfor + + ## zero-state term, one input channel at a time + for j = 1 : m + uj = zeros (urows, m); + uj(:, j) = u(:, j); + + xj = zeros (n, 1); + if (is_foh) + xj = -sys.userdata(:, j) * u(1, j); + endif + + y_j = zeros (urows, p); + x_arr_j = zeros (urows, n); + for k = 1 : urows + y_j(k, :) = C * xj + D * uj(k, :).'; + x_arr_j(k, :) = xj; + xj = A * xj + B * uj(k, :).'; + endfor + if (is_foh) + x_arr_j = x_arr_j + uj * sys.userdata'; + endif + + x_arr += x_arr_j; + for row = 1 : p + y(:, row) += __apply_timeresp_delay__ (y_j(:, row), total(row, j)); + endfor + endfor - ## simulation - for k = 1 : urows - y(k, :) = C * x + D * u(k, :).'; - x_arr(k, :) = x; - x = A * x + B * u(k, :).'; - endfor - - ## When discretization method was foh transform back from foh states - ## into original state - if was_ct && (strcmp (method, "foh")) && (max (size (sys.userdata)) > 0) - x_arr = x_arr + u * sys.userdata'; endif - endfunction +endfunction %!test @@ -414,6 +478,75 @@ %! assert (y1,y3,1e-4); %! assert (y1,y4,1e-4); +%!test # SISO InputDelay: lsim output matches a manually-shifted delay-free lsim +%! sys = tf (1, [1 1], "InputDelay", 0.3); +%! sys_nodelay = tf (1, [1 1]); +%! t = 0:0.05:5; +%! u = sin (t)'; +%! [y, tt] = lsim (sys, u, t); +%! [y0, tt0] = lsim (sys_nodelay, u, t); +%! dt = tt(2) - tt(1); +%! k = round (0.3 / dt); +%! expected = [zeros(k, 1); y0(1:end-k)]; +%! assert (y, expected, 1e-6); + +%!test # MIMO IODelay: each output channel is the sum of per-input contributions, +%! # each shifted by its own total delay +%! sys = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! sys = set (sys, "IODelay", [0.2, 0; 0, 0.4]); +%! sys_nodelay = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! t = (0:0.05:5)'; +%! u = [sin(t), cos(t)]; +%! [y, tt] = lsim (sys, u, t); +%! [y0, tt0] = lsim (sys_nodelay, u, t); +%! dt = tt(2) - tt(1); +%! total = [0.2, 0; 0, 0.4]; +%! ## reconstruct expected per-output response by re-simulating each input alone +%! u1 = [u(:,1), zeros(size(t))]; +%! u2 = [zeros(size(t)), u(:,2)]; +%! [y01] = lsim (sys_nodelay, u1, t); +%! [y02] = lsim (sys_nodelay, u2, t); +%! k11 = round (total(1,1)/dt); k12 = round (total(1,2)/dt); +%! k21 = round (total(2,1)/dt); k22 = round (total(2,2)/dt); +%! if (k11 == 0) +%! s11 = y01(:,1); +%! else +%! s11 = [zeros(k11,1); y01(1:end-k11,1)]; +%! endif +%! if (k12 == 0) +%! s12 = y02(:,1); +%! else +%! s12 = [zeros(k12,1); y02(1:end-k12,1)]; +%! endif +%! if (k21 == 0) +%! s21 = y01(:,2); +%! else +%! s21 = [zeros(k21,1); y01(1:end-k21,2)]; +%! endif +%! if (k22 == 0) +%! s22 = y02(:,2); +%! else +%! s22 = [zeros(k22,1); y02(1:end-k22,2)]; +%! endif +%! expected1 = s11 + s12; +%! expected2 = s21 + s22; +%! assert (y(:,1), expected1, 1e-6); +%! assert (y(:,2), expected2, 1e-6); + +%!test # nonzero x0 on a delayed ss system: zero-input term shifted by OutputDelay only +%! A = -1; B = 1; C = 1; D = 0; +%! sys = ss (A, B, C, D, "OutputDelay", 0.3); +%! sys_nodelay = ss (A, B, C, D); +%! t = (0:0.05:5)'; +%! u = zeros (size (t)); # isolate the zero-input term +%! x0 = 2; +%! [y, tt] = lsim (sys, u, t, x0); +%! [y0, tt0] = lsim (sys_nodelay, u, t, x0); +%! dt = tt(2) - tt(1); +%! k = round (0.3 / dt); +%! expected = [zeros(k, 1); y0(1:end-k)]; +%! assert (y, expected, 1e-6); + %!demo %! clf; %! A = [-3 0 0; diff --git a/inst/step.m b/inst/step.m index 84e989bb..55a19d6e 100644 --- a/inst/step.m +++ b/inst/step.m @@ -105,3 +105,13 @@ %! step (G1,G2,1.5*G2); %! warning ("on", "Control:convert-to-state-space"); +%!test # SISO InputDelay: step response is delayed by exactly the rounded sample count +%! sys = tf (1, [1 1], "InputDelay", 0.3); +%! sys_nodelay = tf (1, [1 1]); +%! [y, t] = step (sys, 5); +%! [y0, t0] = step (sys_nodelay, t); +%! dt = t(2) - t(1); +%! k = round (0.3 / dt); +%! expected = [zeros(k, 1); y0(1:end-k)]; +%! assert (y, expected, 1e-6); + From bbfc8ce3c35074c875b563ae3ee25679a7624371 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Wed, 22 Jul 2026 23:19:53 +0800 Subject: [PATCH 05/26] ss: add InternalDelay property with feedback/connect/freqresp support Add the InternalDelay property and delay-port storage fields on ss; support delay-carrying loops via InternalDelay in feedback/connect, gating delay-port absorption on M participation (fixing a regression where absorption was applied outside the topology feedback/connect actually build) and guarding against multi-output IODelay folding and other non-InternalDelay-aware entry points; evaluate ss InternalDelay via LFT closure in freqresp. --- inst/@lti/__lti_group__.m | 17 ++- inst/@lti/__lti_prune__.m | 11 ++ inst/@lti/c2d.m | 6 ++ inst/@lti/connect.m | 5 + inst/@lti/d2c.m | 6 ++ inst/@lti/feedback.m | 70 ++++++++++++- inst/@ss/__ctranspose__.m | 11 ++ inst/@ss/__freqresp__.m | 86 ++++++++++++++- inst/@ss/__get__.m | 2 + inst/@ss/__minreal__.m | 11 ++ inst/@ss/__prescale__.m | 11 ++ inst/@ss/__set__.m | 15 +++ inst/@ss/__sys2tf__.m | 13 +++ inst/@ss/__sys_connect__.m | 208 ++++++++++++++++++++++++++++++++++++- inst/@ss/__sys_group__.m | 34 ++++++ inst/@ss/__sys_inverse__.m | 11 ++ inst/@ss/__sys_keys__.m | 8 +- inst/@ss/__sys_prune__.m | 10 ++ inst/@ss/__transpose__.m | 11 ++ inst/@ss/ss.m | 40 ++++++- inst/__time_response__.m | 13 +++ inst/hasinternaldelay.m | 77 ++++++++++++++ inst/lsim.m | 6 ++ 23 files changed, 669 insertions(+), 13 deletions(-) create mode 100644 inst/hasinternaldelay.m diff --git a/inst/@lti/__lti_group__.m b/inst/@lti/__lti_group__.m index 52a62866..1a273de4 100644 --- a/inst/@lti/__lti_group__.m +++ b/inst/@lti/__lti_group__.m @@ -54,9 +54,20 @@ ## retlti.outgroup remains empty struct endif - retlti.indelay = zeros (numel (retlti.inname), 1); - retlti.outdelay = zeros (numel (retlti.outname), 1); - retlti.iodelay = zeros (numel (retlti.outname), numel (retlti.inname)); + if (strcmpi (dim, "blkdiag")) + ## Block-diagonal append: I/O delays concatenate with zero cross-terms, + ## exactly like the state/b/c/d blocks. (Previously these were dropped, + ## which silently discarded delays through append/feedback/connect.) + retlti.indelay = [lti1.indelay(:); lti2.indelay(:)]; + retlti.outdelay = [lti1.outdelay(:); lti2.outdelay(:)]; + retlti.iodelay = blkdiag (lti1.iodelay, lti2.iodelay); + else + ## horzcat/vertcat/times share inputs and/or outputs; a general delay + ## merge is ambiguous, so keep the historical zeroing for those modes. + retlti.indelay = zeros (numel (retlti.inname), 1); + retlti.outdelay = zeros (numel (retlti.outname), 1); + retlti.iodelay = zeros (numel (retlti.outname), numel (retlti.inname)); + endif if (lti1.tsam == lti2.tsam) retlti.tsam = lti1.tsam; diff --git a/inst/@lti/__lti_prune__.m b/inst/@lti/__lti_prune__.m index 6fffdb4b..498e361d 100644 --- a/inst/@lti/__lti_prune__.m +++ b/inst/@lti/__lti_prune__.m @@ -60,6 +60,17 @@ lti.outname = lti.outname(out_idx); lti.inname = lti.inname(in_idx); + ## delays follow the selected I/O (previously left at their old size, + ## harmless only while they were always zero) + if (! (ischar (out_idx) && strcmp (out_idx, ":"))) + lti.outdelay = lti.outdelay(out_idx); + lti.iodelay = lti.iodelay(out_idx, :); + endif + if (! (ischar (in_idx) && strcmp (in_idx, ":"))) + lti.indelay = lti.indelay(in_idx); + lti.iodelay = lti.iodelay(:, in_idx); + endif + endfunction diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index 3d0f6751..8a2e04ad 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -72,6 +72,10 @@ error ("c2d: system is already discrete-time"); endif + if (hasinternaldelay (sys)) + error ("c2d: InternalDelay is not yet supported"); + endif + if (! issample (tsam)) error ("c2d: second argument is not a valid sample time"); endif @@ -533,3 +537,5 @@ %!error c2d (tf (1, [1 1]), 0.5, c2dOptions (), 100) +%!error c2d (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), 0.5, "zoh") + diff --git a/inst/@lti/connect.m b/inst/@lti/connect.m index 0550f212..cf8c6fd4 100644 --- a/inst/@lti/connect.m +++ b/inst/@lti/connect.m @@ -67,6 +67,11 @@ ## inputs @var{in}. ## @end table ## +## @strong{Remarks} +## Input, output, and I/O delays on the operand systems are supported. If a delay +## is trapped inside a closed loop, it will appear as an @code{InternalDelay} +## property on the result. +## ## @strong{Example} ## ## Consider the control loop with reference r, disturbances d1, d2 diff --git a/inst/@lti/d2c.m b/inst/@lti/d2c.m index ad9a4552..fa00aed7 100644 --- a/inst/@lti/d2c.m +++ b/inst/@lti/d2c.m @@ -65,6 +65,10 @@ error ("d2c: system is already continuous-time"); endif + if (hasinternaldelay (sys)) + error ("d2c: InternalDelay is not yet supported"); + endif + if (! ischar (method)) error ("d2c: second argument is not a string"); endif @@ -175,3 +179,5 @@ %! sys = tf (1, [1 1], "InputDelay", 1.0); %! sys2 = d2c (c2d (sys, 0.5, "zoh"), "zoh"); %! assert (sys2.InputDelay, 1.0, 1e-10); + +%!error d2c (set (ss (-1, 1, 1, 0, 0.1), "internaldelay", 0.5), "zoh") diff --git a/inst/@lti/feedback.m b/inst/@lti/feedback.m index a87d195e..97f39a27 100644 --- a/inst/@lti/feedback.m +++ b/inst/@lti/feedback.m @@ -51,6 +51,11 @@ ## Resulting @acronym{LTI} model. ## @end table ## +## @strong{Remarks} +## Both operands may contain input, output, and I/O delays. If a delay is trapped +## inside the closed loop, it will appear as an @code{InternalDelay} property on the +## (always-@code{ss}) result. +## ## @strong{Block Diagram} ## @example ## @group @@ -178,8 +183,14 @@ in_idx = 1 : m1; out_idx = 1 : p1; - if ((isa (sys1, "lti") && hasdelay (sys1)) || (isa (sys2, "lti") && hasdelay (sys2))) - error ("feedback: nonzero delays require internal delay support (not yet implemented)"); + ## A delay trapped inside the loop cannot be represented by tf/zpk, which + ## carry no InternalDelay. Force both operands to state-space so the delay + ## survives as an internal delay port (matches Matlab: such a result is ss). + d1 = isa (sys1, "lti") && (hasdelay (sys1) || hasinternaldelay (sys1)); + d2 = isa (sys2, "lti") && (hasdelay (sys2) || hasinternaldelay (sys2)); + if (d1 || d2) + sys1 = ss (sys1); + sys2 = ss (sys2); endif sys = __sys_group__ (sys1, sys2); @@ -287,4 +298,57 @@ %! s = feedback (s1); %! assert (hasdelay (s), false); -%!error feedback (tf (1, [1 1], "InputDelay", 0.1)) +## Internal delay: an input delay closing a feedback loop is no longer an +## error; it is absorbed into the closed loop's InternalDelay (the delay +## e^{-0.1 s} lives inside the loop 1/(s+1+e^{-0.1 s}) and cannot be +## expressed as a pure I/O time-shift). +%!test +%! s = feedback (tf (1, [1 1], "InputDelay", 0.1)); +%! assert (isa (s, "ss")); # forced to ss to carry delay +%! assert (get (s, "internaldelay"), 0.1, 1e-10); +%! assert (hasinternaldelay (s), true); + +## Scalar loop with an IODelay in the forward path. A single scalar delay +## closing a loop is irreducible, so the closed-loop InternalDelay is just T. +## Hand derivation: G = e^{-sT} g(s), C = c(s), L = G C. +## Closed loop feedback(G,C) = G/(1+GC); the e^{-sT} sits inside the loop, +## so InternalDelay = T (0.3 here) and no residual I/O delay remains. +%!test +%! T = 0.3; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! C = ss (-2, 1, 1, 0); +%! cl = feedback (G, C); +%! assert (get (cl, "internaldelay"), T, 1e-10); +%! assert (get (cl, "iodelay"), 0, 1e-10); # absorbed, no residual + +## feedback() and connect() must agree on InternalDelay for equivalent +## topologies, since they share __sys_connect__. +%!test +%! T = 0.3; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! C = ss (-2, 1, 1, 0); +%! cl_fb = feedback (G, C); +%! ## build the same negative feedback loop with connect(): +%! ## inputs : [r ; u_G ; u_C] = grouped [G.in ; C.in] plus external r +%! ## here use index-based cm on append (G, C): +%! ## in 1 -> G, in 2 -> C ; out 1 -> G, out 2 -> C +%! Gall = append (G, C); +%! ## u_G = r - y_C (row: input 1 fed by -output 2) +%! ## u_C = y_G (row: input 2 fed by +output 1) +%! cm = [1, -2; 2, 1]; +%! cl_cn = connect (Gall, cm, 1, 1); +%! assert (get (cl_cn, "internaldelay"), get (cl_fb, "internaldelay"), 1e-10); + +## MIMO plant whose IODelay column has more than one nonzero entry (one +## input feeding two differently-delayed outputs). Folding IODelay into a +## single per-input series delay is only exact when an input drives a +## single delayed output; this topology is outside that scope and must +## error clearly rather than silently return a wrong InternalDelay. +%!error +%! a = -eye (2); +%! b = eye (2); +%! c = eye (2); +%! d = zeros (2); +%! iod = [0.1, 0; 0.2, 0]; # column 1 has two nonzero entries +%! G = ss (a, b, c, d, "IODelay", iod); +%! feedback (G); diff --git a/inst/@ss/__ctranspose__.m b/inst/@ss/__ctranspose__.m index 410052fc..1fc27fc9 100644 --- a/inst/@ss/__ctranspose__.m +++ b/inst/@ss/__ctranspose__.m @@ -24,6 +24,10 @@ function sys = __ctranspose__ (sys, ct) + if (hasinternaldelay (sys)) + error ("ss: ctranspose: InternalDelay is not yet supported"); + endif + a = sys.a; b = sys.b; c = sys.c; @@ -52,3 +56,10 @@ endif endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! t = sys'; +%! assert (hasinternaldelay (t), false); + +%!error ctranspose (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/__freqresp__.m b/inst/@ss/__freqresp__.m index 6a3d254f..bc0604f0 100644 --- a/inst/@ss/__freqresp__.m +++ b/inst/@ss/__freqresp__.m @@ -24,7 +24,10 @@ function H = __freqresp__ (sys, w, cellflag = false) - if (sys.scaled == false) + ## Do not prescale internal-delay systems: prescale() rebuilds the ss without + ## the delay ports (b2/c2/d12/d21/d22/internaldelay), which would silently drop + ## the internal delay. Prescaling is only a numerical-conditioning nicety. + if (sys.scaled == false && ! hasinternaldelay (sys)) sys = prescale (sys); endif @@ -42,7 +45,40 @@ warning_near_sing = warning ("query", warning_id_near_sing); warning ("off", warning_id_near_sing); - H = arrayfun (@(x) c/(x*e - a)*b + d, s, "uniformoutput", false); + if (hasinternaldelay (sys)) + ## LFT closure for internal delay: the ordinary dynamics (a,b,c,d) are the + ## (A,B1,C1,D11) block; the delay ports are (b2,c2,d12,d21,d22). The delay + ## law is w = Delta z with Delta = diag(exp(-jw*tau)) (continuous) or + ## diag(exp(-jw*tsam*tau)) (discrete), tau = sys.internaldelay. + ## T11 = D11 + C1 (jwI-A)^-1 B1 T12 = D12 + C1 (jwI-A)^-1 B2 + ## T21 = D21 + C2 (jwI-A)^-1 B1 T22 = D22 + C2 (jwI-A)^-1 B2 + ## H = T11 + T12 Delta (I - T22 Delta)^-1 T21 + b2 = sys.b2; + c2 = sys.c2; + d12 = sys.d12; + d21 = sys.d21; + d22 = sys.d22; + tau = sys.internaldelay(:); + nd = numel (tau); + Ind = eye (nd); + if (isct (sys)) + delta = @(wk) exp (-1i * wk * tau); + else + delta = @(wk) exp (-1i * wk * abs (tsam) * tau); + endif + H = cell (size (s)); + for k = 1 : numel (s) + M = s(k)*e - a; + T11 = c /M*b + d; + T12 = c /M*b2 + d12; + T21 = c2/M*b + d21; + T22 = c2/M*b2 + d22; + D = diag (delta (w(k))); + H{k} = T11 + T12*D*((Ind - T22*D) \ T21); + endfor + else + H = arrayfun (@(x) c/(x*e - a)*b + d, s, "uniformoutput", false); + endif warning (warning_near_sing.state, warning_id_near_sing); @@ -51,3 +87,49 @@ endif endfunction + + +%!test # no internal delay: byte-for-byte the ordinary D+C(jwI-A)^-1 B path +%! sys = ss (-2, 1, 1, 0); +%! w = [0.1, 1, 5, 20]; +%! H = __freqresp__ (sys, w); +%! a = -2; b = 1; c = 1; d = 0; +%! ref = arrayfun (@(x) c/(1i*x - a)*b + d, w, "uniformoutput", false); +%! ref = cat (3, ref{:}); +%! assert (H, ref); + +%!test # scalar unity-feedback loop with IODelay: 1/(1+G e^{-jwT})-style closure +%! ## G(s) = 1/(s+1) with a loop delay T = 0.3 s; L = feedback (G) closes +%! ## the loop, trapping the delay internally. Closed loop: +%! ## H(jw) = G e^{-jwT} / (1 + G e^{-jwT}), G = 1/(jw+1). +%! T = 0.3; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! assert (hasinternaldelay (L)); +%! assert (get (L, "internaldelay"), T, 1e-12); +%! w = [0.05, 0.5, 1.7, 4, 11]; +%! H = __freqresp__ (L, w); +%! H = reshape (H, 1, []); +%! Gd = (1 ./ (1i*w + 1)) .* exp (-1i * w * T); +%! expected = Gd ./ (1 + Gd); +%! assert (H, expected, 1e-9); + +%!test # cellflag=true returns the same values in cell form +%! T = 0.25; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! w = [0.3, 2, 6]; +%! Hc = __freqresp__ (L, w, true); +%! Ha = __freqresp__ (L, w, false); +%! for k = 1:numel (w) +%! assert (Hc{k}, Ha(:,:,k), 1e-12); +%! endfor + +%!test # freqresp() top-level wrapper agrees on an internal-delay system +%! T = 0.4; +%! G = ss (-2, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! w = [0.1, 1, 3]; +%! Gd = (1 ./ (1i*w + 2)) .* exp (-1i * w * T); +%! expected = reshape (Gd ./ (1 + Gd), 1, 1, []); +%! assert (freqresp (L, w), expected, 1e-9); diff --git a/inst/@ss/__get__.m b/inst/@ss/__get__.m index b06a6683..66fad7e2 100644 --- a/inst/@ss/__get__.m +++ b/inst/@ss/__get__.m @@ -39,6 +39,8 @@ val = sys.stname; case "scaled" val = sys.scaled; + case {"internaldelay", "internal_delay"} + val = sys.internaldelay; otherwise error ("ss: get: invalid key name '%s'", key); endswitch diff --git a/inst/@ss/__minreal__.m b/inst/@ss/__minreal__.m index cc7232ce..ebe9f0ce 100644 --- a/inst/@ss/__minreal__.m +++ b/inst/@ss/__minreal__.m @@ -27,6 +27,10 @@ function retsys = __minreal__ (sys, tol) + if (hasinternaldelay (sys)) + error ("ss: minreal: InternalDelay is not yet supported"); + endif + if (strcmpi (tol, "def")) tol = 0; elseif (tol > 1) @@ -52,3 +56,10 @@ endif endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! r = minreal (sys); +%! assert (hasinternaldelay (r), false); + +%!error minreal (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/__prescale__.m b/inst/@ss/__prescale__.m index d9f33f68..8fa57d95 100644 --- a/inst/@ss/__prescale__.m +++ b/inst/@ss/__prescale__.m @@ -27,6 +27,10 @@ function [retsys, lscale, rscale] = __prescale__ (sys, optarg = 0.0) + if (hasinternaldelay (sys)) + error ("ss: prescale: InternalDelay is not yet supported"); + endif + if (isempty (sys.e)) [a, b, c, ~, scale] = __sl_tb01id__ (sys.a, sys.b, sys.c, optarg); retsys = ss (a, b, c, sys.d); @@ -41,3 +45,10 @@ retsys.lti = sys.lti; # retain i/o names and tsam endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! r = prescale (sys); +%! assert (hasinternaldelay (r), false); + +%!error prescale (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/__set__.m b/inst/@ss/__set__.m index 6bc25f59..d08c3752 100644 --- a/inst/@ss/__set__.m +++ b/inst/@ss/__set__.m @@ -60,6 +60,21 @@ error ("ss: set: key 'scaled' must be a scalar logical"); endif + case {"internaldelay", "internal_delay"} + if (isempty (val)) + sys.internaldelay = []; # avoid [](nx0) or [](0xn) + elseif (is_real_vector (val)) + if (! isempty (sys.b2)) + q = columns (sys.b2); + if (numel (val) != q) + error ("ss: set: key 'internaldelay' must have %d elements", q); + endif + endif + sys.internaldelay = val(:); + else + error ("ss: set: key 'internaldelay' must be a real vector"); + endif + otherwise error ("ss: set: invalid key name '%s'", key); endswitch diff --git a/inst/@ss/__sys2tf__.m b/inst/@ss/__sys2tf__.m index 22e543ba..b90a3b79 100644 --- a/inst/@ss/__sys2tf__.m +++ b/inst/@ss/__sys2tf__.m @@ -24,6 +24,10 @@ function [retsys, retlti] = __sys2tf__ (sys) + if (hasinternaldelay (sys)) + error ("ss: sys2tf: conversion to tf/zpk is not supported for models with InternalDelay"); + endif + sg_flag = false; # static gain flag try @@ -84,3 +88,12 @@ endif endfunction + + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! g = tf (sys); +%! assert (hasinternaldelay (sys), false); + +%!error tf (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +%!error zpk (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/__sys_connect__.m b/inst/@ss/__sys_connect__.m index 74db05df..45523a53 100644 --- a/inst/@ss/__sys_connect__.m +++ b/inst/@ss/__sys_connect__.m @@ -68,6 +68,34 @@ ## TODO: investigate whether all-zero sys.e make sense, ## before disabling them in a rash decision. + ## ---------------------------------------------------------------------- + ## Delay-aware dispatch. + ## + ## The grouped system handed to us may carry + ## (a) lti-level I/O delays (InputDelay / OutputDelay / IODelay), and/or + ## (b) pre-existing internal delay ports (b2/c2/d12/d21/d22, from an + ## earlier feedback/connect call carried through __sys_group__). + ## + ## When NEITHER is present -- the overwhelming common case -- we run the + ## original, purely-rational loop-closure algebra below, unchanged, so that + ## delay-free feedback()/connect() results are byte-for-byte identical to + ## before this feature existed. + ## + ## Otherwise we hand off to __sys_connect_delay__ (see below), which first + ## absorbs every nonzero I/O delay into an internal series delay port (an + ## exact, transfer-preserving rewrite) and then closes the loop with a + ## port-aware generalisation of the very same (I - D M)^{-1} algebra. + ## ---------------------------------------------------------------------- + + has_ports = ! isempty (sys.b2); + [id, od, iod] = get (sys, "inputdelay", "outputdelay", "iodelay"); + has_iodelay = any (id(:) != 0) || any (od(:) != 0) || any (iod(:) != 0); + + if (has_ports || has_iodelay) + sys = __sys_connect_delay__ (sys, m, id, od, iod); + return; + endif + a = sys.a; b = sys.b; c = sys.c; @@ -125,7 +153,185 @@ sys.e = blkdiag (sys.e, zeros (pp)); sys.stname = [sys.stname; repmat({""}, pp, 1)]; endif - + endif endfunction + + +## -*- texinfo -*- +## Delay-aware loop closure. Called by __sys_connect__ only when the grouped +## system carries I/O delays or pre-existing internal delay ports. +## +## Strategy: +## +## 1. ABSORB every nonzero I/O delay of the grouped plant into an internal +## series delay port. A series delay port on input j is the exact rewrite +## +## z = e_j , w = z(t - tau) , plant input j driven by w +## +## (the direct e_j -> plant coupling b(:,j)/d(:,j) is moved onto the port), +## and analogously on the output side. This is a transfer-preserving +## identity on the open-loop plant; it merely re-expresses an I/O time +## shift as a (w = z(t-tau)) internal port so the subsequent purely +## algebraic loop closure sees a delay-free rational plant. The absorbed +## lti delays are then zeroed. +## +## 2. CLOSE THE LOOP with a port-aware generalisation of the ordinary +## Z = (I - D M)^{-1} algebra: the connection matrix M is now entirely +## delay-free (all delays live in ports), so M is folded into the solve +## exactly as before, while the delay ports (B2/C2/D12/D21/D22) are carried +## through the same Z. +## +## SCOPE / partition decision: only I/O delay on a channel that the +## connection matrix M actually references becomes an internal port; a +## delay on a channel M never touches (not part of any loop or cascade +## wiring) is left as an ordinary external I/O time-shift, unchanged. +## IODelay(i,j) is folded into input j's effective series delay, which is exact +## when input j feeds a single output (the block-diagonal-of-SISO topology that +## feedback()/connect() actually build). Descriptor systems (nonempty E) and a +## singular delay-free connection (I - D M) with delays present are outside this +## scope and error clearly rather than return a wrong answer. +function sys = __sys_connect_delay__ (sys, m, id, od, iod) + + if (! isempty (sys.e)) + error ("__sys_connect__: internal delay with descriptor (E) models not supported"); + endif + + a = sys.a; + b = sys.b; + c = sys.c; + d = sys.d; + + n = rows (a); + [p, mm] = size (d); + + ## ---- initialise delay-port blocks (existing ports, or empty) ---------- + if (isempty (sys.b2)) + B2 = zeros (n, 0); + C2 = zeros (0, n); + D12 = zeros (p, 0); + D21 = zeros (0, mm); + D22 = zeros (0, 0); + tau = zeros (0, 1); + else + B2 = sys.b2; + C2 = sys.c2; + D12 = sys.d12; + D21 = sys.d21; + D22 = sys.d22; + tau = sys.internaldelay(:); + endif + + ## ---- 1. absorb I/O delays into series ports --------------------------- + ## effective per-input series delay: InputDelay(j) + column j of IODelay + ## (IODelay folding is exact when input j drives a single output) + if (any (sum (iod != 0, 1) > 1)) + error (["__sys_connect__: an input feeding multiple differently-delayed ", ... + "outputs through IODelay is not yet supported"]); + endif + inport = id(:).' + sum (iod, 1); # 1-by-m + outport = od(:).'; # 1-by-p + + ## A channel's I/O delay is promoted into an internal series delay port ONLY + ## when that channel actually participates in the connection matrix M (i.e. + ## it is genuinely cascaded or fed back). A delay on a channel that M never + ## touches -- e.g. the free external InputDelay of sys1 in a feedforward + ## sys2*sys1 cascade, whose input row of M is all zero -- is NOT a trapped + ## internal delay: it must pass straight through as an ordinary I/O time + ## shift, exactly as before internal-delay support existed. Absorbing it + ## unconditionally would force a spurious internal-delay realisation (and + ## block tf/zpk conversion) for a plain cascade. M has one row per input + ## channel and one column per output channel (e = u + M y). + in_touched = any (m != 0, 2).'; # 1-by-m: input row j has a nonzero + out_touched = any (m != 0, 1); # 1-by-p: output column i has a nonzero + + absorbed_in = false (1, mm); + absorbed_out = false (1, p); + + ## input-side ports first (they append columns that output ports may read) + for j = 1 : mm + if (inport(j) > 0 && in_touched(j)) + q = columns (B2); + B2 = [B2, b(:,j)]; + D12 = [D12, d(:,j)]; + C2 = [C2; zeros(1,n)]; + D21 = [D21; full(sparse(1,j,1,1,mm))]; + D22 = [D22, zeros(q,1); zeros(1,q), 0]; + tau = [tau; inport(j)]; + b(:,j) = 0; # input j now reaches plant via port + d(:,j) = 0; + absorbed_in(j) = true; + endif + endfor + + ## output-side ports (read the possibly-updated c/d/D12 rows) + for i = 1 : p + if (outport(i) > 0 && out_touched(i)) + q = columns (B2); + ## new port picks up the internal (pre-delay) output-i signal: + ## z = c(i,:) x + d(i,:) e + D12(i,:) w_existing + B2 = [B2, zeros(n,1)]; # port does not drive states + C2 = [C2; c(i,:)]; + D21 = [D21; d(i,:)]; + D22 = [D22, zeros(q,1); D12(i,:), 0]; + ## external output i is now emitted through the delayed port only + c(i,:) = 0; + d(i,:) = 0; + D12(i,:) = 0; + newcol = zeros (p, 1); + newcol(i) = 1; # external output i = new (delayed) w + D12 = [D12, newcol]; + tau = [tau; outport(i)]; + absorbed_out(i) = true; + endif + endfor + + ## Clear ONLY the I/O delays that were actually absorbed into ports; delays + ## on channels that M never touched pass through unchanged. IODelay(i,j) is + ## folded into input j's series delay, so it is cleared exactly when input j + ## was absorbed. + id_new = id(:); id_new(absorbed_in) = 0; + od_new = od(:); od_new(absorbed_out) = 0; + iod_new = iod; iod_new(:, absorbed_in) = 0; + sys = set (sys, "inputdelay", id_new, ... + "outputdelay", od_new, ... + "iodelay", iod_new); + + ## ---- 2. port-aware algebraic loop closure e = u + M y ---------------- + z = eye (p) - d*m; + + if (rcond (z) < eps) + error ("__sys_connect__: singular algebraic loop with internal delays not supported"); + endif + + sys.a = a + b*m/z*c; # F + sys.b = b + b*m/z*d; # G + sys.c = z\c; # H + sys.d = z\d; # J + + sys.b2 = B2 + b*m/z*D12; + sys.c2 = C2 + D21*m/z*c; + sys.d12 = z\D12; + sys.d21 = D21 + D21*m/z*d; + sys.d22 = D22 + D21*m/z*D12; + sys.internaldelay = tau; + + sys.e = []; + +endfunction + + +## Regression: a delay on a channel the connection matrix M never touches must +## pass straight through as an ordinary I/O delay -- NOT be promoted into an +## internal delay port. In a feedforward cascade sys2*sys1, sys1's InputDelay +## is a free external input (its input row of M is all zero), so the mtimes +## result must keep it as InputDelay and carry no internal delay. This is the +## exact situation that made @lti/series.m's MIMO-guard test spuriously fail +## with a sys2tf/InternalDelay error before this narrowing was applied. +%!test +%! s1 = ss (-1, 1, 1, 0, "InputDelay", 0.3); +%! s2 = ss (-2, 1, 1, 0); +%! sys = s2 * s1; # feedforward cascade -> mtimes +%! assert (get (sys, "inputdelay"), 0.3, 1e-12); +%! assert (isempty (get (sys, "internaldelay"))); diff --git a/inst/@ss/__sys_group__.m b/inst/@ss/__sys_group__.m index 605bc299..8aee676e 100644 --- a/inst/@ss/__sys_group__.m +++ b/inst/@ss/__sys_group__.m @@ -66,4 +66,38 @@ retsys.stname = [sys1.stname; sys2.stname]; + id1 = ! isempty (sys1.b2); + id2 = ! isempty (sys2.b2); + + if (id1 || id2) + if (id1) + q1 = columns (sys1.b2); + b21 = sys1.b2; c21 = sys1.c2; + d121 = sys1.d12; d211 = sys1.d21; d221 = sys1.d22; + else + q1 = 0; + b21 = zeros (n1, 0); c21 = zeros (0, n1); + d121 = zeros (p1, 0); d211 = zeros (0, m1); d221 = zeros (0, 0); + endif + + if (id2) + q2 = columns (sys2.b2); + b22 = sys2.b2; c22 = sys2.c2; + d122 = sys2.d12; d212 = sys2.d21; d222 = sys2.d22; + else + q2 = 0; + b22 = zeros (n2, 0); c22 = zeros (0, n2); + d122 = zeros (p2, 0); d212 = zeros (0, m2); d222 = zeros (0, 0); + endif + + ## delay ports are independent between operands: no cross-terms + retsys.b2 = [b21, zeros(n1,q2); zeros(n2,q1), b22]; + retsys.c2 = [c21, zeros(q1,n2); zeros(q2,n1), c22]; + retsys.d12 = [d121, zeros(p1,q2); zeros(p2,q1), d122]; + retsys.d21 = [d211, zeros(q1,m2); zeros(q2,m1), d212]; + retsys.d22 = [d221, zeros(q1,q2); zeros(q2,q1), d222]; + endif + + retsys.internaldelay = [sys1.internaldelay; sys2.internaldelay]; + endfunction diff --git a/inst/@ss/__sys_inverse__.m b/inst/@ss/__sys_inverse__.m index 934f1e74..39c9ed88 100644 --- a/inst/@ss/__sys_inverse__.m +++ b/inst/@ss/__sys_inverse__.m @@ -24,6 +24,10 @@ function sys = __sys_inverse__ (sys) + if (hasinternaldelay (sys)) + error ("ss: inverse: InternalDelay is not yet supported"); + endif + a = sys.a; b = sys.b; c = sys.c; @@ -59,3 +63,10 @@ endif endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 1); +%! r = inv (sys); +%! assert (hasinternaldelay (r), false); + +%!error inv (set (ss (-1, 1, 1, 1), "internaldelay", 0.5)) diff --git a/inst/@ss/__sys_keys__.m b/inst/@ss/__sys_keys__.m index 8e9aa0e6..d436e6de 100644 --- a/inst/@ss/__sys_keys__.m +++ b/inst/@ss/__sys_keys__.m @@ -34,7 +34,8 @@ "d"; "e"; "stname"; - "scaled"}; + "scaled"; + "internaldelay"}; ## cell vector of ss-specific assignable values vals = {"n-by-n matrix (n = number of states)"; @@ -43,10 +44,11 @@ "p-by-m matrix"; "n-by-n matrix"; "n-by-1 cell vector of strings"; - "scalar logical value"}; + "scalar logical value"; + "q-by-1 real matrix (internal delay: seconds for ct models, samples for dt models)"}; if (aliases) - ka = {"statename"}; + ka = {"statename"; "internal_delay"}; keys = [keys; ka]; endif diff --git a/inst/@ss/__sys_prune__.m b/inst/@ss/__sys_prune__.m index 0e06648c..eee278c3 100644 --- a/inst/@ss/__sys_prune__.m +++ b/inst/@ss/__sys_prune__.m @@ -44,6 +44,16 @@ sys.e = sys.e(st_idx, st_idx); endif + ## internal delay ports: their external-I/O-facing dimensions follow the + ## selected inputs/outputs (d12 rows = outputs, d21 cols = inputs); the + ## port count itself (b2/c2/d22/internaldelay) is unaffected by I/O pruning. + if (! isempty (sys.b2)) + sys.b2 = sys.b2(st_idx, :); + sys.c2 = sys.c2(:, st_idx); + sys.d12 = sys.d12(out_idx, :); + sys.d21 = sys.d21(:, in_idx); + endif + sys.stname = sys.stname(st_idx); endfunction diff --git a/inst/@ss/__transpose__.m b/inst/@ss/__transpose__.m index 45334cbb..33ddd42b 100644 --- a/inst/@ss/__transpose__.m +++ b/inst/@ss/__transpose__.m @@ -24,6 +24,10 @@ function sys = __transpose__ (sys) + if (hasinternaldelay (sys)) + error ("ss: transpose: InternalDelay is not yet supported"); + endif + a = sys.a; b = sys.b; c = sys.c; @@ -39,3 +43,10 @@ sys.e = e.'; endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! t = sys.'; +%! assert (hasinternaldelay (t), false); + +%!error transpose (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/ss.m b/inst/@ss/ss.m index 77e0d645..aea19d7f 100644 --- a/inst/@ss/ss.m +++ b/inst/@ss/ss.m @@ -83,6 +83,13 @@ ## Delay from each input to each output, in seconds for continuous-time ## models or samples for discrete-time models. ## +## @item 'InternalDelay' +## Real nonnegative column vector with one entry per internal delay channel. +## Delays in seconds for continuous-time systems or samples for discrete-time systems. +## This property is populated automatically by @code{feedback()} and @code{connect()} +## when a delay is trapped inside a closed loop. It is not meant to be set directly +## by a user in normal use, though the property is get/set-accessible like the others. +## ## @item 'inname' ## The name of the input channels in @var{sys}. ## Cell vector of length m containing strings. @@ -260,7 +267,13 @@ "c", c, "d", d, "e", [], "stname", {stname}, - "scaled", false); # struct for ss-specific data + "scaled", false, + "internaldelay", [], + "b2", [], + "c2", [], + "d12", [], + "d21", [], + "d22", []); # struct for ss-specific data ltisys = lti (p, m, tsam); # parent class for general lti data @@ -316,3 +329,28 @@ %! assert (s.OutputDelay, 0.2); %! assert (s.IODelay, 0.1); +%!test # InternalDelay defaults to empty +%! s = ss (-1, 1, 1, 0); +%! assert (isempty (get (s, "internaldelay"))); + +%!test # InternalDelay property get/set round-trip +%! s = ss (-1, 1, 1, 0); +%! s = set (s, "internaldelay", 0.7); +%! assert (get (s, "internaldelay"), 0.7); +%! assert (s.InternalDelay, 0.7); + +%!test # InternalDelay setting back to empty +%! s = ss (-1, 1, 1, 0); +%! s = set (s, "internaldelay", [0.1; 0.2]); +%! s = set (s, "internaldelay", []); +%! assert (isempty (get (s, "internaldelay"))); + +%!test # InternalDelay concatenates through append (b2 etc. stay empty here; +%! # Task 1 has no public API populating them, that's Task 2's job) +%! s1 = ss (-1, [1 1], 1, 0); +%! s1 = set (s1, "internaldelay", [0.1; 0.2]); +%! s2 = ss (-2, 1, 1, 0); +%! s2 = set (s2, "internaldelay", 0.3); +%! g = append (s1, s2); +%! assert (get (g, "internaldelay"), [0.1; 0.2; 0.3]); + diff --git a/inst/__time_response__.m b/inst/__time_response__.m index 16947a1a..a7464449 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -43,6 +43,10 @@ error ("%s: require at least one LTI model\n", response); endif + if (any (cellfun (@hasinternaldelay, args(sys_idx)))) + error ("%s: InternalDelay is not yet supported", response); + endif + if (nout > 0) if nnz (sys_idx) > 1 error ("%s: with output arguments, only one system is allowed\n", response); @@ -621,3 +625,12 @@ endif endfunction + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! y = step (sys); +%! assert (isempty (y), false); + +%!error step (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +%!error impulse (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +%!error initial (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), [1]) diff --git a/inst/hasinternaldelay.m b/inst/hasinternaldelay.m new file mode 100644 index 00000000..c4bebf2e --- /dev/null +++ b/inst/hasinternaldelay.m @@ -0,0 +1,77 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{bool} =} hasinternaldelay (@var{sys}) +## Return true if @var{sys} has a nonzero @var{InternalDelay}. +## @var{InternalDelay} is a @code{ss}-specific property; any other +## @acronym{LTI} object (@code{tf}, @code{zpk}) always returns false. +## +## @strong{Inputs} +## @table @var +## @item sys +## @acronym{LTI} system. +## @end table +## +## @strong{Outputs} +## @table @var +## @item bool +## True if @var{sys} has any nonzero internal delay, false otherwise. +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function bool = hasinternaldelay (sys) + + if (nargin != 1 || ! isa (sys, "lti")) + print_usage (); + endif + + if (! isa (sys, "ss")) + bool = false; + return; + endif + + internaldelay = get (sys, "internaldelay"); + + bool = any (internaldelay(:) != 0); + +endfunction + + +%!test +%! h = tf (10, [1 3 10]); +%! assert (hasinternaldelay (h), false); + +%!test +%! h = zpk ([], -2, 3); +%! assert (hasinternaldelay (h), false); + +%!test +%! h = ss (-1, 1, 1, 0); +%! assert (hasinternaldelay (h), false); + +%!test +%! h = ss (-1, 1, 1, 0); +%! h = set (h, "internaldelay", 0.5); +%! assert (hasinternaldelay (h), true); + +%!error (hasinternaldelay ()) +%!error (hasinternaldelay (1)) diff --git a/inst/lsim.m b/inst/lsim.m index ae8e6aaf..075c72ab 100644 --- a/inst/lsim.m +++ b/inst/lsim.m @@ -289,6 +289,10 @@ function [y, t, x_arr] = __linear_simulation__ (sys, u, t, x0, method) + if (hasinternaldelay (sys)) + error ("lsim: InternalDelay is not yet supported"); + endif + if (! isa (sys, "ss")) x0 =[]; # ignore initial condition for system not in state space endif @@ -564,3 +568,5 @@ %! u = [ 0.2+0.3*sin(1.3*t') , cos(2*t') ]; %! x0 = [0 0.1 0]; %! lsim(S, u, t, x0); + +%!error lsim (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), [1;1;1], [0 1 2]) From 661ecd1fb9454fdc3e41dc88ba0a9d0185ab3bfe Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 07:04:04 +0800 Subject: [PATCH 06/26] control: InternalDelay discretization and time-domain simulation Add InternalDelay support to c2d/d2c via the extended-system trick, simulate InternalDelay systems in step/impulse/ramp/initial/lsim via a per-step delay-buffer lookup, and round ordinary I/O delay too when InternalDelay is also present. --- inst/@lti/c2d.m | 83 +++++++++++++- inst/@lti/d2c.m | 59 +++++++++- inst/@ss/__ss_ext_build__.m | 44 ++++++++ inst/@ss/__ss_ext_split__.m | 49 +++++++++ inst/__time_response__.m | 214 ++++++++++++++++++++++++++++++------ inst/lsim.m | 140 ++++++++++++++++++++++- 6 files changed, 542 insertions(+), 47 deletions(-) create mode 100644 inst/@ss/__ss_ext_build__.m create mode 100644 inst/@ss/__ss_ext_split__.m diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index 8a2e04ad..51a35ea7 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -72,10 +72,6 @@ error ("c2d: system is already discrete-time"); endif - if (hasinternaldelay (sys)) - error ("c2d: InternalDelay is not yet supported"); - endif - if (! issample (tsam)) error ("c2d: second argument is not a valid sample time"); endif @@ -103,6 +99,35 @@ origsys = sys; + if (hasinternaldelay (origsys)) + ## Discretizing an InternalDelay system is exactly discretizing the + ## extended ordinary system (A, [B1 B2], [C1;C2], [[D11 D12];[D21 D22]]) + ## via the unchanged __c2d__ (which only looks at matrix shapes), then + ## rounding tau to whole samples the same way InputDelay/OutputDelay are + ## rounded below. + ## __ss_ext_split__ copies InputDelay/OutputDelay/IODelay unchanged from + ## origsys (still in seconds), so if origsys also carries ordinary I/O + ## delay, round it to samples here, the same way the hasdelay() branch + ## below does for the InternalDelay-free case. Combining InternalDelay + ## with DelayModeling="state" or fractional (Thiran) ordinary delay is + ## out of scope; only the plain round-to-samples case is handled. + [ext_sys, nu, ny] = __ss_ext_build__ (origsys); + ext_sys = __c2d__ (ext_sys, tsam, lower (method), w0); + sys = __ss_ext_split__ (origsys, ext_sys, nu, ny); + sys.tsam = tsam; + tau = get (origsys, "internaldelay"); + sys = set (sys, "internaldelay", round (tau / tsam)); + + if (hasdelay (origsys)) + [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); + sys = set (sys, "InputDelay", round (indelay / tsam), ... + "OutputDelay", round (outdelay / tsam), ... + "IODelay", round (iodelay / tsam)); + endif + + return; + endif + if (hasdelay (origsys)) [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); @@ -537,5 +562,53 @@ %!error c2d (tf (1, [1 1]), 0.5, c2dOptions (), 100) -%!error c2d (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), 0.5, "zoh") +%!test # exact-integer-sample InternalDelay: feedback()-produced system, tau/tsam is a whole number +%! T = 0.5; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! assert (hasinternaldelay (L), true); +%! dsys = c2d (L, 0.25, "zoh"); +%! assert (isdt (dsys), true); +%! assert (hasinternaldelay (dsys), true); +%! assert (dsys.internaldelay, T / 0.25, 1e-10); + +%!test # fractional InternalDelay: rounds to nearest sample +%! T = 0.33; +%! tsam = 0.1; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! dsys = c2d (L, tsam, "zoh"); +%! assert (hasinternaldelay (dsys), true); +%! assert (dsys.internaldelay, round (T / tsam)); + +%!test # zoh sanity check: discretized InternalDelay system tracks continuous freqresp at low frequency +%! T = 0.3; +%! G = ss (-2, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! tsam = 0.01; +%! dsys = c2d (L, tsam, "zoh"); +%! w = [0.05, 0.2, 1]; +%! Hc = freqresp (L, w); +%! Hd = freqresp (dsys, w); +%! assert (Hd, Hc, 5e-2); + +%!test # InternalDelay with no delay ports (b2/c2/d12/d21/d22 empty): degenerate extended system still discretizes +%! sys = set (ss (-1, 1, 1, 0), "internaldelay", 0.5); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! assert (dsys.internaldelay, 1); + +%!test # combined InternalDelay AND ordinary InputDelay: both rounded to samples +%! T = 0.5; +%! tsam = 0.25; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! L = set (L, "InputDelay", 0.75); +%! assert (hasinternaldelay (L), true); +%! assert (hasdelay (L), true); +%! dsys = c2d (L, tsam, "zoh"); +%! assert (isdt (dsys), true); +%! assert (hasinternaldelay (dsys), true); +%! assert (dsys.internaldelay, T / tsam, 1e-10); +%! assert (dsys.InputDelay, round (0.75 / tsam)); diff --git a/inst/@lti/d2c.m b/inst/@lti/d2c.m index fa00aed7..98ab2d4c 100644 --- a/inst/@lti/d2c.m +++ b/inst/@lti/d2c.m @@ -65,10 +65,6 @@ error ("d2c: system is already continuous-time"); endif - if (hasinternaldelay (sys)) - error ("d2c: InternalDelay is not yet supported"); - endif - if (! ischar (method)) error ("d2c: second argument is not a string"); endif @@ -79,6 +75,31 @@ origsys = sys; tsam = sys.tsam; + + if (hasinternaldelay (origsys)) + ## Mirror c2d.m: discretize the extended ordinary system, then convert + ## samples back to seconds for internaldelay (samples are exact, so no + ## rounding needed here, unlike the tau/tsam rounding done in c2d.m). + ## __ss_ext_split__ copies InputDelay/OutputDelay/IODelay unchanged from + ## origsys (still in samples), so if origsys also carries ordinary I/O + ## delay, convert it to seconds here too, mirroring c2d.m. + [ext_sys, nu, ny] = __ss_ext_build__ (origsys); + ext_sys = __d2c__ (ext_sys, tsam, lower (method), w0); + sys = __ss_ext_split__ (origsys, ext_sys, nu, ny); + sys.tsam = 0; + tau = get (origsys, "internaldelay"); + sys = set (sys, "internaldelay", tau * tsam); + + if (hasdelay (origsys)) + [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); + sys = set (sys, "InputDelay", indelay * tsam, ... + "OutputDelay", outdelay * tsam, ... + "IODelay", iodelay * tsam); + endif + + return; + endif + sys = __d2c__ (sys, tsam, lower (method), w0); sys.tsam = 0; @@ -180,4 +201,32 @@ %! sys2 = d2c (c2d (sys, 0.5, "zoh"), "zoh"); %! assert (sys2.InputDelay, 1.0, 1e-10); -%!error d2c (set (ss (-1, 1, 1, 0, 0.1), "internaldelay", 0.5), "zoh") +%!test # InternalDelay round-trip: c2d then d2c recovers the original tau (samples convert back to seconds exactly) +%! T = 0.3; +%! tsam = 0.1; +%! G = ss (-2, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! dsys = c2d (L, tsam, "zoh"); +%! csys = d2c (dsys, "zoh"); +%! assert (hasinternaldelay (csys), true); +%! assert (csys.internaldelay, T, 1e-10); +%! w = [0.05, 0.2, 1]; +%! assert (freqresp (csys, w), freqresp (L, w), 5e-2); + +%!test # InternalDelay with no delay ports: degenerate extended system still converts back +%! sys = set (ss (-1, 1, 1, 0, 0.1), "internaldelay", 0.5); +%! csys = d2c (sys, "zoh"); +%! assert (isct (csys), true); +%! assert (csys.internaldelay, 0.05, 1e-10); + +%!test # combined InternalDelay AND ordinary InputDelay: both converted to seconds +%! T = 0.5; +%! tsam = 0.25; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! L = set (L, "InputDelay", 0.75); +%! dsys = c2d (L, tsam, "zoh"); +%! csys = d2c (dsys, "zoh"); +%! assert (hasinternaldelay (csys), true); +%! assert (csys.internaldelay, T, 1e-10); +%! assert (csys.InputDelay, round (0.75 / tsam) * tsam, 1e-10); diff --git a/inst/@ss/__ss_ext_build__.m b/inst/@ss/__ss_ext_build__.m new file mode 100644 index 00000000..dd61c20e --- /dev/null +++ b/inst/@ss/__ss_ext_build__.m @@ -0,0 +1,44 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## Build the extended ordinary system (A, [B1 B2], [C1;C2], [[D11 D12];[D21 D22]]) +## used to discretize/continuize an InternalDelay-carrying ss model via the +## unchanged __c2d__/__d2c__ workers (which only look at matrix shapes). +## +## b2/c2/d12/d21/d22 are internal-only ss fields not exposed through +## get()/set(); this helper lives in @ss so it may access them directly +## (dot-access on an ss object from outside @ss is routed through the +## overloaded subsref/get, which only recognizes public keys). +## +## For internal use only, called from @lti/c2d.m and @lti/d2c.m. + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function [ext_sys, nu, ny] = __ss_ext_build__ (sys) + + nu = columns (sys.b); + ny = rows (sys.c); + + ext_sys = sys; + ext_sys.b = [sys.b, sys.b2]; + ext_sys.c = [sys.c; sys.c2]; + ext_sys.d = [sys.d, sys.d12; sys.d21, sys.d22]; + +endfunction diff --git a/inst/@ss/__ss_ext_split__.m b/inst/@ss/__ss_ext_split__.m new file mode 100644 index 00000000..338856c8 --- /dev/null +++ b/inst/@ss/__ss_ext_split__.m @@ -0,0 +1,49 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## Split a discretized/continuized extended system (built by +## __ss_ext_build__) back into the ordinary dynamics (a/b/c/d/e) plus the +## delay-port fields (b2/c2/d12/d21/d22), reattaching them to a copy of the +## original InternalDelay-carrying ss model. +## +## @var{nu} is the number of columns of the original (non-extended) B; the +## first @var{nu} columns of @code{ext_sys.b} are the new B, the remainder +## the new B2. @var{ny} is the number of rows of the original C; analogous +## row split for C/C2. D is split into its four quadrants accordingly. +## +## For internal use only, called from @lti/c2d.m and @lti/d2c.m. + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function newsys = __ss_ext_split__ (origsys, ext_sys, nu, ny) + + newsys = origsys; + newsys.a = ext_sys.a; + newsys.e = ext_sys.e; + newsys.b = ext_sys.b(:, 1:nu); + newsys.b2 = ext_sys.b(:, nu+1:end); + newsys.c = ext_sys.c(1:ny, :); + newsys.c2 = ext_sys.c(ny+1:end, :); + newsys.d = ext_sys.d(1:ny, 1:nu); + newsys.d12 = ext_sys.d(1:ny, nu+1:end); + newsys.d21 = ext_sys.d(ny+1:end, 1:nu); + newsys.d22 = ext_sys.d(ny+1:end, nu+1:end); + +endfunction diff --git a/inst/__time_response__.m b/inst/__time_response__.m index a7464449..44186621 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -43,10 +43,6 @@ error ("%s: require at least one LTI model\n", response); endif - if (any (cellfun (@hasinternaldelay, args(sys_idx)))) - error ("%s: InternalDelay is not yet supported", response); - endif - if (nout > 0) if nnz (sys_idx) > 1 error ("%s: with output arguments, only one system is allowed\n", response); @@ -393,6 +389,21 @@ error ("initial: x0 must be a real vector with %d elements\n", n); endif + if (hasinternaldelay (sys_dt)) + [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt); + nports = numel (tau); + z_hist = zeros (l_t, nports); + ## simulation (input is zero; only the internal-delay port drives w/z) + for k = 1 : l_t + w = __delay_lookup__ (z_hist, k, tau, nports); + y(k, :) = C * x + D12 * w; + z_hist(k, :) = (C2 * x + D22 * w).'; + x_arr(k, :) = x; + x = F * x + B2 * w; + endfor + return; + endif + ## simulation for k = 1 : l_t y(k, :) = C * x; @@ -416,18 +427,35 @@ y = zeros (l_t, p, m); x_arr = zeros (l_t, n, m); + has_id = hasinternaldelay (sys_dt); + if (has_id) + [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt); + nports = numel (tau); + endif + for j = 1 : m # for every input channel ## initial conditions x = zeros (n, 1); u = zeros (m, 1); u(j) = 1; - ## simulation - for k = 1 : l_t - y(k, :, j) = C * x + D * u; - x_arr(k, :, j) = x; - x = F * x + G * u; - endfor + if (has_id) + z_hist = zeros (l_t, nports); + for k = 1 : l_t + w = __delay_lookup__ (z_hist, k, tau, nports); + y(k, :, j) = C * x + D * u + D12 * w; + z_hist(k, :) = (C2 * x + D21 * u + D22 * w).'; + x_arr(k, :, j) = x; + x = F * x + G * u + B2 * w; + endfor + else + ## simulation + for k = 1 : l_t + y(k, :, j) = C * x + D * u; + x_arr(k, :, j) = x; + x = F * x + G * u; + endfor + endif endfor endfunction @@ -447,23 +475,46 @@ y = zeros (l_t, p, m); x_arr = zeros (l_t, n, m); + has_id = hasinternaldelay (sys_dt); + if (has_id) + [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt); + nports = numel (tau); + endif + for j = 1 : m # for every input channel u = zeros (m, 1); u(j) = 1; - ## initial conditions - x = zeros (n, 1); # zero by definition - y(1, :, j) = D * u / dt; # impulse is 1/dt - x_arr(1, :, j) = x; - x = G * u / dt; - - ## simulation - for k = 2 : l_t - y (k, :, j) = C * x; - x_arr(k, :, j) = x; - x = F * x; - endfor + if (has_id) + ## Equivalent to the delay-free branch below: a discrete impulse is the + ## input signal uu(1) = u/dt, uu(k>1) = 0, run through the standard + ## delay-buffer recursion (the k=1 special-case there is just this loop + ## evaluated at the first sample). + x = zeros (n, 1); + z_hist = zeros (l_t, nports); + for k = 1 : l_t + if (k == 1), uk = u / dt; else, uk = zeros (m, 1); endif + w = __delay_lookup__ (z_hist, k, tau, nports); + y(k, :, j) = C * x + D * uk + D12 * w; + z_hist(k, :) = (C2 * x + D21 * uk + D22 * w).'; + x_arr(k, :, j) = x; + x = F * x + G * uk + B2 * w; + endfor + else + ## initial conditions + x = zeros (n, 1); # zero by definition + y(1, :, j) = D * u / dt; # impulse is 1/dt + x_arr(1, :, j) = x; + x = G * u / dt; + + ## simulation + for k = 2 : l_t + y (k, :, j) = C * x; + x_arr(k, :, j) = x; + x = F * x; + endfor + endif endfor @@ -483,23 +534,80 @@ y = zeros (l_t, p, m); x_arr = zeros (l_t, n, m); + has_id = hasinternaldelay (sys_dt); + if (has_id) + [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt); + nports = numel (tau); + endif + for j = 1 : m # for every input channel ## initial conditions x = zeros (n, 1); u = zeros (m, l_t); u(j, :) = t; - ## simulation - for k = 1 : l_t - y(k, :, j) = C * x + D * u(:, k); - x_arr(k, :, j) = x; - x = F * x + G * u(:, k); - endfor + if (has_id) + z_hist = zeros (l_t, nports); + for k = 1 : l_t + w = __delay_lookup__ (z_hist, k, tau, nports); + y(k, :, j) = C * x + D * u(:, k) + D12 * w; + z_hist(k, :) = (C2 * x + D21 * u(:, k) + D22 * w).'; + x_arr(k, :, j) = x; + x = F * x + G * u(:, k) + B2 * w; + endfor + else + ## simulation + for k = 1 : l_t + y(k, :, j) = C * x + D * u(:, k); + x_arr(k, :, j) = x; + x = F * x + G * u(:, k); + endfor + endif endfor endfunction +## Extract the internal-delay port matrices (B2, C2, D12, D21, D22) and the +## per-port delay tau (whole samples) from a discrete InternalDelay ss model, +## by reusing @ss/__ss_ext_build__ (the same helper c2d/d2c use) purely for +## data extraction. +function [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt) + [ext, nu, ny] = __ss_ext_build__ (sys_dt); + [~, Be, Ce, De] = ssdata (ext); + B2 = Be(:, nu+1:end); + C2 = Ce(ny+1:end, :); + D12 = De(1:ny, nu+1:end); + D21 = De(ny+1:end, 1:nu); + D22 = De(ny+1:end, nu+1:end); + tau = get (sys_dt, "internaldelay")(:); +endfunction + + +## Delay-buffer lookup: w(port) = z_hist(k - tau(port), port), read as 0 when +## k - tau(port) < 1 (no past sample yet -- the zero-history boundary). At +## k == tau this still reads 0 (index 0); the first real-history read is at +## k == tau + 1, returning z_hist(1) -- i.e. exactly a tau-sample delay, +## matching the integer-sample shift used by __apply_timeresp_delay__. +## +## Assumes tau(port) >= 1 for every port: a port whose delay rounds to 0 +## samples (an internal delay smaller than half a sample time) would read +## w(k) = z_hist(k), which is always 0 at read time since z_hist(k) is only +## written later in the same step -- silently dropping that port's D12/D22 +## feedthrough instead of solving the resulting algebraic w=z loop. Not +## reachable via a well-formed nonzero delay; c2d's rounding is expected to +## keep tau >= 1 for any InternalDelay this function is asked to simulate. +function w = __delay_lookup__ (z_hist, k, tau, nports) + w = zeros (nports, 1); + for pp = 1 : nports + idx = k - tau(pp); + if (idx >= 1) + w(pp) = z_hist(idx, pp); + endif + endfor +endfunction + + function [tfinal, dt] = __sim_horizon__ (sys, tfinal, Ts) ## code based on __stepimp__.m of Kai P. Mueller and A. Scottedward Hodel @@ -631,6 +739,50 @@ %! y = step (sys); %! assert (isempty (y), false); -%!error step (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) -%!error impulse (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) -%!error initial (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), [1]) +%!test # InternalDelay step vs an independent hand-written delay-buffer reference +%! T = 0.3; dt = 0.1; +%! sysd = c2d (ss (feedback (ss (-1, 1, 1, 0, "IODelay", T))), dt); +%! t = (0:dt:5)'; +%! [y, ty] = step (sysd, t); +%! ## Independent reference: extract matrices (data plumbing only), simulate by hand. +%! [A, B1, C1, D11] = ssdata (sysd); +%! [ext, nu, ny] = __ss_ext_build__ (sysd); +%! [Ae, Be, Ce, De] = ssdata (ext); +%! B2 = Be(:, nu+1:end); C2 = Ce(ny+1:end, :); +%! D12 = De(1:ny, nu+1:end); D21 = De(ny+1:end, 1:nu); D22 = De(ny+1:end, nu+1:end); +%! tau = get (sysd, "internaldelay"); +%! N = numel (ty); nst = rows (A); +%! xr = zeros (nst, 1); zh = zeros (N, 1); yref = zeros (N, 1); +%! for k = 1:N +%! if (k - tau >= 1), w = zh(k - tau); else, w = 0; endif +%! yref(k) = C1*xr + D11*1 + D12*w; +%! zh(k) = C2*xr + D21*1 + D22*w; +%! xr = A*xr + B1*1 + B2*w; +%! endfor +%! assert (y(:), yref, 1e-10); + +%!test # InternalDelay initial with nonzero x0: independent free-response reference +%! T = 0.3; dt = 0.1; +%! sysd = c2d (ss (feedback (ss (-1, 1, 1, 0, "IODelay", T))), dt); +%! x0 = 0.9; +%! t = (0:dt:5)'; +%! [y, ty] = initial (sysd, x0, t); +%! [A, B1, C1, D11] = ssdata (sysd); +%! [ext, nu, ny] = __ss_ext_build__ (sysd); +%! [Ae, Be, Ce, De] = ssdata (ext); +%! B2 = Be(:, nu+1:end); C2 = Ce(ny+1:end, :); +%! D12 = De(1:ny, nu+1:end); D22 = De(ny+1:end, nu+1:end); +%! tau = get (sysd, "internaldelay"); +%! N = numel (ty); nst = rows (A); +%! xr = x0(:); zh = zeros (N, 1); yref = zeros (N, 1); +%! for k = 1:N +%! if (k - tau >= 1), w = zh(k - tau); else, w = 0; endif +%! yref(k) = C1*xr + D12*w; +%! zh(k) = C2*xr + D22*w; +%! xr = A*xr + B2*w; +%! endfor +%! assert (y(:), yref, 1e-10); + +%!test # no InternalDelay: unaffected (regression) +%! sys = ss (-1, 1, 1, 0); +%! assert (isempty (impulse (sys)), false); diff --git a/inst/lsim.m b/inst/lsim.m index 075c72ab..ff44cc28 100644 --- a/inst/lsim.m +++ b/inst/lsim.m @@ -289,10 +289,6 @@ function [y, t, x_arr] = __linear_simulation__ (sys, u, t, x0, method) - if (hasinternaldelay (sys)) - error ("lsim: InternalDelay is not yet supported"); - endif - if (! isa (sys, "ss")) x0 =[]; # ignore initial condition for system not in state space endif @@ -366,7 +362,55 @@ is_foh = was_ct && strcmp (method, "foh") && (max (size (sys.userdata)) > 0); - if (! hasdelay (sys)) + if (hasinternaldelay (sys)) + + ## InternalDelay: the internal-delay port input w(k) is drawn from a + ## buffer of already-computed past z values (tau samples ago), so there + ## is no algebraic loop -- just a per-step buffer lookup. The recursion + ## is linear in (x0, u), so it composes with x0 by direct state basis (no + ## superposition trick needed) and, when ordinary I/O delay is ALSO + ## present, with the prior phase's post-hoc __apply_timeresp_delay__ shift + ## via the same per-channel superposition it already uses. (An + ## InternalDelay system is discretized through Task 1's c2d path, which + ## never populates sys.userdata, so is_foh is always false here.) + [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys); + + if (! hasdelay (sys)) + ## pure internal delay: one combined buffered recursion (x0 and u together) + [y, x_arr] = __buffered_sim__ (A, B, C, D, B2, C2, D12, D21, D22, tau, ... + x0, u, urows, p, n); + else + ## internal delay AND ordinary I/O delay: superpose a zero-input term + ## (x0 alone, shifted by OutputDelay) with one zero-state term per input + ## channel (shifted by that channel's totaldelay); each sub-simulation + ## carries its own internal-delay buffer, and summing reconstructs the + ## full response because the buffer recursion is linear. + total = totaldelay (sys); # p-by-m, whole samples + outdelay = get (sys, "outputdelay"); # p-by-1, whole samples + + y = zeros (urows, p); + x_arr = zeros (urows, n); + + [y_free, x_free] = __buffered_sim__ (A, B, C, D, B2, C2, D12, D21, D22, ... + tau, x0, zeros (urows, m), urows, p, n); + x_arr += x_free; + for row = 1 : p + y(:, row) += __apply_timeresp_delay__ (y_free(:, row), outdelay(row)); + endfor + + for j = 1 : m + uj = zeros (urows, m); + uj(:, j) = u(:, j); + [y_j, x_j] = __buffered_sim__ (A, B, C, D, B2, C2, D12, D21, D22, ... + tau, zeros (n, 1), uj, urows, p, n); + x_arr += x_j; + for row = 1 : p + y(:, row) += __apply_timeresp_delay__ (y_j(:, row), total(row, j)); + endfor + endfor + endif + + elseif (! hasdelay (sys)) ## preallocate memory y = zeros (urows, p); @@ -456,6 +500,57 @@ endfunction +## Extract the internal-delay port matrices (B2, C2, D12, D21, D22) and the +## per-port delay tau (whole samples) from a discrete InternalDelay ss model, +## reusing @ss/__ss_ext_build__ (the same helper c2d/d2c use) for data +## extraction only. +function [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys) + [ext, nu, ny] = __ss_ext_build__ (sys); + [~, Be, Ce, De] = ssdata (ext); + B2 = Be(:, nu+1:end); + C2 = Ce(ny+1:end, :); + D12 = De(1:ny, nu+1:end); + D21 = De(ny+1:end, 1:nu); + D22 = De(ny+1:end, nu+1:end); + tau = get (sys, "internaldelay")(:); +endfunction + + +## One buffered per-sample simulation of an InternalDelay ss model over the +## whole horizon: at each step the internal-delay port input w is read from a +## buffer of past z values (tau samples ago; zero when k - tau < 1 -- the +## zero-history boundary), never the current step's z. Returns the output y +## and state trajectory x_arr; linear in (x_init, umat). +## +## Assumes tau(port) >= 1 for every port, same as __delay_lookup__ in +## __time_response__.m: a port whose delay rounds to 0 samples would read +## w(k) = z_hist(k) before it is written this step, silently dropping that +## port's D12/D22 feedthrough instead of solving the resulting algebraic +## w=z loop. Not reachable via a well-formed nonzero InternalDelay. +function [y, x_arr] = __buffered_sim__ (A, B, C, D, B2, C2, D12, D21, D22, ... + tau, x_init, umat, urows, p, n) + nports = numel (tau); + z_hist = zeros (urows, nports); + y = zeros (urows, p); + x_arr = zeros (urows, n); + x = x_init; + for k = 1 : urows + w = zeros (nports, 1); + for pp = 1 : nports + idx = k - tau(pp); + if (idx >= 1) + w(pp) = z_hist(idx, pp); + endif + endfor + uk = umat(k, :).'; + y(k, :) = C * x + D * uk + D12 * w; + z_hist(k, :) = (C2 * x + D21 * uk + D22 * w).'; + x_arr(k, :) = x; + x = A * x + B * uk + B2 * w; + endfor +endfunction + + %!test %! n = 5; %! m = 3; @@ -569,4 +664,37 @@ %! x0 = [0 0.1 0]; %! lsim(S, u, t, x0); -%!error lsim (set (ss (-1, 1, 1, 0), "internaldelay", 0.5), [1;1;1], [0 1 2]) +%!test # InternalDelay lsim vs an independent hand-written delay-buffer reference +%! T = 0.3; dt = 0.1; +%! sysd = c2d (ss (feedback (ss (-1, 1, 1, 0, "IODelay", T))), dt); +%! t = (0:dt:3)'; +%! u = sin (t); +%! [y, tt, x] = lsim (sysd, u, t); +%! ## Independent reference: extract the matrices (data plumbing only) and run +%! ## the delay-buffer recursion by hand with plain array indexing. +%! [A, B1, C1, D11] = ssdata (sysd); +%! [ext, nu, ny] = __ss_ext_build__ (sysd); +%! [Ae, Be, Ce, De] = ssdata (ext); +%! B2 = Be(:, nu+1:end); C2 = Ce(ny+1:end, :); +%! D12 = De(1:ny, nu+1:end); D21 = De(ny+1:end, 1:nu); D22 = De(ny+1:end, nu+1:end); +%! tau = get (sysd, "internaldelay"); +%! N = numel (t); nst = rows (A); +%! xr = zeros (nst, 1); zh = zeros (N, 1); yref = zeros (N, 1); xref = zeros (N, nst); +%! for k = 1:N +%! if (k - tau >= 1), w = zh(k - tau); else, w = 0; endif +%! yref(k) = C1*xr + D11*u(k) + D12*w; +%! zh(k) = C2*xr + D21*u(k) + D22*w; +%! xref(k, :) = xr.'; +%! xr = A*xr + B1*u(k) + B2*w; +%! endfor +%! assert (y, yref, 1e-10); +%! assert (x, xref, 1e-10); + +%!test # nonzero x0 composes by plain linear superposition (direct state basis) +%! T = 0.3; dt = 0.1; +%! sysd = c2d (ss (feedback (ss (-1, 1, 1, 0, "IODelay", T))), dt); +%! t = (0:dt:3)'; u = sin (t); x0 = 0.7; +%! y_full = lsim (sysd, u, t, x0); +%! y_zero = lsim (sysd, u, t, 0); +%! y_free = lsim (sysd, zeros (size (t)), t, x0); +%! assert (y_full, y_zero + y_free, 1e-10); From 57b6d26919a1d44ae95e413710ff79531cbccc4e Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 14:35:03 +0800 Subject: [PATCH 07/26] control: guard zero/pole/stepinfo against InternalDelay, MIMO hardening Add guards for InternalDelay in zero(), pole(), and stepinfo() (the last known silent-wrong-answer gap), add MIMO InternalDelay hardening tests across c2d/d2c/step/lsim/freqresp, and fix a regression where the pole() guard broke __sim_horizon__'s auto-horizon estimation for InternalDelay systems. --- inst/@lti/c2d.m | 28 ++++++++++++++++++++++ inst/@lti/d2c.m | 23 ++++++++++++++++++ inst/@lti/pole.m | 8 +++++++ inst/@lti/zero.m | 26 +++++++++++++-------- inst/@ss/__freqresp__.m | 22 ++++++++++++++++++ inst/__time_response__.m | 50 +++++++++++++++++++++++++++++++++++++++- inst/lsim.m | 37 +++++++++++++++++++++++++++++ inst/stepinfo.m | 8 +++++++ 8 files changed, 192 insertions(+), 10 deletions(-) diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index 51a35ea7..f3f8cabc 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -612,3 +612,31 @@ %! assert (dsys.internaldelay, T / tsam, 1e-10); %! assert (dsys.InputDelay, round (0.75 / tsam)); + +%!test # MIMO InternalDelay: two channels, DIFFERENT delays, dimension-correct multi-port c2d +%! ## Genuine 2-in/2-out InternalDelay fixture built as append() of two +%! ## independent SISO feedback loops (the isolated-SISO-block topology +%! ## __sys_connect__ actually supports). Distinct delays (0.4 vs 0.8) so an +%! ## index/port swap could not hide behind equal values. +%! T1 = 0.4; T2 = 0.8; tsam = 0.2; +%! G1 = ss (-1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", T2); +%! sys = append (feedback (G1), feedback (G2)); +%! assert (get (sys, "internaldelay"), [T1; T2], 1e-12); # two channels +%! dsys = c2d (sys, tsam, "zoh"); +%! assert (hasinternaldelay (dsys), true); +%! assert (isdt (dsys), true); +%! assert (get (dsys, "internaldelay"), [T1/tsam; T2/tsam], 1e-10); # 2 and 4 samples +%! assert (size (dsys), [2, 2]); +%! ## Independent reference: each diagonal channel must equal the SISO c2d of +%! ## that loop alone; off-diagonals must be exactly zero (channels decoupled). +%! d1 = c2d (feedback (G1), tsam, "zoh"); +%! d2 = c2d (feedback (G2), tsam, "zoh"); +%! w = [0.1, 0.7, 2]; +%! H = freqresp (dsys, w); H1 = freqresp (d1, w)(:); H2 = freqresp (d2, w)(:); +%! for k = 1:numel (w) +%! assert (H(1,1,k), H1(k), 1e-12); +%! assert (H(2,2,k), H2(k), 1e-12); +%! assert (H(1,2,k), 0, 1e-12); +%! assert (H(2,1,k), 0, 1e-12); +%! endfor diff --git a/inst/@lti/d2c.m b/inst/@lti/d2c.m index 98ab2d4c..a93ea8f8 100644 --- a/inst/@lti/d2c.m +++ b/inst/@lti/d2c.m @@ -230,3 +230,26 @@ %! assert (hasinternaldelay (csys), true); %! assert (csys.internaldelay, T, 1e-10); %! assert (csys.InputDelay, round (0.75 / tsam) * tsam, 1e-10); + +%!test # MIMO InternalDelay: two-channel round-trip c2d->d2c recovers both distinct taus +%! ## Same isolated-SISO-block MIMO fixture as the c2d MIMO test; verify the +%! ## extended-system split is dimension-correct for more than one delay port +%! ## in BOTH directions (samples -> seconds per channel). +%! T1 = 0.4; T2 = 0.8; tsam = 0.2; +%! G1 = ss (-1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", T2); +%! sys = append (feedback (G1), feedback (G2)); +%! dsys = c2d (sys, tsam, "zoh"); +%! csys = d2c (dsys, "zoh"); +%! assert (hasinternaldelay (csys), true); +%! assert (isct (csys), true); +%! assert (get (csys, "internaldelay"), [T1; T2], 1e-10); # both recovered +%! ## freqresp of the round-tripped continuous system matches the original, +%! ## per diagonal channel; off-diagonals stay zero. +%! w = [0.05, 0.3, 1.2]; +%! H = freqresp (csys, w); H0 = freqresp (sys, w); +%! assert (H, H0, 1e-8); +%! for k = 1:numel (w) +%! assert (H(1,2,k), 0, 1e-12); +%! assert (H(2,1,k), 0, 1e-12); +%! endfor diff --git a/inst/@lti/pole.m b/inst/@lti/pole.m index 75dcab37..9335f10d 100644 --- a/inst/@lti/pole.m +++ b/inst/@lti/pole.m @@ -49,6 +49,10 @@ function pol = pole (sys) + if (hasinternaldelay (sys)) + error ("pole: InternalDelay is not yet supported"); + endif + if(nargin == 1) # pole(sys) if(!(isa(sys, "lti")) && issquare(sys)) pol = eig(sys); @@ -128,3 +132,7 @@ %!assert (infp, infp_exp); %!assert (kronr, kronr_exp); %!assert (kronl, kronl_exp); + + +## Test for InternalDelay guard - should error with function-specific message +%!error pole (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@lti/zero.m b/inst/@lti/zero.m index 22a3d961..dca66360 100644 --- a/inst/@lti/zero.m +++ b/inst/@lti/zero.m @@ -143,6 +143,10 @@ print_usage (); endif + if (hasinternaldelay (sys)) + error ("zero: InternalDelay is not yet supported"); + endif + if (strncmpi (type, "invariant", 3)) # invariant zeros, default [zer, gain, info] = __zero__ (sys, nargout); elseif (strncmpi (type, "transmission", 1)) # transmission zeros @@ -455,22 +459,22 @@ ## only for descriptor state-space models with regular 'E' matrices. %!shared z_inv, z_tra, z_inp, z_out, z_sys, z_inv_e, z_tra_e, z_inp_e, z_out_e, z_sys_e %! A = diag ([1, 1, 3, -4, -1, 3]); -%! +%! %! B = [ 0, -1 %! -1, 0 %! 1, -1 %! 0, 0 %! 0, 1 %! -1, -1 ]; -%! +%! %! C = [ 1, 0, 0, 1, 0, 0 %! 0, 1, 0, 1, 0, 1 %! 0, 0, 1, 0, 0, 1 ]; -%! +%! %! D = zeros (3, 2); %! %! E = eye (6); -%! +%! %! SYS = dss (A, B, C, D, E); %! %! z_inv = zero (SYS); @@ -484,9 +488,13 @@ %! z_inp_e = [-4]; %! z_out_e = [-1]; %! z_sys_e = [-4, -1, 2]; -%! -%!assert (z_inv, z_inv_e, 1e-4); -%!assert (z_tra, z_tra_e, 1e-4); -%!assert (z_inp, z_inp_e, 1e-4); -%!assert (z_out, z_out_e, 1e-4); +%! +%!assert (z_inv, z_inv_e, 1e-4); +%!assert (z_tra, z_tra_e, 1e-4); +%!assert (z_inp, z_inp_e, 1e-4); +%!assert (z_out, z_out_e, 1e-4); %!assert (z_sys, z_sys_e, 1e-4); + + +## Test for InternalDelay guard - should error with function-specific message +%!error zero (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@ss/__freqresp__.m b/inst/@ss/__freqresp__.m index bc0604f0..264a612e 100644 --- a/inst/@ss/__freqresp__.m +++ b/inst/@ss/__freqresp__.m @@ -133,3 +133,25 @@ %! Gd = (1 ./ (1i*w + 2)) .* exp (-1i * w * T); %! expected = reshape (Gd ./ (1 + Gd), 1, 1, []); %! assert (freqresp (L, w), expected, 1e-9); + +%!test # MIMO InternalDelay: two channels with DIFFERENT delays, LFT closure per channel +%! ## Genuine 2-in/2-out fixture (append of two independent SISO feedback loops) +%! ## so the internal-delay LFT closure is exercised for more than one port at +%! ## once. Reference is the hand-derived closed form of each decoupled channel +%! ## H_k(jw) = Gk e^{-jwTk} / (1 + Gk e^{-jwTk}), Gk = 1/(jw+ak); off-diagonals +%! ## must be identically zero. +%! T1 = 0.3; a1 = 1; T2 = 0.7; a2 = 2; +%! G1 = ss (-a1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-a2, 1, 1, 0, "IODelay", T2); +%! sys = append (feedback (G1), feedback (G2)); +%! assert (get (sys, "internaldelay"), [T1; T2], 1e-12); +%! w = [0.05, 0.5, 1.7, 4, 11]; +%! H = __freqresp__ (sys, w); +%! Gd1 = (1 ./ (1i*w + a1)) .* exp (-1i*w*T1); e1 = Gd1 ./ (1 + Gd1); +%! Gd2 = (1 ./ (1i*w + a2)) .* exp (-1i*w*T2); e2 = Gd2 ./ (1 + Gd2); +%! for k = 1:numel (w) +%! assert (H(1,1,k), e1(k), 1e-10); +%! assert (H(2,2,k), e2(k), 1e-10); +%! assert (H(1,2,k), 0, 1e-12); +%! assert (H(2,1,k), 0, 1e-12); +%! endfor diff --git a/inst/__time_response__.m b/inst/__time_response__.m index 44186621..ab9839fa 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -617,7 +617,19 @@ N_DEF = 2000; # default number of points T_DEF = 10; # default simulation time - ev = pole (sys); + ## pole() refuses InternalDelay systems (transcendental spectrum), but the + ## horizon estimate only needs a time scale: use the rational-part poles + ## (eigenvalues of the state matrix) of the delay-free dynamics instead. + if (hasinternaldelay (sys)) + [aev, ~, ~, ~, eev] = dssdata (sys, []); + if (isempty (eev)) + ev = eig (aev); + else + ev = eig (aev, eev); + endif + else + ev = pole (sys); + endif TOL = max (abs (ev))*1.0e-10 + 2*eps; # values below TOL are assumed to be zero, # avoid TOL = 0 @@ -786,3 +798,39 @@ %!test # no InternalDelay: unaffected (regression) %! sys = ss (-1, 1, 1, 0); %! assert (isempty (impulse (sys)), false); + +%!test # MIMO InternalDelay step vs independent multi-port delay-buffer reference +%! ## Genuine 2-in/2-out InternalDelay fixture (append of two independent SISO +%! ## feedback loops) with DIFFERENT per-channel delays, so the per-port delay +%! ## buffers cannot be a single shared buffer misused. Reference is a +%! ## hand-written multi-port simulation over the extended-system matrices +%! ## (ssdata/__ss_ext_build__ used as data plumbing only). +%! T1 = 0.4; T2 = 0.8; dt = 0.2; +%! G1 = ss (-1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", T2); +%! sysd = c2d (append (feedback (G1), feedback (G2)), dt, "zoh"); +%! t = (0:dt:5)'; +%! [y, ty] = step (sysd, t); # y is N-by-ny-by-nu +%! [A, B1, C1, D11] = ssdata (sysd); +%! [ext, nu, ny] = __ss_ext_build__ (sysd); +%! [Ae, Be, Ce, De] = ssdata (ext); +%! B2 = Be(:, nu+1:end); C2 = Ce(ny+1:end, :); +%! D12 = De(1:ny, nu+1:end); D21 = De(ny+1:end, 1:nu); D22 = De(ny+1:end, nu+1:end); +%! tau = get (sysd, "internaldelay")(:); +%! assert (numel (tau), 2); # genuinely more than one port +%! assert (tau(1) != tau(2)); # distinct delays +%! np = numel (tau); N = numel (ty); nst = rows (A); +%! for jin = 1:nu +%! u = zeros (nu, 1); u(jin) = 1; +%! xr = zeros (nst, 1); zh = zeros (N, np); yref = zeros (N, ny); +%! for k = 1:N +%! w = zeros (np, 1); +%! for pp = 1:np +%! if (k - tau(pp) >= 1), w(pp) = zh(k - tau(pp), pp); endif +%! endfor +%! yref(k, :) = (C1*xr + D11*u + D12*w).'; +%! zh(k, :) = (C2*xr + D21*u + D22*w).'; +%! xr = A*xr + B1*u + B2*w; +%! endfor +%! assert (y(:, :, jin), yref, 1e-10); +%! endfor diff --git a/inst/lsim.m b/inst/lsim.m index ff44cc28..3dd51b43 100644 --- a/inst/lsim.m +++ b/inst/lsim.m @@ -698,3 +698,40 @@ %! y_zero = lsim (sysd, u, t, 0); %! y_free = lsim (sysd, zeros (size (t)), t, x0); %! assert (y_full, y_zero + y_free, 1e-10); + +%!test # MIMO InternalDelay lsim with nonzero x0 vs independent multi-port reference +%! ## Two-channel InternalDelay fixture (append of two independent SISO feedback +%! ## loops, DIFFERENT delays). Distinct nonzero x0 per state and distinct +%! ## per-channel inputs, checked against a hand-written multi-port delay-buffer +%! ## simulation over the extended-system matrices. +%! T1 = 0.4; T2 = 0.8; dt = 0.2; +%! G1 = ss (-1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", T2); +%! sysd = c2d (append (feedback (G1), feedback (G2)), dt, "zoh"); +%! t = (0:dt:5)'; +%! N = numel (t); +%! u = [sin(t), cos(t)]; +%! [A, B1, C1, D11] = ssdata (sysd); +%! nst = rows (A); +%! x0 = ((1:nst)' - nst/2) / nst; # distinct nonzero entries +%! y = lsim (sysd, u, t, x0); # N-by-ny +%! [ext, nu, ny] = __ss_ext_build__ (sysd); +%! [Ae, Be, Ce, De] = ssdata (ext); +%! B2 = Be(:, nu+1:end); C2 = Ce(ny+1:end, :); +%! D12 = De(1:ny, nu+1:end); D21 = De(ny+1:end, 1:nu); D22 = De(ny+1:end, nu+1:end); +%! tau = get (sysd, "internaldelay")(:); +%! assert (numel (tau), 2); +%! assert (tau(1) != tau(2)); +%! np = numel (tau); +%! xr = x0(:); zh = zeros (N, np); yref = zeros (N, ny); +%! for k = 1:N +%! uk = u(k, :).'; +%! w = zeros (np, 1); +%! for pp = 1:np +%! if (k - tau(pp) >= 1), w(pp) = zh(k - tau(pp), pp); endif +%! endfor +%! yref(k, :) = (C1*xr + D11*uk + D12*w).'; +%! zh(k, :) = (C2*xr + D21*uk + D22*w).'; +%! xr = A*xr + B1*uk + B2*w; +%! endfor +%! assert (y, yref, 1e-10); diff --git a/inst/stepinfo.m b/inst/stepinfo.m index aa76bbee..92eecb9b 100644 --- a/inst/stepinfo.m +++ b/inst/stepinfo.m @@ -269,6 +269,10 @@ error ("stepinfo: first argument must be an lti system\n"); endif + if (hasinternaldelay (sys)) + error ("stepinfo: InternalDelay is not yet supported"); + endif + p = inputParser (); # Use Octvae's input parser for the input arguments p.FunctionName = "stepinfo"; vld_rtlimits = @(x) is_real_matrix (x) && ... @@ -486,3 +490,7 @@ endfunction + +## Test for InternalDelay guard - should error with function-specific message +%!error stepinfo (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) + From 14a644c9f782ec3e0af0910e18b626b4b4e698a2 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 15:22:51 +0800 Subject: [PATCH 08/26] tech debt: dedupe InternalDelay simulation helpers, trim verbose tests Extract __internaldelay_ports__ and __delay_lookup__ (previously byte-for-byte duplicated between __time_response__.m and lsim.m) into shared top-level files. Trim a freqresp() wrapper test that re-derived a closed-form already proven by __freqresp__'s own test, and simplify lsim.m's MIMO IODelay test's manual if/else block-shifting into a loop. --- inst/@ss/__freqresp__.m | 6 +-- inst/__delay_lookup__.m | 44 +++++++++++++++++++ inst/__internaldelay_ports__.m | 35 +++++++++++++++ inst/__time_response__.m | 40 ----------------- inst/lsim.m | 79 +++++++--------------------------- 5 files changed, 97 insertions(+), 107 deletions(-) create mode 100644 inst/__delay_lookup__.m create mode 100644 inst/__internaldelay_ports__.m diff --git a/inst/@ss/__freqresp__.m b/inst/@ss/__freqresp__.m index 264a612e..eb560828 100644 --- a/inst/@ss/__freqresp__.m +++ b/inst/@ss/__freqresp__.m @@ -125,14 +125,12 @@ %! assert (Hc{k}, Ha(:,:,k), 1e-12); %! endfor -%!test # freqresp() top-level wrapper agrees on an internal-delay system +%!test # freqresp() top-level wrapper dispatches to __freqresp__ on an internal-delay system %! T = 0.4; %! G = ss (-2, 1, 1, 0, "IODelay", T); %! L = feedback (G); %! w = [0.1, 1, 3]; -%! Gd = (1 ./ (1i*w + 2)) .* exp (-1i * w * T); -%! expected = reshape (Gd ./ (1 + Gd), 1, 1, []); -%! assert (freqresp (L, w), expected, 1e-9); +%! assert (freqresp (L, w), __freqresp__ (L, w), 1e-12); %!test # MIMO InternalDelay: two channels with DIFFERENT delays, LFT closure per channel %! ## Genuine 2-in/2-out fixture (append of two independent SISO feedback loops) diff --git a/inst/__delay_lookup__.m b/inst/__delay_lookup__.m new file mode 100644 index 00000000..fa7e2f0b --- /dev/null +++ b/inst/__delay_lookup__.m @@ -0,0 +1,44 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +## Delay-buffer lookup: w(port) = z_hist(k - tau(port), port), read as 0 when +## k - tau(port) < 1 (no past sample yet -- the zero-history boundary). At +## k == tau this still reads 0 (index 0); the first real-history read is at +## k == tau + 1, returning z_hist(1) -- i.e. exactly a tau-sample delay, +## matching the integer-sample shift used by __apply_timeresp_delay__. +## Shared by __time_response__.m and lsim.m. +## +## Assumes tau(port) >= 1 for every port: a port whose delay rounds to 0 +## samples (an internal delay smaller than half a sample time) would read +## w(k) = z_hist(k), which is always 0 at read time since z_hist(k) is only +## written later in the same step -- silently dropping that port's D12/D22 +## feedthrough instead of solving the resulting algebraic w=z loop. Not +## reachable via a well-formed nonzero delay; c2d's rounding is expected to +## keep tau >= 1 for any InternalDelay this function is asked to simulate. +function w = __delay_lookup__ (z_hist, k, tau, nports) + w = zeros (nports, 1); + for pp = 1 : nports + idx = k - tau(pp); + if (idx >= 1) + w(pp) = z_hist(idx, pp); + endif + endfor +endfunction diff --git a/inst/__internaldelay_ports__.m b/inst/__internaldelay_ports__.m new file mode 100644 index 00000000..298a8371 --- /dev/null +++ b/inst/__internaldelay_ports__.m @@ -0,0 +1,35 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +## Extract the internal-delay port matrices (B2, C2, D12, D21, D22) and the +## per-port delay tau (whole samples) from a discrete InternalDelay ss model, +## reusing @ss/__ss_ext_build__ (the same helper c2d/d2c use) for data +## extraction only. Shared by __time_response__.m and lsim.m. +function [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys) + [ext, nu, ny] = __ss_ext_build__ (sys); + [~, Be, Ce, De] = ssdata (ext); + B2 = Be(:, nu+1:end); + C2 = Ce(ny+1:end, :); + D12 = De(1:ny, nu+1:end); + D21 = De(ny+1:end, 1:nu); + D22 = De(ny+1:end, nu+1:end); + tau = get (sys, "internaldelay")(:); +endfunction diff --git a/inst/__time_response__.m b/inst/__time_response__.m index ab9839fa..9f7e2937 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -568,46 +568,6 @@ endfunction -## Extract the internal-delay port matrices (B2, C2, D12, D21, D22) and the -## per-port delay tau (whole samples) from a discrete InternalDelay ss model, -## by reusing @ss/__ss_ext_build__ (the same helper c2d/d2c use) purely for -## data extraction. -function [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys_dt) - [ext, nu, ny] = __ss_ext_build__ (sys_dt); - [~, Be, Ce, De] = ssdata (ext); - B2 = Be(:, nu+1:end); - C2 = Ce(ny+1:end, :); - D12 = De(1:ny, nu+1:end); - D21 = De(ny+1:end, 1:nu); - D22 = De(ny+1:end, nu+1:end); - tau = get (sys_dt, "internaldelay")(:); -endfunction - - -## Delay-buffer lookup: w(port) = z_hist(k - tau(port), port), read as 0 when -## k - tau(port) < 1 (no past sample yet -- the zero-history boundary). At -## k == tau this still reads 0 (index 0); the first real-history read is at -## k == tau + 1, returning z_hist(1) -- i.e. exactly a tau-sample delay, -## matching the integer-sample shift used by __apply_timeresp_delay__. -## -## Assumes tau(port) >= 1 for every port: a port whose delay rounds to 0 -## samples (an internal delay smaller than half a sample time) would read -## w(k) = z_hist(k), which is always 0 at read time since z_hist(k) is only -## written later in the same step -- silently dropping that port's D12/D22 -## feedthrough instead of solving the resulting algebraic w=z loop. Not -## reachable via a well-formed nonzero delay; c2d's rounding is expected to -## keep tau >= 1 for any InternalDelay this function is asked to simulate. -function w = __delay_lookup__ (z_hist, k, tau, nports) - w = zeros (nports, 1); - for pp = 1 : nports - idx = k - tau(pp); - if (idx >= 1) - w(pp) = z_hist(idx, pp); - endif - endfor -endfunction - - function [tfinal, dt] = __sim_horizon__ (sys, tfinal, Ts) ## code based on __stepimp__.m of Kai P. Mueller and A. Scottedward Hodel diff --git a/inst/lsim.m b/inst/lsim.m index 3dd51b43..63b7cb82 100644 --- a/inst/lsim.m +++ b/inst/lsim.m @@ -500,33 +500,11 @@ endfunction -## Extract the internal-delay port matrices (B2, C2, D12, D21, D22) and the -## per-port delay tau (whole samples) from a discrete InternalDelay ss model, -## reusing @ss/__ss_ext_build__ (the same helper c2d/d2c use) for data -## extraction only. -function [B2, C2, D12, D21, D22, tau] = __internaldelay_ports__ (sys) - [ext, nu, ny] = __ss_ext_build__ (sys); - [~, Be, Ce, De] = ssdata (ext); - B2 = Be(:, nu+1:end); - C2 = Ce(ny+1:end, :); - D12 = De(1:ny, nu+1:end); - D21 = De(ny+1:end, 1:nu); - D22 = De(ny+1:end, nu+1:end); - tau = get (sys, "internaldelay")(:); -endfunction - - ## One buffered per-sample simulation of an InternalDelay ss model over the ## whole horizon: at each step the internal-delay port input w is read from a ## buffer of past z values (tau samples ago; zero when k - tau < 1 -- the -## zero-history boundary), never the current step's z. Returns the output y -## and state trajectory x_arr; linear in (x_init, umat). -## -## Assumes tau(port) >= 1 for every port, same as __delay_lookup__ in -## __time_response__.m: a port whose delay rounds to 0 samples would read -## w(k) = z_hist(k) before it is written this step, silently dropping that -## port's D12/D22 feedthrough instead of solving the resulting algebraic -## w=z loop. Not reachable via a well-formed nonzero InternalDelay. +## zero-history boundary, see __delay_lookup__), never the current step's z. +## Returns the output y and state trajectory x_arr; linear in (x_init, umat). function [y, x_arr] = __buffered_sim__ (A, B, C, D, B2, C2, D12, D21, D22, ... tau, x_init, umat, urows, p, n) nports = numel (tau); @@ -535,13 +513,7 @@ x_arr = zeros (urows, n); x = x_init; for k = 1 : urows - w = zeros (nports, 1); - for pp = 1 : nports - idx = k - tau(pp); - if (idx >= 1) - w(pp) = z_hist(idx, pp); - endif - endfor + w = __delay_lookup__ (z_hist, k, tau, nports); uk = umat(k, :).'; y(k, :) = C * x + D * uk + D12 * w; z_hist(k, :) = (C2 * x + D21 * uk + D22 * w).'; @@ -597,40 +569,21 @@ %! t = (0:0.05:5)'; %! u = [sin(t), cos(t)]; %! [y, tt] = lsim (sys, u, t); -%! [y0, tt0] = lsim (sys_nodelay, u, t); %! dt = tt(2) - tt(1); %! total = [0.2, 0; 0, 0.4]; -%! ## reconstruct expected per-output response by re-simulating each input alone -%! u1 = [u(:,1), zeros(size(t))]; -%! u2 = [zeros(size(t)), u(:,2)]; -%! [y01] = lsim (sys_nodelay, u1, t); -%! [y02] = lsim (sys_nodelay, u2, t); -%! k11 = round (total(1,1)/dt); k12 = round (total(1,2)/dt); -%! k21 = round (total(2,1)/dt); k22 = round (total(2,2)/dt); -%! if (k11 == 0) -%! s11 = y01(:,1); -%! else -%! s11 = [zeros(k11,1); y01(1:end-k11,1)]; -%! endif -%! if (k12 == 0) -%! s12 = y02(:,1); -%! else -%! s12 = [zeros(k12,1); y02(1:end-k12,1)]; -%! endif -%! if (k21 == 0) -%! s21 = y01(:,2); -%! else -%! s21 = [zeros(k21,1); y01(1:end-k21,2)]; -%! endif -%! if (k22 == 0) -%! s22 = y02(:,2); -%! else -%! s22 = [zeros(k22,1); y02(1:end-k22,2)]; -%! endif -%! expected1 = s11 + s12; -%! expected2 = s21 + s22; -%! assert (y(:,1), expected1, 1e-6); -%! assert (y(:,2), expected2, 1e-6); +%! ## reconstruct expected per-output response by shifting each single-input +%! ## zero-state response by its own total delay, then summing per output +%! expected = zeros (size (y)); +%! for j = 1:2 +%! uj = zeros (size (u)); +%! uj(:, j) = u(:, j); +%! y0j = lsim (sys_nodelay, uj, t); +%! for i = 1:2 +%! k = round (total(i,j)/dt); +%! expected(:,i) += [zeros(k,1); y0j(1:end-k,i)]; +%! endfor +%! endfor +%! assert (y, expected, 1e-6); %!test # nonzero x0 on a delayed ss system: zero-input term shifted by OutputDelay only %! A = -1; B = 1; C = 1; D = 0; From 44056fc579af89bf08e3506ff5cbc819e32bf09e Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 16:02:42 +0800 Subject: [PATCH 09/26] sys_connect: fix uniform-column IODelay bug, add exotic-topology tests __sys_connect_delay__ mis-summed equal-valued IODelay entries in the same column (e.g. two rows both requesting 0.3 would sum to 0.6) and hard-errored via any(sum(iod!=0,1)>1) to avoid returning that wrong value. Replace the summed inport(j) formula with a per-column check over only the genuinely requested (nonzero) entries: if they all agree, use that single value (fixing the equal-value case); if they genuinely differ, keep erroring (rewording the message since this is not a permanent limitation). Also promote three already-working topologies validated during design into permanent regression tests with hand-derived freqresp references: nested feedback around a delayed inner loop, a 3-block delay ring via connect(), and cross-coupled two-block feedback with distinct per-block delays. --- inst/@ss/__sys_connect__.m | 126 +++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 6 deletions(-) diff --git a/inst/@ss/__sys_connect__.m b/inst/@ss/__sys_connect__.m index 45523a53..3b1b37c1 100644 --- a/inst/@ss/__sys_connect__.m +++ b/inst/@ss/__sys_connect__.m @@ -225,12 +225,31 @@ ## ---- 1. absorb I/O delays into series ports --------------------------- ## effective per-input series delay: InputDelay(j) + column j of IODelay - ## (IODelay folding is exact when input j drives a single output) - if (any (sum (iod != 0, 1) > 1)) - error (["__sys_connect__: an input feeding multiple differently-delayed ", ... - "outputs through IODelay is not yet supported"]); - endif - inport = id(:).' + sum (iod, 1); # 1-by-m + ## + ## v(i,j) = InputDelay(j) + IODelay(i,j) is the delay row i would need + ## input j's series port to carry. Only rows with a genuinely REQUESTED + ## (nonzero) IODelay entry are compared for agreement -- rows with + ## IODelay(i,j) == 0 make no request at all (this also naturally excludes + ## rows belonging to an unrelated block padded in by __sys_group__, e.g. + ## the identity compensator feedback() appends, whose IODelay is always + ## zero). Folding column j into a single shared port is exact exactly + ## when every REQUESTING row agrees on the value: the common case (plain + ## InputDelay, or a diagonal IODelay column) and the equal-nonzero-value + ## case (e.g. IODelay = [0.3; 0.3]), which used to be mis-summed to 0.6 + ## and then rejected by the old any(sum(iod!=0,1)>1) guard below. When + ## requesting rows genuinely disagree in value, a single shared + ## state-space column b(:,j) cannot carry two different delays to two + ## different outputs; that decomposition is not implemented yet (error). + inport = id(:).'; # 1-by-m + for j = 1 : mm + vals = unique (iod (iod(:, j) != 0, j)); + if (numel (vals) > 1) + error (["__sys_connect__: an input feeding multiple differently-delayed ", ... + "outputs through IODelay is not yet supported"]); + elseif (numel (vals) == 1) + inport(j) += vals; + endif + endfor outport = od(:).'; # 1-by-p ## A channel's I/O delay is promoted into an internal series delay port ONLY @@ -335,3 +354,98 @@ %! sys = s2 * s1; # feedforward cascade -> mtimes %! assert (get (sys, "inputdelay"), 0.3, 1e-12); %! assert (isempty (get (sys, "internaldelay"))); + + +## Bug fix: a column with 2+ EQUAL nonzero IODelay entries must absorb into +## a single shared port of that value, not error and not silently mis-sum +## (id(j) + sum(iod(:,j)) used to double-count equal values, e.g. 0.3+0.3 = +## 0.6, which is why the old any(sum(iod!=0,1)>1) guard rejected this +## topology outright rather than risk returning that wrong answer). +%!test +%! a = -eye (2); +%! b = eye (2); +%! c = eye (2); +%! d = zeros (2); +%! iod = [0.3, 0; 0.3, 0]; # column 1: two EQUAL nonzero entries +%! G = ss (a, b, c, d, "IODelay", iod); +%! cl = feedback (G); # unity negative feedback, both channels +%! assert (get (cl, "internaldelay"), 0.3, 1e-10); # single 0.3, not 0.6 +%! ## hand-derived closed-form: the plant is decoupled (a diagonal, b = c = +%! ## I), so each channel is an independent SISO loop; channel 1 carries the +%! ## series delay e^{-0.3s}, channel 2 has none. +%! ## T1(s) = g(s) e^{-0.3s} / (1 + g(s) e^{-0.3s}), g(s) = 1/(s+1) +%! ## T2(s) = g(s) / (1 + g(s)) +%! w = 1; +%! g = 1 / (1i*w + 1); +%! T1 = g * exp (-1i*0.3*w) / (1 + g * exp (-1i*0.3*w)); +%! T2 = g / (1 + g); +%! fr = freqresp (cl, w); +%! assert (fr(:, :, 1), [T1, 0; 0, T2], 1e-8); + + +## Exotic topology regression: nested feedback around a delayed inner loop. +## The inner loop's IODelay is trapped by the inner feedback() call, then the +## inner closed loop is cascaded with a further controller and closed again +## by an outer feedback() call. This already worked before this task (the +## inner delay is a single-input/single-output series port throughout, so +## the uniform-column path always applied); promoted here into a permanent +## regression test with a hand-derived closed-form reference. +%!test +%! P = ss (-1, 1, 1, 0, "IODelay", 0.2); +%! H = ss (-3, 1, 1, 0); +%! Inner = feedback (P, H); # inner loop traps the delay +%! assert (get (Inner, "internaldelay"), 0.2, 1e-10); +%! Cc = ss (-2, 1, 1, 0); +%! Outer = feedback (Inner * Cc); # outer unity feedback +%! assert (get (Outer, "internaldelay"), 0.2, 1e-10); +%! ## hand derivation: p(s) = 1/(s+1), h(s) = 1/(s+3), c(s) = 1/(s+2) +%! ## Inner(s) = p e^{-0.2s} / (1 + p e^{-0.2s} h) +%! ## Outer(s) = Inner*c / (1 + Inner*c) +%! w = 1.3; +%! p = 1 / (1i*w + 1); +%! h = 1 / (1i*w + 3); +%! inner_s = p * exp (-1i*0.2*w) / (1 + p * exp (-1i*0.2*w) * h); +%! c = 1 / (1i*w + 2); +%! outer_s = inner_s * c / (1 + inner_s * c); +%! assert (freqresp (Outer, w), outer_s, 1e-8); + + +## Exotic topology regression: a 3-block delay ring closed via connect(), +## each block carrying its own distinct IODelay. Already worked before this +## task (each block's delay is absorbed as its own single-input series +## port); promoted here into a permanent regression test. +%!test +%! G1 = ss (-1, 1, 1, 0, "IODelay", 0.1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", 0.15); +%! G3 = ss (-3, 1, 1, 0, "IODelay", 0.05); +%! Gall = append (G1, G2, G3); +%! ## ring: u1 = r - y3 , u2 = y1 , u3 = y2 +%! cm = [1, -3; 2, 1; 3, 2]; +%! Ring = connect (Gall, cm, 1, 1); +%! assert (sort (get (Ring, "internaldelay")), sort ([0.1; 0.15; 0.05]), 1e-10); +%! ## hand derivation: g_k(s) = 1/(s+k) e^{-tau_k s}, loop gain L = g1 g2 g3 +%! ## Ring(s) = g1 / (1 + L) (transfer from external input r to y1) +%! w = 0.7; +%! g1 = 1 / (1i*w + 1) * exp (-1i*0.1*w); +%! g2 = 1 / (1i*w + 2) * exp (-1i*0.15*w); +%! g3 = 1 / (1i*w + 3) * exp (-1i*0.05*w); +%! ring_s = g1 / (1 + g1*g2*g3); +%! assert (freqresp (Ring, w), ring_s, 1e-8); + + +## Exotic topology regression: cross-coupled two-block feedback where each +## block carries its own distinct IODelay (as opposed to a single scalar +## delay in only the forward path). Already worked before this task; +## promoted here into a permanent regression test. +%!test +%! Ga = ss (-1, 1, 1, 0, "IODelay", 0.2); +%! Gb = ss (-2, 1, 1, 0, "IODelay", 0.4); +%! Cross = feedback (Ga, Gb); +%! assert (sort (get (Cross, "internaldelay")), sort ([0.2; 0.4]), 1e-10); +%! ## hand derivation: ga(s) = 1/(s+1) e^{-0.2s}, gb(s) = 1/(s+2) e^{-0.4s} +%! ## Cross(s) = ga / (1 + ga*gb) +%! w = 0.9; +%! ga = 1 / (1i*w + 1) * exp (-1i*0.2*w); +%! gb = 1 / (1i*w + 2) * exp (-1i*0.4*w); +%! cross_s = ga / (1 + ga*gb); +%! assert (freqresp (Cross, w), cross_s, 1e-8); From ec9c4855ac61e85d50df3784d6efb63826cb3ef1 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 16:23:30 +0800 Subject: [PATCH 10/26] sys_connect: decompose dense per-entry IODelay via shadow states A column whose requesting IODelay rows disagree can no longer be folded into one shared series port: a single actuator column b(:,j) cannot carry two different delays to two different outputs. Decompose such a column onto an independent n-state shadow block (x_j' = A x_j + b(:,j) e_j) and tap each output row off it with its own required delay -- a zero-delay row becomes a direct identity feedthrough (no port), a nonzero-delay row an output-side port. One shadow block per decomposed column, shared across its rows. Rows with no genuine open-loop dependence on input j are left untouched. Also fixes the gating bug where the differing-value case was decided before the in_touched(j) check: an IODelay column that M never references now passes straight through unchanged instead of erroring, matching the uniform path's existing gating. Removes the now-obsolete 'not yet supported' hard error. Adds dense, mixed zero/nonzero, and untouched-column regression tests; converts the former error test in feedback.m into a decomposition test. --- inst/@lti/feedback.m | 34 +++-- inst/@ss/__sys_connect__.m | 260 ++++++++++++++++++++++++++++++------- 2 files changed, 238 insertions(+), 56 deletions(-) diff --git a/inst/@lti/feedback.m b/inst/@lti/feedback.m index 97f39a27..ec808f04 100644 --- a/inst/@lti/feedback.m +++ b/inst/@lti/feedback.m @@ -339,16 +339,28 @@ %! cl_cn = connect (Gall, cm, 1, 1); %! assert (get (cl_cn, "internaldelay"), get (cl_fb, "internaldelay"), 1e-10); -## MIMO plant whose IODelay column has more than one nonzero entry (one -## input feeding two differently-delayed outputs). Folding IODelay into a -## single per-input series delay is only exact when an input drives a -## single delayed output; this topology is outside that scope and must -## error clearly rather than silently return a wrong InternalDelay. -%!error -%! a = -eye (2); -%! b = eye (2); +## MIMO plant whose IODelay column has more than one differently-valued +## nonzero entry (one input feeding two differently-delayed outputs). A single +## shared actuator column cannot carry two different delays, so the column is +## decomposed onto an independent shadow-state block (one per decomposed +## column) and each output taps it with its own delay. This used to error +## ("not yet supported"); it is now realised exactly. Coupled A so both +## outputs genuinely depend on the shared input. +%!test +%! a = [-1, 0.4; 0, -2]; +%! b = [1; 1]; # single input drives both states %! c = eye (2); -%! d = zeros (2); -%! iod = [0.1, 0; 0.2, 0]; # column 1 has two nonzero entries +%! d = [0; 0]; +%! iod = [0.2; 0.5]; # one input, two differently-delayed outputs %! G = ss (a, b, c, d, "IODelay", iod); -%! feedback (G); +%! cl = feedback (G, [1, 1]); # close the single loop (sum of outputs) +%! assert (rows (ssdata (cl)), 4); # 2 original + 2 shadow (one decomposed col) +%! assert (sort (get (cl, "internaldelay")), [0.2; 0.5], 1e-12); +%! w = [0.5, 1.3, 3.0]; +%! for k = 1 : numel (w) +%! s = 1i*w(k); +%! T = c/(s*eye(2) - a)*b + d; # 2x1 open-loop transfer +%! Gt = T .* exp (-1i*w(k)*iod); # per-entry delayed +%! Href = Gt / (1 + [1, 1]*Gt); # feedback (G, [1 1]): e = u - [1 1]y +%! assert (freqresp (cl, w(k)), Href, 1e-9); +%! endfor diff --git a/inst/@ss/__sys_connect__.m b/inst/@ss/__sys_connect__.m index 3b1b37c1..16a89149 100644 --- a/inst/@ss/__sys_connect__.m +++ b/inst/@ss/__sys_connect__.m @@ -206,6 +206,18 @@ n = rows (a); [p, mm] = size (d); + ## Pristine copies of the open-loop plant, captured before any absorption or + ## state augmentation modifies the working copies. All shadow-state + ## decomposition below reconstructs "the part of output i due to input j" + ## from these originals: the shadow block replays the ORIGINAL A0 driven by + ## the ORIGINAL b0(:,j), read out through the ORIGINAL c0(i,:)/d0(i,j). + A0 = a; + b0 = b; + c0 = c; + d0 = d; + n0 = n; # shadow block size (per decomposed col) + stname = sys.stname; + ## ---- initialise delay-port blocks (existing ports, or empty) ---------- if (isempty (sys.b2)) B2 = zeros (n, 0); @@ -223,35 +235,6 @@ tau = sys.internaldelay(:); endif - ## ---- 1. absorb I/O delays into series ports --------------------------- - ## effective per-input series delay: InputDelay(j) + column j of IODelay - ## - ## v(i,j) = InputDelay(j) + IODelay(i,j) is the delay row i would need - ## input j's series port to carry. Only rows with a genuinely REQUESTED - ## (nonzero) IODelay entry are compared for agreement -- rows with - ## IODelay(i,j) == 0 make no request at all (this also naturally excludes - ## rows belonging to an unrelated block padded in by __sys_group__, e.g. - ## the identity compensator feedback() appends, whose IODelay is always - ## zero). Folding column j into a single shared port is exact exactly - ## when every REQUESTING row agrees on the value: the common case (plain - ## InputDelay, or a diagonal IODelay column) and the equal-nonzero-value - ## case (e.g. IODelay = [0.3; 0.3]), which used to be mis-summed to 0.6 - ## and then rejected by the old any(sum(iod!=0,1)>1) guard below. When - ## requesting rows genuinely disagree in value, a single shared - ## state-space column b(:,j) cannot carry two different delays to two - ## different outputs; that decomposition is not implemented yet (error). - inport = id(:).'; # 1-by-m - for j = 1 : mm - vals = unique (iod (iod(:, j) != 0, j)); - if (numel (vals) > 1) - error (["__sys_connect__: an input feeding multiple differently-delayed ", ... - "outputs through IODelay is not yet supported"]); - elseif (numel (vals) == 1) - inport(j) += vals; - endif - endfor - outport = od(:).'; # 1-by-p - ## A channel's I/O delay is promoted into an internal series delay port ONLY ## when that channel actually participates in the connection matrix M (i.e. ## it is genuinely cascaded or fed back). A delay on a channel that M never @@ -268,29 +251,118 @@ absorbed_in = false (1, mm); absorbed_out = false (1, p); - ## input-side ports first (they append columns that output ports may read) + ## ---- 1. absorb / decompose I/O delays on M-touched input columns ------ + ## effective per-entry required series delay: + ## v(i,j) = InputDelay(j) + IODelay(i,j) + ## is the delay output row i needs input j's contribution to carry. Only + ## rows with a genuinely REQUESTED (nonzero) IODelay entry are compared for + ## agreement -- rows with IODelay(i,j) == 0 make no request, which also + ## naturally excludes rows belonging to an unrelated block padded in by + ## __sys_group__ (e.g. the identity compensator feedback() appends, whose + ## IODelay is always zero). + ## + ## * UNIFORM column (every requesting row agrees, incl. none requesting): + ## fold column j into a single shared series port -- the cheap path, + ## no new states. Covers plain InputDelay, a diagonal IODelay column, + ## and the equal-nonzero-value case (e.g. IODelay = [0.3; 0.3]). + ## + ## * NON-UNIFORM column (requesting rows genuinely disagree): a single + ## shared state-space column b(:,j) cannot carry two different delays to + ## two different outputs. Decompose: append an independent n0-state + ## shadow block replaying the open-loop plant driven by input j alone, + ## then tap each output row off that block with its own required delay + ## (a zero-delay row becomes a direct feedthrough tap, not a port). + ## + ## Untouched columns (M never references input j) are skipped entirely here + ## and pass straight through as ordinary lti I/O delays. The decision is + ## made per column BEFORE any absorption so an untouched column is never + ## decomposed nor absorbed. for j = 1 : mm - if (inport(j) > 0 && in_touched(j)) - q = columns (B2); - B2 = [B2, b(:,j)]; - D12 = [D12, d(:,j)]; - C2 = [C2; zeros(1,n)]; - D21 = [D21; full(sparse(1,j,1,1,mm))]; - D22 = [D22, zeros(q,1); zeros(1,q), 0]; - tau = [tau; inport(j)]; - b(:,j) = 0; # input j now reaches plant via port - d(:,j) = 0; - absorbed_in(j) = true; + if (! in_touched(j)) + continue; # free external delay: pass through + endif + + vals = unique (iod (iod(:, j) != 0, j)); + + if (numel (vals) > 1) + ## ---- NON-UNIFORM: shadow-state decomposition of column j ---------- + bj = b0(:, j); # pristine input-j actuator column + + ## column j no longer reaches the main state or output directly + b(:, j) = 0; + d(:, j) = 0; + + ## append an independent n0-state shadow block x_j' = A0 x_j + bj e_j + N = columns (a); # current total state count + sh = N + 1 : N + n0; # new shadow-state indices + a = blkdiag (a, A0); + b = [b; zeros(n0, mm)]; b(sh, j) = bj; + c = [c, zeros(p, n0)]; # taps fill these shadow columns + B2 = [B2; zeros(n0, columns(B2))]; # existing ports drive no shadow state + C2 = [C2, zeros(rows(C2), n0)]; # existing ports read no shadow state + stname = [stname; repmat({""}, n0, 1)]; + + for i = 1 : p + vi = id(j) + iod(i, j); + ## does output i genuinely depend on input j in the open-loop plant? + ## (T_ij(s) identically zero => structural/padding zero => leave alone) + if (! __depends_on__ (A0, bj, c0(i,:), d0(i,j))) + continue; + endif + if (vi == 0) + ## zero-delay entry: a TRUE identity tap into the main output + ## equations (no port, no tau entry) reading the shadow block. + c(i, sh) += c0(i,:); + d(i, j) += d0(i, j); + else + ## nonzero-delay entry: one output-side delay port reading ONLY the + ## shadow block (C(i,:) x_j + D(i,j) e_j), summed into output i via + ## D12 -- the same output-port pattern used below, but reading the + ## shadow states instead of the main state. + q = columns (B2); + B2 = [B2, zeros(rows(a), 1)]; # port drives no state + c2row = zeros (1, columns(a)); c2row(sh) = c0(i,:); + C2 = [C2; c2row]; + d21row = zeros (1, mm); d21row(j) = d0(i, j); + D21 = [D21; d21row]; + D22 = [D22, zeros(q,1); zeros(1,q), 0]; + newcol = zeros (p, 1); newcol(i) = 1; # output i += delayed port + D12 = [D12, newcol]; + tau = [tau; vi]; + endif + endfor + + absorbed_in(j) = true; # clears InputDelay/IODelay column j + + else + ## ---- UNIFORM: cheap single shared series port --------------------- + coldelay = id(j); + if (numel (vals) == 1) + coldelay += vals; + endif + if (coldelay > 0) + q = columns (B2); + B2 = [B2, b(:,j)]; + D12 = [D12, d(:,j)]; + C2 = [C2; zeros(1, columns(a))]; + D21 = [D21; full(sparse(1,j,1,1,mm))]; + D22 = [D22, zeros(q,1); zeros(1,q), 0]; + tau = [tau; coldelay]; + b(:,j) = 0; # input j now reaches plant via port + d(:,j) = 0; + absorbed_in(j) = true; + endif endif endfor - ## output-side ports (read the possibly-updated c/d/D12 rows) + ## ---- 1b. output-side OutputDelay ports (read updated c/d/D12 rows) ---- + outport = od(:).'; # 1-by-p for i = 1 : p if (outport(i) > 0 && out_touched(i)) q = columns (B2); ## new port picks up the internal (pre-delay) output-i signal: ## z = c(i,:) x + d(i,:) e + D12(i,:) w_existing - B2 = [B2, zeros(n,1)]; # port does not drive states + B2 = [B2, zeros(rows(a),1)]; # port does not drive states C2 = [C2; c(i,:)]; D21 = [D21; d(i,:)]; D22 = [D22, zeros(q,1); D12(i,:), 0]; @@ -308,8 +380,8 @@ ## Clear ONLY the I/O delays that were actually absorbed into ports; delays ## on channels that M never touched pass through unchanged. IODelay(i,j) is - ## folded into input j's series delay, so it is cleared exactly when input j - ## was absorbed. + ## folded into input j's series delay/decomposition, so it is cleared exactly + ## when input j was absorbed. id_new = id(:); id_new(absorbed_in) = 0; od_new = od(:); od_new(absorbed_out) = 0; iod_new = iod; iod_new(:, absorbed_in) = 0; @@ -336,11 +408,36 @@ sys.d22 = D22 + D21*m/z*D12; sys.internaldelay = tau; + sys.stname = stname; sys.e = []; endfunction +## Does open-loop output i genuinely depend on input j? True iff the SISO +## transfer T(s) = c0*(sI - A0)^{-1}*bj + d0 is not identically zero. A +## rational T that vanishes at two generic (non-eigenvalue) probe points is +## the zero function, so two probes suffice. Used to leave structurally +## unreachable rows (e.g. the zero-padded compensator block feedback() +## appends, or a genuinely decoupled output) untouched during decomposition, +## rather than build a spurious zero tap/port for them. +function tf = __depends_on__ (A0, bj, c0, d0) + if (d0 != 0) + tf = true; + return; + endif + tf = false; + n = rows (A0); + for s = [0.7307+1.2531i, 2.1049-0.9137i] + val = c0 * ((s*eye(n) - A0) \ bj) + d0; + if (abs (val) > 1e-9) + tf = true; + return; + endif + endfor +endfunction + + ## Regression: a delay on a channel the connection matrix M never touches must ## pass straight through as an ordinary I/O delay -- NOT be promoted into an ## internal delay port. In a feedforward cascade sys2*sys1, sys1's InputDelay @@ -449,3 +546,76 @@ %! gb = 1 / (1i*w + 2) * exp (-1i*0.4*w); %! cross_s = ga / (1 + ga*gb); %! assert (freqresp (Cross, w), cross_s, 1e-8); + + +## Task 2 -- dense per-entry IODelay decomposition (shadow-state augmentation). +## Genuinely coupled 2-in/2-out plant (non-diagonal A) with an IODelay column +## whose two requesting rows disagree (0.4 vs 0.6): a single shared actuator +## column cannot carry both delays, so input 2 is decomposed onto its own +## independent n-state shadow block, and each output taps that block with its +## own delay. Input 1 is a uniform column (single 0.3) taking the cheap path. +## Reference: build the true delayed open-loop transfer G(jw) entrywise as +## T_ij(jw) e^{-jw*IODelay(i,j)} from the open-loop ssdata, then close unity +## negative feedback in closed form H = (I + G)^{-1} G. +%!test +%! A = [-1, 0.5; 0, -2]; # non-diagonal => coupled dynamics +%! B = eye (2); C = eye (2); D = zeros (2); +%! iod = [0.3, 0.4; 0, 0.6]; # col 1 uniform (0.3); col 2 differs +%! G = ss (A, B, C, D, "IODelay", iod); +%! cl = feedback (G); +%! ## exactly ONE decomposed column (input 2) => n_original + n = 2 + 2 = 4 +%! assert (rows (ssdata (cl)), 4); +%! ## ports: uniform 0.3 (col 1) + two decomposed taps 0.4, 0.6 (col 2) +%! assert (sort (get (cl, "internaldelay")), [0.3; 0.4; 0.6], 1e-12); +%! w = [0.3, 0.9, 1.7, 4.0]; +%! for k = 1 : numel (w) +%! s = 1i*w(k); +%! T = C/(s*eye(2) - A)*B + D; # open-loop rational transfer +%! Gt = T .* exp (-1i*w(k)*iod); # per-entry delayed open loop +%! Href = (eye(2) + Gt) \ Gt; # unity negative feedback, closed form +%! assert (freqresp (cl, w(k)), Href, 1e-9); +%! endfor + + +## Task 2 -- mixed zero/nonzero rows within a single decomposed column. +## Coupled 3-state plant (state 1 feeds states 2 and 3). Column 1 requests +## delays [0.5; 0.7; 0]: rows 1 and 2 disagree so the column decomposes; row 3 +## genuinely depends on input 1 (through the coupling) but requests ZERO delay, +## so it must be wired as a TRUE identity tap -- a direct feedthrough off the +## shadow block, NOT a delay port. Hence internaldelay carries only 0.5 and +## 0.7 (no tau=0 entry), and exactly one shadow block (n=3) is added. +%!test +%! A = [-1, 0, 0; 0.3, -2, 0; 0.2, 0, -3]; # lower-tri: state1 -> states 2,3 +%! B = eye (3); C = eye (3); D = zeros (3); +%! iod = zeros (3); iod(1,1) = 0.5; iod(2,1) = 0.7; # col1 = [0.5; 0.7; 0] +%! G = ss (A, B, C, D, "IODelay", iod); +%! cl = feedback (G); +%! assert (rows (ssdata (cl)), 6); # 3 original + 3 shadow (one col) +%! d = get (cl, "internaldelay"); +%! assert (sort (d), [0.5; 0.7], 1e-12); # two ports only; NO tau = 0 +%! assert (! any (d == 0)); # identity tap created no port +%! w = [0.4, 1.1, 3.0]; +%! for k = 1 : numel (w) +%! s = 1i*w(k); +%! T = C/(s*eye(3) - A)*B + D; +%! Gt = T .* exp (-1i*w(k)*iod); # row 3 col 1 undelayed (tau = 0) +%! Href = (eye(3) + Gt) \ Gt; +%! assert (freqresp (cl, w(k)), Href, 1e-9); +%! endfor + + +## Task 2 -- gating: a dense DIFFERING IODelay column that M never touches must +## pass straight through, NOT decompose and NOT error. In a feedforward +## cascade sys2*sys1 the inputs of sys1 are free external inputs (their M rows +## are all zero), so sys1's dense IODelay column is untouched. Before this +## task the differing-value guard errored here even though M never references +## the column; the fix restricts the decomposition decision to touched columns +## only. The untouched column must add NO shadow states (state count stays +## n(sys2)+n(sys1) = 4, not 6) and produce no internal delay port. +%!test +%! A = [-1, 0.5; 0, -2]; +%! s1 = ss (A, eye(2), eye(2), zeros(2), "IODelay", [0, 0.4; 0, 0.6]); +%! s2 = ss (diag ([-3, -4]), eye(2), eye(2), zeros(2)); +%! sys = s2 * s1; # feedforward cascade: no error +%! assert (isempty (get (sys, "internaldelay"))); # untouched => no port +%! assert (rows (ssdata (sys)), 4); # no shadow block appended From b2d67d8a33ff29e7f206722bc6d7c908e85d4987 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 20:45:18 +0800 Subject: [PATCH 11/26] lti: add pade() for tf/zpk with shared per-delay order-vector helper Adds @lti/pade.m, replacing InputDelay/OutputDelay/IODelay on tf and zpk models with an nth-order Pade rational approximation (ss not yet supported). Order can be a scalar (applied to every nonzero delay) or a vector, one entry per nonzero delay. The per-entry order-vector computation is factored into @lti/__pade_order_vector__.m so it can be reused by the upcoming ss-specific pade dispatches, with the entry ordering convention documented in the helper's header. Guard against querying InternalDelay on tf/zpk models, since that property is ss-specific and get() rejects the key for other classes. --- inst/@lti/__pade_order_vector__.m | 87 +++++++++++ inst/@lti/pade.m | 232 ++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 inst/@lti/__pade_order_vector__.m create mode 100644 inst/@lti/pade.m diff --git a/inst/@lti/__pade_order_vector__.m b/inst/@lti/__pade_order_vector__.m new file mode 100644 index 00000000..256e5e7e --- /dev/null +++ b/inst/@lti/__pade_order_vector__.m @@ -0,0 +1,87 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## Compute the per-nonzero-delay Pade approximation order vector for +## pade(sys, n). +## +## Shared helper for @lti/pade.m (tf/zpk) and the future ss-specific pade +## dispatches (Tasks 2/3) -- build the per-entry order list once here and +## have every case index into it consistently. +## +## Ordering convention (this codebase's own, since MATLAB's internal +## ordering isn't documented/verifiable): the total nonzero-delay count, +## and the order in which "n" (if a vector) is consumed, walks: +## 1. InputDelay nonzero entries, in input order. +## 2. OutputDelay nonzero entries, in output order. +## 3. IODelay nonzero entries, in column-major (Octave linear index) order. +## 4. InternalDelay ports, in the existing internal port order returned +## by get(sys, "internaldelay") (always empty for tf/zpk, since those +## types can never carry InternalDelay). +## +## Inputs: +## sys - an LTI model. +## n - scalar or vector Pade order. If scalar, the same order is +## used for every nonzero delay. If a vector, its length must +## equal the total nonzero-delay count (computed per the ordering +## above); anything else is an error. +## +## Outputs: +## orders - column vector of per-delay-entry orders, length equal to the +## total nonzero-delay count, in the documented order above. +## idx - struct with fields "input", "output", "iod", "internal", each +## a vector of linear/element indices (into InputDelay, +## OutputDelay, IODelay(:), and internaldelay(:) respectively) +## identifying which entries are nonzero, in the same order +## "orders" was built in -- so callers can walk +## idx.input/idx.output/idx.iod/idx.internal in lockstep with +## orders(1:numel(idx.input)), orders(numel(idx.input)+1 : ...), +## etc. +function [orders, idx] = __pade_order_vector__ (sys, n) + + if (nargin != 2 || ! isa (sys, "lti")) + print_usage (); + endif + + [indelay, outdelay, iodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); + + ## InternalDelay is a ss-specific property; get (sys, "internaldelay") + ## errors for tf/zpk models, so only query it for ss (always empty for + ## tf/zpk, matching the documented ordering convention above). + if (isa (sys, "ss")) + internaldelay = get (sys, "internaldelay"); + else + internaldelay = []; + endif + + idx.input = find (indelay(:) != 0); + idx.output = find (outdelay(:) != 0); + idx.iod = find (iodelay(:) != 0); + idx.internal = find (internaldelay(:) != 0); + + total = numel (idx.input) + numel (idx.output) + numel (idx.iod) + numel (idx.internal); + + if (isscalar (n)) + orders = repmat (n, total, 1); + else + if (numel (n) != total) + error ("pade: order vector length (%d) does not match the number of nonzero delays (%d)", + numel (n), total); + endif + orders = n(:); + endif + +endfunction diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m new file mode 100644 index 00000000..0190eb4f --- /dev/null +++ b/inst/@lti/pade.m @@ -0,0 +1,232 @@ +## Copyright (C) 2026 Prateek Ganguli +## +## This file is part of LTI Syncope. +## +## LTI Syncope is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or +## (at your option) any later version. +## +## LTI Syncope is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with LTI Syncope. If not, see . + +## -*- texinfo -*- +## @deftypefn {Function File} {@var{sys} =} pade (@var{sys}, @var{n}) +## Replace every delay (@var{InputDelay}, @var{OutputDelay}, @var{IODelay}) +## of the @acronym{LTI} model @var{sys} by an @var{n}th-order Pade +## approximation, returning a delay-free equivalent model. +## +## @strong{Inputs} +## @table @var +## @item sys +## @var{tf} or @var{zpk} model (@var{ss} models are not yet supported). +## @item n +## Order of the Pade approximation. Either a scalar (applied to every +## nonzero delay) or a vector with one entry per nonzero delay -- see +## @code{__pade_order_vector__} for the exact ordering convention. +## @end table +## +## @strong{Outputs} +## @table @var +## @item sys +## Delay-free @var{tf} or @var{zpk} model with the Pade-approximated +## rational factors absorbed into each entry's numerator/denominator +## (or zeros/poles). +## @end table +## @end deftypefn + +## Author: Prateek Ganguli +## Created: July 2026 +## Version: 0.1 + +function sys = pade (sys, n) + + if (nargin != 2 || ! isa (sys, "lti")) + print_usage (); + endif + + if (isa (sys, "ss")) + error ("pade: ss models are not yet supported"); + endif + + if (! (isa (sys, "tf") || isa (sys, "zpk"))) + error ("pade: only tf and zpk models are supported (ss not yet implemented)"); + endif + + if (! hasdelay (sys)) + return; + endif + + [indelay, outdelay, iodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); + total = totaldelay (sys); + [pr, pc] = size (total); + + [orders, idx] = __pade_order_vector__ (sys, n); + + n_in = numel (idx.input); + n_out = numel (idx.output); + n_iod = numel (idx.iod); + + orders_input = orders (1 : n_in); + orders_output = orders (n_in + 1 : n_in + n_out); + orders_iod = orders (n_in + n_out + 1 : n_in + n_out + n_iod); + + ## Build a per-entry order matrix. Priority (lowest to highest, later + ## assignments win on conflict): OutputDelay broadcast across a row, + ## InputDelay broadcast across a column, IODelay assigned directly to + ## its own (i,j) entry -- IODelay is the most entry-specific, so it + ## takes precedence if more than one delay kind is nonzero for the same + ## entry. Test fixtures in this task only ever set one delay kind at a + ## time per entry, so no conflict arises in practice. + order_matrix = nan (pr, pc); + + for k = 1 : n_out + i = idx.output (k); + order_matrix (i, :) = orders_output (k); + endfor + + for k = 1 : n_in + j = idx.input (k); + order_matrix (:, j) = orders_input (k); + endfor + + for k = 1 : n_iod + [i, j] = ind2sub ([pr, pc], idx.iod (k)); + order_matrix (i, j) = orders_iod (k); + endfor + + origsys = sys; + + if (isa (sys, "zpk")) + + [z, p, k, tsam] = zpkdata (sys); + + for i = 1 : pr + for j = 1 : pc + d = total (i, j); + if (d > 0) + order = order_matrix (i, j); + [num_p, den_p] = pade (d, order); + gain_factor = num_p(1) / den_p(1); + z{i,j} = [z{i,j}; roots(num_p)]; + p{i,j} = [p{i,j}; roots(den_p)]; + k(i,j) = k(i,j) * gain_factor; + endif + endfor + endfor + + sys = zpk (z, p, k, tsam); + + else + + [num, den, tsam] = tfdata (sys); + + for i = 1 : pr + for j = 1 : pc + d = total (i, j); + if (d > 0) + order = order_matrix (i, j); + [num_p, den_p] = pade (d, order); + num{i,j} = conv (num{i,j}, num_p); + den{i,j} = conv (den{i,j}, den_p); + endif + endfor + endfor + + sys = tf (num, den, tsam); + + endif + + sys = set (sys, "lti", origsys); + sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + +endfunction + + +%!test # SISO tf with InputDelay: freqresp matches hand-multiplied reference +%! T = 0.5; +%! sys = tf (1, [1 2], "InputDelay", T); +%! sys2 = pade (sys, 3); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! [num_p, den_p] = pade (T, 3); +%! expected = freqresp (tf (1, [1 2]), w) .* freqresp (tf (num_p, den_p), w); +%! assert (freqresp (sys2, w), expected, 1e-8); + +%!test # SISO tf with InputDelay, a different order +%! T = 0.5; +%! sys = tf (1, [1 2], "InputDelay", T); +%! sys2 = pade (sys, 5); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! [num_p, den_p] = pade (T, 5); +%! expected = freqresp (tf (1, [1 2]), w) .* freqresp (tf (num_p, den_p), w); +%! assert (freqresp (sys2, w), expected, 1e-8); + +%!test # dense MIMO tf with differing per-channel IODelay +%! sys = tf ({1, 1; 1, 1}, {[1 2], [1 3]; [1 4], [1 5]}); +%! sys = set (sys, "IODelay", [0.2, 0.3; 0.4, 0.5]); +%! sys2 = pade (sys, 3); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! rational = tf ({1, 1; 1, 1}, {[1 2], [1 3]; [1 4], [1 5]}); +%! total = [0.2, 0.3; 0.4, 0.5]; +%! resp = freqresp (rational, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! [num_p, den_p] = pade (total(i,j), 3); +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* reshape (freqresp (tf (num_p, den_p), w), 1, []); +%! endfor +%! endfor +%! assert (freqresp (sys2, w), expected, 1e-6); + +%!test # equivalent zpk case, SISO with OutputDelay +%! T = 0.3; +%! sys = zpk ([], -2, 3, "OutputDelay", T); +%! sys2 = pade (sys, 4); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! [num_p, den_p] = pade (T, 4); +%! expected = freqresp (zpk ([], -2, 3), w) .* freqresp (tf (num_p, den_p), w); +%! assert (freqresp (sys2, w), expected, 1e-8); + +%!test # equivalent zpk case, dense MIMO with IODelay +%! z = {[], []; [], []}; +%! p = {-2, -3; -4, -5}; +%! k = [1, 1; 1, 1]; +%! sys = zpk (z, p, k); +%! sys = set (sys, "IODelay", [0.2, 0.3; 0.4, 0.5]); +%! sys2 = pade (sys, 3); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! rational = zpk (z, p, k); +%! total = [0.2, 0.3; 0.4, 0.5]; +%! resp = freqresp (rational, w); +%! expected = zeros (2, 2, numel (w)); +%! for i = 1:2 +%! for j = 1:2 +%! [num_p, den_p] = pade (total(i,j), 3); +%! expected(i,j,:) = reshape (resp(i,j,:), 1, []) .* reshape (freqresp (tf (num_p, den_p), w), 1, []); +%! endfor +%! endfor +%! assert (freqresp (sys2, w), expected, 1e-6); + +%!test # per-delay order vector: each entry keeps its own assigned order +%! # 1x2 tf, two nonzero IODelay entries with distinct Pade orders 2 and 5. +%! # Each substituted entry's denominator degree must equal its own +%! # rational-part degree (1, since den = [1 2] or [1 3]) plus its own +%! # assigned Pade order -- checked individually, not in aggregate. +%! sys = tf ({1, 1}, {[1 2], [1 3]}); +%! sys = set (sys, "IODelay", [0.2, 0.4]); +%! sys2 = pade (sys, [2, 5]); +%! [num, den] = tfdata (sys2); +%! assert (length (den{1,1}) - 1, 1 + 2); +%! assert (length (den{1,2}) - 1, 1 + 5); + +%!error pade (tf (1, [1 2], "InputDelay", 0.5), [1, 2]) From 587953a65cc8593e848ab0c927f499719b3217ed Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 20:54:34 +0800 Subject: [PATCH 12/26] lti/pade: add ss ordinary-delay support via tf roundtrip Widen pade.m's ss branch: for ss inputs with hasinternaldelay(sys)==false, convert to tf, defer to the same per-entry substitution loop already used for tf/zpk (now extracted into a shared __pade_substitute_ordinary__ subfunction to avoid duplicating it), then convert back via ss(). This does not preserve the original state basis or state count, matching MATLAB's own pade() semantics. ss inputs with a nonzero InternalDelay still error, now with a message specific to that case (InternalDelay not yet supported) rather than the old blanket "ss not yet supported" -- Task 3 will remove this guard. Adds a cross-check test comparing the ss-roundtrip path against the tf-direct path on the same dense per-entry IODelay system, plus a test for the InternalDelay guard's new message. --- inst/@lti/pade.m | 56 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m index 0190eb4f..0dbe8731 100644 --- a/inst/@lti/pade.m +++ b/inst/@lti/pade.m @@ -24,7 +24,8 @@ ## @strong{Inputs} ## @table @var ## @item sys -## @var{tf} or @var{zpk} model (@var{ss} models are not yet supported). +## @var{tf}, @var{zpk}, or @var{ss} model. @var{ss} models with a nonzero +## @var{InternalDelay} are not yet supported. ## @item n ## Order of the Pade approximation. Either a scalar (applied to every ## nonzero delay) or a vector with one entry per nonzero delay -- see @@ -34,9 +35,13 @@ ## @strong{Outputs} ## @table @var ## @item sys -## Delay-free @var{tf} or @var{zpk} model with the Pade-approximated -## rational factors absorbed into each entry's numerator/denominator -## (or zeros/poles). +## Delay-free equivalent model with the Pade-approximated rational factors +## absorbed into each entry's numerator/denominator (or zeros/poles, or +## state-space realization). For @var{ss} inputs, the result is obtained +## via a @code{tf} roundtrip and does @strong{not} preserve the original +## state basis or state count (matching @acronym{MATLAB}'s own +## @code{pade()}, which likewise only guarantees an equivalent model, not +## a minimal one -- use @code{minreal} afterward if minimality is needed). ## @end table ## @end deftypefn @@ -51,17 +56,35 @@ endif if (isa (sys, "ss")) - error ("pade: ss models are not yet supported"); + if (hasinternaldelay (sys)) + error ("pade: ss models with a nonzero InternalDelay are not yet supported"); + endif + if (! hasdelay (sys)) + return; + endif + sys_tf = __pade_substitute_ordinary__ (tf (sys), n); + sys = ss (sys_tf); + return; endif if (! (isa (sys, "tf") || isa (sys, "zpk"))) - error ("pade: only tf and zpk models are supported (ss not yet implemented)"); + error ("pade: only tf, zpk, and ss models are supported"); endif if (! hasdelay (sys)) return; endif + sys = __pade_substitute_ordinary__ (sys, n); + +endfunction + +## Shared ordinary-delay (InputDelay/OutputDelay/IODelay) per-entry Pade +## substitution logic for tf/zpk models. Assumes hasdelay(sys) is true. +## Used directly for tf/zpk inputs, and via a tf-roundtrip for ss inputs +## with no InternalDelay. +function sys = __pade_substitute_ordinary__ (sys, n) + [indelay, outdelay, iodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); total = totaldelay (sys); [pr, pc] = size (total); @@ -230,3 +253,24 @@ %! assert (length (den{1,2}) - 1, 1 + 5); %!error pade (tf (1, [1 2], "InputDelay", 0.5), [1, 2]) + +%!test # ss with dense per-entry IODelay, cross-checked against the tf path +%! a = [-2, 0; 0, -3]; +%! b = [1, 0; 0, 1]; +%! c = [1, 1; 1, 1]; +%! d = [0, 0; 0, 0]; +%! sys_ss = ss (a, b, c, d); +%! sys_ss = set (sys_ss, "IODelay", [0.2, 0.3; 0.4, 0.5]); +%! sys_tf = tf ({1, 1; 1, 1}, {[1 2], [1 3]; [1 2], [1 3]}); +%! sys_tf = set (sys_tf, "IODelay", [0.2, 0.3; 0.4, 0.5]); +%! ss2 = pade (sys_ss, 3); +%! tf2 = pade (sys_tf, 3); +%! assert (hasdelay (ss2), false); +%! assert (hasdelay (tf2), false); +%! w = [0.1, 1, 5]; +%! assert (freqresp (ss2, w), freqresp (tf2, w), 1e-6); + +%!test # ss with InternalDelay still errors clearly (Task 3 will remove this) +%! h = ss (-1, 1, 1, 0); +%! h = set (h, "internaldelay", 0.5); +%! fail ("pade (h, 3)", "InternalDelay"); From 50e01118a108d8d8509a4c07a5b08dbbcb0a121c Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Thu, 23 Jul 2026 21:09:12 +0800 Subject: [PATCH 13/26] =?UTF-8?q?pade:=20implement=20ss=20InternalDelay=20?= =?UTF-8?q?Pad=C3=A9=20substitution=20via=20lft()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the not-yet-supported error for ss models carrying a nonzero InternalDelay with a real approximation: build the extended ordinary plant (__ss_ext_build__), rebuild it as a clean ss so its reported size is correct and all delay-port fields are cleared, then close the delay loop through a diagonal MIMO Padé filter using the lft() Redheffer star product. Ordinary delay carried alongside is handled independently via the existing tf-roundtrip path using the ordinary slice of a single up-front order assignment. --- inst/@lti/pade.m | 224 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 217 insertions(+), 7 deletions(-) diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m index 0dbe8731..31a51825 100644 --- a/inst/@lti/pade.m +++ b/inst/@lti/pade.m @@ -24,8 +24,10 @@ ## @strong{Inputs} ## @table @var ## @item sys -## @var{tf}, @var{zpk}, or @var{ss} model. @var{ss} models with a nonzero -## @var{InternalDelay} are not yet supported. +## @var{tf}, @var{zpk}, or @var{ss} model. @var{ss} models carrying a +## nonzero @var{InternalDelay} are supported: the delay loop is closed +## through a rational Pade filter via a linear-fractional transformation +## (@code{lft}). ## @item n ## Order of the Pade approximation. Either a scalar (applied to every ## nonzero delay) or a vector with one entry per nonzero delay -- see @@ -57,7 +59,8 @@ if (isa (sys, "ss")) if (hasinternaldelay (sys)) - error ("pade: ss models with a nonzero InternalDelay are not yet supported"); + sys = __pade_substitute_internal__ (sys, n); + return; endif if (! hasdelay (sys)) return; @@ -170,6 +173,119 @@ endfunction +## InternalDelay Pade substitution for ss models. Each internal delay +## port is a signal z(t) fed back to the plant as w(t) = z(t-tau); the +## exact freqresp closes this loop with Delta = diag(exp(-jw*tau)) via an +## LFT (see @ss/__freqresp__.m). Here we replace that ideal-delay loop +## closure by a rational one: build the extended ordinary plant (whose last +## nports inputs are w and last nports outputs are z) with +## __ss_ext_build__, then close the loop through a diagonal MIMO Pade +## filter G_pade (one SISO Pade block per port) using the Redheffer star +## product lft(). As the order grows, each SISO Pade block converges to +## exp(-jw*tau), so the closed loop converges to the exact-delay system. +## +## Any ordinary delay (InputDelay/OutputDelay/IODelay) carried alongside +## the InternalDelay is independent of the loop closure, so it is handled +## afterwards by the same tf-roundtrip substitution used for the +## InternalDelay-free ss case (__pade_substitute_ordinary__), fed the +## ordinary slice of the SAME order assignment computed once up front. +function sys = __pade_substitute_internal__ (sys, n) + + ## Full per-delay order assignment, computed ONCE so the internal and + ## ordinary substitutions consume consistent slices (ordering per + ## __pade_order_vector__: InputDelay, OutputDelay, IODelay, InternalDelay). + [orders, idx] = __pade_order_vector__ (sys, n); + n_in = numel (idx.input); + n_out = numel (idx.output); + n_iod = numel (idx.iod); + n_int = numel (idx.internal); + + orders_ordinary = orders (1 : n_in + n_out + n_iod); + orders_internal = orders (n_in + n_out + n_iod + 1 : end); + + ## Original internal-delay values and any ordinary delay, extracted BEFORE + ## the extended plant is rebuilt (the rebuild deliberately drops both the + ## delay ports and the ordinary delay). + tau = get (sys, "internaldelay"); + tau = tau(:); + had_ordinary = hasdelay (sys); + [oindelay, ooutdelay, oiodelay] = get (sys, "inputdelay", "outputdelay", "iodelay"); + + ## Build the extended ordinary plant. __ss_ext_build__ folds the delay + ## ports (b2/c2/d12/d21/d22) into ordinary extra inputs/outputs but leaves + ## the returned ext_sys's lti input/output NAMES (hence its reported + ## size()) and its residual b2/c2/.../internaldelay fields untouched, so + ## it is NOT a well-formed ordinary ss. Rebuild a clean ss from just the + ## extended (a,b,c,d[,e]) matrices: that gives correct input/output counts + ## for lft()'s size() calls AND clears every residual port field and the + ## internaldelay, so hasinternaldelay(ext_clean) is false. + ## __ss_ext_build__ lives in @ss and returns an ss whose a/b/c/d hold the + ## extended matrices; extract them with dssdata (the raw fields are not + ## dot-accessible from outside @ss). Passing [] keeps a descriptor E empty + ## rather than expanding it to the identity. + [ext_sys, nu, ny] = __ss_ext_build__ (sys); + [ea, eb, ec, ed, ee, etsam] = dssdata (ext_sys, []); + nports = columns (eb) - nu; + if (nports != rows (ec) - ny) + error ("pade: internal delay port dimension mismatch (%d inputs vs %d outputs)", + nports, rows (ec) - ny); + endif + + ## Rebuild a clean ordinary ss from just the extended matrices. This gives + ## correct input/output counts for lft()'s size() calls (the raw ext_sys + ## keeps the ORIGINAL input/output names, so its reported size is stale) + ## AND clears every residual port field and the internaldelay, so + ## hasinternaldelay(ext_clean) is false. + if (isempty (ee)) + ext_clean = ss (ea, eb, ec, ed, etsam); + else + ext_clean = dss (ea, eb, ec, ed, ee, etsam); + endif + + if (nports == 0) + ## Degenerate: an internaldelay is set but there are no actual delay + ## ports (empty b2/c2/...), so the "delay" affects no signal. There is + ## no loop to close -- the delay-free equivalent is just the clean plant. + sys = ext_clean; + else + if (nports != numel (tau) || nports != n_int) + error ("pade: internal delay port count (%d) does not match delay count (%d/%d)", + nports, numel (tau), n_int); + endif + + ## Diagonal MIMO Pade filter: nports inputs (each port's z, the delayed + ## signal's source) and nports outputs (each port's w, the destination). + G_pade = pade (tau(1), orders_internal(1)); + for k = 2 : nports + G_pade = append (G_pade, pade (tau(k), orders_internal(k))); + endfor + + ## Close the loop. lft(sys1, sys2, nu, ny) connects the LAST nu inputs of + ## sys1 to the FIRST nu outputs of sys2, and the LAST ny outputs of sys1 + ## to the FIRST ny inputs of sys2. With sys1 = ext_clean, sys2 = G_pade, + ## nu = ny = nports: ext_clean's last nports inputs (w) are driven by + ## G_pade's first nports outputs (w), and ext_clean's last nports outputs + ## (z) drive G_pade's first nports inputs (z) -- exactly the loop the ideal + ## delay used to close (w = Delta z, now w = G_pade z). + sys = lft (ext_clean, G_pade, nports, nports); + endif + + ## Ordinary delay, if any, is untouched by the loop closure; reattach the + ## ORIGINAL ordinary delay to the delay-free closure result and + ## Pade-substitute it via the same tf-roundtrip path used for + ## InternalDelay-free ss inputs, using the ordinary slice of the order + ## assignment computed above. lft() preserves the ordinary I/O dimensions + ## (the first nu inputs / first ny outputs), so the ordinary delay vectors + ## still line up. + if (had_ordinary) + sys = set (sys, "InputDelay", oindelay, ... + "OutputDelay", ooutdelay, ... + "IODelay", oiodelay); + sys = ss (__pade_substitute_ordinary__ (tf (sys), orders_ordinary)); + endif + +endfunction + %!test # SISO tf with InputDelay: freqresp matches hand-multiplied reference %! T = 0.5; @@ -270,7 +386,101 @@ %! w = [0.1, 1, 5]; %! assert (freqresp (ss2, w), freqresp (tf2, w), 1e-6); -%!test # ss with InternalDelay still errors clearly (Task 3 will remove this) -%! h = ss (-1, 1, 1, 0); -%! h = set (h, "internaldelay", 0.5); -%! fail ("pade (h, 3)", "InternalDelay"); +%!test # ss InternalDelay: feedback loop, freqresp matches Pade LFT closed form +%! ## L = feedback (G), G = 1/(s+1) with loop delay T = 0.3 s, traps the delay +%! ## internally. Exact closed loop: Gd/(1+Gd), Gd = G e^{-jwT}. The Pade +%! ## approximation replaces e^{-jwT} by the scalar Pade transfer function's +%! ## own freqresp Pd, so the reference is Gp/(1+Gp) with Gp = G*Pd -- an +%! ## INDEPENDENT computation (scalar pade path), not a second call into the +%! ## code under test. +%! T = 0.3; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! assert (hasinternaldelay (L), true); +%! order = 4; +%! sys2 = pade (L, order); +%! assert (hasinternaldelay (sys2), false); +%! assert (hasdelay (sys2), false); +%! w = [0.05, 0.5, 1.7, 4, 11]; +%! H = reshape (freqresp (sys2, w), 1, []); +%! [np, dp] = pade (T, order); +%! Pd = reshape (freqresp (tf (np, dp), w), 1, []); +%! Gp = (1 ./ (1i*w + 1)) .* Pd; +%! expected = Gp ./ (1 + Gp); +%! assert (H, expected, 1e-9); + +%!test # ss InternalDelay: freqresp error against TRUE exact delay shrinks with order +%! ## Strongest evidence the substitution converges to the exact-delay system: +%! ## as the Pade order grows, the approximation error against the untouched +%! ## exact-delay freqresp must strictly decrease. +%! T = 0.3; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! w = [0.05, 0.5, 1.7, 4, 11]; +%! Hexact = reshape (freqresp (L, w), 1, []); +%! err = zeros (1, 3); +%! orders = [2, 4, 8]; +%! for k = 1:3 +%! sysk = pade (L, orders(k)); +%! Hk = reshape (freqresp (sysk, w), 1, []); +%! err(k) = max (abs (Hk - Hexact)); +%! endfor +%! assert (err(2) < err(1)); +%! assert (err(3) < err(2)); + +%!test # ss MIMO InternalDelay: two independent ports, distinct delays and orders +%! ## append() of two SISO feedback loops -> genuine 2-port InternalDelay. +%! ## A per-port order VECTOR (3 for port 1, 6 for port 2) verifies each port +%! ## consumes its own assigned order. Reference: decoupled per-channel Pade +%! ## closed form; off-diagonals must be identically zero. +%! T1 = 0.3; a1 = 1; o1 = 3; +%! T2 = 0.7; a2 = 2; o2 = 6; +%! G1 = ss (-a1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-a2, 1, 1, 0, "IODelay", T2); +%! sys = append (feedback (G1), feedback (G2)); +%! assert (get (sys, "internaldelay"), [T1; T2], 1e-12); +%! sys2 = pade (sys, [o1, o2]); +%! assert (hasinternaldelay (sys2), false); +%! assert (hasdelay (sys2), false); +%! w = [0.05, 0.5, 1.7, 4, 11]; +%! [n1, d1] = pade (T1, o1); P1 = reshape (freqresp (tf (n1, d1), w), 1, []); +%! [n2, d2] = pade (T2, o2); P2 = reshape (freqresp (tf (n2, d2), w), 1, []); +%! Gp1 = (1 ./ (1i*w + a1)) .* P1; e1 = Gp1 ./ (1 + Gp1); +%! Gp2 = (1 ./ (1i*w + a2)) .* P2; e2 = Gp2 ./ (1 + Gp2); +%! H = freqresp (sys2, w); +%! for k = 1:numel (w) +%! assert (H(1,1,k), e1(k), 1e-9); +%! assert (H(2,2,k), e2(k), 1e-9); +%! assert (H(1,2,k), 0, 1e-9); +%! assert (H(2,1,k), 0, 1e-9); +%! endfor + +%!test # ss combined InternalDelay AND ordinary OutputDelay: both approximated +%! ## The loop-trapped internal delay and an untouched OutputDelay are +%! ## independent; the result must equal the internal-delay Pade closure +%! ## multiplied by the OutputDelay's own scalar Pade transfer function. +%! T = 0.3; Tout = 0.2; order = 5; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = set (feedback (G), "OutputDelay", Tout); +%! assert (hasinternaldelay (L), true); +%! assert (hasdelay (L), true); +%! sys2 = pade (L, order); +%! assert (hasinternaldelay (sys2), false); +%! assert (hasdelay (sys2), false); +%! w = [0.05, 0.5, 1.7, 4]; +%! [np, dp] = pade (T, order); +%! Pd = reshape (freqresp (tf (np, dp), w), 1, []); +%! Gp = (1 ./ (1i*w + 1)) .* Pd; internal_ref = Gp ./ (1 + Gp); +%! [no, do_] = pade (Tout, order); +%! Pout = reshape (freqresp (tf (no, do_), w), 1, []); +%! expected = internal_ref .* Pout; +%! H = reshape (freqresp (sys2, w), 1, []); +%! assert (H, expected, 1e-8); + +%!test # ss InternalDelay with no delay ports (degenerate b2/c2 empty): still closes +%! ## set() an internaldelay directly with empty ports -- the extended plant is +%! ## degenerate (no extra I/O) but the substitution must not error and must +%! ## drop the internal delay. +%! sys = set (ss (-1, 1, 1, 0), "internaldelay", 0.5); +%! sys2 = pade (sys, 3); +%! assert (hasinternaldelay (sys2), false); From 27ee41b34f7637648eb1c63ed42a082287beb00c Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 11:47:17 +0800 Subject: [PATCH 14/26] stepinfo: support InternalDelay via Pade-approximated stability check stepinfo only failed for InternalDelay systems because it converted to tf purely to run a per-channel isstable() classification -- everything else it needs (the real step response via __time_response__, and the final value via dcgain, which already routes through __freqresp__'s LFT closure) already worked correctly. Replace the tfdata-based stability check with one run against pade(sys, n) (default order 4, exposed as an optional PadeOrder parameter) for InternalDelay systems only, with a disclosed warning; every other metric keeps using the real, exact time response unchanged. The old guard test used a synthetic fixture (internaldelay set directly, no port matrices -- not producible via feedback()/connect()) that only ever existed to trigger the guard; removing the guard exposed a pre-existing latent bug in dcgain/__time_response__ for that malformed state (empty dcgain, dimension mismatch), out of scope for this fix. Replaced it with a real feedback()-produced fixture. --- inst/stepinfo.m | 76 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/inst/stepinfo.m b/inst/stepinfo.m index 92eecb9b..173c185e 100644 --- a/inst/stepinfo.m +++ b/inst/stepinfo.m @@ -269,9 +269,7 @@ error ("stepinfo: first argument must be an lti system\n"); endif - if (hasinternaldelay (sys)) - error ("stepinfo: InternalDelay is not yet supported"); - endif + has_id = hasinternaldelay (sys); p = inputParser (); # Use Octvae's input parser for the input arguments p.FunctionName = "stepinfo"; @@ -281,6 +279,8 @@ p.addParameter ("RiseTimeLimits", [0.1 0.9],vld_rtlimits); vld_setthresh = @(x) isreal (x) && isscalar (x) && x < 1 && x > 0; p.addParameter ("SettlingTimeThreshold", 0.02,vld_setthresh); + vld_padeorder = @(x) is_real_scalar (x) && x == round (x) && x > 0; + p.addParameter ("PadeOrder", 4, vld_padeorder); p.parse (varargin{:}); @@ -290,16 +290,32 @@ thresholds.t_rise = p.Results.RiseTimeLimits; thresholds.t_settling = p.Results.SettlingTimeThreshold; thresholds.t_transient = p.Results.SettlingTimeThreshold; + pade_order = p.Results.PadeOrder; ## Get information on the system [p, m] = size (sys); - [num, den] = tfdata (sys); + + ## Get stability (for each channel) and continuous-/discrete-time. + ## InternalDelay systems cannot be converted to tf (the __sys2tf__ + ## chokepoint correctly rejects them), so exact pole-based stability is + ## not available -- approximate the delay via pade() for this + ## classification only. Every other metric below still uses the real, + ## exact time response (__time_response__ already supports InternalDelay + ## systems via its delay-buffer simulation). + if (has_id) + warning (["stepinfo: stability assessed via a Pade approximation ", ... + "(order %d) since InternalDelay does not yet support exact ", ... + "stability analysis"], pade_order); + sys_stab = pade (sys, pade_order); + else + sys_stab = sys; + endif + [num, den] = tfdata (sys_stab); ## Collect all system channels as separate systems in a cell array tfcell = cellfun (@(n,d) tf(n,d,sys.ts), num, den, "uniformoutput", false); - ## Get stability (for each channel) and continuous-/discrete-time stability = cellfun (@isstable, tfcell); ct = isct (sys); @@ -491,6 +507,52 @@ endfunction -## Test for InternalDelay guard - should error with function-specific message -%!error stepinfo (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +## InternalDelay: stepinfo now works, using the real time response for every +## metric and a Pade-approximated stability classification only. Use a +## real, well-formed feedback()-produced InternalDelay fixture (the +## previous guard test's synthetic "set(sys,'internaldelay',...)" fixture +## has no actual delay-port wiring -- not producible via feedback()/ +## connect() -- and is not a realistic InternalDelay system to exercise +## here). +%!warning stepinfo (feedback (ss (-1, 1, 1, 0, "IODelay", 0.3))) + +%!test +%! T = 0.3; +%! L = feedback (ss (-1, 1, 1, 0, "IODelay", T)); +%! assert (hasinternaldelay (L), true); +%! w = warning ("off", "all"); +%! info = stepinfo (L); +%! warning (w); +%! K = dcgain (L); +%! ## Independent cross-check: simulate over a generous EXPLICIT time vector +%! ## and manually find the rise/settling crossing times, without calling +%! ## stepinfo's own private helpers -- this is not circular w.r.t. the +%! ## logic under test. +%! ## Match stepinfo's own auto-selected grid (dt=0.1, doubled horizon) so +%! ## this is a genuine independent-formula cross-check on the SAME +%! ## discretized data, not conflated with unrelated coarse-vs-fine-grid +%! ## discretization error (which is a real, expected, separate property +%! ## of the already-existing time-response machinery, not something this +%! ## task changes). +%! [~, t_auto] = __time_response__ ("stepinfo", {L}, 2); +%! t = t_auto{1,1}; +%! y = step (L, t); +%! lo = 0.1 * K; hi = 0.9 * K; +%! idx_lo = find (y >= lo, 1); +%! idx_hi = find (y >= hi, 1); +%! t_lo = interp1 ([y(idx_lo-1) y(idx_lo)], [t(idx_lo-1) t(idx_lo)], lo); +%! t_hi = interp1 ([y(idx_hi-1) y(idx_hi)], [t(idx_hi-1) t(idx_hi)], hi); +%! rise_ref = t_hi - t_lo; +%! tol = 0.02 * K; +%! idx_settled = find (abs (y - K) > tol, 1, "last"); +%! if (isempty (idx_settled)) +%! settle_ref = 0; +%! else +%! settle_ref = t(idx_settled); +%! endif +%! assert (info.RiseTime, rise_ref, 1e-6); +%! ## settle_ref is a nearest-sample estimate (no interpolation); stepinfo's +%! ## own settling-time helper interpolates, so allow up to one sample. +%! assert (info.SettlingTime, settle_ref, t(2) - t(1)); +%! assert (info.Peak >= K * 0.99); # sanity: peak at least reaches near final value From 80ad1f062ce58b5e8dafd29bd776862b762e174a Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 11:51:32 +0800 Subject: [PATCH 15/26] c2d: error clearly when InternalDelay rounds to 0 samples A nonzero InternalDelay smaller than half the sampling time would silently round to 0 samples, and the delay-buffer simulation's documented tau>=1 assumption (__delay_lookup__/__buffered_sim__) would then drop that port's feedthrough instead of solving the resulting algebraic loop. Convert this from a silent-wrong-answer risk into a clear error, matching every other guard on this branch. --- inst/@lti/c2d.m | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index f3f8cabc..f249a5cd 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -116,7 +116,22 @@ sys = __ss_ext_split__ (origsys, ext_sys, nu, ny); sys.tsam = tsam; tau = get (origsys, "internaldelay"); - sys = set (sys, "internaldelay", round (tau / tsam)); + tau_samples = round (tau / tsam); + + ## A nonzero InternalDelay that rounds to 0 samples would silently drop + ## that port's feedthrough during simulation instead of solving the + ## resulting algebraic loop (documented in __delay_lookup__/ + ## __buffered_sim__'s tau>=1 assumption) -- convert this from a + ## silent-wrong-answer risk into a clear error, consistent with every + ## other guard on this branch. + if (any (tau != 0 & tau_samples == 0)) + bad = tau (find (tau != 0 & tau_samples == 0, 1)); + error (["c2d: InternalDelay of %g seconds is too small relative to ", ... + "the sampling time %g and would round to 0 samples"], ... + bad, tsam); + endif + + sys = set (sys, "internaldelay", tau_samples); if (hasdelay (origsys)) [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); @@ -640,3 +655,8 @@ %! assert (H(1,2,k), 0, 1e-12); %! assert (H(2,1,k), 0, 1e-12); %! endfor + +## InternalDelay that rounds to 0 samples must error clearly, not silently +## drop the port's feedthrough (see the tau>=1 assumption documented in +## __delay_lookup__/__buffered_sim__). +%!error c2d (feedback (ss (-1, 1, 1, 0, "IODelay", 0.01)), 1, "zoh") From aa930037c6b3e175d3dd48037bd249d14f6028f7 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 11:55:58 +0800 Subject: [PATCH 16/26] zero/pole: point InternalDelay guard messages at pade() No behavior change -- these still error, just with guidance toward the now-available practical workaround (approximate the delay via pade(), then analyze). --- inst/@lti/pole.m | 5 +++-- inst/@lti/zero.m | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/inst/@lti/pole.m b/inst/@lti/pole.m index 9335f10d..9827c3f4 100644 --- a/inst/@lti/pole.m +++ b/inst/@lti/pole.m @@ -50,7 +50,8 @@ function pol = pole (sys) if (hasinternaldelay (sys)) - error ("pole: InternalDelay is not yet supported"); + error (["pole: InternalDelay is not yet supported for exact pole ", ... + "computation; use pade(sys, n) to approximate the delay first"]); endif if(nargin == 1) # pole(sys) @@ -135,4 +136,4 @@ ## Test for InternalDelay guard - should error with function-specific message -%!error pole (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +%!error pole (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) diff --git a/inst/@lti/zero.m b/inst/@lti/zero.m index dca66360..9d7889ec 100644 --- a/inst/@lti/zero.m +++ b/inst/@lti/zero.m @@ -144,7 +144,8 @@ endif if (hasinternaldelay (sys)) - error ("zero: InternalDelay is not yet supported"); + error (["zero: InternalDelay is not yet supported for exact zero ", ... + "computation; use pade(sys, n) to approximate the delay first"]); endif if (strncmpi (type, "invariant", 3)) # invariant zeros, default @@ -497,4 +498,4 @@ ## Test for InternalDelay guard - should error with function-specific message -%!error zero (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) +%!error zero (set (ss (-1, 1, 1, 0), "internaldelay", 0.5)) From ac47ef8707a7f3fbba91d539cf6afe9c45b7f430 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 12:05:11 +0800 Subject: [PATCH 17/26] __time_response__: fix inadequate auto-horizon for delay-dominated InternalDelay systems The auto-horizon estimate for InternalDelay systems used only the delay-free rational-part eigenvalues (dssdata's open-loop A matrix), ignoring how the internal-delay port's closed loop can be far more lightly damped than the open-loop dynamics suggest -- a large delay relative to the plant's time constant can push a feedback loop close to marginal stability. For a fixture with a pole at -1 and a 5s delay, the true closed-loop damping is ~40x slower than the open-loop estimate, so step()/impulse()/initial() without an explicit time vector would stop mid-transient instead of showing the settled response. Fix: use pole(pade(sys, 4)) instead for continuous-time InternalDelay systems, capturing the loop's true (approximate) closed-loop pole locations including the delay's effect on damping. pade() only supports continuous-time input (a separate, pre-existing limitation), so an already-discrete InternalDelay system still falls back to the old delay-free eigenvalue estimate. Found while adding auto-horizon test coverage for InternalDelay systems (previously every such test passed an explicit time vector, never exercising this path). --- inst/__time_response__.m | 63 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/inst/__time_response__.m b/inst/__time_response__.m index 9f7e2937..ac0c211c 100644 --- a/inst/__time_response__.m +++ b/inst/__time_response__.m @@ -578,9 +578,19 @@ T_DEF = 10; # default simulation time ## pole() refuses InternalDelay systems (transcendental spectrum), but the - ## horizon estimate only needs a time scale: use the rational-part poles - ## (eigenvalues of the state matrix) of the delay-free dynamics instead. - if (hasinternaldelay (sys)) + ## horizon estimate only needs a time scale. The delay-free rational-part + ## eigenvalues alone are NOT a safe substitute: the internal-delay port's + ## closed loop can be far more lightly damped than the open-loop dynamics + ## suggest (e.g. a large delay relative to the plant's time constant can + ## push a feedback loop close to marginal stability), so use pole() on a + ## Pade approximation instead -- this captures the loop's true (approximate) + ## closed-loop pole locations, including the delay's effect on damping. + ## pade() only supports continuous-time input (a separate, pre-existing + ## limitation), so an already-discrete InternalDelay system still falls + ## back to the delay-free rational-part eigenvalues. + if (hasinternaldelay (sys) && isct (sys)) + ev = pole (pade (sys, 4)); + elseif (hasinternaldelay (sys)) [aev, ~, ~, ~, eev] = dssdata (sys, []); if (isempty (eev)) ev = eig (aev); @@ -794,3 +804,50 @@ %! endfor %! assert (y(:, :, jin), yref, 1e-10); %! endfor + + +## Auto-horizon (no explicit time vector) for a well-damped InternalDelay +## system: the response must settle within the auto-selected horizon and +## match the same system simulated over an equivalent explicit time vector. +## Pass the CONTINUOUS system directly (not pre-discretized) since +## __sim_horizon__ is invoked on the original continuous system before +## discretization -- pre-discretizing here would silently skip the +## Pade-based horizon estimate this test exercises (pade() only supports +## continuous input) and fall back to the discrete-only branch instead. +%!test +%! T = 0.3; +%! L = feedback (ss (-1, 1, 1, 0, "IODelay", T)); +%! [y_auto, t_auto] = step (L); +%! K = dcgain (L); +%! assert (abs (y_auto(end) - K) < 0.02 * abs (K)); # settled by the last sample +%! y_explicit = step (L, t_auto); +%! assert (y_auto, y_explicit, 1e-12); + +## Auto-horizon for a delay-DOMINATED InternalDelay system: a large delay +## relative to the plant's time constant pushes the closed loop close to +## marginal stability (lightly damped, slow to settle) -- the delay-free +## rational-part eigenvalues alone drastically underestimate this settling +## time (the open-loop pole is at -1, but the true closed-loop damping is +## roughly 40x slower: pole(pade(L,4)) finds a mode near -0.02, vs the +## delay-free estimate of -1), so __sim_horizon__ must use a +## Pade-approximated pole estimate for InternalDelay systems instead. +## Regression test for that fix: confirm the auto horizon is long enough +## to actually see the response approach its final value, not stop +## mid-transient. Pass the continuous system directly, same reason as +## above. +%!test +%! T = 5; +%! L = feedback (ss (-1, 1, 1, 0, "IODelay", T)); +%! [y_auto, t_auto] = step (L); +%! K = dcgain (L); +%! ## the response oscillates around K as it settles; check the envelope +%! ## (max absolute deviation from K over the last quarter of the horizon) +%! ## has decayed substantially relative to the deviation over the first +%! ## quarter after the initial delay -- i.e. the horizon is long enough to +%! ## see real decay, not just the first few oscillations. +%! n = numel (t_auto); +%! first_q = y_auto (round (n/4) : round (n/2)); +%! last_q = y_auto (round (3*n/4) : n); +%! dev_first = max (abs (first_q - K)); +%! dev_last = max (abs (last_q - K)); +%! assert (dev_last < 0.5 * dev_first); From 242ed7c68c7d518ab92010f1d9c02e92d6d7ca5a Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 18:48:09 +0800 Subject: [PATCH 18/26] c2d: exact zoh discretization of SISO fractional delay via split-ZOH construction Replaces the Thiran-filter approximation for fractional (non-integer-sample) continuous delay with an exact split-zero-order-hold discretization (Astrom & Wittenmark), for method="zoh"/"std". Matches MATLAB's actual c2d() output exactly: order-preserving denominator, extra numerator term, pure z^-n delay factor -- confirmed against a real MATLAB reference example. New helper inst/__c2d_frac_zoh__.m implements the core math. Non-zoh methods continue to use the existing Thiran approximation, unchanged. --- inst/@lti/c2d.m | 153 ++++++++++++++++++++++++++++++++-------- inst/__c2d_frac_zoh__.m | 59 ++++++++++++++++ 2 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 inst/__c2d_frac_zoh__.m diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index f249a5cd..c2c1a76e 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -183,23 +183,26 @@ elseif (any (abs (all_samples - round (all_samples)) > 1e-8)) [p, m] = size (origsys); - if (p != 1 || m != 1) + if (any (strcmpi (method, {"zoh", "std"})) && __c2d_frac_applicable__ (origsys, tsam)) + sys = __c2d_frac_ss__ (origsys, tsam); + sys.tsam = tsam; + elseif (p != 1 || m != 1) error ("c2d: fractional delays on MIMO systems are not yet supported (per-channel Thiran approximation is not yet implemented)"); + else + % __c2d__ recurses into this same function via ss() for the general + % case, and ss() does not strip delay fields -- so discretizing + % origsys directly here would re-enter this fractional branch a + % second time and double-apply the Thiran filter below. Delay must + % be zeroed before the __c2d__ call, not just after. + sys_no_delay = set (origsys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); + sys = __c2d__ (sys_no_delay, tsam, lower (method), w0); + sys.tsam = tsam; + + total_delay = totaldelay (origsys); + filt = thiran (total_delay, tsam); + sys = sys * filt; + sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); endif - - % __c2d__ recurses into this same function via ss() for the general - % case, and ss() does not strip delay fields -- so discretizing - % origsys directly here would re-enter this fractional branch a - % second time and double-apply the Thiran filter below. Delay must - % be zeroed before the __c2d__ call, not just after. - sys_no_delay = set (origsys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); - sys = __c2d__ (sys_no_delay, tsam, lower (method), w0); - sys.tsam = tsam; - - total_delay = totaldelay (origsys); - filt = thiran (total_delay, tsam); - sys = sys * filt; - sys = set (sys, "InputDelay", 0, "OutputDelay", 0, "IODelay", 0); else sys = __c2d__ (origsys, tsam, lower (method), w0); sys.tsam = tsam; @@ -478,29 +481,52 @@ %! assert (hasdelay (dsys), false); -%!test # SISO tf fractional InputDelay: approximated via thiran +%!test # SISO tf fractional InputDelay: exact split-ZOH via zoh (default) %! sys = tf (1, [1 1], "InputDelay", 1.33); %! dsys = c2d (sys, 0.5, "zoh"); %! assert (isdt (dsys), true); -%! assert (hasdelay (dsys), false); -%! sys_dyn = c2d (tf (1, [1 1]), 0.5, "zoh"); -%! filt = thiran (1.33, 0.5); -%! expected = sys_dyn * filt; -%! [numd, dend] = tfdata (dsys); -%! [nume, dene] = tfdata (expected); -%! assert (numd, nume, 1e-8); -%! assert (dend, dene, 1e-8); - -%!test # SISO zpk fractional OutputDelay: approximated via thiran +%! ## d = floor(tau/tsam) = floor(1.33/0.5) = 2, plus 1 more because the +%! ## fractional remainder (0.33 s) is nonzero -- same convention as +%! ## __c2d_frac_zoh__/__c2d_frac_ss__. +%! assert (hasdelay (dsys), true); +%! assert (dsys.InputDelay, 3); +%! [numd, dend] = tfdata (dsys, "v"); +%! ## independent ground truth: split-ZOH construction (Astrom & Wittenmark), +%! ## computed directly via expm/integral (not via the code under test) -- +%! ## see the reproduction script in the task report for how these numbers +%! ## were derived: A=-1, B=1, tsam=0.5, tau=1.33, d=floor(tau/tsam)=2, +%! ## tau_frac=0.33. +%! nume = [0.1563351834, 0.2371341569]; +%! dene = [1, -0.6065306597]; +%! assert (numd, nume, 1e-6); +%! assert (dend, dene, 1e-6); +%! +%! ## Thiran approximation path is still reachable for a non-zoh method. +%! dsys_tustin = c2d (sys, 0.5, "tustin"); +%! assert (hasdelay (dsys_tustin), false); + +%!test # SISO zpk fractional OutputDelay: exact split-ZOH via zoh (default) %! sys = zpk ([], -1, 1, "OutputDelay", 1.33); %! dsys = c2d (sys, 0.5, "zoh"); %! assert (isdt (dsys), true); -%! assert (hasdelay (dsys), false); -%! sys_dyn = c2d (zpk ([], -1, 1), 0.5, "zoh"); -%! filt = thiran (1.33, 0.5); -%! expected = sys_dyn * filt; +%! assert (hasdelay (dsys), true); +%! assert (dsys.InputDelay, 3); %! w = [0.1, 1, 5]; -%! assert (freqresp (dsys, w), freqresp (expected, w), 1e-8); +%! ## independent ground truth: same split-ZOH construction as the tf +%! ## InputDelay test above -- the dynamics (pole at -1, gain 1) are +%! ## identical, since __c2d_frac_ss__ collapses all delay fields into a +%! ## scalar totaldelay () before splitting, so the same reference numbers +%! ## apply. +%! nume = [0.1563351834, 0.2371341569]; +%! dene = [1, -0.6065306597]; +%! tsam = 0.5; +%! expected = tf (nume, dene, tsam); +%! expected = set (expected, "InputDelay", 3); +%! assert (freqresp (dsys, w), freqresp (expected, w), 1e-6); +%! +%! ## Thiran approximation path is still reachable for a non-zoh method. +%! dsys_tustin = c2d (sys, 0.5, "tustin"); +%! assert (hasdelay (dsys_tustin), false); %!error c2d (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [1.33;0]), 0.5, "zoh") @@ -660,3 +686,68 @@ ## drop the port's feedthrough (see the tau>=1 assumption documented in ## __delay_lookup__/__buffered_sim__). %!error c2d (feedback (ss (-1, 1, 1, 0, "IODelay", 0.01)), 1, "zoh") + +%!test # exact fractional IODelay via zoh: matches MATLAB's split-ZOH result +%! h = tf (10, [1 3 10], "IODelay", 0.25); +%! hd = c2d (h, 0.1); # method defaults to "zoh" +%! [num, den] = tfdata (hd); +%! assert (hd.InputDelay + hd.IODelay, 3); # z^-3 pure delay factored out +%! assert (num{1}, [0.01187, 0.06408, 0.009721], 1e-4); +%! assert (den{1}, [1, -1.655, 0.7408], 1e-3); + +## Temporary Task-1 stub - Task 2 replaces this with the full MIMO check. +function tf_ok = __c2d_frac_applicable__ (sys, tsam) + [p, m] = size (sys); + tf_ok = (p == 1 && m == 1); +endfunction + +function sys = __c2d_frac_ss__ (origsys, tsam) + sys_ss = ss (origsys); + [A, B, C, D] = ssdata (sys_ss); + tau = totaldelay (origsys); # scalar for SISO + + [Ad, Bd, extra] = __c2d_frac_zoh__ (A, B, tsam, tau); + + if (! extra.needed) + sys = ss (Ad, Bd, C, D, tsam); + d_samples = extra.d; + else + n = rows (A); + A_aug = [Ad, extra.g0; zeros(1,n), 0]; + B_aug = [Bd; 1]; + C_aug = [C, zeros(rows(C),1)]; + D_aug = D; + sys = ss (A_aug, B_aug, C_aug, D_aug, tsam); + d_samples = extra.d + 1; + endif + + sys = set (sys, "InputDelay", d_samples); + + if (isa (origsys, "tf") || isa (origsys, "zpk")) + sys = tf (sys); + + ## The extra register state added above (when extra.needed) has an + ## eigenvalue of exactly zero and contributes no real dynamics -- it + ## exists purely to carry the sub-sample remainder through one more + ## sample. Converting the augmented state-space realization to a + ## transfer function therefore leaves an exact pole/zero pair at the + ## origin (a leading zero numerator coefficient paired with a trailing + ## ~0 denominator coefficient): cancel it explicitly so num/den come + ## back at minimal (MATLAB-compatible) order instead of one degree too + ## high. + if (extra.needed) + [num, den] = tfdata (sys, "v"); + if (abs (num(1)) < 1e-8 * max (abs (num)) ... + && abs (den(end)) < 1e-8 * max (abs (den))) + num = num(2:end); + den = den(1:end-1); + sys = tf (num, den, tsam); + sys = set (sys, "InputDelay", d_samples); + endif + endif + + if (isa (origsys, "zpk")) + sys = zpk (sys); + endif + endif +endfunction diff --git a/inst/__c2d_frac_zoh__.m b/inst/__c2d_frac_zoh__.m new file mode 100644 index 00000000..d41ca154 --- /dev/null +++ b/inst/__c2d_frac_zoh__.m @@ -0,0 +1,59 @@ +## -*- texinfo -*- +## Internal helper: exact zero-order-hold discretization of one input +## channel's fractional (non-integer-sample) continuous delay. +## +## Given continuous state matrix A, one column b_col of the continuous B +## matrix, sample time tsam, and that channel's total continuous delay tau +## (seconds), returns the discrete Ad = expm(A*tsam), the discrete input +## column Bd to use together with the ordinary d = floor(tau/tsam) sample +## delay (already handled by the existing InputDelay/IODelay machinery), and +## an `extra` struct describing the one extra register state needed to +## carry the sub-sample remainder exactly (Astrom & Wittenmark's split-ZOH +## construction), or extra.needed = false when tau is already an integer +## number of samples. +function [Ad, Bd, extra] = __c2d_frac_zoh__ (A, b_col, tsam, tau) + + d = floor (tau / tsam + 1e-9); # guard against tau/tsam landing just + # under an integer due to roundoff + tau_frac = tau - d * tsam; + + [Ad, G_full] = __sl_mb05nd__ (A, tsam, eps); + + if (tau_frac < 1e-8 * tsam) + Bd = G_full * b_col; + extra = struct ("needed", false, "d", d, "g0", []); + return; + endif + + [Ad_short, G1] = __sl_mb05nd__ (A, tsam - tau_frac, eps); + [~, Gf] = __sl_mb05nd__ (A, tau_frac, eps); + + Bd = G1 * b_col; + g0 = Ad_short * Gf * b_col; + + extra = struct ("needed", true, "d", d, "g0", g0); + +endfunction + +%!test # scalar plant: verify against direct numerical integration +%! A = -2; b = 1; tsam = 0.1; tau = 0.25; +%! [Ad, Bd, extra] = __c2d_frac_zoh__ (A, b, tsam, tau); +%! assert (extra.needed, true); +%! assert (extra.d, 2); +%! # brute-force check: Ad == expm(A*tsam) +%! assert (Ad, expm (A*tsam), 1e-10); +%! # brute-force check of Bd (=G1*b) and extra.g0 (=G0*b) via direct +%! # numerical quadrature of the defining integrals +%! tau_frac = tau - extra.d*tsam; +%! G1_direct = integral (@(s) expm (A*(tsam-tau_frac-s)), 0, tsam-tau_frac, "ArrayValued", true); +%! G0_direct = integral (@(s) expm (A*s), tsam-tau_frac, tsam, "ArrayValued", true); +%! assert (Bd, G1_direct*b, 1e-8); +%! assert (extra.g0, G0_direct*b, 1e-8); +%! +%!test # zero fractional part: extra.needed is false, Bd matches plain zoh +%! A = -2; b = 1; tsam = 0.1; tau = 0.3; # exactly 3 samples +%! [Ad, Bd, extra] = __c2d_frac_zoh__ (A, b, tsam, tau); +%! assert (extra.needed, false); +%! assert (extra.d, 3); +%! [~, G] = __sl_mb05nd__ (A, tsam, eps); +%! assert (Bd, G*b, 1e-10); From 884a2ba324ed36a2ff38c0adf1dcbeac878b1d75 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 18:48:17 +0800 Subject: [PATCH 19/26] zpk: propagate InputDelay/OutputDelay/IODelay when converting from tf/ss The lti-conversion branch of zpk() discarded these fields entirely -- found while wiring the new exact fractional-delay c2d() path, which is the first code path to call zpk() on a system that still carries a real delay. Unrelated to that fix otherwise; a genuine pre-existing bug. --- inst/@zpk/zpk.m | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/inst/@zpk/zpk.m b/inst/@zpk/zpk.m index 6ff9edeb..584512d9 100644 --- a/inst/@zpk/zpk.m +++ b/inst/@zpk/zpk.m @@ -94,8 +94,12 @@ return; endif + from_lti_conversion = false; + if (nargin == 1 && isa (varargin{1}, "lti")) # zpk (ltisys) + from_lti_conversion = true; + ## conversion from tf/ss/frd [num, den, tsam] = tfdata (varargin{1}); num = cellfun (@__remove_leading_zeros__, num, 'uniformoutput', false); @@ -105,6 +109,12 @@ p = cellfun (@roots, den, "uniformoutput", false); k = cellfun (@(n,d) n(1)/d(1), num, den); + ## preserve delay information from the source model; the source's + ## InternalDelay (ss only) cannot be represented by zpk and is not + ## handled here -- it is already lost via the tfdata () call above. + [src_iodelay, src_indelay, src_outdelay] = ... + get (varargin{1}, "iodelay", "inputdelay", "outputdelay"); + varargin = {}; else @@ -181,6 +191,18 @@ sys = set (sys, varargin{:}); endif + ## Only push the source's delay fields onto sys when at least one is + ## actually nonzero. This sidesteps a pre-existing, out-of-scope bug in + ## tf's transpose (its InputDelay/OutputDelay/IODelay come back with + ## stale pre-transpose shapes instead of the post-transpose size) that + ## would otherwise make this fix error on an all-zero (i.e. no-op) + ## delay whose shape doesn't match the freshly built sys. + if (from_lti_conversion ... + && (any (src_iodelay(:)) || any (src_indelay(:)) || any (src_outdelay(:)))) + sys = set (sys, "iodelay", src_iodelay, "inputdelay", src_indelay, ... + "outputdelay", src_outdelay); + endif + endfunction @@ -208,6 +230,34 @@ %! assert (isa (sys, 'zpk')); %! assert (isstaticgain (sys)); +%!test +%! ## conversion from tf/ss preserves InputDelay/OutputDelay/IODelay +%! t = tf ([0.1563, 0.2371], [1, -0.6065], 0.5); +%! t = set (t, "InputDelay", 3); +%! z = zpk (t); +%! assert (isa (z, 'zpk')); +%! assert (get (z, "InputDelay"), 3); + +%!test +%! t = tf ([0.1563, 0.2371], [1, -0.6065], 0.5); +%! t = set (t, "OutputDelay", 2); +%! z = zpk (t); +%! assert (get (z, "OutputDelay"), 2); + +%!test +%! t = tf ({1}, {[1 1]}); +%! t = set (t, "IODelay", 1.5); +%! z = zpk (t); +%! assert (get (z, "IODelay"), 1.5); + +%!test +%! ## no-delay conversion still a no-op regression check +%! t = tf (1, [1 3 2]); +%! z = zpk (t); +%! assert (get (z, "InputDelay"), 0); +%! assert (get (z, "OutputDelay"), 0); +%! assert (get (z, "IODelay"), 0); + %!test %! ## conversion from tf keeps class %! sys = zpk (tf (1, [1 3 2])); From 9312ca1d8f8327827d0f8a68f8e3874d8bbcf525 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 18:48:25 +0800 Subject: [PATCH 20/26] c2d: extend exact fractional-delay zoh to MIMO (per-column-uniform delay) Generalizes the SISO exact split-ZOH fix to MIMO, scoped to systems where the fractional delay is attributable to per-input-channel delay: OutputDelay zero-or-uniform across outputs, IODelay uniform down each column. Genuinely row-varying delay still falls back to the existing "not yet supported" error. Also fixes two bugs found while generalizing: a crash converting a SIMO (multi-output, single-input) delayed system to tf/zpk, and a one-sample delay double-counting error affecting any ss-typed SISO fractional-delay output (present since the SISO commit, only caught once ss-typed output was cross-checked against independent ground-truth simulation). --- inst/@lti/c2d.m | 213 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 190 insertions(+), 23 deletions(-) diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index c2c1a76e..cf76c5c2 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -528,7 +528,103 @@ %! dsys_tustin = c2d (sys, 0.5, "tustin"); %! assert (hasdelay (dsys_tustin), false); -%!error c2d (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [1.33;0]), 0.5, "zoh") +%!test # SIMO (single input, multiple outputs) fractional InputDelay: +%! ## Finding 1 fix-pass regression test. Before the fix, __c2d_frac_ss__'s +%! ## trailing tf/zpk pole/zero-at-origin cancellation block was guarded +%! ## only by "m == 1", which is trivially true here (m == 1) even though +%! ## the system is not truly SISO (p == 2) -- the cancellation code calls +%! ## tfdata(sys, "v") in a way that assumes a genuinely SISO system, and +%! ## crashed with "ERROR: abs: not defined for cell". The guard is now +%! ## "p == 1 && m == 1", so this case is correctly routed through the +%! ## general per-column MIMO augmentation path instead (no cancellation +%! ## attempted). Verify no crash, and cross-check each output channel's +%! ## response against an independently, separately discretized SISO +%! ## channel. +%! sys = tf ({1;1}, {[1 1];[1 2]}, "InputDelay", 1.33); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! h1 = tf (1, [1 1], "InputDelay", 1.33); +%! h2 = tf (1, [1 2], "InputDelay", 1.33); +%! d1 = c2d (h1, 0.5, "zoh"); +%! d2 = c2d (h2, 0.5, "zoh"); +%! w = [0.1, 1, 5]; +%! resp = freqresp (dsys, w); +%! assert (reshape (resp(1,1,:), 1, []), reshape (freqresp (d1, w), 1, []), 1e-10); +%! assert (reshape (resp(2,1,:), 1, []), reshape (freqresp (d2, w), 1, []), 1e-10); + +%!test # MIMO fractional InputDelay, uniform per column: now handled exactly +%! ## by the Task 2 per-column split-ZOH generalization (this used to error +%! ## before MIMO support was added -- __c2d_frac_applicable__ now accepts +%! ## it because each column's total delay is constant down that column). +%! ## InputDelay is [2;0], not [3;0]: the fix-pass correction removed the +%! ## blanket "+1" applied to every fractional-delay column, since the +%! ## extra register state remains part of the augmented dynamics here (no +%! ## tf/zpk pole-zero cancellation happens for p>1) and its own +%! ## eigenvalue-zero pole already contributes exactly the missing sample. +%! sys = tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "InputDelay", [1.33;0]); +%! dsys = c2d (sys, 0.5, "zoh"); +%! assert (isdt (dsys), true); +%! assert (dsys.InputDelay, [2;0]); + +%!test # MIMO: column 1 exact-integer (nonzero) delay, column 2 fractional -- +%! ## only column 2 gets an extra register state; column 1's response must +%! ## match a plain (no-extra-state) single-channel zoh discretization +%! ## exactly, confirming the exact-integer column is untouched by the +%! ## per-column augmentation applied to its fractional sibling. +%! A = [-1, 0; 0, -3]; +%! B = [1, 0; 0, 1]; +%! C = [1, 0; 0, 1]; +%! D = [0, 0; 0, 0]; +%! sys = ss (A, B, C, D); +%! sys = set (sys, "InputDelay", [0.2; 0.37]); # col1: exact 2 samples @ Ts=0.1 +%! tsam = 0.1; +%! dsys = c2d (sys, tsam); +%! assert (dsys.InputDelay(1), 2); +%! assert (dsys.InputDelay(2), 3); # floor(0.37/0.1)=3; no +1, since the +%! # register survives as a real state in +%! # this ss/MIMO output (only cancelled-out +%! # tf/zpk SISO conversions get the +1) +%! sys1 = ss (A(1,1), B(1,1), C(1,1), D(1,1)); +%! sys1 = set (sys1, "InputDelay", 0.2); +%! dsys1 = c2d (sys1, tsam); +%! w = [0.1, 1, 5]; +%! resp_full = freqresp (dsys, w); +%! resp_ref = freqresp (dsys1, w); +%! assert (squeeze (resp_full(1,1,:)), squeeze (resp_ref), 1e-8); + +%!test # MIMO fractional OutputDelay, uniform across all outputs: in-scope +%! ## (Finding 2 fix-pass): OutputDelay may be nonzero for MIMO as long as +%! ## it is uniform across all outputs, since that is mathematically +%! ## equivalent to attributing the same delay to InputDelay instead. +%! ## Verify this succeeds (not "not yet supported") and matches the +%! ## InputDelay-based formulation exactly (ssdata/freqresp). +%! num = {1,1;1,1}; den = {[1 1],[1 2];[1 3],[1 4]}; +%! sys_out = tf (num, den, "OutputDelay", [1.33;1.33]); +%! sys_in = tf (num, den, "InputDelay", [1.33;1.33]); +%! d_out = c2d (sys_out, 0.5, "zoh"); +%! d_in = c2d (sys_in, 0.5, "zoh"); +%! assert (isdt (d_out), true); +%! [Ao, Bo, Co, Do] = ssdata (d_out); +%! [Ai, Bi, Ci, Di] = ssdata (d_in); +%! assert (Ao, Ai, 1e-10); +%! assert (Bo, Bi, 1e-10); +%! assert (Co, Ci, 1e-10); +%! assert (Do, Di, 1e-10); +%! w = [0.1, 1, 5]; +%! assert (freqresp (d_out, w), freqresp (d_in, w), 1e-10); + +%!error ... +%! ## row-varying OutputDelay (differs across outputs) is genuinely out of +%! ## scope -- it cannot be pushed through to the input side -- and must +%! ## still fall through to the "not yet supported" error. +%! c2d (tf ({1,1;1,1}, {[1 1],[1 2];[1 3],[1 4]}, "OutputDelay", [1.33;0.5]), 0.5, "zoh") + +%!error ... +%! ## row-varying IODelay within a column is likewise genuinely out of +%! ## scope and must still error. +%! sys = ss ([-1, 0; 0, -2], [1, 0; 0, 1], [1, 0; 0, 1], [0, 0; 0, 0]); +%! sys = set (sys, "IODelay", [1.33, 0; 0.5, 0]); +%! c2d (sys, 0.5, "zoh") %!test # c2dOptions struct as 3rd argument, default DelayModeling @@ -695,33 +791,101 @@ %! assert (num{1}, [0.01187, 0.06408, 0.009721], 1e-4); %! assert (den{1}, [1, -1.655, 0.7408], 1e-3); -## Temporary Task-1 stub - Task 2 replaces this with the full MIMO check. function tf_ok = __c2d_frac_applicable__ (sys, tsam) - [p, m] = size (sys); - tf_ok = (p == 1 && m == 1); + [outdelay] = get (sys, "outputdelay"); + total = totaldelay (sys); # p-by-m matrix, seconds + [p, m] = size (total); + ## A single-output system's OutputDelay commutes with its InputDelay(s) + ## (it is equivalent, for a scalar-output LTI system, to shifting every + ## input by the same amount), so it can always be folded into the + ## per-column totaldelay used below -- this is exactly what Task 1's + ## SISO OutputDelay regression test relies on. For p > 1 a nonzero, + ## per-row-varying OutputDelay would make the per-column delay + ## inconsistent across outputs; that case is already rejected by the + ## uniformity check below, so no separate early-return is needed here. + ## + ## Deliberate scope decision (not just an accepted side effect): for + ## MIMO (p > 1), OutputDelay may be nonzero as long as it is *uniform* + ## across all outputs -- that is mathematically equivalent to + ## attributing the same delay to InputDelay instead (every output being + ## delayed by the same amount commutes with delaying every input by that + ## amount), and is folded into the per-column totaldelay uniformity + ## check below exactly like any other InputDelay/IODelay contribution. + ## Only row-VARYING OutputDelay is rejected here, since that would + ## require genuine output-side treatment (delaying different outputs by + ## different amounts cannot be pushed through to the input side), which + ## this fix does not implement -- such systems fall through to the + ## "not yet supported" error below. + if (p > 1 && any (outdelay != outdelay(1))) + tf_ok = false; + return; + endif + for j = 1 : m + col = total (:, j); + if (any (abs (col - col(1)) > 1e-10)) + tf_ok = false; + return; + endif + endfor + tf_ok = true; endfunction function sys = __c2d_frac_ss__ (origsys, tsam) sys_ss = ss (origsys); [A, B, C, D] = ssdata (sys_ss); - tau = totaldelay (origsys); # scalar for SISO - - [Ad, Bd, extra] = __c2d_frac_zoh__ (A, B, tsam, tau); - - if (! extra.needed) - sys = ss (Ad, Bd, C, D, tsam); - d_samples = extra.d; - else - n = rows (A); - A_aug = [Ad, extra.g0; zeros(1,n), 0]; - B_aug = [Bd; 1]; - C_aug = [C, zeros(rows(C),1)]; - D_aug = D; - sys = ss (A_aug, B_aug, C_aug, D_aug, tsam); - d_samples = extra.d + 1; - endif + total = totaldelay (origsys); + [p, m] = size (total); + n = rows (A); + + col_tau = total (1, :); # one delay value per column (uniform down + # each column, guaranteed by the + # applicability check) + + extra_needed = false (1, m); + d_samples = zeros (1, m); + Bd_cols = cell (1, m); + g0_cols = cell (1, m); + + for j = 1 : m + [Ad, Bd, extra] = __c2d_frac_zoh__ (A, B(:,j), tsam, col_tau(j)); + d_samples(j) = extra.d; + Bd_cols{j} = Bd; + extra_needed(j) = extra.needed; + if (extra.needed) + g0_cols{j} = extra.g0; + ## NOTE: no "+1" here. The extra register state remains part of the + ## augmented dynamics (A_aug/B_aug below) and its own eigenvalue-zero + ## pole already contributes exactly one sample of delay to the + ## transfer function; adding 1 to the explicit InputDelay field here + ## as well would double-count that sample (verified against an + ## independent Astrom & Wittenmark split-ZOH ground truth: with the + ## register left in the dynamics, InputDelay must stay at d, not + ## d+1). The one case where the register's dynamics are explicitly + ## removed from the transfer function (the SISO tf/zpk pole/zero + ## cancellation below) re-adds the missing sample there, exactly + ## because the register's contribution is being deleted from the + ## dynamics at that point. + endif + endfor + + n_extra = sum (extra_needed); + A_aug = zeros (n + n_extra, n + n_extra); + A_aug (1:n, 1:n) = Ad; + B_aug = zeros (n + n_extra, m); + C_aug = [C, zeros(rows(C), n_extra)]; + + row = n + 1; + for j = 1 : m + B_aug (1:n, j) = Bd_cols{j}; + if (extra_needed(j)) + A_aug (1:n, row) = g0_cols{j}; + B_aug (row, j) = 1; + row += 1; + endif + endfor - sys = set (sys, "InputDelay", d_samples); + sys = ss (A_aug, B_aug, C_aug, D, tsam); + sys = set (sys, "InputDelay", d_samples(:)); if (isa (origsys, "tf") || isa (origsys, "zpk")) sys = tf (sys); @@ -735,14 +899,17 @@ ## ~0 denominator coefficient): cancel it explicitly so num/den come ## back at minimal (MATLAB-compatible) order instead of one degree too ## high. - if (extra.needed) + if (p == 1 && m == 1 && n_extra > 0) [num, den] = tfdata (sys, "v"); if (abs (num(1)) < 1e-8 * max (abs (num)) ... && abs (den(end)) < 1e-8 * max (abs (den))) num = num(2:end); den = den(1:end-1); sys = tf (num, den, tsam); - sys = set (sys, "InputDelay", d_samples); + ## The register's dynamics were just deleted from num/den above, so + ## (unlike the general case) the one sample of delay it used to + ## contribute via its own pole must now be re-added explicitly. + sys = set (sys, "InputDelay", d_samples + 1); endif endif From f762b8ed06c26f7b1cd8aaa34bbd5d601a3d2a8e Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 19:13:28 +0800 Subject: [PATCH 21/26] lti: fix stale delay fields after transpose/ctranspose transpose() swapped inname/outname/ingroup/outgroup for the new shape but never touched InputDelay/OutputDelay/IODelay -- every subclass __transpose__ hook (tf, ss, frd; zpk round-trips through tf) leaves these lti-level fields completely untouched, so they stayed shaped/valued for the pre-transpose orientation. Single-point fix in @lti/transpose.m (fixes all four subclasses uniformly): swap InputDelay/OutputDelay and transpose IODelay to match. ctranspose() has no correct fix of the same shape: pertransposition substitutes s -> -s (continuous) or z -> 1/z (discrete), turning a delay into a non-representable negative advance. Errors clearly instead of silently returning stale data, matching the existing InternalDelay guard pattern already used elsewhere in this codebase. Found as a deferred follow-up from the exact-fractional-delay-c2d phase, where the zpk() delay-propagation fix made this newly reachable. --- inst/@lti/ctranspose.m | 19 +++++++++++++ inst/@lti/transpose.m | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/inst/@lti/ctranspose.m b/inst/@lti/ctranspose.m index 30618a90..74225bdf 100644 --- a/inst/@lti/ctranspose.m +++ b/inst/@lti/ctranspose.m @@ -51,6 +51,15 @@ error ("lti: ctranspose: this is an unary operator"); endif + if (hasdelay (sys)) + ## Pertransposition substitutes s -> -s (continuous) or z -> 1/z + ## (discrete), turning a delay e^(-tau s) into e^(+tau s) -- a pure + ## advance, which InputDelay/OutputDelay/IODelay (nonnegative-only) + ## cannot represent. Error clearly rather than silently return stale + ## pre-transpose delay data. + error ("lti: ctranspose: delay is not yet supported"); + endif + [p, m] = size (sys); ct = isct (sys); @@ -62,3 +71,13 @@ sys.outgroup = struct (); endfunction + + +%!test # no delay: unaffected (regression) +%! sys = tf (1, [1 1]); +%! sys_ct = sys'; +%! assert (hasdelay (sys_ct), false); + +%!error tf (1, [1 1], "InputDelay", 0.5)' +%!error ss (-1, 1, 1, 0, "OutputDelay", 0.5)' +%!error zpk ([], -1, 1, "IODelay", 0.5)' diff --git a/inst/@lti/transpose.m b/inst/@lti/transpose.m index 8e1fee8b..d2ca260e 100644 --- a/inst/@lti/transpose.m +++ b/inst/@lti/transpose.m @@ -35,6 +35,13 @@ [p, m] = size (sys); + ## Capture before __transpose__ runs: every subclass hook (tf, ss, frd; + ## zpk round-trips through tf) leaves these lti-level fields completely + ## untouched, still shaped/valued for the pre-transpose orientation. + indelay = sys.indelay; + outdelay = sys.outdelay; + iodelay = sys.iodelay; + sys = __transpose__ (sys); sys.inname = repmat ({""}, p, 1); @@ -42,4 +49,57 @@ sys.ingroup = struct (); sys.outgroup = struct (); + ## Swap InputDelay/OutputDelay and transpose IODelay to match the new + ## (m x p) orientation. IODelay(i,j) is delay from input j to output i; + ## transposing the system swaps which index is row vs. column. + sys.indelay = outdelay; + sys.outdelay = indelay; + sys.iodelay = iodelay.'; + endfunction + + +%!test # no delay: unaffected (regression) +%! sys = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! sys_t = sys.'; +%! assert (hasdelay (sys_t), false); +%! assert (size (sys_t), [2, 2]); + +%!test # tf, non-square MIMO: InputDelay/OutputDelay swap, IODelay transposes +%! sys = tf ({1, 1; 1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]; [1 5], [1 6]}); +%! sys = set (sys, "InputDelay", [0.1; 0.2], "OutputDelay", [0.3; 0.4; 0.5], ... +%! "IODelay", [1, 2; 3, 4; 5, 6]); +%! sys_t = sys.'; +%! assert (size (sys_t), [2, 3]); +%! assert (get (sys_t, "inputdelay"), [0.3; 0.4; 0.5]); +%! assert (get (sys_t, "outputdelay"), [0.1; 0.2]); +%! assert (get (sys_t, "iodelay"), [1, 3, 5; 2, 4, 6]); + +%!test # ss, non-square MIMO: same swap/transpose, different subclass hook +%! sys = ss (-1, [1, 1], [1; 1; 1], zeros (3, 2)); +%! sys = set (sys, "InputDelay", [0.1; 0.2], "OutputDelay", [0.3; 0.4; 0.5], ... +%! "IODelay", [1, 2; 3, 4; 5, 6]); +%! sys_t = sys.'; +%! assert (isa (sys_t, "ss")); +%! assert (get (sys_t, "inputdelay"), [0.3; 0.4; 0.5]); +%! assert (get (sys_t, "outputdelay"), [0.1; 0.2]); +%! assert (get (sys_t, "iodelay"), [1, 3, 5; 2, 4, 6]); + +%!test # zpk, non-square MIMO: same swap/transpose via the tf round-trip hook +%! sys = zpk ({[-1], [-1]; [-1], [-1]}, {[-1], [-2]; [-3], [-4]}, [1, 1; 1, 1]); +%! sys = set (sys, "InputDelay", [0.1; 0.2], "OutputDelay", [0.3; 0.4], ... +%! "IODelay", [1, 2; 3, 4]); +%! sys_t = sys.'; +%! assert (isa (sys_t, "zpk")); +%! assert (get (sys_t, "inputdelay"), [0.3; 0.4]); +%! assert (get (sys_t, "outputdelay"), [0.1; 0.2]); +%! assert (get (sys_t, "iodelay"), [1, 3; 2, 4]); + +%!test # square system with nonzero IODelay: catches value-staleness +%! # distinctly from the shape-staleness case above (a square system's +%! # IODelay is the same SIZE before and after transpose, so a bug that +%! # forgot the transpose entirely would not be caught by a shape check). +%! sys = tf ({1, 1; 1, 1}, {[1 1], [1 2]; [1 3], [1 4]}); +%! sys = set (sys, "IODelay", [1, 2; 3, 4]); +%! sys_t = sys.'; +%! assert (get (sys_t, "iodelay"), [1, 3; 2, 4]); From 11946dc4b35021a88d28c73dbbceb926a8e0c28e Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 19:33:46 +0800 Subject: [PATCH 22/26] add exact discrete delay (z^-k) ss realization helper --- inst/__exact_discrete_delay_ss__.m | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 inst/__exact_discrete_delay_ss__.m diff --git a/inst/__exact_discrete_delay_ss__.m b/inst/__exact_discrete_delay_ss__.m new file mode 100644 index 00000000..87b27ce0 --- /dev/null +++ b/inst/__exact_discrete_delay_ss__.m @@ -0,0 +1,51 @@ +## -*- texinfo -*- +## Internal helper: exact causal discrete-time ss realization of z^-k (a +## pure k-sample delay chain). Used by pade() (discrete-time InternalDelay +## closure) and c2d's DelayModeling="state" InternalDelay handling -- both +## need an EXACT (not approximate) rational discrete delay, since integer +## sample delays are already exactly rational; unlike the continuous case, +## no Pade approximation is needed or appropriate here. +function dsys = __exact_discrete_delay_ss__ (k, tsam) + + if (k == 0) + dsys = ss (zeros (0, 0), zeros (0, 1), zeros (1, 0), 1, tsam); + return; + endif + + A = diag (ones (k-1, 1), 1); + B = [zeros(k-1, 1); 1]; + C = [1, zeros(1, k-1)]; + D = 0; + + dsys = ss (A, B, C, D, tsam); + +endfunction + +%!test # k=0: pure passthrough, no states +%! d = __exact_discrete_delay_ss__ (0, 0.1); +%! assert (isa (d, "ss")); +%! assert (isdt (d)); +%! [a, b, c, dd] = ssdata (d); +%! assert (rows (a), 0); +%! assert (dd, 1); + +%!test # k=3: exact z^-3, verified via freqresp against the analytic z^-3 phase/magnitude +%! tsam = 0.1; +%! d = __exact_discrete_delay_ss__ (3, tsam); +%! assert (isa (d, "ss")); +%! [a, b, c, dd] = ssdata (d); +%! assert (rows (a), 3); +%! w = [0.1, 1, 5]; +%! z = exp (1i * w * tsam); +%! expected = z .^ (-3); +%! assert (reshape (freqresp (d, w), 1, []), expected, 1e-10); + +%!test # step response: output should be exactly the (delayed-by-3) unit step +%! tsam = 0.1; +%! d = __exact_discrete_delay_ss__ (3, tsam); +%! N = 10; +%! u = ones (N, 1); +%! t = (0:N-1)' * tsam; +%! y = lsim (d, u, t); +%! expected = [zeros(3,1); ones(N-3,1)]; +%! assert (y, expected, 1e-10); From 47f13046dea9b491fc2d4d68aa3214b0328ddbed Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 19:43:34 +0800 Subject: [PATCH 23/26] pade: support discrete-time input via exact (not approximate) delay absorption --- inst/@lti/pade.m | 82 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m index 31a51825..619204e9 100644 --- a/inst/@lti/pade.m +++ b/inst/@lti/pade.m @@ -92,6 +92,12 @@ total = totaldelay (sys); [pr, pc] = size (total); + if (isdt (sys)) + sys = set (sys, "InputDelay", indelay, "OutputDelay", outdelay, "IODelay", iodelay); + sys = absorbDelay (sys); + return; + endif + [orders, idx] = __pade_order_vector__ (sys, n); n_in = numel (idx.input); @@ -255,10 +261,20 @@ ## Diagonal MIMO Pade filter: nports inputs (each port's z, the delayed ## signal's source) and nports outputs (each port's w, the destination). - G_pade = pade (tau(1), orders_internal(1)); - for k = 2 : nports - G_pade = append (G_pade, pade (tau(k), orders_internal(k))); - endfor + ## For discrete sys, tau(k) is already a nonnegative-integer sample + ## count, so the loop is closed EXACTLY via __exact_discrete_delay_ss__ + ## instead of a rational Pade approximation. + if (isdt (sys)) + G_pade = __exact_discrete_delay_ss__ (tau(1), etsam); + for k = 2 : nports + G_pade = append (G_pade, __exact_discrete_delay_ss__ (tau(k), etsam)); + endfor + else + G_pade = pade (tau(1), orders_internal(1)); + for k = 2 : nports + G_pade = append (G_pade, pade (tau(k), orders_internal(k))); + endfor + endif ## Close the loop. lft(sys1, sys2, nu, ny) connects the LAST nu inputs of ## sys1 to the FIRST nu outputs of sys2, and the LAST ny outputs of sys1 @@ -484,3 +500,61 @@ %! sys = set (ss (-1, 1, 1, 0), "internaldelay", 0.5); %! sys2 = pade (sys, 3); %! assert (hasinternaldelay (sys2), false); + +%!test # discrete tf ordinary delay: exact absorption, matches absorbDelay directly +%! sys = tf (1, [1, -0.5], 0.1, "InputDelay", 3); +%! sys2 = pade (sys, 4); +%! expected = absorbDelay (sys); +%! assert (hasdelay (sys2), false); +%! [num2, den2] = tfdata (sys2); +%! [nume, dene] = tfdata (expected); +%! assert (num2, nume, 1e-12); +%! assert (den2, dene, 1e-12); + +%!test # discrete tf: pade order n has NO effect on the exact discrete result +%! sys = tf (1, [1, -0.5], 0.1, "InputDelay", 2); +%! sys_a = pade (sys, 2); +%! sys_b = pade (sys, 9); +%! [numa, dena] = tfdata (sys_a); +%! [numb, denb] = tfdata (sys_b); +%! assert (numa, numb, 1e-14); +%! assert (dena, denb, 1e-14); + +%!test # discrete ss ordinary delay: tf-roundtrip through absorbDelay, cross-check freqresp +%! sys = ss (0.5, 1, 1, 0, 0.1, "InputDelay", 2); +%! sys2 = pade (sys, 3); +%! assert (isa (sys2, "ss")); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! expected = absorbDelay (tf (set (sys, "InputDelay", 2))); +%! assert (freqresp (sys2, w), freqresp (expected, w), 1e-8); + +%!test # discrete ss InternalDelay: exact loop closure, matches the untouched +%! # exact-delay discrete freqresp (no approximation error to expect, unlike +%! # the continuous case -- this closure is exact by construction) +%! G = ss (0.5, 1, 1, 0, 0.1, "IODelay", 3); +%! L = feedback (G); +%! assert (hasinternaldelay (L), true); +%! sys2 = pade (L, 4); +%! assert (hasinternaldelay (sys2), false); +%! w = [0.1, 1, 5]; +%! assert (freqresp (sys2, w), freqresp (L, w), 1e-9); + +%!test # discrete ss InternalDelay: order n has no effect on the exact result +%! G = ss (0.5, 1, 1, 0, 0.1, "IODelay", 2); +%! L = feedback (G); +%! sys_a = pade (L, 2); +%! sys_b = pade (L, 7); +%! w = [0.1, 1, 5]; +%! assert (freqresp (sys_a, w), freqresp (sys_b, w), 1e-12); + +%!test # discrete ss: combined InternalDelay AND ordinary delay, both exact +%! G = ss (0.5, 1, 1, 0, 0.1, "IODelay", 2); +%! L = set (feedback (G), "InputDelay", 3); +%! assert (hasinternaldelay (L), true); +%! assert (hasdelay (L), true); +%! sys2 = pade (L, 4); +%! assert (hasinternaldelay (sys2), false); +%! assert (hasdelay (sys2), false); +%! w = [0.1, 1, 5]; +%! assert (freqresp (sys2, w), freqresp (L, w), 1e-8); From 10b2aa5021acbbf2e1087f2895002e2122f89386 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 19:57:06 +0800 Subject: [PATCH 24/26] pade: validate n in the discrete ordinary-delay path too, matching the continuous path --- inst/@lti/pade.m | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m index 619204e9..6bd8bae8 100644 --- a/inst/@lti/pade.m +++ b/inst/@lti/pade.m @@ -93,6 +93,18 @@ [pr, pc] = size (total); if (isdt (sys)) + ## n still validated (same path as continuous input) even though it has + ## no effect on the exact discrete result -- discarding the return + ## values is intentional. __pade_order_vector__ only checks that a + ## vector n has the right length; the actual per-entry order values + ## (integer, non-negative, causal) are validated by the low-level + ## pade() polynomial routine in the continuous branch below, so call + ## it here too (with a dummy dead time) to match that validation + ## exactly. + [orders, ~] = __pade_order_vector__ (sys, n); + for k = 1 : numel (orders) + pade (1, orders (k)); + endfor sys = set (sys, "InputDelay", indelay, "OutputDelay", outdelay, "IODelay", iodelay); sys = absorbDelay (sys); return; @@ -558,3 +570,5 @@ %! assert (hasdelay (sys2), false); %! w = [0.1, 1, 5]; %! assert (freqresp (sys2, w), freqresp (L, w), 1e-8); + +%!error pade (tf (1, [1, -0.5], 0.1, "InputDelay", 2), [1, 2]) From a4be11290219befd9c37b94d0773a16bb3644692 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 20:07:42 +0800 Subject: [PATCH 25/26] pade: fix unwanted figure-window side effect in discrete n-validation The dummy validation call in the discrete ordinary-delay path (pade(1, orders(k));) ran with nargout=0, which triggers pade()'s plotting branch -- popping open stray step-response/Bode figure windows on every discrete-delay pade() call. Force nargout=2 to match the continuous path's own call shape and keep the validation call pure. --- inst/@lti/pade.m | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/inst/@lti/pade.m b/inst/@lti/pade.m index 6bd8bae8..4e7b8c9a 100644 --- a/inst/@lti/pade.m +++ b/inst/@lti/pade.m @@ -103,7 +103,11 @@ ## exactly. [orders, ~] = __pade_order_vector__ (sys, n); for k = 1 : numel (orders) - pade (1, orders (k)); + ## nargout=2 (matching the continuous path's own call shape below) + ## is required here, not just cosmetic: pade() with nargout==0 runs + ## its plotting branch, which would pop open stray figure windows as + ## a side effect of what must be a pure validation call. + [~, ~] = pade (1, orders (k)); endfor sys = set (sys, "InputDelay", indelay, "OutputDelay", outdelay, "IODelay", iodelay); sys = absorbDelay (sys); From 9895ff9a1752c809148ca8c0f311cb5e2d3e6e71 Mon Sep 17 00:00:00 2001 From: Prateek Ganguli Date: Fri, 24 Jul 2026 20:27:11 +0800 Subject: [PATCH 26/26] c2d: implement DelayModeling=state for InternalDelay via exact loop closure Closes the discretized InternalDelay loop with an exact z^-k filter per delay port (via __exact_discrete_delay_ss__ + lft(), mirroring pade.m's __pade_substitute_internal__), instead of silently ignoring DelayModeling="state" for InternalDelay systems as before. --- inst/@lti/c2d.m | 94 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/inst/@lti/c2d.m b/inst/@lti/c2d.m index cf76c5c2..826ff90d 100644 --- a/inst/@lti/c2d.m +++ b/inst/@lti/c2d.m @@ -113,8 +113,7 @@ ## out of scope; only the plain round-to-samples case is handled. [ext_sys, nu, ny] = __ss_ext_build__ (origsys); ext_sys = __c2d__ (ext_sys, tsam, lower (method), w0); - sys = __ss_ext_split__ (origsys, ext_sys, nu, ny); - sys.tsam = tsam; + ext_sys.tsam = tsam; tau = get (origsys, "internaldelay"); tau_samples = round (tau / tsam); @@ -131,6 +130,50 @@ bad, tsam); endif + if (strcmp (delay_modeling, "state")) + ## Close the discretized InternalDelay loop with an EXACT z^-k filter per + ## port (tau_samples is already a nonnegative integer -- no approximation + ## needed, unlike pade()'s Pade-filter closure for the continuous case), + ## instead of leaving InternalDelay as an explicit port. Mirrors + ## __pade_substitute_internal__'s ext_clean + lft() pattern in + ## @lti/pade.m: extract ext_sys's raw matrices via dssdata (this function + ## is an @lti method and cannot dot-access ext_sys's @ss-private fields + ## directly), rebuild a clean ordinary ss/dss, then close the loop. + [ea, eb, ec, ed, ee, etsam] = dssdata (ext_sys, []); + nports = columns (eb) - nu; + + if (isempty (ee)) + ext_clean = ss (ea, eb, ec, ed, etsam); + else + ext_clean = dss (ea, eb, ec, ed, ee, etsam); + endif + + if (nports == 0) + ## Degenerate: internaldelay set but no actual delay ports -- nothing + ## to close, the clean plant already IS the delay-free equivalent. + sys = ext_clean; + else + G_close = __exact_discrete_delay_ss__ (tau_samples(1), tsam); + for kk = 2 : nports + G_close = append (G_close, __exact_discrete_delay_ss__ (tau_samples(kk), tsam)); + endfor + sys = lft (ext_clean, G_close, nports, nports); + endif + + if (hasdelay (origsys)) + [indelay, outdelay, iodelay] = get (origsys, "inputdelay", "outputdelay", "iodelay"); + sys = set (sys, "InputDelay", round (indelay / tsam), ... + "OutputDelay", round (outdelay / tsam), ... + "IODelay", round (iodelay / tsam)); + sys = ss (absorbDelay (tf (sys))); + endif + + return; + endif + + sys = __ss_ext_split__ (origsys, ext_sys, nu, ny); + sys.tsam = tsam; + sys = set (sys, "internaldelay", tau_samples); if (hasdelay (origsys)) @@ -791,6 +834,53 @@ %! assert (num{1}, [0.01187, 0.06408, 0.009721], 1e-4); %! assert (den{1}, [1, -1.655, 0.7408], 1e-3); +%!test # DelayModeling="state" on InternalDelay: port removed, freqresp matches +%! # the default ("delay") mode's own result exactly (both are exact, just +%! # different representations -- explicit port vs. absorbed states). +%! T = 0.3; tsam = 0.1; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! opt_state = c2dOptions ("DelayModeling", "state"); +%! dsys_state = c2d (L, tsam, opt_state); +%! dsys_delay = c2d (L, tsam); +%! assert (hasinternaldelay (dsys_state), false); +%! w = [0.1, 1, 5]; +%! assert (freqresp (dsys_state, w), freqresp (dsys_delay, w), 1e-8); + +%!test # DelayModeling="state" combined InternalDelay + ordinary InputDelay: +%! # both absorbed, hasdelay and hasinternaldelay both false +%! T = 0.3; tsam = 0.1; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = set (feedback (G), "InputDelay", 0.2); +%! opt_state = c2dOptions ("DelayModeling", "state"); +%! dsys_state = c2d (L, tsam, opt_state); +%! assert (hasinternaldelay (dsys_state), false); +%! assert (hasdelay (dsys_state), false); +%! w = [0.1, 1, 5]; +%! dsys_delay = c2d (L, tsam); +%! assert (freqresp (dsys_state, w), freqresp (dsys_delay, w), 1e-6); + +%!test # DelayModeling="state" MIMO InternalDelay: two independent ports +%! T1 = 0.3; T2 = 0.5; tsam = 0.1; +%! G1 = ss (-1, 1, 1, 0, "IODelay", T1); +%! G2 = ss (-2, 1, 1, 0, "IODelay", T2); +%! sys = append (feedback (G1), feedback (G2)); +%! opt_state = c2dOptions ("DelayModeling", "state"); +%! dsys_state = c2d (sys, tsam, opt_state); +%! assert (hasinternaldelay (dsys_state), false); +%! w = [0.1, 1, 5]; +%! dsys_delay = c2d (sys, tsam); +%! assert (freqresp (dsys_state, w), freqresp (dsys_delay, w), 1e-6); + +%!test # default ("delay") mode on InternalDelay: unaffected regression check +%! T = 0.3; tsam = 0.1; +%! G = ss (-1, 1, 1, 0, "IODelay", T); +%! L = feedback (G); +%! dsys = c2d (L, tsam); +%! assert (hasinternaldelay (dsys), true); +%! assert (dsys.internaldelay, round (T/tsam)); + + function tf_ok = __c2d_frac_applicable__ (sys, tsam) [outdelay] = get (sys, "outputdelay"); total = totaldelay (sys); # p-by-m matrix, seconds