Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ of being developed elsewhere.
-----


### Version `3.5.X`, first released 2026-10-09
### Version `3.5.1`, first released XXXXXX

* Prevent striped artefacts on curvilinear longitude/latitude grids such as NEMO.

#### Version `3.5.0`, release 2026-10-09

* Modularise and refactor the codebase, which has no user-facing implications
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ More detail about installation is provided on the
(`https://ncas-cms.github.io/cf-plot/installation.html`)
of the documentation.

### Refreshing image-test references

If image tests fail after an environment change, inspect the reference,
generated plot and failure diff before accepting the differences. From the
repository root, generate fresh plots using your testing environment:

```bash
python -m pytest tests/integration/test_contour_plot_examples.py tests/integration/test_advanced_plot_examples.py
```

For a complete refresh, avoid `-k` or `::` selectors: targeted runs preserve
old generated images. The
[reference refresh script](scripts/refresh_image_references.py) previews
updates by default. Select examples by filename suffix (for example,
`gen_fig_16b.png` has ID `16b`):

```bash
python scripts/refresh_image_references.py 4 5 16b
# After visually approving these generated plots:
python scripts/refresh_image_references.py 4 5 16b --write
```

Use `--all` instead of example IDs to preview every available generated
baseline, then `--all --write` only after reviewing all selected plots.
The script validates the selection before writing, replaces existing
references, and removes only their failure-diff images; generated plots
are retained. It does not render or automatically approve plots.

Rerun the image tests afterward to verify the updated references and clear
pytest's recorded failures. Review and commit the reference-image changes;
do not accept a genuine plotting regression merely to make tests pass.

### Contributing

Everyone is welcome to contribute to cf-plot in the form
Expand Down
8 changes: 5 additions & 3 deletions cfplot/blockfill.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ def _bfill(
colarr[pts] = np.size(levels) - 2

if isinstance(field, np.ma.MaskedArray):
pts = np.ma.where(field.mask)
if np.size(pts) > 0:
colarr[pts] = -1
# np.ma.where on a scalar False mask raises on newer NumPy.
# Use a normalized boolean mask array instead.
mask = np.ma.getmaskarray(field)
if np.any(mask):
colarr[mask] = -1

norm = matplotlib.colors.BoundaryNorm(levels, cmap.N)

Expand Down
93 changes: 85 additions & 8 deletions cfplot/contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ def render_filled(
runtime.image = runtime.mymap.contourf(
lons,
lats,
self.data.field * self.data.fmult,
_mask_curvilinear_contour_seams(self.data) * self.data.fmult,
self.data.levels,
extend=scale.levels_extend,
cmap=cmap,
Expand Down Expand Up @@ -788,10 +788,11 @@ def render_lines(

runtime = plotvars.runtime
dec = plotvars.decoration
field = _mask_curvilinear_contour_seams(self.data) * self.data.fmult
cs = runtime.mymap.contour(
self.data.x,
self.data.y,
self.data.field * self.data.fmult,
field,
self.data.levels,
colors=colors,
linewidths=linewidths,
Expand Down Expand Up @@ -821,7 +822,7 @@ def render_lines(
cs0 = runtime.mymap.contour(
self.data.x,
self.data.y,
self.data.field * self.data.fmult,
field,
[-1e-32, 0],
colors=colors,
linewidths=zero_thick,
Expand Down Expand Up @@ -1693,9 +1694,71 @@ def _window_irregular_map_data(
return field_irregular, lons_irregular, lats_irregular


def _render_with_new_xy(
f: Any, x: Any, y: Any, kwargs: dict[str, Any]
) -> bool:
def _is_curvilinear_map_grid(data: ContourData) -> bool:
"""Return True for map plots using 2D lon/lat coordinates.

Curvilinear grids (for example NEMO/ORCA ocean meshes) are prone to
severe contourf/contour artefacts. These are better rendered through
blockfill/pcolormesh.
"""
if data.ptype != 1 or data.x is None or data.y is None:
return False
if np.ndim(data.field) != 2:
return False
return np.ndim(data.x) == 2 and np.ndim(data.y) == 2


def _mask_curvilinear_contour_seams(data: ContourData) -> np.ndarray:
"""Exclude vertices adjoining longitude jumps from structured contours."""
if not _is_curvilinear_map_grid(data):
return data.field

mask = np.ma.getmaskarray(data.field).copy()
for axis in (0, 1):
jumps = np.abs(np.diff(data.x, axis=axis)) > 180.0
before = [slice(None), slice(None)]
after = before.copy()
before[axis] = slice(None, -1)
after[axis] = slice(1, None)
# Mask both ends so corner masking cannot retain a seam-crossing
# triangle and interpolate a contour across the whole map.
mask[tuple(before)] |= jumps
mask[tuple(after)] |= jumps

return np.ma.array(data.field, mask=mask)


def _resolve_curvilinear_render_defaults(
*,
data: ContourData,
kwargs: dict[str, Any],
fill: bool,
lines: bool,
blockfill: bool,
blockfill_fast: bool | None,
) -> tuple[bool, bool, bool, bool | None]:
"""Adjust renderer defaults for curvilinear lon/lat map grids.

Returns the possibly updated (fill, lines, blockfill, blockfill_fast)
tuple.
"""
if not _is_curvilinear_map_grid(data):
return fill, lines, blockfill, blockfill_fast

# Use robust defaults for 2D map grids unless the caller explicitly
# requested alternative behavior.
if "blockfill" not in kwargs:
blockfill = True
fill = False
if blockfill and blockfill_fast is None:
blockfill_fast = True
if "lines" not in kwargs:
lines = False

return fill, lines, blockfill, blockfill_fast


def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool:
"""Attempt rendering via new XY renderer and return True on success.

Note: Imports from cfplot are local (inside function) to maintain
Expand Down Expand Up @@ -1772,6 +1835,7 @@ def _render_with_new_xy(
fill = kwargs.get("fill", global_fill)
lines = kwargs.get("lines", global_lines)
blockfill = kwargs.get("blockfill", global_blockfill)
blockfill_fast = kwargs.get("blockfill_fast", None)
line_labels = kwargs.get("line_labels", True)
zero_thick = kwargs.get("zero_thick", False)
colors = kwargs.get("colors", "k")
Expand All @@ -1783,6 +1847,15 @@ def _render_with_new_xy(
if blockfill:
fill = False

fill, lines, blockfill, blockfill_fast = _resolve_curvilinear_render_defaults(
data=data,
kwargs=kwargs,
fill=fill,
lines=lines,
blockfill=blockfill,
blockfill_fast=blockfill_fast,
)

colorbar = kwargs.get("colorbar", True)
if not fill and not blockfill:
colorbar = False
Expand Down Expand Up @@ -2167,7 +2240,7 @@ def _render_with_new_xy(
)
if blockfill:
renderer.render_blockfill(
fast=kwargs.get("blockfill_fast", None), alpha=alpha, zorder=zorder
fast=blockfill_fast, alpha=alpha, zorder=zorder
)
if lines:
renderer.render_lines(
Expand Down Expand Up @@ -2346,7 +2419,11 @@ def con(f=None, x=None, y=None, **kwargs):
| 6 = rotated pole
| negative_linestyle='solid' - set to one of 'solid', 'dashed'
| zero_thick=False - add a thick zero contour line. Set to 3 for example.
| blockfill=False - set to True for a blockfill plot
| blockfill=False - set to True for a blockfill plot. For 2D lon/lat
| grids, defaults to fast blockfill without lines.
| Explicit False selects contours; vertices adjoining
| longitude wrap jumps are excluded to avoid stripes,
| leaving a narrow gap at the seam.
| colorbar_title=colbar_title - title for the colour bar
| colorbar=True - add a colour bar if a filled contour plot
| colorbar_label_skip=None - skip colour bar labels. Set to 2 to skip
Expand Down
155 changes: 155 additions & 0 deletions scripts/refresh_image_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Promote human-reviewed generated plots to test references."""

import argparse
import io
import os
import re
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path

from PIL import Image

REPO_ROOT = Path(__file__).resolve().parents[1]
GENERATED_DIRS = (
"generated-example-images-cont",
"generated-example-images-adv",
)
EXAMPLE_ID = re.compile(r"[A-Za-z0-9_]+")


@dataclass(frozen=True)
class ReferenceUpdate:
generated: Path
reference: Path
data: bytes
changed: bool
size: tuple[int, int]


def plan_updates(
root: Path, example_ids: list[str], all_generated: bool = False
) -> list[ReferenceUpdate]:
"""Validate the entire selection before allowing any file changes."""
if all_generated:
example_ids = sorted(
{
path.stem.removeprefix("gen_fig_")
for directory in GENERATED_DIRS
for path in (root / directory).glob("gen_fig_*.png")
if EXAMPLE_ID.fullmatch(
path.stem.removeprefix("gen_fig_")
)
}
)
if not example_ids:
raise ValueError("No generated examples selected or found.")

updates = []
for example_id in dict.fromkeys(example_ids):
if not EXAMPLE_ID.fullmatch(example_id):
raise ValueError(f"Invalid example ID: {example_id!r}")
candidates = [
root / directory / f"gen_fig_{example_id}.png"
for directory in GENERATED_DIRS
]
sources = [path for path in candidates if path.exists()]
if len(sources) != 1:
raise ValueError(
f"Example {example_id}: expected one generated image, "
f"found {len(sources)}."
)
generated = sources[0]
reference = (
root / "tests" / "reference-example-images"
/ f"ref_fig_{example_id}.png"
)
if generated.is_symlink() or reference.is_symlink():
raise ValueError(f"Example {example_id}: symlinks not allowed.")
if not reference.is_file():
raise ValueError(f"Missing existing reference: {reference}")
data = generated.read_bytes()
with Image.open(io.BytesIO(data)) as image:
if image.format != "PNG":
raise ValueError(f"Not a PNG image: {generated}")
image.load()
size = image.size
updates.append(
ReferenceUpdate(
generated, reference, data,
data != reference.read_bytes(), size,
)
)
return updates


def apply_updates(updates: list[ReferenceUpdate]) -> None:
"""Replace selected references and remove only their stale diff images."""
for update in updates:
if update.changed:
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=update.reference.parent, suffix=".png", delete=False
) as output:
temporary = Path(output.name)
output.write(update.data)
temporary.chmod(update.reference.stat().st_mode & 0o777)
os.replace(temporary, update.reference)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
diff = update.generated.with_name(
f"{update.generated.stem}-failed-diff.png"
)
diff.unlink(missing_ok=True)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"examples", nargs="*", help="Reviewed example IDs, e.g. 4 5 16b"
)
parser.add_argument(
"--all", action="store_true",
help="Select all generated examples, not just current failures",
)
parser.add_argument(
"--write", action="store_true",
help="Confirm human review and replace references; otherwise preview",
)
args = parser.parse_args(argv)
if args.all and args.examples:
parser.error("Use either explicit example IDs or --all, not both.")
if not args.all and not args.examples:
parser.error("Select example IDs or use --all.")
try:
updates = plan_updates(REPO_ROOT, args.examples, args.all)
for update in updates:
action = "UPDATE" if update.changed else "UNCHANGED"
print(
f"{action}: {update.generated.relative_to(REPO_ROOT)} "
f"-> {update.reference.relative_to(REPO_ROOT)} "
f"({update.size[0]}x{update.size[1]})"
)
if args.write:
apply_updates(updates)
print(
"References refreshed; selected failure diffs removed. "
"Rerun pytest to verify and clear its failure cache."
)
else:
print(
"Preview only; no files changed. Inspect the generated "
"plots, then repeat with --write to confirm approval. "
"Writing also removes the selected failure diffs."
)
except (OSError, ValueError) as error:
print(f"Reference refresh failed: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
Binary file added tests/data/nemo_eg260916.nc
Binary file not shown.
Binary file added tests/data/qrparm.c48.orog.nc
Binary file not shown.
Binary file modified tests/reference-example-images/ref_fig_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_15.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_16a.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_16b.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_16c.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_17.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_18.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_19.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_21other.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_23.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_31.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_33.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_34.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_35.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_36.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_37.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_38.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_40.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/reference-example-images/ref_fig_canari_1.png
Binary file modified tests/reference-example-images/ref_fig_npstere_da193.png
Loading