Skip to content

Fix matched c2d/sample returning NaN for a pole or zero at the origin - #1241

Merged
murrayrm merged 1 commit into
python-control:mainfrom
gaoflow:fix-matched-c2d-origin-pole-zero
Aug 11, 2026
Merged

Fix matched c2d/sample returning NaN for a pole or zero at the origin#1241
murrayrm merged 1 commit into
python-control:mainfrom
gaoflow:fix-matched-c2d-origin-pole-zero

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

method='matched' returns a discrete transfer function with an all-NaN numerator for any SISO system that has a pole or zero at s = 0 — integrators, PI/PID controllers, differentiators, and other type-1/type-2 plants:

>>> ct.tf([2., 5.], [1., 0.]).sample(0.01, method='matched')  # PI controller
    num = [nan nan]   den = [1. -1.]
>>> ct.tf([1.], [1., 0.]).sample(0.1, method='matched')       # integrator
    num = [nan]       den = [1. -1.]

The gain step matches the DC gain, gain = dcgain / zgain. A pole or zero at the origin maps to z = 1, so its 1 - z factor vanishes: zgain becomes 0 or inf while dcgain is simultaneously inf or 0, giving 0/0 or inf/inf. This is the origin case of the DC-gain match added in #951 (which corrected the finite-DC offset from #950 but is undefined at the singularity).

The fix leaves the origin factors out of the gain product and restores their scaling analytically through the z - 1 ≈ s·T limit. This recovers the textbook matched transforms 1/s → T/(z-1), 1/s² → T²/(z-1)², … and preserves |G(jω)| across the band; finite-DC systems are byte-for-byte unchanged, so #951 is unaffected.

Regression tests cover integrator / PI / differentiator / double-integrator / mixed pole-and-zero-at-origin systems over several sample times, asserting no NaN, the closed-form integrator coefficients, and the low-frequency gain.

matched also doesn't add the z = -1 zeros for pole excess, but that (standard vs. modified MPZ) is a separate design choice and is left out of scope here.

@coveralls

coveralls commented Jul 21, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 94.743% (+0.003%) from 94.74% — gaoflow:fix-matched-c2d-origin-pole-zero into python-control:main

@sawyerbfuller

sawyerbfuller commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Thanks for the PR!

This function has gotten almost no attention and has really atrophied, good find that it fails for systems with poles or zeros at the origin, I agree it shouldn’t do that. So thanks for taking this on.

Something to consider: In the intervening years, scipy has introduced an impulse-matched conversion in cont2discrete called “impulse”. https://github.com/scipy/scipy/blob/v1.18.0/scipy/signal/_lti_conversion.py#L384-L583

Its implementation goes through a state-space conversion and back, but it looks clean and simple (only about 4 lines). Do you think it’s better to stick with your algorithm, that works directly with the poles and zeros of the TF, or switch to calling scipy, which is probably more tested?

(My take - just call the scipy code, but make sure it passes your unit tests. Edit: Unless you know that this direct-from-the-transfer function approach is better!)

@sawyerbfuller

Copy link
Copy Markdown
Contributor

I should be clear - I don’t know whether it is numerically more accurate to compute the impulse-matched conversion as it is done in this PR, from the transfer function directly, or as it is done in scipy using a state-space conversion.

Do you know ?

@gaoflow

gaoflow commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

I think the question has a wrinkle in it: matched and scipy's impulse aren't two implementations of the same transform, so it isn't really a question of which is numerically better.

_c2d_matched is matched pole-zero: it maps z = exp(s*T) and then sets the gain from sysC.dcgain(), so DC gain is preserved exactly. scipy's impulse is impulse invariance: it makes the discrete impulse response equal T*h(kT), and DC gain is whatever falls out. Both on T = 0.1:

G(s) = 1/((s+1)(s+3)), continuous DC gain 0.333333

DC gain impulse response, first 4 samples
matched 0.333333 0.008221, 0.013530, 0.016754, 0.018502
scipy impulse 0.332502 0, 0.008201, 0.013496, 0.016712
T*h(kT) 0, 0.008201, 0.013496, 0.016712

G(s) = 10/(s+2), continuous DC gain 5.0

DC gain impulse response, first 4 samples
matched 5.000000 0.906346, 0.742054, 0.607542, 0.497413
scipy impulse 5.516656 1, 0.818731, 0.670320, 0.548812
T*h(kT) 1, 0.818731, 0.670320, 0.548812

Each is exact on its own invariant and wrong on the other — 10% off in DC gain for the first-order case. There's also a sample of delay: impulse invariance gives a strictly proper discrete system, matched pole-zero doesn't.

So switching method='matched' to call cont2discrete(..., 'impulse') would change what existing users get, not just how accurately they get it. If you want impulse invariance available I'd add it as its own method name and leave matched alone.

On the narrower numerical question I honestly don't know, and I didn't want to guess: matched needs tf2zpk root-finding, which is badly conditioned for high-order polynomials, while the state-space route builds a companion matrix and takes expm, which has its own conditioning problems. I'd expect both to degrade for high order, differently, but I haven't measured it and wouldn't want to claim a winner without doing so.

Either way this PR is the origin-pole/zero NaN, which is orthogonal — happy to keep it scoped to that.

@gaoflow

gaoflow commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

I measured it. For the same transform neither route wins everywhere, but this PR's algorithm sidesteps the failure mode of both.

Method: a 120-digit mpmath reference per transform (verified against closed forms, including repeated poles, and against an independent residue oracle), both implementations fed identical float64 coefficients, error = max relative error of H(e^jw) on the unit circle. The middle and right columns are the same transform, impulse invariance, computed two ways, so they isolate the route.

system, T matched (this PR) impulse, TF-direct impulse, scipy ss
poles -1, -3, T=0.1 7e-15 6e-15 5e-14
Butterworth 6, T=0.1 5e-9 1e-6 5e-7
Butterworth 10, T=0.1 1e-4 1e+2 1e+0
poles -1..-10, T=0.1 1e-10 6e+2 3e-1
poles -0.01..-1000, T=0.1 2e-9 2e-9 9e-1
4 pairs zeta=0.005, T=0.05 2e-7 9e-8 4e-2
poles -1, -3, T=1e-6 2e-10 1e-10 6e-4

The state-space route loses in ss2tf, not in expm: the denominator comes out right either way (coefficients agree to ~1e-15), but the numerator is poly(A - B@C) + (D-1)*poly(A), a difference of two characteristic polynomials. I measured that cancellation at 1e10-1e13 for n=8-10, and 2e12 at T=1e-6 even for n=2, which is where the last row comes from. The TF-direct route has the mirror problem — partial-fraction residues blow up for clustered poles — so it loses badly on Butterworth 10 and on poles -1..-10.

_c2d_matched hits neither, because it maps poles and zeros and multiplies out: no residues, no difference of characteristic polynomials. Against a floor of "compute exactly, then round to float64" it sits at or near that floor on everything I ran (worst case 60x, 8th-order Chebyshev I), including elliptic and cheby2 filters with a full set of finite zeros, so it isn't just riding a trivial numerator.

Independent of accuracy: scipy's impulse requires a strictly proper system, so it raises ValueError for a PI controller, PID, a differentiator, any biproper plant, and elliptic/cheby2 filters — most of what this PR unbreaks.

Where neither helps: at high order the TF representation is the limit, not the algorithm. That rounding floor is 3e-5 for Butterworth 10 and ~1.0 for poles -0.01..-1000 at T=1e-3, so nothing returning a float64 TransferFunction does better there; zpk or state space is the answer. Not tested: MIMO (matched is SISO-only) and anything other than float64.

Also, a correction to my earlier comment: impulse invariance is only strictly proper when the relative degree is at least 2 — for 10/(s+2) scipy returns D = T*C*B != 0.

@murrayrm

murrayrm commented Aug 9, 2026

Copy link
Copy Markdown
Member

FYI: CI failures have been addressed in #1243. Once that has been merged into main, you should be able to rebase and clear the errors.

TransferFunction.sample(Ts, method='matched') (and c2d) returned an
all-NaN numerator for any system with a pole or zero at s = 0 -
integrators, PI/PID controllers, differentiators and other type-1/2
systems.  The DC-gain match divides the continuous DC gain (inf for an
origin pole, 0 for an origin zero) by a zgain that carries the same
vanishing 1 - z factor, so the gain is 0/0 or inf/inf.

Match the gain from the non-origin factors only and restore the origin
scaling analytically through the z - 1 ~ s*Ts limit, which recovers the
textbook Ts/(z - 1) for 1/s, Ts**2/(z - 1)**2 for 1/s**2, and so on.
Finite-DC systems are byte-for-byte unchanged.  Completes the gain fix
from python-control#950/python-control#951.
@gaoflow
gaoflow force-pushed the fix-matched-c2d-origin-pole-zero branch from 3bca02b to f80d990 Compare August 9, 2026 15:08
@gaoflow

gaoflow commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #1243 has merged. The branch head is f80d990; control/tests/discrete_test.py passes 109/109 and ruff is clean on the two touched files.

@murrayrm
murrayrm merged commit 260eb0f into python-control:main Aug 11, 2026
24 checks passed
@sawyerbfuller

Copy link
Copy Markdown
Contributor

Sorry, just got back to my computer after being away for awhile. My mistake about the two different conversions - you're right, scipy's matched is matching impulse responses, not poles and zeros.

This looks good, thanks for the pr and interesting to see the result of numerical testing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants