diff --git a/CHANGELOG.md b/CHANGELOG.md index 51dfa00..e345135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8d21184..9ff3a49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cfplot/blockfill.py b/cfplot/blockfill.py index 5d1e88f..f937dc5 100644 --- a/cfplot/blockfill.py +++ b/cfplot/blockfill.py @@ -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) diff --git a/cfplot/contour.py b/cfplot/contour.py index 84a9f2b..5f4765a 100644 --- a/cfplot/contour.py +++ b/cfplot/contour.py @@ -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, @@ -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, @@ -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, @@ -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 @@ -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") @@ -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 @@ -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( @@ -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 diff --git a/scripts/refresh_image_references.py b/scripts/refresh_image_references.py new file mode 100644 index 0000000..bd2e930 --- /dev/null +++ b/scripts/refresh_image_references.py @@ -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()) diff --git a/tests/data/nemo_eg260916.nc b/tests/data/nemo_eg260916.nc new file mode 100644 index 0000000..87cee8b Binary files /dev/null and b/tests/data/nemo_eg260916.nc differ diff --git a/tests/data/qrparm.c48.orog.nc b/tests/data/qrparm.c48.orog.nc new file mode 100644 index 0000000..f9e8028 Binary files /dev/null and b/tests/data/qrparm.c48.orog.nc differ diff --git a/tests/reference-example-images/ref_fig_1.png b/tests/reference-example-images/ref_fig_1.png index 68e348a..8531724 100644 Binary files a/tests/reference-example-images/ref_fig_1.png and b/tests/reference-example-images/ref_fig_1.png differ diff --git a/tests/reference-example-images/ref_fig_14.png b/tests/reference-example-images/ref_fig_14.png index 2135856..7965140 100644 Binary files a/tests/reference-example-images/ref_fig_14.png and b/tests/reference-example-images/ref_fig_14.png differ diff --git a/tests/reference-example-images/ref_fig_15.png b/tests/reference-example-images/ref_fig_15.png index 70b52eb..f7c8546 100644 Binary files a/tests/reference-example-images/ref_fig_15.png and b/tests/reference-example-images/ref_fig_15.png differ diff --git a/tests/reference-example-images/ref_fig_16a.png b/tests/reference-example-images/ref_fig_16a.png index cfb1f88..02c05ae 100644 Binary files a/tests/reference-example-images/ref_fig_16a.png and b/tests/reference-example-images/ref_fig_16a.png differ diff --git a/tests/reference-example-images/ref_fig_16b.png b/tests/reference-example-images/ref_fig_16b.png index 2f8f036..b2f7bb2 100644 Binary files a/tests/reference-example-images/ref_fig_16b.png and b/tests/reference-example-images/ref_fig_16b.png differ diff --git a/tests/reference-example-images/ref_fig_16c.png b/tests/reference-example-images/ref_fig_16c.png index 52f7ce7..64af133 100644 Binary files a/tests/reference-example-images/ref_fig_16c.png and b/tests/reference-example-images/ref_fig_16c.png differ diff --git a/tests/reference-example-images/ref_fig_17.png b/tests/reference-example-images/ref_fig_17.png index 67895ee..813e9fb 100644 Binary files a/tests/reference-example-images/ref_fig_17.png and b/tests/reference-example-images/ref_fig_17.png differ diff --git a/tests/reference-example-images/ref_fig_18.png b/tests/reference-example-images/ref_fig_18.png index 2bff7ac..a3cb717 100644 Binary files a/tests/reference-example-images/ref_fig_18.png and b/tests/reference-example-images/ref_fig_18.png differ diff --git a/tests/reference-example-images/ref_fig_19.png b/tests/reference-example-images/ref_fig_19.png index 1c0ae85..5d394ad 100644 Binary files a/tests/reference-example-images/ref_fig_19.png and b/tests/reference-example-images/ref_fig_19.png differ diff --git a/tests/reference-example-images/ref_fig_21other.png b/tests/reference-example-images/ref_fig_21other.png index 156e1ee..e8c824d 100644 Binary files a/tests/reference-example-images/ref_fig_21other.png and b/tests/reference-example-images/ref_fig_21other.png differ diff --git a/tests/reference-example-images/ref_fig_22.png b/tests/reference-example-images/ref_fig_22.png index 64838f3..c9c0c1d 100644 Binary files a/tests/reference-example-images/ref_fig_22.png and b/tests/reference-example-images/ref_fig_22.png differ diff --git a/tests/reference-example-images/ref_fig_23.png b/tests/reference-example-images/ref_fig_23.png index ba4a66e..724ebb6 100644 Binary files a/tests/reference-example-images/ref_fig_23.png and b/tests/reference-example-images/ref_fig_23.png differ diff --git a/tests/reference-example-images/ref_fig_3.png b/tests/reference-example-images/ref_fig_3.png index cba75c0..e50fb14 100644 Binary files a/tests/reference-example-images/ref_fig_3.png and b/tests/reference-example-images/ref_fig_3.png differ diff --git a/tests/reference-example-images/ref_fig_31.png b/tests/reference-example-images/ref_fig_31.png index b6c0740..551dab9 100644 Binary files a/tests/reference-example-images/ref_fig_31.png and b/tests/reference-example-images/ref_fig_31.png differ diff --git a/tests/reference-example-images/ref_fig_33.png b/tests/reference-example-images/ref_fig_33.png index 2fbf197..638502f 100644 Binary files a/tests/reference-example-images/ref_fig_33.png and b/tests/reference-example-images/ref_fig_33.png differ diff --git a/tests/reference-example-images/ref_fig_34.png b/tests/reference-example-images/ref_fig_34.png index e769d12..a11dc4c 100644 Binary files a/tests/reference-example-images/ref_fig_34.png and b/tests/reference-example-images/ref_fig_34.png differ diff --git a/tests/reference-example-images/ref_fig_35.png b/tests/reference-example-images/ref_fig_35.png index 463ce58..6f0e289 100644 Binary files a/tests/reference-example-images/ref_fig_35.png and b/tests/reference-example-images/ref_fig_35.png differ diff --git a/tests/reference-example-images/ref_fig_36.png b/tests/reference-example-images/ref_fig_36.png index 1fd1cc0..1f2144b 100644 Binary files a/tests/reference-example-images/ref_fig_36.png and b/tests/reference-example-images/ref_fig_36.png differ diff --git a/tests/reference-example-images/ref_fig_37.png b/tests/reference-example-images/ref_fig_37.png index 67013f4..355eaa3 100644 Binary files a/tests/reference-example-images/ref_fig_37.png and b/tests/reference-example-images/ref_fig_37.png differ diff --git a/tests/reference-example-images/ref_fig_38.png b/tests/reference-example-images/ref_fig_38.png index f7f119f..2884ac6 100644 Binary files a/tests/reference-example-images/ref_fig_38.png and b/tests/reference-example-images/ref_fig_38.png differ diff --git a/tests/reference-example-images/ref_fig_4.png b/tests/reference-example-images/ref_fig_4.png index 7eed4f8..7b9bf68 100644 Binary files a/tests/reference-example-images/ref_fig_4.png and b/tests/reference-example-images/ref_fig_4.png differ diff --git a/tests/reference-example-images/ref_fig_40.png b/tests/reference-example-images/ref_fig_40.png index 7b5257b..afcf435 100644 Binary files a/tests/reference-example-images/ref_fig_40.png and b/tests/reference-example-images/ref_fig_40.png differ diff --git a/tests/reference-example-images/ref_fig_5.png b/tests/reference-example-images/ref_fig_5.png index 20ca10a..2dacf3b 100644 Binary files a/tests/reference-example-images/ref_fig_5.png and b/tests/reference-example-images/ref_fig_5.png differ diff --git a/tests/reference-example-images/ref_fig_7.png b/tests/reference-example-images/ref_fig_7.png index dabc0b3..ef1a3f6 100644 Binary files a/tests/reference-example-images/ref_fig_7.png and b/tests/reference-example-images/ref_fig_7.png differ diff --git a/tests/reference-example-images/ref_fig_8.png b/tests/reference-example-images/ref_fig_8.png index b975176..43d9923 100644 Binary files a/tests/reference-example-images/ref_fig_8.png and b/tests/reference-example-images/ref_fig_8.png differ diff --git a/tests/reference-example-images/ref_fig_canari_1.png b/tests/reference-example-images/ref_fig_canari_1.png index af5dbad..e9e85cc 100644 Binary files a/tests/reference-example-images/ref_fig_canari_1.png and b/tests/reference-example-images/ref_fig_canari_1.png differ diff --git a/tests/reference-example-images/ref_fig_npstere_da193.png b/tests/reference-example-images/ref_fig_npstere_da193.png index 9060749..335fc39 100644 Binary files a/tests/reference-example-images/ref_fig_npstere_da193.png and b/tests/reference-example-images/ref_fig_npstere_da193.png differ diff --git a/tests/unit/test_contour_boundary.py b/tests/unit/test_contour_boundary.py index 5517296..c82c186 100644 --- a/tests/unit/test_contour_boundary.py +++ b/tests/unit/test_contour_boundary.py @@ -1,5 +1,9 @@ import inspect +from pathlib import Path +from types import SimpleNamespace +import cf +import contourpy import numpy as np import pytest @@ -88,3 +92,188 @@ def test_blockfill_signature_does_not_advertise_lonlat(): params = inspect.signature(blockfill._bfill).parameters assert "lonlat" not in params + + +def test_curvilinear_defaults_enable_fast_blockfill_and_disable_lines(): + data = contour.ContourData( + field=np.ones((3, 4)), + x=np.ones((3, 4)), + y=np.ones((3, 4)), + ptype=1, + ) + + fill, lines, blockfill, blockfill_fast = contour._resolve_curvilinear_render_defaults( + data=data, + kwargs={}, + fill=True, + lines=True, + blockfill=False, + blockfill_fast=None, + ) + + assert fill is False + assert lines is False + assert blockfill is True + assert blockfill_fast is True + + +def test_curvilinear_defaults_respect_explicit_user_choices(): + data = contour.ContourData( + field=np.ones((3, 4)), + x=np.ones((3, 4)), + y=np.ones((3, 4)), + ptype=1, + ) + + fill, lines, blockfill, blockfill_fast = contour._resolve_curvilinear_render_defaults( + data=data, + kwargs={"blockfill": False, "lines": True}, + fill=True, + lines=True, + blockfill=False, + blockfill_fast=None, + ) + + assert fill is True + assert lines is True + assert blockfill is False + assert blockfill_fast is None + + +@pytest.mark.parametrize("axis", [0, 1]) +def test_curvilinear_contours_mask_both_sides_of_seam(axis): + x = np.tile([178.0, 179.0, -179.0, -178.0], (4, 1)) + if axis == 0: + x = x.T + field = np.ma.array(np.ones((4, 4)), mask=False) + field.mask[0, 0] = True + data = contour.ContourData( + field=field, x=x, y=np.ones((4, 4)), ptype=1 + ) + + result = contour._mask_curvilinear_contour_seams(data) + + expected = np.zeros((4, 4), dtype=bool) + if axis == 0: + expected[1:3, :] = True + else: + expected[:, 1:3] = True + expected[0, 0] = True + np.testing.assert_array_equal(result.mask, expected) + np.testing.assert_array_equal(result.data, field.data) + assert np.ma.count_masked(field) == 1 + + +@pytest.mark.parametrize("curvilinear", [False, True]) +def test_contour_seam_mask_preserves_continuous_grids(curvilinear): + field = np.arange(12.0).reshape(3, 4) + x, y = np.arange(4.0), np.arange(3.0) + if curvilinear: + x, y = np.meshgrid(x, y) + data = contour.ContourData(field=field, x=x, y=y, ptype=1) + + result = contour._mask_curvilinear_contour_seams(data) + + np.testing.assert_array_equal(result, field) + assert np.ma.count_masked(result) == 0 + + +def test_map_renderers_use_seam_mask_for_fill_lines_and_zero(monkeypatch): + calls = [] + + def record(x, y, field, levels, **kwargs): + calls.append(field) + return SimpleNamespace() + + monkeypatch.setattr( + contour.plotvars.runtime, + "mymap", + SimpleNamespace(contourf=record, contour=record), + ) + data = contour.ContourData( + field=np.ones((3, 4)), + x=np.tile([178.0, 179.0, -179.0, -178.0], (3, 1)), + y=np.tile(np.arange(3.0)[:, None], (1, 4)), + ptype=1, + levels=np.array([-1.0, 0.0, 1.0]), + fmult=2, + ) + renderer = contour.MapContourRenderer( + layout=None, + data=data, + colour_scale=SimpleNamespace(get_cmap=lambda: None), + ) + + renderer.render_filled(alpha=1, zorder=1, transform_first=False) + renderer.render_lines( + colors="k", + linewidths=None, + linestyles=None, + line_labels=False, + zero_thick=3, + ) + + assert len(calls) == 3 + for field in calls: + np.testing.assert_array_equal(field.mask[:, 1:3], True) + np.testing.assert_array_equal(field.compressed(), 2.0) + assert np.ma.count_masked(data.field) == 0 + + +def test_map_blockfill_does_not_apply_contour_seam_mask(monkeypatch): + calls = [] + monkeypatch.setattr( + contour, "_bfill", lambda **kwargs: calls.append(kwargs["f"]) + ) + data = contour.ContourData( + field=np.ones((3, 4)), + x=np.tile([178.0, 179.0, -179.0, -178.0], (3, 1)), + y=np.ones((3, 4)), + ptype=1, + levels=np.array([0.0, 1.0]), + ) + renderer = contour.MapContourRenderer( + layout=None, data=data, colour_scale=None + ) + + renderer.render_blockfill(fast=True, alpha=1, zorder=1) + + assert len(calls) == 1 + assert np.ma.count_masked(calls[0]) == 0 + np.testing.assert_array_equal(calls[0], data.field) + + +@pytest.mark.parametrize("filled", [False, True]) +def test_nemo_contours_do_not_interpolate_across_longitude_seam(filled): + path = Path(__file__).parents[1] / "data" / "nemo_eg260916.nc" + data = contour.ContourData.from_cf_field(cf.read(str(path))[0], None) + field = contour._mask_curvilinear_contour_seams(data) + levels = np.array([0.5, 1.5, 2.5, 3.5]) * 1e-4 + + def longest_edge(values): + generator = contourpy.contour_generator( + x=data.x, y=data.y, z=values, corner_mask=True + ) + if filled: + paths = [] + for lower, upper in zip(levels[:-1], levels[1:]): + vertices, offsets = generator.filled(lower, upper) + for points, boundaries in zip(vertices, offsets): + paths.extend( + points[start:end] + for start, end in zip( + boundaries[:-1], boundaries[1:] + ) + ) + else: + paths = [ + path for level in levels for path in generator.lines(level) + ] + return max( + np.max(np.abs(np.diff(path[:, 0]))) + for path in paths + if len(path) > 1 + ) + + assert longest_edge(data.field) > 300.0 + assert longest_edge(field) < 180.0 diff --git a/tests/unit/test_refresh_image_references.py b/tests/unit/test_refresh_image_references.py new file mode 100644 index 0000000..6904902 --- /dev/null +++ b/tests/unit/test_refresh_image_references.py @@ -0,0 +1,98 @@ +"""Safety and cleanup checks for the human-approved baseline refresh.""" + +import pytest +from PIL import Image + +from scripts import refresh_image_references as refresh + + +def _example(root, example_id="4", directory=None): + directory = directory or refresh.GENERATED_DIRS[0] + generated = root / directory / f"gen_fig_{example_id}.png" + reference = ( + root / "tests" / "reference-example-images" + / f"ref_fig_{example_id}.png" + ) + generated.parent.mkdir(parents=True, exist_ok=True) + reference.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (10, 10), "white").save(reference) + Image.new("RGB", (10, 10), "red").save(generated) + diff = generated.with_name(f"{generated.stem}-failed-diff.png") + Image.new("RGB", (10, 10), "black").save(diff) + return generated, reference, diff + + +def test_preview_changes_nothing(tmp_path, monkeypatch, capsys): + generated, reference, diff = _example(tmp_path) + original = reference.read_bytes() + monkeypatch.setattr(refresh, "REPO_ROOT", tmp_path) + assert refresh.main(["4"]) == 0 + assert reference.read_bytes() == original + assert generated.exists() and diff.exists() + assert "Preview only" in capsys.readouterr().out + + +def test_write_updates_only_selected_and_cleans_its_diff( + tmp_path, monkeypatch +): + generated, reference, diff = _example(tmp_path) + other_generated, other_reference, other_diff = _example(tmp_path, "5") + original_other = other_reference.read_bytes() + monkeypatch.setattr(refresh, "REPO_ROOT", tmp_path) + assert refresh.main(["4", "--write"]) == 0 + assert reference.read_bytes() == generated.read_bytes() + assert not diff.exists() + assert other_reference.read_bytes() == original_other + assert other_generated.exists() and other_diff.exists() + assert not list(reference.parent.glob("tmp*.png")) + + +def test_all_selects_both_directories_but_not_diffs(tmp_path): + _example(tmp_path, "4") + _example(tmp_path, "16b", refresh.GENERATED_DIRS[1]) + updates = refresh.plan_updates(tmp_path, [], all_generated=True) + assert {update.reference.name for update in updates} == { + "ref_fig_4.png", "ref_fig_16b.png" + } + + +@pytest.mark.parametrize("problem", ["missing", "corrupt", "duplicate"]) +def test_preflight_failure_writes_nothing( + tmp_path, monkeypatch, problem +): + _, reference, diff = _example(tmp_path, "4") + generated, _, _ = _example(tmp_path, "5") + original = reference.read_bytes() + if problem == "missing": + generated.unlink() + elif problem == "corrupt": + generated.write_bytes(b"not an image") + else: + _example(tmp_path, "5", refresh.GENERATED_DIRS[1]) + monkeypatch.setattr(refresh, "REPO_ROOT", tmp_path) + assert refresh.main(["4", "5", "--write"]) == 1 + assert reference.read_bytes() == original + assert diff.exists() + + +def test_missing_reference_is_not_silently_created(tmp_path): + _, reference, _ = _example(tmp_path) + reference.unlink() + with pytest.raises(ValueError, match="Missing existing reference"): + refresh.plan_updates(tmp_path, ["4"]) + + +def test_path_traversal_is_rejected(tmp_path): + with pytest.raises(ValueError, match="Invalid example ID"): + refresh.plan_updates(tmp_path, ["../../outside"]) + + +def test_unchanged_reference_still_cleans_selected_diff(tmp_path): + generated, reference, diff = _example(tmp_path) + reference.write_bytes(generated.read_bytes()) + updates = refresh.plan_updates(tmp_path, ["4", "4"]) + assert len(updates) == 1 + assert not updates[0].changed + refresh.apply_updates(updates) + assert not diff.exists() + assert generated.exists()