Skip to content

Fix s-grid rendering on Matplotlib 3.11 by disabling longitude unwrapping - #1250

Open
aduan5567-netizen wants to merge 3 commits into
python-control:mainfrom
aduan5567-netizen:fix/extreme-finder-matplotlib-311
Open

aduan5567-netizen wants to merge 3 commits into
python-control:mainfrom
aduan5567-netizen:fix/extreme-finder-matplotlib-311

Conversation

@aduan5567-netizen

@aduan5567-netizen aduan5567-netizen commented Sep 19, 2026 •

Copy link
Copy Markdown

Refs #1147.

Rationale

The discussion in #1147 covers two version-dependent symptoms of the
omega-damping (s-plane) grid on root-locus plots:

  • With Matplotlib 3.10, enabling the grid adds extra whitespace around
    the plot — the original report. This PR does not change that behavior;
    it is a separate problem and is left untouched here.
  • With Matplotlib 3.11, the damping grid lines themselves render
    incorrectly. As diagnosed in the issue discussion, this is a regression
    caused by matplotlib's axisartist refactor, and it is what this PR
    fixes.

control/grid.py renders the s-plane damping-ratio / natural-frequency
grid for root-locus and pole-zero diagrams using axisartist's
GridHelperCurveLinear. The grid only covers the left half-plane, a fixed
longitude interval of 90°–270°. To keep that interval from being unwrapped
at the 180° boundary, ModifiedExtremeFinderCycle subclassed
ExtremeFinderCycle and overrode __call__ to apply a 360° unwrapping
threshold instead of the upstream 180°.

Matplotlib 3.11 moved the sampling/bounding-box logic into a new
ExtremeFinderSimple._find_transformed_bbox(transform, bbox) method,
which GridFinder.get_grid_info() now calls directly; __call__ is only
a thin backward-compatible wrapper. Our override is therefore bypassed on
3.11, the upstream 180° unwrapping is applied, and the grid extremes are
computed incorrectly.

Rather than porting the override to the new interface, this PR removes
the custom subclass entirely and uses the stock ExtremeFinderCycle:

  • Setting lon_cycle=None disables the periodic longitude unwrapping
    altogether, so the 90°–270° interval can never be wrapped across the
    180° boundary.
  • The existing lon_minmax=(90, 270) setting still clips the computed
    grid bbox to the left half-plane, which is what actually enforces the
    fixed interval.

This configuration works on both Matplotlib 3.10 and 3.11, and the 3.10
behavior — including the whitespace reported in #1147 — is unchanged.

Changes

  • Delete ModifiedExtremeFinderCycle from control/grid.py.
  • In sgrid(), construct the stock angle_helper.ExtremeFinderCycle
    with lon_cycle=None (instead of the custom subclass with
    lon_cycle=360); lat_cycle=None, lon_minmax=(90, 270), and
    lat_minmax=(0, np.inf) are unchanged.
  • Add control/tests/grid_test.py::test_sgrid_does_not_unwrap_longitude,
    which obtains the extreme finder from the axes built by sgrid(),
    asserts finder.lon_cycle is None, and checks that an identity
    transform over 90°–270.001° returns extremes (90, 270, 0, 1.05).
    The 270.001° edge would trigger the stock 180° unwrapping
    (270.001 - 90 > 180), so the test guards against this regression on
    both Matplotlib versions.

The removed class is internal to control/grid.py; there are no public
API changes.

Testing

  • Matplotlib 3.11.2: I ran the relevant tests
    • grid_test.py and rlocus_test.py (grid and root-locus): 80
      passed, 1 skipped;
    • pzmap_test.py and ctrlplot_test.py (pole-zero and control
      plots): 126 passed, 2 skipped;
    • total: 206 passed, 3 skipped.
  • Matplotlib 3.10.8: I ran grid_test.py and rlocus_test.py — 80
    passed, 1 skipped; the legacy behavior is unchanged.
  • I ran ruff check and git diff --check; both are clean.

AI-assisted development disclosure

I used AI tools while preparing this patch:

  • OpenAI Codex drafted the code changes: removing
    ModifiedExtremeFinderCycle, switching sgrid() to the stock
    ExtremeFinderCycle with lon_cycle=None, and adding
    control/tests/grid_test.py.
  • Doubao (ByteDance's AI assistant) assisted with problem analysis:
    I used it to trace the Matplotlib 3.11 refactor against the upstream
    3.11.2 source, confirming that GridFinder.get_grid_info() now calls
    _find_transformed_bbox() directly, which is why the pre-existing
    __call__ override was bypassed.

I reviewed every AI-assisted change line by line, and I can explain what
the code does and why it is implemented this way — including why
lon_cycle=None prevents the 180° unwrapping, how lon_minmax keeps the
grid within the left half-plane, and the test's expected
(90, 270, 0, 1.05) bounds (the 1.05 comes from the bbox padding with
ny = 20, clamped by lat_minmax). This PR description was written by
me. I take full responsibility for the correctness of this patch.

@slivingston slivingston left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a solution that does not involve duplicating lots of code?

@aduan5567-netizen

Copy link
Copy Markdown
Author

Thanks — I refactored the shared sampling, unwrapping, padding, and clipping logic into _find_extremes(). The legacy __call__ path now only adapts the callable transform and tuple ordering, while the Matplotlib 3.11 path forwards trans.transform. This removes the duplicated algorithm while preserving compatibility with both versions. I tested it against Matplotlib 3.10.8 and 3.11.2.

Comment thread control/grid.py Outdated
# Matplotlib 3.11 moved this calculation to
# _find_transformed_bbox(), which is also called directly by
# GridFinder. Keep the legacy implementation for older releases.
if hasattr(super(), '_find_transformed_bbox'):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for refactoring. Now that I am investigating this more, I think this approach overall is not the best. My main concerns:

  1. This code checks for the presence of an intended-as-hidden superclass method (_find_transformed_bbox). Such inspection is bad because _find_transformed_bbox is not part of the public API of Matplotlib. (I might be wrong about this; please correct me if so.). Therefore, this check is fragile and could break unexpectedly.
  2. Could this be a bug in Matplotlib? I doubt it, but the fact that output changed in a surprising way with the new version of Matplotlib suggests it is worth exploring the Matplotlib side more: check issue trackers, changelogs, etc.
  3. Perhaps our design here is wrong and needs to be reconsidered. Is there some way to achieve the same result without making a subclass of ExtremeFinderCycle that overrides __call__ ?

Concerns 2 and 3 require more investigation. Hopefully you can do it :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — I agree that overriding _find_transformed_bbox and
branching on hasattr would lean on a private API that was just
reorganized, and would be fragile against future matplotlib releases.

I went through matplotlib/matplotlib#27551 (the refactor that
introduced _find_transformed_bbox and deprecated
GridFinder.transform_xy/inv_transform_xy), the 3.11 API changes
notes, and the matplotlib issue tracker to check whether there is a
supported hook for this — there isn't one. But stepping back, the
custom subclass's only change was the unwrap threshold (180° → 360°).
Since the s-grid longitude always lies within the fixed 90°–270°
interval, that 360° threshold effectively never unwraps anything, so
it is equivalent to disabling cycle unwrapping altogether.

I've therefore removed ModifiedExtremeFinderCycle entirely and now
use the stock ExtremeFinderCycle with lon_cycle=None, relying on
the existing lon_minmax=(90, 270) to clip the grid to the left
half-plane. The new test asserts lon_cycle is None and that an
identity transform over 90–270.001° gives extremes
(90, 270, 0, 1.05) — the 270.001° edge would trigger the stock 180°
unwrapping, so it guards against this regression.

Behavior on 3.10 is unchanged (including the whitespace issue from
#1147, which is out of scope here). The relevant tests pass on both
versions: matplotlib 3.11.2 — 206 passed, 3 skipped (grid, root-locus,
pole-zero, and control-plot tests); matplotlib 3.10.8 — 80 passed,
1 skipped. ruff check and git diff --check are clean.

@aduan5567-netizen aduan5567-netizen changed the title Fix s-grid rendering on Matplotlib 3.11 by overriding _find_transformed_bbox Fix s-grid rendering on Matplotlib 3.11 by disabling longitude unwrapping Sep 20, 2026
@slivingston

Copy link
Copy Markdown
Member

To help with review, can you generate before and after images to demonstrate that behavior is unchanged with Matplotlib 3.10 and fixed with Matplotlib 3.11 ? Please show the code used to generate the images.

(This would be a lot of effort if done manually, but you seem to be using AI tools for most/all of this, so my request seems reasonable :) )

@aduan5567-netizen

Copy link
Copy Markdown
Author

Visual comparison

before (a775479) after (2a140b1)
Matplotlib 3.10.8 mpl-3.10-before mpl-3.10-after
Matplotlib 3.11.2 mpl-3.11-before mpl-3.11-after

I rendered the same root-locus plot (ct.tf([1, 2], [1, 2, 3]),
grid=True) at a775479 (before) and 2a140b1 (after) with
Matplotlib 3.10.8 and 3.11.2, using one fixed Agg script (same figure
size, DPI, and rcParams).

  • 3.10.8: the before/after PNGs are byte-for-byte identical — both
    have SHA-256 28DDCB09C8C09B99EC1481E9416FA3714684658987BC89774E470F3B4E8C7D8B.
  • 3.11.2: before draws the damping grid only in the upper
    half-plane; after renders it symmetrically, matching 3.10.

The images were generated with:

"""Generate a deterministic root-locus image for PR #1250."""

import argparse

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt

import control as ct


parser = argparse.ArgumentParser()
parser.add_argument("output")
args = parser.parse_args()

matplotlib.rcParams.update({
    "figure.dpi": 120,
    "font.size": 10,
    "savefig.dpi": 120,
})

system = ct.tf([1, 2], [1, 2, 3])
ct.root_locus_plot(system, grid=True)

figure = plt.gcf()
figure.set_size_inches(6.4, 4.8)
figure.suptitle(f"Matplotlib {matplotlib.__version__}", fontsize=11)
figure.savefig(args.output)
plt.close(figure)

Run python generate_comparison.py OUTPUT.png in each checkout.

@slivingston

Copy link
Copy Markdown
Member

@aduan5567-netizen Regarding the statement in the PR description, "With Matplotlib 3.10, enabling the grid adds extra whitespace around the plot — the original report. This PR does not change that behavior; it is a separate problem and is left untouched here.", can you explain why that problem is not fixed here? Do you know how to fix it? Maybe we can just include both in this PR.

@slivingston

Copy link
Copy Markdown
Member

@aduan5567-netizen Also, can you confirm that there is a human in the loop? Or, are you an AI agent running autonomously (opening PRs, making comments, etc. without a human).

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.

2 participants

Sponsor
SponsoredKunjungi sekarang
Promo