diff --git a/.gitignore b/.gitignore index f9c0203..e63790d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# User specfic gubbins +.vscode/ +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -159,3 +163,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Test-generated plot outputs +/generated-example-images/ diff --git a/README.md b/README.md index 8d21184..edfc4ba 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,32 @@ f = cf.read('.nc')[0] # picks out a read-in field of the dataset cfp.con(f.subspace(time=)) # creates a contour plot of the field at that time value ``` +### Contour Animation Titles + +`cfp.con()` supports animation-aware title updates via: + +- `animation=True` +- `animation_axis="auto"` (or one of `"T"`, `"Z"`, `"Y"`, `"X"`) +- `animation_title_template="{title} [{frame}]"` (optional) + +With `animation_axis="auto"`, axis inference is based on `ptype`: + +- for `ptype != 0`, choose a singleton axis not used by that `ptype`; +- for `ptype == 0`, fallback preference is singleton `T`, then `Z`, then `Y`, then `X`. + +Example: + +```python +cfp.con( + f, + animation=True, + reuse_map_background=True, + animation_axis="auto", + animation_title_template="{title} [{frame}]", + title="Air temperature", +) +``` + ### Examples Gallery diff --git a/cf-plot-refactor-claude.pdf b/cf-plot-refactor-claude.pdf new file mode 100644 index 0000000..63c762f Binary files /dev/null and b/cf-plot-refactor-claude.pdf differ diff --git a/cfplot.png b/cfplot.png new file mode 100644 index 0000000..9dd50bb Binary files /dev/null and b/cfplot.png differ diff --git a/cfplot/__init__.py b/cfplot/__init__.py index a0dfbf3..01236a7 100644 --- a/cfplot/__init__.py +++ b/cfplot/__init__.py @@ -4,114 +4,126 @@ Documentation is hosted and found at: https://ncas-cms.github.io/cf-plot/ """ -__author__ = "Andy Heaps" -__maintainer__ = "Sadie Bartholomew" -__date__ = "28th April 2025" -__version__ = "3.4.0" +from importlib.metadata import PackageNotFoundError, version as pkg_version +from pathlib import Path +__author__ = "Andy Heaps, Sadie Bartholomew, Bryan Lawrence" +__date__ = "16th May, 2026" -import os -from distutils.version import StrictVersion -import cartopy -import cf -import matplotlib +def _version_from_pyproject(): + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + if not pyproject.exists(): + return None -# Import module functionality ------------------------------------------- -# Imports to export functions: cfp.. -> cfp. -# either as intended going forward or to preserve existing API. -# TODO review what should be available at module level. + in_project = False + for raw_line in pyproject.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if in_project and line.startswith("version"): + _, _, value = line.partition("=") + return value.strip().strip('"').strip("'") -from .calculate import calculate_levels -from .colour import ( - # Internal fuctions: don't expose, but leave commented here to track: - # _cscale_get_map,; _process_color_scales, - cbar, -) + return None + +def _resolve_version(): + # In a source checkout, pyproject.toml is the version source of truth. + pyproject_version = _version_from_pyproject() + if pyproject_version: + return pyproject_version + + # In installed-package contexts, use distribution metadata. + try: + return pkg_version("cf-plot") + except PackageNotFoundError: + return "0+unknown" + + +__version__ = _resolve_version() + +from .colorbar import cbar +from .colour import cscale from .contour import con -from .graphic import ( - # Internal fuctions: don't expose, but leave commented here to track: - # _which, - gclose, - gopen, - gpos, -) +from .contour import levs +from .layout_runtime import gclose, gopen, gset +from .layout_runtime import gpos from .line import lineplot -from .mapping import ( - # Internal fuctions: don't expose, but leave commented here to track: - # _mapaxis,; _map_title,; _plot_map_axes,; _set_map, - axes_plot, - map_grid, -) -from .parameters import ( - allvars_defaults, - axes, - cscale, - cscale1, - global_blockfill, - global_fill, - global_lines, - gset, - levs, - mapset, - plotvars, - plotvars_defaults, - pvars, - reset, - setvars, - setvars_defaults, - viridis, -) +from .map_runtime import mapset +from .state import plotvars, setvars from .stipple import stipple from .stream import stream from .trajectory import traj -from .utils import ( - # Internal fuctions: don't expose, but leave commented here to track:; - # _bfill,; _bfill_ugrid,; _cf_data_assign,; _dim_titles,; _gvals,; _supscr,; - # _timeaxis, - add_cyclic, - cf_var_name, - cf_var_name_titles, - find_dim_names, - find_pos_in_array, - find_z, - fix_floats, - generate_titles, - irregular_window, - max_ndecs_data, - ndecs, - pcon, - polar_regular_grid, - regrid, - rgaxes, - stipple_points, - vloc, -) -from .validate import check_well_formed, orca_check # _check_data internal +from .state import reset_runtime_state +from .utility import gvals as _gvals_impl, mapaxis as _mapaxis_impl, regrid from .vector import vect +from .rotated_runtime import _render_rotated_grid_axes + + +def reset(): + gset() + cscale() + levs() + mapset() + setvars() + reset_runtime_state() + + +def _gvals(*args, **kwargs): + return _gvals_impl(*args, **kwargs) + + +def rgaxes(*, xpole=None, ypole=None, xvec=None, yvec=None, xticks=None, xticklabels=None, yticks=None, yticklabels=None, axes=True, xaxis=True, yaxis=True, xlabel=None, ylabel=None): + return _render_rotated_grid_axes( + xpole=xpole, + ypole=ypole, + xvec=xvec, + yvec=yvec, + xticks=xticks, + xticklabels=xticklabels, + yticks=yticks, + yticklabels=yticklabels, + axes=axes, + xaxis=xaxis, + yaxis=yaxis, + xlabel=xlabel, + ylabel=ylabel, + ) + + +def _mapaxis(min=None, max=None, type=None): + return _mapaxis_impl( + min_val=min, + max_val=max, + axis_type=type, + degsym=bool(plotvars.degsym), + ) + -# Process versions and display ------------------------------------------ - -# Check for the minimum cf-python version -cf_version_min = "3.17.0" -errstr = f"cf-python >= {cf_version_min} needs to be installed to use cf-plot" -if StrictVersion(cf.__version__) < StrictVersion(cf_version_min): - raise Warning(errstr) -# TODO add these checks for all other dependencies too? - -# Check for a display and use the Agg backing store if none is present -# This is for batch mode processing -try: - disp = os.environ["DISPLAY"] -except Exception: - matplotlib.use("Agg") - -# Check for user setting of pre_existing_data_dir pointing to central -# cartopy setup -# This is used in the cfview simple setup process -try: - pre_existing_data_dir = os.environ["pre_existing_data_dir"] - cartopy.config["pre_existing_data_dir"] = pre_existing_data_dir -except KeyError: - pass +__all__ = [ + "cbar", + "con", + "cscale", + "gclose", + "gopen", + "gpos", + "gset", + "levs", + "lineplot", + "mapset", + "plotvars", + "regrid", + "reset", + "rgaxes", + "setvars", + "stipple", + "stream", + "traj", + "vect", + "_gvals", + "_mapaxis", +] diff --git a/cfplot/blockfill.py b/cfplot/blockfill.py new file mode 100644 index 0000000..11336a4 --- /dev/null +++ b/cfplot/blockfill.py @@ -0,0 +1,494 @@ +"""Block-fill rendering helpers for contour workflows. + +This module hosts contour-specific block-fill logic used by the refactored +contour path so it does not depend on cfplot.py internals. +""" + +from __future__ import annotations + +from copy import deepcopy + +import cartopy.crs as ccrs +import cf +import matplotlib.colors +import matplotlib.patches as mpatches +import numpy as np +import shapely.geometry as sgeom +from matplotlib.collections import PolyCollection + +from .colour import get_colour_scale_map +from . import utility +from .state import plotvars + + +def _bfill( + f=None, + x=None, + y=None, + clevs=False, + bound=False, + alpha=1.0, + single_fill_color=None, + white=True, + zorder=4, + fast=None, + transform=False, + orca=False, +): + """Block-fill a field with coloured rectangles.""" + _ = (orca,) + + lonlat = plotvars.plot_type == 1 + + if single_fill_color is not None: + white = False + + two_d = False + if not isinstance(f, cf.Field): + if np.ndim(x) == 2 and np.ndim(x) == 2: + two_d = True + + plotargs = {} + if lonlat: + plotargs = {"transform": ccrs.PlateCarree()} + + if isinstance(f, cf.Field): + field = f.array + else: + field = f + + levels = np.array(deepcopy(clevs)).astype("float") + + if single_fill_color is None: + colmap = get_colour_scale_map() + cmap = matplotlib.colors.ListedColormap(colmap) + fill_colors = list(colmap) + if plotvars.levels_extend in ["min", "both"]: + cmap.set_under(plotvars.cs[0]) + if plotvars.levels_extend in ["max", "both"]: + cmap.set_over(plotvars.cs[-1]) + + under_index = None + over_index = None + if plotvars.levels_extend in ["min", "both"]: + under_index = len(fill_colors) + fill_colors.append(plotvars.cs[0]) + if plotvars.levels_extend in ["max", "both"]: + over_index = len(fill_colors) + fill_colors.append(plotvars.cs[-1]) + else: + cols = single_fill_color + cmap = matplotlib.colors.ListedColormap(cols) + fill_colors = None + under_index = None + over_index = None + + colarr = np.zeros([np.shape(field)[0], np.shape(field)[1]]) - 1 + for i in np.arange(np.size(levels) - 1): + lev = levels[i] + pts = np.where(np.logical_and(field >= lev, field < levels[i + 1])) + colarr[pts] = int(i) + + # Keep out-of-range values away from "white" so it is reserved for + # actual missing/masked values only. + if np.size(levels) >= 2: + pts = np.where(field < levels[0]) + if np.size(pts) > 0: + if under_index is not None: + colarr[pts] = under_index + else: + colarr[pts] = 0 + + pts = np.where(field >= levels[-1]) + if np.size(pts) > 0: + if over_index is not None: + colarr[pts] = over_index + else: + 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 + + norm = matplotlib.colors.BoundaryNorm(levels, cmap.N) + + if isinstance(f, cf.Field): + if f.ref("grid_mapping_name:transverse_mercator", default=False): + lonlat = True + + ref = f.ref("grid_mapping_name:transverse_mercator") + false_easting = ref["false_easting"] + false_northing = ref["false_northing"] + central_longitude = ref["longitude_of_central_meridian"] + central_latitude = ref["latitude_of_projection_origin"] + scale_factor = ref["scale_factor_at_central_meridian"] + + transform = ccrs.TransverseMercator( + false_easting=false_easting, + false_northing=false_northing, + central_longitude=central_longitude, + central_latitude=central_latitude, + scale_factor=scale_factor, + ) + + xpts = np.append( + f.dim("X").bounds.array[:, 0], f.dim("X").bounds.array[-1, 1] + ) + ypts = np.append( + f.dim("Y").bounds.array[:, 0], f.dim("Y").bounds.array[-1, 1] + ) + field = np.squeeze(f.array) + plotargs = {"transform": transform} + + else: + if two_d is False: + if bound: + xpts = x + ypts = y + else: + xpts = x[0] - (x[1] - x[0]) / 2.0 + for ix in np.arange(np.size(x) - 1): + xpts = np.append(xpts, x[ix] + (x[ix + 1] - x[ix]) / 2.0) + xpts = np.append(xpts, x[ix + 1] + (x[ix + 1] - x[ix]) / 2.0) + + ypts = y[0] - (y[1] - y[0]) / 2.0 + for iy in np.arange(np.size(y) - 1): + ypts = np.append(ypts, y[iy] + (y[iy + 1] - y[iy]) / 2.0) + ypts = np.append(ypts, y[iy + 1] + (y[iy + 1] - y[iy]) / 2.0) + + if lonlat: + upper_bound = ypts[-1] + + xpts = xpts[0:-1] + ypts = ypts[0:-1] + + if plotvars.lonmin < np.nanmin(xpts): + xpts = xpts - 360 + if plotvars.lonmin > np.nanmax(xpts): + xpts = xpts + 360 + + lonrange = np.nanmax(xpts) - np.nanmin(xpts) + if lonrange < 360 and lonrange > 350: + field, xpts = utility.add_cyclic(field, xpts) + + right_bound = xpts[-1] + (xpts[-1] - xpts[-2]) + + xpts = np.append(xpts, right_bound) + ypts = np.append(ypts, upper_bound) + + if two_d: + if fast: + xpts = x + ypts = y + else: + nx = np.shape(x)[1] + ny = np.shape(x)[0] + + for ix in np.arange(nx): + for iy in np.arange(ny): + if ix < nx - 2: + xdiff = (x[iy, ix + 1] - x[iy, ix]) / 2 + else: + xdiff = (x[iy, ix] - x[iy, ix - 1]) / 2 + + if iy < ny - 2: + ydiff = (y[iy + 1, ix] - y[iy, ix]) / 2 + else: + ydiff = (y[iy, ix] - y[iy - 1, ix]) / 2 + + xpts = [ + x[iy, ix] - xdiff, + x[iy, ix] + xdiff, + x[iy, ix] + xdiff, + x[iy, ix] - xdiff, + x[iy, ix] - xdiff, + ] + ypts = [ + y[iy, ix] - ydiff, + y[iy, ix] - ydiff, + y[iy, ix] + ydiff, + y[iy, ix] + ydiff, + y[iy, ix] - ydiff, + ] + + plotvars.mymap.add_patch( + mpatches.Polygon( + [ + [xpts[0], ypts[0]], + [xpts[1], ypts[1]], + [xpts[2], ypts[2]], + [xpts[3], ypts[3]], + [xpts[4], ypts[4]], + ], + facecolor=fill_colors[int(colarr[iy, ix])], + zorder=zorder, + transform=ccrs.PlateCarree(), + ) + ) + + return + + if plotvars.proj == "npstere": + pts = np.where(ypts < plotvars.boundinglat) + if np.size(pts) > 0: + ypts[pts] = plotvars.boundinglat + pts = np.where(ypts > 90.0) + if np.size(pts) > 0: + ypts[pts] = 90.0 + + if plotvars.proj == "spstere": + pts = np.where(ypts > plotvars.boundinglat) + if np.size(pts) > 0: + ypts[pts] = plotvars.boundinglat + pts = np.where(ypts < -90.0) + if np.size(pts) > 0: + ypts[pts] = -90.0 + + if transform: + lonlat = True + else: + transform = ccrs.PlateCarree() + + if fast: + if isinstance(clevs, int): + norm = False + + if two_d: + fixed_x = x.copy() + for i, start in enumerate(np.argmax(np.abs(np.diff(x)) > 180, axis=1)): + fixed_x[i, start + 1 :] += 360 + plotvars.image = plotvars.mymap.pcolormesh( + fixed_x, y, field, cmap=cmap, transform=transform, norm=norm + ) + + else: + if lonlat: + for offset in [0, 360.0]: + if isinstance(clevs, int): + plotvars.image = plotvars.mymap.pcolormesh( + xpts + offset, + ypts, + field, + transform=transform, + cmap=cmap, + ) + else: + plotvars.image = plotvars.mymap.pcolormesh( + xpts + offset, + ypts, + field, + transform=transform, + cmap=cmap, + norm=norm, + ) + + else: + if isinstance(clevs, int): + plotvars.image = plotvars.plot.pcolormesh(xpts, ypts, field, cmap=cmap) + else: + plotvars.image = plotvars.plot.pcolormesh( + xpts, ypts, field, cmap=cmap, norm=norm + ) + + else: + if plotvars.plot_type == 1 and plotvars.proj != "cyl": + for i in np.unique(colarr[colarr >= 0]).astype(int): + allverts = [] + xy_stack = np.column_stack(np.where(colarr == i)) + + for pt in np.arange(np.shape(xy_stack)[0]): + ix = xy_stack[pt][1] + iy = xy_stack[pt][0] + lons = [xpts[ix], xpts[ix + 1], xpts[ix + 1], xpts[ix], xpts[ix]] + lats = [ypts[iy], ypts[iy], ypts[iy + 1], ypts[iy + 1], ypts[iy]] + + verts = [ + (lons[0], lats[0]), + (lons[1], lats[1]), + (lons[2], lats[2]), + (lons[3], lats[3]), + (lons[4], lats[4]), + ] + + allverts.append(verts) + + if single_fill_color is None: + color = fill_colors[i] + else: + color = single_fill_color + coll = PolyCollection( + allverts, + facecolor=color, + edgecolors=color, + alpha=alpha, + zorder=zorder, + **plotargs, + ) + + if lonlat: + plotvars.mymap.add_collection(coll) + else: + plotvars.plot.add_collection(coll) + else: + for i in np.unique(colarr[colarr >= 0]).astype(int): + allverts = [] + xy_stack = np.column_stack(np.where(colarr == i)) + for pt in np.arange(np.shape(xy_stack)[0]): + ix = xy_stack[pt][1] + iy = xy_stack[pt][0] + verts = [ + (xpts[ix], ypts[iy]), + (xpts[ix + 1], ypts[iy]), + (xpts[ix + 1], ypts[iy + 1]), + (xpts[ix], ypts[iy + 1]), + (xpts[ix], ypts[iy]), + ] + + allverts.append(verts) + + if single_fill_color is None: + color = fill_colors[i] + else: + color = single_fill_color + + coll = PolyCollection( + allverts, + facecolor=color, + edgecolors=color, + alpha=alpha, + zorder=zorder, + **plotargs, + ) + + if lonlat: + plotvars.mymap.add_collection(coll) + else: + plotvars.plot.add_collection(coll) + + if white: + allverts = [] + xy_stack = np.column_stack(np.where(colarr == -1)) + for pt in np.arange(np.shape(xy_stack)[0]): + ix = xy_stack[pt][1] + iy = xy_stack[pt][0] + + verts = [ + (xpts[ix], ypts[iy]), + (xpts[ix + 1], ypts[iy]), + (xpts[ix + 1], ypts[iy + 1]), + (xpts[ix], ypts[iy + 1]), + (xpts[ix], ypts[iy]), + ] + + allverts.append(verts) + + coll = PolyCollection( + allverts, + facecolor="#ffffff", + edgecolors="#ffffff", + alpha=alpha, + zorder=zorder, + **plotargs, + ) + + if lonlat: + plotvars.mymap.add_collection(coll) + else: + plotvars.plot.add_collection(coll) + + +def _bfill_ugrid( + f=None, + face_lons=None, + face_lats=None, + face_connectivity=None, + clevs=None, + alpha=None, + zorder=None, +): + """ + | Block fill a irregular field with colour rectangles. + | This is an internal routine and is not generally used by the user. + | + | f=None - field + | face_lons=None - longitude points for face vertices + | face_lats=None - latitude points for face verticies + | face_connectivity=None - connectivity for face verticies + | clevs=None - levels for filling + | lonlat=False - lonlat data + | bound=False - x and y are cf data boundaries + | alpha=alpha - transparency setting 0 to 1 + | zorder=None - plotting order + | + :Returns: + None + | + """ + + # Colour faces according to value. + cols = ["#000000" for _ in range(len(face_connectivity))] + + levs = deepcopy(np.array(clevs)) + + if plotvars.levels_extend == "min" or plotvars.levels_extend == "both": + levs = np.concatenate([[-1e20], levs]) + ilevs_max = np.size(levs) + if plotvars.levels_extend == "max" or plotvars.levels_extend == "both": + levs = np.concatenate([levs, [1e20]]) + else: + ilevs_max = ilevs_max - 1 + + for ilev in np.arange(ilevs_max): + lev = levs[ilev] + col = plotvars.cs[ilev] + pts = np.where(f.squeeze() >= lev)[0] + + if len(pts) > 0 and np.min(pts) >= 0: + for val in np.arange(np.size(pts)): + pt = pts[val] + cols[pt] = col + + plotargs = {"transform": ccrs.PlateCarree()} + + coords_all = [] + + nfaces = np.shape(face_connectivity)[0] + for iface in np.arange(nfaces): + lons = np.array(face_lons[iface, :], copy=True) + lats = np.array(face_lats[iface, :], copy=True) + + # Wrapping in longitude. + if (np.max(lons) - np.min(lons)) > 100: + if np.max(lons) > 180: + for j in np.arange(len(lons)): + lons[j] = (lons[j] + 180) % 360 - 180 + else: + for j in np.arange(len(lons)): + lons[j] = lons[j] % 360 + + nverts = len(lons) + + # Add extra vertices if any of the points are at the north or south pole. + if np.max(lats) == 90 or np.min(lats) == -90: + geom = sgeom.Polygon([(lons[k], lats[k]) for k in np.arange(nverts)]) + geom_cyl = ccrs.PlateCarree().project_geometry(geom, ccrs.Geodetic()) + + # New method for shapely 2.0 + + poly_mapped = sgeom.mapping(geom_cyl.geoms[0]) + coords = list(poly_mapped["coordinates"][0]) + else: + coords = [(lons[k], lats[k]) for k in np.arange(nverts)] + + coords_all.append(coords) + + plotvars.mymap.add_collection( + PolyCollection( + coords_all, + facecolors=cols, + edgecolors=None, + alpha=alpha, + zorder=zorder, + **plotargs, + ) + ) diff --git a/cfplot/calculate/__init__.py b/cfplot/calculate/__init__.py deleted file mode 100644 index 36dd76e..0000000 --- a/cfplot/calculate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .calculate import calculate_levels diff --git a/cfplot/calculate/calculate.py b/cfplot/calculate/calculate.py deleted file mode 100644 index 1161fa3..0000000 --- a/cfplot/calculate/calculate.py +++ /dev/null @@ -1,164 +0,0 @@ -from copy import deepcopy - -import numpy as np - -from ..parameters import plotvars -from ..utils import _gvals, fix_floats - - -def calculate_levels(field=None, level_spacing=None, verbose=None): - """Calculate contour levels.""" - - dmin = np.nanmin(field) - dmax = np.nanmax(field) - - tight = True - - field2 = deepcopy(field) - - if plotvars.user_levs == 1: - # User defined - if verbose: - print("cfp.calculate_levels - using user defined contour levels") - clevs = plotvars.levels - mult = 0 - fmult = 1 - else: - if plotvars.levels_step is None: - # Automatic levels - mult = 0 - fmult = 1 - if verbose: - print( - "cfp.calculate_levels - generating automatic contour " - "levels" - ) - - if level_spacing == "outlier" or level_spacing == "inspect": - hist = np.histogram(field, 100)[0] - pts = np.size(field) - rate = 0.01 - - if sum(hist[1:-2]) == 0: - if hist[0] / hist[-1] < rate: - pts = np.where(field == dmin) - field2[pts] = dmax - dmin = np.nanmin(field2) - - if hist[-1] / hist[0] < rate: - pts = np.where(field == dmax) - field2[pts] = dmin - dmax = np.nanmax(field2) - - clevs, mult = _gvals(dmin=dmin, dmax=dmax) - fmult = 10**-mult - tight = False - - if level_spacing == "linear": - if isinstance( - np.ma.min(dmin), np.ma.core.MaskedConstant - ) or isinstance(np.ma.min(dmax), np.ma.core.MaskedConstant): - errstr = ( - "cf-plot calculate_levels error - data is entirely " - "masked\n" - "setting levels to 0 and 0.1 to produce a plot" - ) - print(errstr) - dmin = 0.0 - dmax = 0.1 - - clevs, mult = _gvals(dmin=dmin, dmax=dmax) - fmult = 10**-mult - tight = False - - if level_spacing == "log" or level_spacing == "loglike": - - if dmin < 0.0 and dmax < 0.0: - dmin1 = abs(dmax) - dmax1 = abs(dmin) - - if dmin > 0.0 and dmax > 0.0: - dmin1 = abs(dmin) - dmax1 = abs(dmax) - - if dmin <= 0.0 and dmax >= 0.0: - dmax1 = max(abs(dmin), dmax) - pts = np.where(field < 0.0) - close_below = np.max(field[pts]) - pts = np.where(field > 0.0) - close_above = np.min(field[pts]) - dmin1 = min(abs(close_below), close_above) - - # Generate levels - if level_spacing == "log": - clevs = [] - for i in np.arange(31): - val = 10 ** (i - 30.0) - clevs.append("{:.0e}".format(val)) - - if level_spacing == "loglike": - clevs = [] - for i in np.arange(61): - val = 10 ** (i - 30.0) - clevs.append("{:.0e}".format(val)) - clevs.append("{:.0e}".format(val * 2)) - clevs.append("{:.0e}".format(val * 5)) - clevs = np.float64(clevs) - - # Remove out of range levels - clevs = np.float64(clevs) - pts = np.where( - np.logical_and(clevs >= abs(dmin1), clevs <= abs(dmax1)) - ) - clevs = clevs[pts] - - if dmin < 0.0 and dmax < 0.0: - clevs = -1.0 * clevs[::-1] - - if dmin <= 0.0 and dmax >= 0.0: - clevs = np.concatenate([-1.0 * clevs[::-1], [0.0], clevs]) - - # Use step to generate the levels - if plotvars.levels_step is not None: - if verbose: - print( - "calculate_levels - using specified step to generate " - "contour levels" - ) - - step = plotvars.levels_step - - if isinstance(step, int): - dmin = int(dmin) - dmax = int(dmax) - - fmult = 1 - mult = 0 - clevs = [] - if dmin < 0: - clevs = (np.arange(-1 * dmin / step + 1) * -step)[::-1] - if dmax > 0: - if np.size(clevs) > 0: - clevs = np.concatenate( - (clevs[:-1], np.arange(dmax / step + 1) * step) - ) - else: - clevs = np.arange(dmax / step + 1) * step - if isinstance(step, int): - clevs = clevs.astype(int) - - # Remove any out of data range values - if tight: - pts = np.where(np.logical_and(clevs >= dmin, clevs <= dmax)) - clevs = clevs[pts] - - # Add an extra contour level if less than two levels are present - if np.size(clevs) < 2: - clevs.append(clevs[0] + 0.001) - - # Test for large numer of decimal places and fix if necessary - if plotvars.levels is None: - if isinstance(clevs[0], float): - clevs = fix_floats(clevs) - - return (clevs, mult, fmult) diff --git a/cfplot/colorbar.py b/cfplot/colorbar.py new file mode 100644 index 0000000..37339a0 --- /dev/null +++ b/cfplot/colorbar.py @@ -0,0 +1,284 @@ +"""Stateful colorbar rendering helpers for contour workflows.""" + +from __future__ import annotations + +import matplotlib +import numpy as np + +from .colour import get_colour_scale_map +from .state import plotvars + + +def cbar( + labels=None, + orientation=None, + position=None, + shrink=None, + fraction=None, + title=None, + fontsize=None, + fontweight=None, + text_up_down=None, + text_down_up=None, + drawedges=None, + levs=None, + thick=None, + anchor=None, + extend=None, + mid=None, + verbose=None, +): + """The cf-plot interface to Matplotlib colorbar for contour rendering.""" + if verbose: + print("con - adding a colour bar") + + if fontsize is None: + fontsize = plotvars.colorbar_fontsize + if fontweight is None: + fontweight = plotvars.colorbar_fontweight + if thick is None: + thick = 0.012 + if plotvars.rows == 2: + thick = 0.008 + if plotvars.rows == 3: + thick = 0.005 + if plotvars.rows >= 4: + thick = 0.003 + if drawedges is None: + drawedges = True + if orientation is None: + orientation = "horizontal" + if fraction is None: + fraction = 0.12 + if plotvars.rows == 2: + fraction = 0.08 + if plotvars.rows == 3: + fraction = 0.06 + if plotvars.rows >= 4: + fraction = 0.04 + if shrink is None: + shrink = 1.0 + if plotvars.plot_type == 1 and plotvars.proj in ("npstere", "spstere"): + shrink = 0.8 + if anchor is None: + anchor = 0.3 + if plotvars.plot_type > 1: + anchor = 0.5 + + if isinstance(levs, int): + if plotvars.plot_type == 0: + myplot = plotvars.mymap + else: + myplot = plotvars.plot + + from mpl_toolkits.axes_grid1 import make_axes_locatable + + divider = make_axes_locatable(myplot) + if orientation == "horizontal": + if plotvars.plot_type == 1: + cax = divider.append_axes("bottom", size="2%", pad=0.3, title=title) + else: + cax = divider.append_axes("bottom", size="2%", pad=1.0, title=title) + else: + cax = divider.append_axes("right", size="2%", pad=0.5, title=title) + + return plotvars.master_plot.colorbar( + plotvars.image, cax=cax, orientation=orientation + ) + + ax1 = None + if position is None: + if plotvars.plot_type == 1 or plotvars.plot_type == 6: + this_plot = plotvars.mymap + else: + this_plot = plotvars.plot + + if plotvars.plot_type == 6 and ( + plotvars.proj == "rotated" or plotvars.proj == "UKCP" + ): + this_plot = plotvars.plot + + left, bottom, width, height = this_plot.get_position().bounds + + # Cartopy can occasionally report inverted bounds for some map + # projections. Normalise to positive sizes so add_axes is valid. + width = abs(width) + height = abs(height) + width = max(width, 1e-6) + height = max(height, 1e-6) + + if orientation == "horizontal": + if plotvars.plot_type == 1: + left, bottom, width, height = this_plot.get_position().bounds + width = abs(width) + height = abs(height) + width = max(width, 1e-6) + height = max(height, 1e-6) + if height / width >= 0.9: + this_plot.set_position([left, bottom + fraction, width, height - fraction]) + left, bottom, width, height = this_plot.get_position().bounds + width = abs(width) + height = abs(height) + width = max(width, 1e-6) + height = max(height, 1e-6) + + ax1 = plotvars.master_plot.add_axes( + [ + left + width * (1.0 - shrink) / 2.0, + bottom + fraction * (anchor - 1.0), + max(width * shrink, 1e-6), + thick, + ] + ) + else: + this_plot.set_position([left, bottom + fraction, width, height - fraction]) + ax1 = plotvars.master_plot.add_axes( + [ + left + width * (1.0 - shrink) / 2.0, + bottom, + max(width * shrink, 1e-6), + thick, + ] + ) + else: + ax1 = plotvars.master_plot.add_axes( + [ + left + width + fraction * (anchor - 1), + bottom + height * (1.0 - shrink) / 2.0, + thick, + height * shrink, + ] + ) + this_plot.set_position([left, bottom, width - fraction, height]) + else: + ax1 = plotvars.master_plot.add_axes(position) + + if levs is None: + if plotvars.levels is not None: + levs = np.array(plotvars.levels) + else: + if labels is None: + errstr = "\n\ncbar error - No levels or labels supplied \n\n" + raise TypeError(errstr) + levs = np.arange(len(labels)) + + if labels is None: + labels = levs + + lbot = levs + if text_up_down: + lbot = levs[1:][::2] + ltop = levs[::2] + if text_down_up: + lbot = levs[::2] + ltop = levs[1:][::2] + + colmap = get_colour_scale_map() + cmap = matplotlib.colors.ListedColormap(colmap) + if extend is None: + extend = plotvars.levels_extend + + ncolors = np.size(levs) + if extend in ("both", "max"): + ncolors -= 1 + + if mid is not None: + lbot = [(lbot[i + 1] - lbot[i]) / 2.0 + lbot[i] for i in np.arange(len(labels))] + + if not isinstance(levs, int): + plotvars.norm = matplotlib.colors.BoundaryNorm(boundaries=levs, ncolors=ncolors) + + boundaries = levs.astype(float) + if extend in ("both", "min"): + cmap.set_under(plotvars.cs[0]) + boundaries = np.insert(boundaries, 0, boundaries[0] - 1) + if extend in ("both", "max"): + cmap.set_over(plotvars.cs[-1]) + boundaries = np.insert(boundaries, len(boundaries), boundaries[-1] + 1) + + if not isinstance(levs, list): + lbot = None + + colorbar = matplotlib.colorbar.ColorbarBase( + ax1, + cmap=cmap, + norm=plotvars.norm, + extend=extend, + extendfrac="auto", + boundaries=boundaries, + ticks=lbot, + spacing="uniform", + orientation=orientation, + drawedges=drawedges, + ) + else: + ax1 = plotvars.master_plot.add_axes(position) + colorbar = matplotlib.colorbar.ColorbarBase( + ax1, + cmap=cmap, + norm=plotvars.norm, + extend=extend, + extendfrac="auto", + boundaries=boundaries, + ticks=lbot, + spacing="uniform", + orientation=orientation, + drawedges=drawedges, + ) + + colorbar.set_label(title, fontsize=fontsize, fontweight=fontweight) + + if len(labels) > len(levs): + labels = labels[: len(levs)] + + colorbar.set_ticklabels([str(i) for i in labels]) + if orientation == "horizontal": + for tick in colorbar.ax.xaxis.get_ticklines(): + tick.set_visible(False) + for tick_label in colorbar.ax.get_xticklabels(): + tick_label.set_fontsize(fontsize) + tick_label.set_fontweight(fontweight) + else: + for tick in colorbar.ax.yaxis.get_ticklines(): + tick.set_visible(False) + for tick_label in colorbar.ax.get_yticklabels(): + tick_label.set_fontsize(fontsize) + tick_label.set_fontweight(fontweight) + + if text_up_down or text_down_up: + vmin = colorbar.norm.vmin + vmax = colorbar.norm.vmax + + cbar_extend = colorbar.extend + if cbar_extend == "min": + shift_l = 0.05 + scaling = 0.95 + elif cbar_extend == "max": + shift_l = 0.0 + scaling = 0.95 + elif cbar_extend == "both": + shift_l = 0.05 + scaling = 0.9 + else: + shift_l = 0.0 + scaling = 1.0 + + colorbar.ax.set_xticklabels(lbot) + + for ii in ltop: + colorbar.ax.text( + shift_l + scaling * (ii - vmin) / (vmax - vmin), + 1.5, + str(ii), + transform=colorbar.ax.transAxes, + va="bottom", + ha="center", + fontsize=fontsize, + fontweight=fontweight, + ) + + for tick_label in colorbar.ax.get_xticklabels(): + tick_label.set_fontsize(fontsize) + tick_label.set_fontweight(fontweight) + + return colorbar diff --git a/cfplot/colour/__init__.py b/cfplot/colour/__init__.py index 06d1881..989e5ef 100644 --- a/cfplot/colour/__init__.py +++ b/cfplot/colour/__init__.py @@ -1 +1,32 @@ -from .colour import _cscale_get_map, _process_color_scales, cbar +"""Lazy exports for colour helpers.""" + +__all__ = [ + "_cscale_get_map", + "_process_color_scales", + "apply_colour_scale", + "cbar", + "cscale", + "get_colour_scale_map", +] + + +def __getattr__(name): + if name in __all__: + from .colour import ( + _cscale_get_map, + _process_color_scales, + apply_colour_scale, + cbar, + cscale, + get_colour_scale_map, + ) + + return { + "_cscale_get_map": _cscale_get_map, + "_process_color_scales": _process_color_scales, + "apply_colour_scale": apply_colour_scale, + "cbar": cbar, + "cscale": cscale, + "get_colour_scale_map": get_colour_scale_map, + }[name] + raise AttributeError(name) diff --git a/cfplot/colour/colour.py b/cfplot/colour/colour.py index 92ddb44..a791f03 100644 --- a/cfplot/colour/colour.py +++ b/cfplot/colour/colour.py @@ -1,10 +1,131 @@ import subprocess +from typing import Any import matplotlib import matplotlib.pyplot as plot import numpy as np -from ..parameters import cscale, plotvars +from .. import utility +from ..state import plotvars + + +def get_colour_scale_map() -> list[str]: + """Return the active colour scale trimmed for extend settings.""" + cscale_ncols = len(plotvars.cs) + if plotvars.levels_extend == "both": + return plotvars.cs[1 : cscale_ncols - 1] + if plotvars.levels_extend == "min": + return plotvars.cs[1:] + if plotvars.levels_extend == "max": + return plotvars.cs[: cscale_ncols - 1] + return plotvars.cs + + +def apply_colour_scale( + scale: str | None = None, + ncols: int | None = None, + white: Any = None, + below: int | None = None, + above: int | None = None, + reverse: bool = False, + uniform: bool = False, +) -> None: + """Apply a colour scale to shared plotting state.""" + if scale is None or scale == "": + scale = "scale1" + + red, green, blue = utility.load_colour_scale_rgb(scale) + + if reverse: + red = red[::-1] + green = green[::-1] + blue = blue[::-1] + + if ncols is not None: + positions = np.linspace(0, np.size(red) - 1, num=ncols, endpoint=True) + red, green, blue = utility.interpolate_colour_channels( + red, green, blue, positions + ) + + if below is not None or above is not None: + npoints = np.size(red) // 2 + + x_below: np.ndarray | float | list[float] = [] + lower = npoints if below is None else below + if below is not None and uniform: + lower = max(above, below) + if below == 1: + x_below = 0 + if lower > 1: + x_below = ((npoints - 1) / float(lower - 1)) * np.arange(lower) + + x_above: np.ndarray | float | list[float] = [] + upper = npoints if above is None else above + if above is not None and uniform: + upper = max(above, below) + if above == 1: + x_above = npoints * 2 - 1 + if upper > 1: + x_above = ((npoints - 1) / float(upper - 1)) * np.arange(upper) + npoints + + positions = np.append(x_below, x_above) + red, green, blue = utility.interpolate_colour_channels( + red, green, blue, positions + ) + + if uniform: + midpoint = max(below, above) + red = red[midpoint - below : midpoint + above] + green = green[midpoint - below : midpoint + above] + blue = blue[midpoint - below : midpoint + above] + + hexarr = [ + f"#{int(red[idx]):02x}{int(green[idx]):02x}{int(blue[idx]):02x}" + for idx in np.arange(np.size(red)) + ] + + if white is not None: + if np.size(white) == 1: + hexarr[white] = "#ffffff" + else: + for col in white: + hexarr[col] = "#ffffff" + + plotvars.cs = hexarr + + +def cscale( + scale: str | None = None, + ncols: int | None = None, + white: Any = None, + below: int | None = None, + above: int | None = None, + reverse: bool = False, + uniform: bool = False, +) -> None: + """Choose and manipulate colour maps in shared plotting state.""" + if scale is None: + plotvars.cscale_flag = 0 + return + + plotvars.cs_user = scale + plotvars.cscale_flag = 1 + + vals = [ncols, white, below, above] + if any(val is not None for val in vals): + plotvars.cscale_flag = 2 + if reverse is not False or uniform is not False: + plotvars.cscale_flag = 2 + + apply_colour_scale( + scale=scale, + ncols=ncols, + white=white, + below=below, + above=above, + reverse=reverse, + uniform=uniform, + ) def _cscale_get_map(): @@ -18,16 +139,7 @@ def _cscale_get_map(): colour map | """ - cscale_ncols = np.size(plotvars.cs) - if plotvars.levels_extend == "both": - colmap = plotvars.cs[1 : cscale_ncols - 1] - if plotvars.levels_extend == "min": - colmap = plotvars.cs[1:] - if plotvars.levels_extend == "max": - colmap = plotvars.cs[: cscale_ncols - 1] - if plotvars.levels_extend == "neither": - colmap = plotvars.cs - return colmap + return get_colour_scale_map() def _process_color_scales(): diff --git a/cfplot/colour/colourmaps/__init__.py b/cfplot/colour/colourmaps/__init__.py index e69de29..49cf851 100644 --- a/cfplot/colour/colourmaps/__init__.py +++ b/cfplot/colour/colourmaps/__init__.py @@ -0,0 +1,24 @@ +"""Built-in colourmap data for cf-plot.""" + +cscale1 = [ + "#0a3278", + "#0f4ba5", + "#1e6ec8", + "#3ca0f0", + "#50b4fa", + "#82d2ff", + "#a0f0ff", + "#c8faff", + "#e6ffff", + "#fffadc", + "#ffe878", + "#ffc03c", + "#ffa000", + "#ff6000", + "#ff3200", + "#e11400", + "#c00000", + "#a50000", +] + +__all__ = ["cscale1"] diff --git a/cfplot/contour.py b/cfplot/contour.py index 0851eb6..f6bf405 100644 --- a/cfplot/contour.py +++ b/cfplot/contour.py @@ -1,2580 +1,1932 @@ -from copy import deepcopy +"""Contour plotting module. + +Provides the refactored contour plotting interface. + +Architecture: +- ContourData: Immutable container for arrays and metadata +- ContourLayout: Manages viewport allocation +- ColourScale: Encapsulates colormap/level logic +- ContourRenderer: Base class for rendering strategy +- MapContourRenderer: Renders to map (Cartopy) +- XYContourRenderer: Renders to Cartesian axes + +Module Independence: +This module is currently independent at the module level (no module-level +imports from cfplot.py), though functions do import locally from cfplot +for essential utilities like calculate_levels and axis helpers. This keeps +module boundaries clear while preserving functionality during gradual refactoring. + +Future work will move more utilities (calculate_levels, _stimeaxis, etc.) +into standalone modules (see utility.py, state.py) and eliminate even the +function-level cfplot imports. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any -import cartopy -import cartopy.crs as ccrs -import cartopy.feature as cfeature import cf -import matplotlib +import cartopy.crs as ccrs +import matplotlib.colors import numpy as np - -from .calculate import calculate_levels -from .colour import _cscale_get_map, cbar -from .graphic import gclose, gopen, gpos -from .mapping import ( - _map_title, - _mapaxis, - _plot_map_axes, - _set_map, - axes_plot, - map_grid, +from matplotlib.axes import Axes + +from . import utility +from .blockfill import _bfill, _bfill_ugrid +from .colour import apply_colour_scale, get_colour_scale_map +from .colorbar import cbar +from .layout_runtime import ( + apply_axes, + ensure_xy_viewport, + maybe_autosave, + set_plot_limits, ) -from .parameters import ( - cscale, - cscale1, +from .map_runtime import ( + MapSet, + _apply_dim_titles, + _apply_map_title, + _apply_map_features, + ensure_map_viewport, +) +from .rotated_runtime import _render_ptype6_rotated_pole +from .state import ( global_blockfill, global_fill, global_lines, - gset, - mapset, plotvars, ) -from .utils import ( - _bfill, - _bfill_ugrid, - _cf_data_assign, - _dim_titles, - _gvals, - _timeaxis, - add_cyclic, - find_pos_in_array, - find_z, - generate_titles, - irregular_window, - ndecs, - rgaxes, -) -from .validate import _check_data, check_well_formed, orca_check - - -def con( - f=None, - x=None, - y=None, - fill=global_fill, - lines=global_lines, - line_labels=True, - title=None, - colorbar_title=None, - colorbar=True, - colorbar_label_skip=None, - ptype=0, - negative_linestyle="solid", - blockfill=global_blockfill, - zero_thick=False, - colorbar_shrink=None, - colorbar_orientation=None, - colorbar_position=None, - xlog=False, - ylog=False, - axes=True, - xaxis=True, - yaxis=True, - xticks=None, - xticklabels=None, - yticks=None, - yticklabels=None, - xlabel=None, - ylabel=None, - colors="k", - swap_axes=False, - verbose=None, - linewidths=None, - alpha=1.0, - colorbar_text_up_down=False, - colorbar_fontsize=None, - colorbar_fontweight=None, - colorbar_text_down_up=False, - colorbar_drawedges=True, - colorbar_fraction=None, - colorbar_thick=None, - colorbar_anchor=None, - colorbar_labels=None, - linestyles=None, - zorder=1, - level_spacing=None, - irregular=None, - face_lons=False, - face_lats=False, - face_connectivity=False, - titles=False, - mytest=False, - transform_first=None, - blockfill_fast=None, - nlevs=False, - orca=None, - orca_skip=None, - grid=False, -): - """ - | The interface to contouring in cf-plot. - | - | The minimum use is con(f) - | where f is a 2 dimensional array. If a cf field is passed then an - | appropriate plot will be produced i.e. a longitude-latitude or - | latitude-height plot for example. If a 2d numeric array is passed then - | the optional arrays x and y can be used to describe the x and y data - | point locations. - | - | f - array to contour - | x - x locations of data in f (optional) - | y - y locations of data in f (optional) - | fill=True - colour fill contours - | lines=True - draw contour lines and labels - | line_labels=True - label contour lines - | title=title - title for the plot - | ptype=0 - plot type - not needed for cf fields. - | 0 = no specific plot type, - | 1 = longitude-latitude, - | 2 = latitude - height, - | 3 = longitude - height, - | 4 = latitude - time, - | 5 = longitude - time - | 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 - | 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 - | every other label. - | colorbar_orientation=None - options are 'horizontal' and 'vertical' - | The default for most plots is horizontal but - | for polar stereographic plots this is vertical. - | colorbar_shrink=None - value to shrink the colorbar by. If the colorbar - | exceeds the plot area then values of 1.0, 0.55 - | or 0.5m may help it better fit the plot area. - | colorbar_position=None - position of colorbar - | [xmin, ymin, x_extent,y_extent] in normalised - | coordinates. Use when a common colorbar - | is required for a set of plots. A typical set - | of values would be [0.1, 0.05, 0.8, 0.02] - | colorbar_fontsize=None - text size for colorbar labels and title - | colorbar_fontweight=None - font weight for colorbar labels and title - | colorbar_text_up_down=False - if True horizontal colour bar labels - | alternate above (start) and below the - | colour bar - | colorbar_text_down_up=False - if True horizontal colour bar labels - | alternate below (start) and above the - | colour bar - | colorbar_drawedges=True - draw internal divisions in the colorbar - | colorbar_fraction=None - space for the colorbar - default = 0.21, - | in normalised - | coordinates - | colorbar_thick=None - thickness of the colorbar - default = 0.015, - | in normalised coordinates - | colorbar_anchor=None - default=0.5 - anchor point of colorbar within - | the fraction space. - | 0.0 = close to plot, 1.0 = further away - | colorbar_labels=None - labels to use for colorbar. The default is to - | use the contour levels as labels - | colorbar_text_up_down=False - on a horizontal colorbar alternate the - | labels top and bottom starting in the - | up position - | colorbar_text_down_up=False - on a horizontal colorbar alternate the - | labels bottom and top starting in the - | bottom position - | colorbar_drawedges=True - draw internal delimeter lines in the colorbar - | colors='k' - contour line colors - takes one or many values. - | xlog=False - logarithmic x axis - | ylog=False - logarithmic y axis - | axes=True - plot x and y axes - | xaxis=True - plot xaxis - | yaxis=True - plot y axis - | xticks=None - xtick positions - | xticklabels=None - xtick labels - | yticks=None - y tick positions - | yticklabels=None - ytick labels - | xlabel=None - label for x axis - | ylabel=None - label for y axis - | swap_axes=False - swap plotted axes - only valid for X, Y, Z vs T plots - | verbose=None - change to 1 to get a verbose listing of what con - | is doing - | linewidths=None - contour linewidths. Either a single number for all - | lines or array of widths - | linestyles=None - takes 'solid', 'dashed', 'dashdot' or 'dotted' - | alpha=1.0 - transparency setting. The default is no transparency. - | zorder=1 - order of drawing - | level_spacing=None - Default of 'linear' level spacing. Also takes - | 'log', 'loglike', 'outlier' and 'inspect' - | irregular=None - flag for contouring irregular data - | face_lons=None - longitude points for face vertices - | face_lats=None - latitude points for face verticies - | face_connectivity=None - connectivity for face verticies - | titles=False - set to True to have a dimensions title - | transform_first=None - Cartopy should transform the points before - | calling the contouring algorithm, which can have - | a significant impact on speed (it is much - | faster to transform points than it is to - | transform patches) If this is unset and the - | number of points in the x direction is > 400 - | then it is set to True. - | blockfill_fast=None - Use pcolormesh blockfill. This is possibly less - | reliable that the usual code but is - | faster for higher resolution datasets - | nlevs=False - Let Matplotlib work out the levels for the contour plot - | orca=None - User specifies this is an orca tripolar grid. Internally - | cf-plot tries to detect this by looking for a single - | discontinuity in the logitude 2D array. If found a fix - | it make to the longitudes so that they are no longer - | discontinuous. - | orca_skip=None - Only plot every nth grid point in the 2D longitude - | and latitude arrays. This is useful for when - | plotting his resolution data over the whole globe - | which would otherwise be very slow to visualize. - | grid=False - Draw a grid on the map using the parameters set by - | cfp.setvars. Defaults are grid_x_spacing=60, - | grid_y_spacing=30, grid_colour='k', - | grid_linestyle = '--', grid_thickness=1.0 - | - :Returns: - None - - """ - # Turn off divide warning in contour routine which is a numpy issue - old_settings = np.seterr(all="ignore") - np.seterr(divide="ignore") - - # Set potential user axis labels - user_xlabel = xlabel - user_ylabel = ylabel - - # Set blockfill to True if blockfill_fast is not None - if blockfill_fast is not None: - blockfill = True - - # Check if the field is a CF ugrid field with cell faces - blockfill_ugrid = False - if isinstance(f, cf.Field) and blockfill: - if f.domain_topologies(): - if f.domain_topology("cell:face", default=None) is not None: - face_lons_array = f.aux("X").bounds.array - face_lats_array = f.aux("Y").bounds.array - face_connectivity_array = f.domain_topology("cell:face").array - blockfill_ugrid = True - fill = False - lines = False - irregular = True - else: - errstr = ( - "\n\nError - field does not contain the UGRID face " - "information to plot a blockfill plot\n\n\n" - ) - raise TypeError(errstr) - - # Set blockfill_2d if blockfill and x and y are 2D - blockfill_2d = False - if blockfill and not isinstance(f, cf.Field): - if np.ndim(x) == 2 and np.ndim(y) == 2: - blockfill_2d = True - - # Call gpos(1) if not already called - if plotvars.rows > 1 or plotvars.columns > 1: - if plotvars.gpos_called is False: - gpos(1) - - # Extract required data for contouring - # If a cf-python field - if isinstance(f, cf.Field): - - ndims = np.squeeze(f.data).ndim - if ndims > 2: - errstr = ( - "\n\ncfp.con error need a 1 or 2 dimensional field to " - "contour\n" - f"received {np.squeeze(f.data).ndim} dimensions\n\n" - f"{f}" - ) - raise TypeError(errstr) - - # Extract data - if verbose: - print("con - calling _cf_data_assign") - - # Subset the data if a user map is set - # This is used to speed up the plotting - # myfield is used for calculating the contour levels - # myfield_extended is used to make the contour plot - if plotvars.user_mapset and not blockfill_ugrid: - if plotvars.proj == "npstere": - f = f.subspace(Y=cf.wi(plotvars.boundinglat, 90.0)) - elif plotvars.proj == "spstere": - f = f.subspace(Y=cf.wi(-90.0, plotvars.boundinglat)) - - # Extract the data - field, x, y, ptype, colorbar_title, xlabel, ylabel, xpole, ypole = ( - _cf_data_assign(f, colorbar_title, verbose=verbose) - ) - if user_xlabel is not None: - xlabel = user_xlabel - if user_ylabel is not None: - ylabel = user_ylabel - elif isinstance(f, cf.FieldList): - raise TypeError("\n\ncfp.con - cannot contour a field list\n\n") - else: - if verbose: - print("con - using user assigned data") - field = f # field data passed in as f - if x is None: - x = np.arange(np.shape(field)[1]) - if y is None: - y = np.arange(np.shape(field)[0]) - - _check_data(field, x, y) - xlabel = "" - ylabel = "" - - # Assign irregular and orca keywords unless already set - if irregular is None: - if np.size(x) == np.size(np.unique(x)): - irregular = False - else: - irregular = True - if np.ndim(x) == 2 and np.ndim(y) == 2: - if orca is None: - orca = orca_check(x) - if orca: - - # Apply orca_skip if set - if orca_skip is not None: - print("applying orca_skip value of ", orca_skip) - x = x[::orca_skip, ::orca_skip] - y = y[::orca_skip, ::orca_skip] - field = field[::orca_skip, ::orca_skip] - - # orca grids have a discontinuity in the longitude grid - # use the method at - # https://gist.github.com/pelson/79cf31ef324774c97ae7 - # to remove the discontinuity - - fixed_x = x.copy() - for i, start in enumerate( - np.argmax(np.abs(np.diff(x)) > 180, axis=1) - ): - fixed_x[i, start + 1 :] += 360 - x = fixed_x - - if np.ndim(x) == 2: - irregular = False - - # Set contour line styles - matplotlib.rcParams["contour.negative_linestyle"] = negative_linestyle - - # Set contour lines off on block plots - if blockfill: - fill = False - field_orig = deepcopy(field) - x_orig = deepcopy(x) - y_orig = deepcopy(y) - - # Check number of colours and levels match if user has modified the - # number of colours - if plotvars.cscale_flag == 2: - ncols = np.size(plotvars.cs) - nintervals = np.size(plotvars.levels) - 1 - if plotvars.levels_extend == "min": - nintervals += 1 - if plotvars.levels_extend == "max": - nintervals += 1 - if plotvars.levels_extend == "both": - nintervals += 2 - if ncols != nintervals: - errstr = ( - "\n\ncfp.con - blockfill error \n" - "need to match number of colours and contour intervals\n" - "Don't forget to take account of the colorbar " - "extensions\n\n" - ) - raise TypeError(errstr) +def _detect_lon_cyclic(f: "cf.Field", x: "np.ndarray | None") -> bool: + """Return True when the longitude axis closes on itself at 360°. - # Turn off colorbar if fill is turned off - if not fill and not blockfill and not blockfill_ugrid: - colorbar = False + Prefers cell bounds from the CF field when available. Falls back to a + centre-point heuristic (range + step ≈ 360°) when bounds are absent. + Returns False for non-1-D or irregular grids (e.g. ORCA). + """ + try: + xdim = f.dim("X", default=None) + if xdim is not None and xdim.has_bounds(): + b = xdim.bounds.data.array + if b.ndim == 2: + # Cyclic: right edge of last cell == left edge of first cell + 360° + return abs(float(b[-1, 1]) - float(b[0, 0]) - 360.0) < 1.0 + + # Fallback: centre-point heuristic — only valid for 1-D lon arrays + if x is not None and x.ndim == 1 and len(x) > 1: + step = (float(x[-1]) - float(x[0])) / (len(x) - 1) + return abs((float(x[-1]) - float(x[0]) + step) - 360.0) < 0.5 * abs(step) + + except Exception: + pass + + return False + + +@dataclass(frozen=True) +class ContourData: + """Read-only contour inputs after extraction and validation. + + Holds extracted, validated, and pre-processed arrays ready for rendering. + Immutable by design to prevent unintended state mutations during plotting. + """ - # Revert to default colour scale if cscale_flag flag is set - if plotvars.cscale_flag == 0: - plotvars.cs = cscale1 + field: np.ndarray + x: np.ndarray | None + y: np.ndarray | None + ptype: int = 0 + colorbar_title: str = "" + xlabel: str = "" + ylabel: str = "" + levels: np.ndarray | None = None + mult: int = 0 + fmult: float = 1.0 + irregular: bool = False + is_ugrid: bool = False + is_orca: bool = False + fill: bool = True + lines: bool = True + blockfill: bool = False + xpole: float | None = None + ypole: float | None = None + x_is_cyclic: bool = False + face_lons: np.ndarray | None = None + face_lats: np.ndarray | None = None + face_connectivity: np.ndarray | None = None + + @classmethod + def from_cf_field( + cls, + f: cf.Field, + colorbar_title: str | None, + verbose: bool | None = None, + proj: str = "cyl", + ) -> "ContourData": + """Extract and prepare CF field for contouring.""" + ( + field, + x, + y, + ptype, + cbar_title, + xlabel, + ylabel, + xpole, + ypole, + ) = utility.cf_data_assign(f, colorbar_title, verbose=verbose, proj=proj) + + if colorbar_title is not None: + cbar_title = colorbar_title + + x_arr = x if x is None else np.asarray(x) + x_is_cyclic = _detect_lon_cyclic(f, x_arr) + irregular = ( + np.asanyarray(field).ndim == 1 + and x_arr is not None + and y is not None + and np.ndim(x_arr) == 1 + and np.ndim(y) == 1 + and np.asanyarray(field).size == np.asarray(x_arr).size == np.asarray(y).size + ) - # Set the orientation of the colorbar - if plotvars.plot_type == 1: - if plotvars.proj == "npstere" or plotvars.proj == "spstere": - if colorbar_orientation is None: - colorbar_orientation = "vertical" - if colorbar_orientation is None: - colorbar_orientation = "horizontal" + if irregular and proj == "cyl": + x_arr = _normalize_longitudes_for_map(x_arr) + + return cls( + field=np.asanyarray(field), + x=x_arr, + y=y if y is None else np.asarray(y), + ptype=ptype if ptype is not None else 0, + colorbar_title=cbar_title or "", + xlabel=xlabel or "", + ylabel=ylabel or "", + xpole=utility.to_float_or_none(xpole), + ypole=utility.to_float_or_none(ypole), + x_is_cyclic=x_is_cyclic, + irregular=irregular, + ) - # Store original map resolution - resolution_orig = plotvars.resolution + @classmethod + def from_arrays( + cls, + field: np.ndarray, + x: np.ndarray | None = None, + y: np.ndarray | None = None, + ) -> "ContourData": + """Create from raw numpy arrays with validation.""" + field = np.asanyarray(field) + x = np.asarray(x) if x is not None else np.arange(field.shape[1]) + y = np.asarray(y) if y is not None else np.arange(field.shape[0]) + + # Validate array dimensions - support both 1D coordinates and 2D (e.g., ORCA grids) + if field.ndim not in (1, 2, 3): + raise ValueError(f"Field must be 1D, 2D, or 3D, got shape {field.shape}") + + return cls( + field=field, + x=x, + y=y, + ptype=0, + colorbar_title="", + xlabel="", + ylabel="", + ) - # Set size of color bar if not specified - if colorbar_shrink is None: - colorbar_shrink = 1.0 - if plotvars.proj == "npstere" or plotvars.proj == "spstere": - colorbar_shrink = 0.8 - # Set plot type if user specified - if ptype is not None: - plotvars.plot_type = ptype +class ContourLayout: + """Manage viewport and annotation geometry for contour plots. + + Separates concerns: layout calculates space, rendering uses it. + Currently still delegates to legacy gopen/gset/gpos system. + """ - # Get contour levels if none are defined - spacing = "linear" - if plotvars.level_spacing is not None: - spacing = plotvars.level_spacing - if level_spacing is not None: - spacing = level_spacing + def __init__(self, plotvars: Any): + self.viewport: Axes | None = None + self.colorbar_ax: Axes | None = None + self.title_ax: Axes | None = None + self._plotvars = plotvars + self.colorbar_orientation: str = "horizontal" + self.colorbar_position: list[float] | None = None + + def allocate_xy_viewport( + self, + colorbar_orientation: str | None, + colorbar_position: list[float] | None, + ) -> "ContourLayout": + """Reserve viewport for Cartesian/non-map rendering. + + Coordinates with plotvars for multi-plot grids. + """ + # Set colorbar orientation + self.colorbar_orientation = colorbar_orientation or "horizontal" + self.colorbar_position = colorbar_position + + ensure_xy_viewport() + + # Store reference to current axes/map for later use + self.viewport = self._plotvars.runtime.plot + + return self + + def allocate_map_viewport( + self, + colorbar_orientation: str | None, + colorbar_position: list[float] | None, + ) -> "ContourLayout": + """Reserve viewport for map rendering without map-axis operations. + + This intentionally keeps map setup in a dedicated flow where projection + creation (mapset/_set_map) happens after base viewport selection. + """ + self.colorbar_orientation = colorbar_orientation or "horizontal" + self.colorbar_position = colorbar_position + + ensure_map_viewport() + + self.viewport = self._plotvars.runtime.plot + + return self + + def allocate( + self, + colorbar_orientation: str | None, + colorbar_position: list[float] | None, + ) -> "ContourLayout": + """Backward-compatible alias for Cartesian viewport allocation.""" + return self.allocate_xy_viewport(colorbar_orientation, colorbar_position) + + def apply_title( + self, + title: str | None, + dims_title: bool, + fontsize: int | None, + fontweight: str | None, + ) -> None: + """Apply title and dimension titles to plot.""" + pv = self._plotvars + runtime = pv.runtime + map_state = pv.map + dec = pv.decoration + + if title and title != "": + if runtime.plot_type == 1: + _apply_map_title( + mymap=runtime.mymap, + title=title, + proj=map_state.proj, + boundinglat=map_state.boundinglat, + lon_0=map_state.lon_0, + lonmin=map_state.lonmin, + lonmax=map_state.lonmax, + latmin=map_state.latmin, + latmax=map_state.latmax, + title_fontsize=fontsize or dec.title_fontsize, + title_fontweight=fontweight or dec.title_fontweight, + ) + else: + if self.viewport: + self.viewport.set_title( + title, + y=1.03, + fontsize=fontsize or dec.title_fontsize, + fontweight=fontweight or dec.title_fontweight, + ) - if plotvars.levels is None: + if dims_title: + _apply_dim_titles( + plot=runtime.plot, + mymap=runtime.mymap, + plot_type=runtime.plot_type, + proj=map_state.proj, + lonmin=map_state.lonmin, + lonmax=map_state.lonmax, + latmin=map_state.latmin, + latmax=map_state.latmax, + axis_label_fontsize=dec.axis_label_fontsize, + axis_label_fontweight=dec.axis_label_fontweight, + title=dims_title if isinstance(dims_title, str) else None, + ) - if isinstance(f, cf.Field): - ( - field, - x, - y, - ptype, - colorbar_title, - xlabel, - ylabel, - xpole, - ypole, - ) = _cf_data_assign(f, colorbar_title, verbose=verbose) - clevs, mult, fmult = calculate_levels( - field=field, level_spacing=spacing, verbose=verbose + def apply_axis_labels( + self, + xlabel: str | None, + ylabel: str | None, + xticks: Any, + yticks: Any, + xticklabels: Any | None = None, + yticklabels: Any | None = None, + ) -> None: + """Apply axis labels and ticks to plot.""" + if self.viewport is None: + return + + apply_axes( + plot_type=self._plotvars.runtime.plot_type, + xticks=xticks, + yticks=yticks, + xlabel=xlabel, + ylabel=ylabel, + xticklabels=xticklabels, + yticklabels=yticklabels, ) - else: - clevs = plotvars.levels - mult = 0 - fmult = 1 - # Set the colour scale if nothing is defined - includes_zero = False - if plotvars.cscale_flag == 0: - col_zero = 0 - for cval in clevs: - if includes_zero is False: - col_zero = col_zero + 1 - if cval == 0: - includes_zero = True - - if includes_zero: - cs_below = col_zero - cs_above = np.size(clevs) - col_zero + 1 - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "neither" - ): - cs_below = cs_below - 1 - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "neither" - ): - cs_above = cs_above - 1 - uniform = True - if plotvars.cs_uniform is False: - uniform = False - cscale("scale1", below=cs_below, above=cs_above, uniform=uniform) - else: - ncols = np.size(clevs) + 1 - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "max" - ): +class ColourScale: + """Encapsulate level fitting, colormap selection, and cbar labels. + + Replaces the scattered cscale_flag (0/1/2) branching with explicit methods. + """ + + def __init__(self, plotvars: Any): + self._plotvars = plotvars + self._levels: np.ndarray | None = None + self._includes_zero: bool = False + self._levels_extend: str = "neither" + + def fit_to_levels( + self, + levels: np.ndarray, + includes_zero: bool, + levels_extend: str, + ) -> "ColourScale": + """Fit color scale to contour levels, handling zero if present.""" + self._levels = np.asarray(levels) + self._includes_zero = includes_zero + self._levels_extend = levels_extend + scale = self._plotvars.scale + + # Replicate legacy cscale_flag == 0 logic (default colour scale). + # If zero is present in levels, split scale1 around zero so + # blue shades are strictly below zero and warm shades above. + if scale.cscale_flag == 0: + col_zero = 0 + includes_zero = False + for cval in self._levels: + if not includes_zero: + col_zero += 1 + if cval == 0: + includes_zero = True + + if includes_zero: + cs_below = col_zero + cs_above = np.size(self._levels) - col_zero + 1 + if scale.levels_extend in ("max", "neither"): + cs_below = cs_below - 1 + if scale.levels_extend in ("min", "neither"): + cs_above = cs_above - 1 + apply_colour_scale( + "scale1", + below=cs_below, + above=cs_above, + uniform=bool(scale.cs_uniform), + ) + else: + ncols = np.size(self._levels) + 1 + if scale.levels_extend in ("min", "max"): + ncols = ncols - 1 + elif scale.levels_extend == "neither": + ncols = ncols - 2 + apply_colour_scale("viridis", ncols=ncols) + + scale.cscale_flag = 0 + + # Replicate cscale_flag == 1 logic (user-selected color map, fit to levels) + if scale.cscale_flag == 1: + ncols = np.size(self._levels) + 1 + if scale.levels_extend == "min" or scale.levels_extend == "max": ncols = ncols - 1 - if plotvars.levels_extend == "neither": + if scale.levels_extend == "neither": ncols = ncols - 2 - cscale("viridis", ncols=ncols) - - plotvars.cscale_flag = 0 - - # User selected colour map but no mods so fit to levels - if plotvars.cscale_flag == 1: - ncols = np.size(clevs) + 1 - if plotvars.levels_extend == "min" or plotvars.levels_extend == "max": - ncols = ncols - 1 - if plotvars.levels_extend == "neither": - ncols = ncols - 2 - cscale(plotvars.cs_user, ncols=ncols) - plotvars.cscale_flag = 1 - - # Set colorbar labels - # Set a sensible label spacing if the user hasn't already done so - if colorbar_label_skip is None: - if colorbar_orientation == "horizontal": - nchars = 0 - for lev in clevs: - nchars = nchars + len(str(lev)) - colorbar_label_skip = int(nchars / 80 + 1) - if plotvars.columns > 1: - colorbar_label_skip = int(nchars * (plotvars.columns) / 80) - else: - colorbar_label_skip = 1 - - if colorbar_label_skip > 1: - if includes_zero: - # include zero in the colorbar labels - zero_pos = [i for i, mylev in enumerate(clevs) if mylev == 0][0] - cbar_labels = clevs[zero_pos] - i = zero_pos + colorbar_label_skip - while i <= len(clevs) - 1: - cbar_labels = np.append(cbar_labels, clevs[i]) - i = i + colorbar_label_skip - i = zero_pos - colorbar_label_skip - if i >= 0: - while i >= 0: - cbar_labels = np.append(clevs[i], cbar_labels) - i = i - colorbar_label_skip - else: - cbar_labels = clevs[0] - i = int(colorbar_label_skip) - while i <= len(clevs) - 1: - cbar_labels = np.append(cbar_labels, clevs[i]) - i = i + colorbar_label_skip - - else: - cbar_labels = clevs - - if colorbar_label_skip is None: - colorbar_label_skip = 1 - - # Make a list of strings of the colorbar levels for later labelling - clabels = [] - for i in cbar_labels: - clabels.append(str(i)) - if colorbar_label_skip > 1: - for skip in np.arange(colorbar_label_skip - 1): - clabels.append("") - - if colorbar_labels is not None: - cbar_labels = colorbar_labels - else: - cbar_labels = clabels - - # Turn off line_labels if the field is all the same - # Matplotlib 3.2.2 throws an error if there are no line labels - if np.nanmin(field) == np.nanmax(field): - line_labels = False - - # Add mult to colorbar_title if used - if colorbar_title is None: - colorbar_title = "" - if mult != 0: - colorbar_title = colorbar_title + " *10$^{" + str(mult) + "}$" - - # Catch null title - if title is None: - title = "" - if plotvars.title is not None: - title = plotvars.title - - # Set plot variables - title_fontsize = plotvars.title_fontsize - text_fontsize = plotvars.text_fontsize - if colorbar_fontsize is None: - colorbar_fontsize = plotvars.colorbar_fontsize - if colorbar_fontweight is None: - colorbar_fontweight = plotvars.colorbar_fontweight - continent_thickness = plotvars.continent_thickness - continent_color = plotvars.continent_color - continent_linestyle = plotvars.continent_linestyle - land_color = plotvars.land_color - ocean_color = plotvars.ocean_color - lake_color = plotvars.lake_color - title_fontweight = plotvars.title_fontweight - if continent_thickness is None: - continent_thickness = 1.5 - if continent_color is None: - continent_color = "k" - if continent_linestyle is None: - continent_linestyle = "solid" - cb_orient = colorbar_orientation - - # Retrieve any user defined axis labels - if xlabel == "" and plotvars.xlabel is not None: - xlabel = plotvars.xlabel - if ylabel == "" and plotvars.ylabel is not None: - ylabel = plotvars.ylabel - if xticks is None and plotvars.xticks is not None: - xticks = plotvars.xticks - if plotvars.xticklabels is not None: - xticklabels = plotvars.xticklabels - else: - xticklabels = list(map(str, xticks)) - if yticks is None and plotvars.yticks is not None: - yticks = plotvars.yticks - if plotvars.yticklabels is not None: - yticklabels = plotvars.yticklabels - else: - yticklabels = list(map(str, yticks)) - - # Calculate a set of dimension titles if requested - if titles: - plotvars.titles_con_called = True - title_dims = generate_titles(f) - if not colorbar: - title_dims = colorbar_title + "\n" + title_dims - - # Check if data is well formed - # i.e. dimensions have only recognizable X, Y, Z, T or a subset - well_formed = False - if isinstance(f, cf.Field): - well_formed = check_well_formed(f) - # TODO SLB - probably there should be an error raised here if not - # 'well-formed'? - - if nlevs is not False: - clevs = nlevs - plotvars.levels_extend = "neither" - if plotvars.cscale_flag == 0: - if np.min(field) < 0 and np.max(field) > 0: - cscale("scale1", ncols=nlevs) + apply_colour_scale(scale.cs_user, ncols=ncols) + scale.cscale_flag = 1 + + return self + + def get_cmap(self) -> matplotlib.colors.ListedColormap: + """Get colormap after fitting to levels.""" + scale = self._plotvars.scale + colmap = get_colour_scale_map() + cmap = matplotlib.colors.ListedColormap(colmap) + + if scale.levels_extend == "min" or scale.levels_extend == "both": + cmap.set_under(scale.cs[0]) + if scale.levels_extend == "max" or scale.levels_extend == "both": + cmap.set_over(scale.cs[-1]) + + return cmap + + def colourbar_labels( + self, + levels: np.ndarray, + orientation: str, + n_columns: int, + label_skip: int | None, + custom_labels: list[str] | None, + ) -> list[str]: + """Generate colourbar labels from levels with skip/custom overrides.""" + if custom_labels is not None: + return custom_labels + + # Legacy default: estimate skip for horizontal colour bars from the + # total character count, and include fewer labels for readability. + if label_skip is None: + if orientation == "horizontal": + nchars = sum(len(str(level)) for level in levels) + label_skip = int(nchars / 80 + 1) + if n_columns > 1: + label_skip = int(nchars * n_columns / 80) else: - cscale("viridis", ncols=nlevs) - plotvars.cscale_flag = 0 - else: - cscale(plotvars.cs_user, ncols=nlevs) - - ################## - # Map contour plot - ################## - if ptype == 1: - if verbose: - print("con - making a map plot") - - # Open a new plot if necessary - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Reset the stored mapping - if plotvars.user_mapset == 0: - plotvars.lonmin = -180 - plotvars.lonmax = 180 - plotvars.latmin = -90 - plotvars.latmax = 90 - - # Set up mapping - mylonmin = np.nanmin(x) - mylonmax = np.nanmax(x) - mylatmin = np.nanmin(y) - mylatmax = np.nanmax(y) - lonrange = mylonmax - mylonmin - latrange = mylatmax - mylatmin + label_skip = 1 + + if label_skip <= 1: + return [str(level) for level in levels] + + if self._includes_zero: + zero_positions = np.where(np.asarray(levels) == 0)[0] + if np.size(zero_positions) > 0: + zero_pos = int(zero_positions[0]) + labels = [levels[zero_pos]] + + i = zero_pos + label_skip + while i <= len(levels) - 1: + labels = list(np.append(labels, levels[i])) + i += label_skip + + i = zero_pos - label_skip + if i >= 0: + while i >= 0: + labels = list(np.append([levels[i]], labels)) + i -= label_skip + + return self._expand_skipped_labels(labels, label_skip) + + labels = [levels[0]] + i = int(label_skip) + while i <= len(levels) - 1: + labels = list(np.append(labels, levels[i])) + i += label_skip + + return self._expand_skipped_labels(labels, label_skip) + + @staticmethod + def _expand_skipped_labels(labels: list[Any], label_skip: int) -> list[str]: + """Interleave skipped colour-bar labels with blank placeholders.""" + clabels: list[str] = [] + for label in labels: + clabels.append(str(label)) + if label_skip > 1: + clabels.extend([""] * (label_skip - 1)) + + return clabels + + +class ContourRenderer: + """Base renderer for shared contour drawing responsibilities.""" + + def __init__( + self, + layout: ContourLayout, + data: ContourData, + colour_scale: ColourScale, + ): + self.layout = layout + self.data = data + self.cs = colour_scale + self.frame_artists: list[Any] = [] + + def render_filled( + self, alpha: float, zorder: int, transform_first: bool | None + ) -> None: + """Render filled contours. Subclass implements plot-type-specific logic.""" + _ = (alpha, zorder, transform_first) + + def render_blockfill( + self, fast: bool | None, alpha: float, zorder: int + ) -> None: + """Render block-filled contours.""" + _ = (fast, alpha, zorder) + + def render_lines( + self, + colors: Any, + linewidths: Any, + linestyles: Any, + line_labels: bool, + zero_thick: bool | int, + zorder: int = 1, + ) -> None: + """Render contour lines and labels.""" + _ = (colors, linewidths, linestyles, line_labels, zero_thick, zorder) + + def render_colorbar( + self, + orientation: str | None, + shrink: float | None, + position: list[float] | None, + fraction: float | None, + thick: float | None, + anchor: float | None, + fontsize: int | None, + fontweight: str | None, + text_up_down: bool, + text_down_up: bool, + drawedges: bool, + labels: list[str] | None = None, + title: str | None = None, + ) -> Any: + """Render colorbar for filled contours.""" + _ = ( + orientation, + shrink, + position, + fraction, + thick, + anchor, + fontsize, + fontweight, + text_up_down, + text_down_up, + drawedges, + labels, + title, + ) + return None - if lonrange > 360.0: - mylonmax = mylonmin + 360.0 - lonrange = 360.0 - if (lonrange > 350 and latrange > 160) or plotvars.user_mapset == 1: - _set_map() - else: - mapset( - lonmin=mylonmin, - lonmax=mylonmax, - latmin=mylatmin, - latmax=mylatmax, - user_mapset=0, - resolution=resolution_orig, - ) +class MapContourRenderer(ContourRenderer): + """Map renderer specialization for ptype == 1 (lon-lat plots). + + Handles Cartopy transformations, coastlines, and polar projections. + """ - _set_map() - - mymap = plotvars.mymap - user_mapset = plotvars.user_mapset - - lonrange = np.nanmax(x) - np.nanmin(x) - - if not blockfill_ugrid and not blockfill_2d: - if not irregular: - if lonrange > 350 and np.ndim(y) == 1: - # Add cyclic information if missing. - if lonrange < 360: - # field, x = cartopy_util.add_cyclic_point(field, x) - # Call add_cyclic_point it spacing is regular - x_regular = True - xspacing = x[1] - x[0] - for ix in np.arange(len(x) - 1): - if x[ix + 1] - x[ix] != xspacing: - x_regular = False - if x_regular: - field, x = add_cyclic(field, x) - - lonrange = np.nanmax(x) - np.nanmin(x) - - # cartopy line drawing fix - if x[-1] - x[0] == 360.0: - x[-1] = x[-1] + 0.001 - - # Shift grid if needed - if plotvars.lonmin < np.nanmin(x): - # Cartopy feature at version 0.20.0 - # -360 to 0 creates strange contours - vers = cartopy.__version__.split(".") - val = int(vers[0] + vers[1]) - if val < 20: - x = x - 360 - if plotvars.lonmin > np.nanmax(x): - x = x + 360 - elif not orca: - # Get the irregular data within the map coordinates - # Matplotlib tricontour cannot plot missing data so we need to - # split the missing data into a separate field to deal with - # this - - field_modified = deepcopy(field) - pts_nan = np.where(np.isnan(field_modified)) - field_modified[pts_nan] = -1e30 - - field_irregular, lons_irregular, lats_irregular = ( - irregular_window(field_modified, x, y) - ) - # pts_real = np.where(np.isfinite(field_irregular)) - pts_real = np.where(field_irregular > -1e29) - pts_nan = np.where(field_irregular < -1e29) - - field_irregular_nan = [] - lons_irregular_nan = [] - lats_irregular_nan = [] - if np.size(pts_nan) > 0: - field_irregular_nan = deepcopy(field_irregular) - lons_irregular_nan = deepcopy(lons_irregular) - lats_irregular_nan = deepcopy(lats_irregular) - field_irregular_nan[:] = 0 - field_irregular_nan[pts_nan] = 1 - - field_irregular_real = deepcopy(field_irregular[pts_real]) - lons_irregular_real = deepcopy(lons_irregular[pts_real]) - lats_irregular_real = deepcopy(lats_irregular[pts_real]) - - if not irregular: - # Flip latitudes and field if latitudes are in descending order - if np.ndim(y) == 1: - if y[0] > y[-1]: - y = y[::-1] - field = np.flipud(field) - - # Plotting a sub-area of the grid produces stray contour labels - # in polar plots. Subsample the latitudes to remove this problem - - if plotvars.proj == "npstere" and np.ndim(y) == 1: - if not blockfill_ugrid and not blockfill_2d: - if irregular: - pts = np.where(lats_irregular > plotvars.boundinglat - 5) - pts = np.array(pts).flatten() - lons_irregular_real = lons_irregular_real[pts] - lats_irregular_real = lats_irregular_real[pts] - field_irregular_real = field_irregular_real[pts] - else: - myypos = find_pos_in_array( - vals=y, val=plotvars.boundinglat - ) - if myypos != -1: - y = y[myypos:] - field = field[myypos:, :] - - if plotvars.proj == "spstere" and np.ndim(y) == 1: - if not blockfill_ugrid and not blockfill_2d: - if irregular: - pts = np.where( - lats_irregular_real < plotvars.boundinglat + 5 - ) - lons_irregular_real = lons_irregular_real[pts] - lats_irregular_real = lats_irregular_real[pts] - field_irregular_real = field_irregular_real[pts] - else: - myypos = find_pos_in_array( - vals=y, val=plotvars.boundinglat, above=True - ) - if myypos != -1: - y = y[0 : myypos + 1] - field = field[0 : myypos + 1, :] - - # Set the longitudes and latitudes - lons, lats = x, y - - # Set the plot limits - if lonrange > 350: - gset( - xmin=plotvars.lonmin, - xmax=plotvars.lonmax, - ymin=plotvars.latmin, - ymax=plotvars.latmax, - user_gset=0, + def render_filled( + self, alpha: float, zorder: int, transform_first: bool | None + ) -> None: + """Render filled contours on a map with Cartopy.""" + if self.data.x is None or self.data.y is None or self.data.levels is None: + return + + lons = self.data.x + lats = self.data.y + + if self.data.irregular: + field, lons, lats = _window_irregular_map_data( + self.data.field * self.data.fmult, + lons, + lats, ) - else: - if user_mapset == 1: - gset( - xmin=plotvars.lonmin, - xmax=plotvars.lonmax, - ymin=plotvars.latmin, - ymax=plotvars.latmax, - user_gset=0, - ) - else: - gset( - xmin=np.nanmin(lons), - xmax=np.nanmax(lons), - ymin=np.nanmin(lats), - ymax=np.nanmax(lats), - user_gset=0, - ) - - # Filled contours - if fill: - if verbose: - print("con - adding filled contours") - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # from 1 to ncols-2. The colours for the colour bar extensions - # are then changed on the colorbar and plot after the plot is made - colmap = _cscale_get_map() - - cmap = matplotlib.colors.ListedColormap(colmap) - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "both" - ): - cmap.set_under(plotvars.cs[0]) - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "both" - ): - cmap.set_over(plotvars.cs[-1]) - - # For fast map contours add transform_first=True to contourf - # command and make lons and lats 2D - if ( - transform_first is None - and np.ndim(lons) == 1 - and np.ndim(lats) == 1 - ): - if np.size(lons) >= 400: - transform_first = True + runtime = plotvars.runtime + scale = plotvars.scale + cmap = self.cs.get_cmap() + runtime.image = runtime.mymap.tricontourf( + lons, + lats, + field, + self.data.levels, + extend=scale.levels_extend, + cmap=cmap, + norm=scale.norm, + alpha=alpha, + transform=ccrs.PlateCarree(), + zorder=zorder, + ) + if hasattr(runtime.image, "collections"): + self.frame_artists.extend(list(runtime.image.collections)) + return - # Fast map contours are also needed when clevs is a integer - if ( - isinstance(clevs, int) - and plotvars.plot_type == 1 - and plotvars.proj == "cyl" - ): + if transform_first is None and np.ndim(lons) == 1 and np.ndim(lats) == 1: + if np.size(lons) >= 400: transform_first = True - if transform_first: - if np.ndim(lons) == 1 and np.ndim(lats) == 1: - lons, lats = np.meshgrid(lons, lats) - - # Filled colour contours - if not irregular or orca is True: - plotvars.image = mymap.contourf( - lons, - lats, - field * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, - transform=ccrs.PlateCarree(), - zorder=zorder, - transform_first=transform_first, - ) - - else: - if np.size(field_irregular_real) > 0: - plotvars.image = mymap.tricontourf( - lons_irregular_real, - lats_irregular_real, - field_irregular_real * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, - transform=ccrs.PlateCarree(), - zorder=zorder, - ) - - # Block fill - if blockfill and not blockfill_ugrid: - if verbose: - print("con - adding blockfill") - - two_d = False - if np.ndim(x) == 2 and np.ndim(y) == 2: - two_d = True - - if isinstance(f, cf.Field): - - if f.ref( - "grid_mapping_name:transverse_mercator", default=False - ): - # Special case for transverse mercator - _bfill( - f=f.squeeze() * fmult, - x=x, - y=y, - clevs=clevs, - lonlat=False, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - - # elif orca: - elif two_d: - _bfill( - f=field * fmult, - x=x, - y=y, - clevs=clevs, - lonlat=False, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) + if transform_first and np.ndim(lons) == 1 and np.ndim(lats) == 1: + lons, lats = np.meshgrid(lons, lats) + + cmap = self.cs.get_cmap() + runtime = plotvars.runtime + scale = plotvars.scale + runtime.image = runtime.mymap.contourf( + lons, + lats, + self.data.field * self.data.fmult, + self.data.levels, + extend=scale.levels_extend, + cmap=cmap, + norm=scale.norm, + alpha=alpha, + transform=ccrs.PlateCarree(), + zorder=zorder, + transform_first=transform_first, + ) + if hasattr(runtime.image, "collections"): + self.frame_artists.extend(list(runtime.image.collections)) - else: - - if f.coord("X").has_bounds() and f.coord("Y").has_bounds(): - xpts = np.squeeze(f.coord("X").bounds.array[:, 0]) - ypts = np.squeeze(f.coord("Y").bounds.array[:, 0]) - # Add last longitude point - xpts = np.append( - xpts, f.coord("X").bounds.array[-1, 1] - ) - # Add last latitude point - ypts = np.append( - ypts, f.coord("Y").bounds.array[-1, 1] - ) - - _bfill( - f=field_orig * fmult, - x=xpts, - y=ypts, - clevs=clevs, - lonlat=True, - bound=1, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - else: - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=True, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) + def render_blockfill( + self, fast: bool | None, alpha: float, zorder: int + ) -> None: + """Render block-filled contours on a map.""" + if self.data.levels is None: + return - else: - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=True, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) + if self.data.is_ugrid: + if ( + self.data.face_lons is None + or self.data.face_lats is None + or self.data.face_connectivity is None + ): + return - # Block fill for irregular - if blockfill_ugrid and not blockfill_2d: - if verbose: - print("con - adding blockfill for irregular") _bfill_ugrid( - f=field_orig * fmult, - face_lons=face_lons_array, - face_lats=face_lats_array, - face_connectivity=face_connectivity_array, - clevs=clevs, + f=self.data.field * self.data.fmult, + face_lons=self.data.face_lons, + face_lats=self.data.face_lats, + face_connectivity=self.data.face_connectivity, + clevs=self.data.levels, alpha=alpha, zorder=zorder, ) + return + + if self.data.x is None or self.data.y is None: + return + + _bfill( + f=self.data.field * self.data.fmult, + x=self.data.x, + y=self.data.y, + clevs=self.data.levels, + bound=0, + alpha=alpha, + fast=fast, + zorder=zorder, + ) - # Contour lines and labels - if lines: - if verbose: - print("con - adding contour lines and labels") - - if not irregular or blockfill_2d or orca: - cs = mymap.contour( - lons, - lats, - field * fmult, - clevs, - colors=colors, - linewidths=linewidths, - linestyles=linestyles, - alpha=alpha, - transform=ccrs.PlateCarree(), - zorder=zorder, - ) - else: - cs = mymap.tricontour( - lons_irregular_real, - lats_irregular_real, - field_irregular_real * fmult, - clevs, - colors=colors, - linewidths=linewidths, - linestyles=linestyles, - alpha=alpha, - transform=ccrs.PlateCarree(), - zorder=zorder, - ) + def render_lines( + self, + colors: Any, + linewidths: Any, + linestyles: Any, + line_labels: bool, + zero_thick: bool | int, + zorder: int = 1, + ) -> None: + """Render contour lines on a map with Cartopy transform.""" + if self.data.x is None or self.data.y is None or self.data.levels is None: + return + + if self.data.irregular: + field, lons, lats = _window_irregular_map_data( + self.data.field * self.data.fmult, + self.data.x, + self.data.y, + ) + runtime = plotvars.runtime + dec = plotvars.decoration + cs = runtime.mymap.tricontour( + lons, + lats, + field, + self.data.levels, + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + alpha=1.0, + transform=ccrs.PlateCarree(), + zorder=zorder, + ) + if hasattr(cs, "collections"): + self.frame_artists.extend(list(cs.collections)) - if line_labels and not isinstance(clevs, int): - nd = ndecs(clevs) + if line_labels and not isinstance(self.data.levels, int): + nd = utility.ndecs(self.data.levels) fmt = "%d" if nd != 0: fmt = "%1." + str(nd) + "f" - plotvars.plot.clabel( + runtime.plot.clabel( cs, - levels=clevs, + levels=self.data.levels, fmt=fmt, - zorder=zorder, - colors=colors, - fontsize=text_fontsize, - ) - - # Thick zero contour line - if zero_thick: - cs = mymap.contour( - lons, - lats, - field * fmult, - [-1e-32, 0], colors=colors, - linewidths=zero_thick, - linestyles=linestyles, - alpha=alpha, - transform=ccrs.PlateCarree(), + fontsize=dec.text_fontsize, zorder=zorder, ) - - # Add a irregular mask if there is one - if irregular and not blockfill_ugrid and not orca and not blockfill_2d: - if np.size(field_irregular_nan) > 0: - cmap_white = matplotlib.colors.ListedColormap([1.0, 1.0, 1.0]) - mymap.tricontourf( - lons_irregular_nan, - lats_irregular_nan, - field_irregular_nan, - [0.5, 1.5], - extend="neither", - cmap=cmap_white, - norm=plotvars.norm, - alpha=alpha, - transform=ccrs.PlateCarree(), - zorder=zorder, - ) - - # Axes - _plot_map_axes( - axes=axes, - xaxis=xaxis, - yaxis=yaxis, - xticks=xticks, - xticklabels=xticklabels, - yticks=yticks, - yticklabels=yticklabels, - user_xlabel=user_xlabel, - user_ylabel=user_ylabel, - verbose=verbose, - ) - - # Coastlines and features - feature = cfeature.NaturalEarthFeature( - name="land", - category="physical", - scale=plotvars.resolution, - facecolor="none", - ) - mymap.add_feature( - feature, - edgecolor=continent_color, - linewidth=continent_thickness, - linestyle=continent_linestyle, + return + + runtime = plotvars.runtime + dec = plotvars.decoration + cs = runtime.mymap.contour( + self.data.x, + self.data.y, + self.data.field * self.data.fmult, + self.data.levels, + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + alpha=1.0, + transform=ccrs.PlateCarree(), zorder=zorder, ) - - if ocean_color is not None: - mymap.add_feature( - cfeature.OCEAN, - edgecolor="face", - facecolor=ocean_color, - zorder=plotvars.feature_zorder, - ) - if land_color is not None: - mymap.add_feature( - cfeature.LAND, - edgecolor="face", - facecolor=land_color, - zorder=plotvars.feature_zorder, - ) - if lake_color is not None: - mymap.add_feature( - cfeature.LAKES, - edgecolor="face", - facecolor=lake_color, - zorder=plotvars.feature_zorder, + if hasattr(cs, "collections"): + self.frame_artists.extend(list(cs.collections)) + + if line_labels and not isinstance(self.data.levels, int): + nd = utility.ndecs(self.data.levels) + fmt = "%d" + if nd != 0: + fmt = "%1." + str(nd) + "f" + runtime.plot.clabel( + cs, + levels=self.data.levels, + fmt=fmt, + colors=colors, + fontsize=dec.text_fontsize, + zorder=zorder, ) - if grid: - map_grid() - - # Title - if title != "": - _map_title(title) - - # Titles for dimensions - if titles: - _dim_titles(title=title_dims) - - # Color bar - if colorbar: - cbar( - labels=cbar_labels, - orientation=cb_orient, - position=colorbar_position, - shrink=colorbar_shrink, - title=colorbar_title, - fontsize=colorbar_fontsize, - fontweight=colorbar_fontweight, - text_up_down=colorbar_text_up_down, - text_down_up=colorbar_text_down_up, - drawedges=colorbar_drawedges, - fraction=colorbar_fraction, - thick=colorbar_thick, - anchor=colorbar_anchor, - levs=clevs, - verbose=verbose, + if zero_thick: + cs0 = runtime.mymap.contour( + self.data.x, + self.data.y, + self.data.field * self.data.fmult, + [-1e-32, 0], + colors=colors, + linewidths=zero_thick, + linestyles=linestyles, + alpha=1.0, + transform=ccrs.PlateCarree(), + zorder=zorder, ) + if hasattr(cs0, "collections"): + self.frame_artists.extend(list(cs0.collections)) + + def render_colorbar( + self, + orientation: str | None, + shrink: float | None, + position: list[float] | None, + fraction: float | None, + thick: float | None, + anchor: float | None, + fontsize: int | None, + fontweight: str | None, + text_up_down: bool, + text_down_up: bool, + drawedges: bool, + labels: list[str] | None = None, + title: str | None = None, + ) -> Any: + """Render colorbar for map contour plots.""" + if self.data.levels is None: + return None + + return cbar( + labels=labels, + orientation=orientation, + position=position, + shrink=shrink, + title=title or self.data.colorbar_title, + fontsize=fontsize, + fontweight=fontweight, + text_up_down=text_up_down, + text_down_up=text_down_up, + drawedges=drawedges, + fraction=fraction, + thick=thick, + levs=self.data.levels, + anchor=anchor, + ) - # Reset plot limits if not a user plot - if plotvars.user_gset == 0: - gset() - - ################################################ - # Latitude, longitude or time vs Z contour plots - ################################################ - if ptype == 2 or ptype == 3 or ptype == 7: - - if verbose: - if ptype == 2: - print("con - making a latitude-pressure plot") - if ptype == 3: - print("con - making a longitude-pressure plot") - if ptype == 7: - print("con - making a time-pressure plot") - - # Work out which way is up - positive = None - myz = find_z(f) - - if isinstance(f, cf.Field) and well_formed: - if hasattr(f.construct(myz), "positive"): - positive = f.construct(myz).positive - else: - errstr = ( - "\ncf-plot - data error \n" - "data needs a vertical coordinate direction" - " as required in CF data conventions" - "\nMaking a contour plot assuming positive is down\n\n" - "If this is incorrect the data needs to be modified to \n" - "include a correct value for the direction attribute\n" - "such as in f.coord('Z').positive='down'\n\n" - ) - print(errstr) - positive = "down" - else: - positive = "down" - if "theta" in ylabel.split(" "): - positive = "up" - if "height" in ylabel.split(" "): - positive = "up" - - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Use gset parameter of ylog if user has set this - if plotvars.ylog is True or plotvars.ylog == 1: - ylog = True - - # Set plot limits - user_gset = plotvars.user_gset - if user_gset == 0: - # Program selected data plot limits - xmin = np.nanmin(x) - if xmin < -80 and xmin >= -90: - xmin = -90 - xmax = np.nanmax(x) - if xmax > 80 and xmax <= 90: - xmax = 90 - - if positive == "down": - ymin = np.nanmax(y) - ymax = np.nanmin(y) - if ymax < 10: - ymax = 0 - else: - ymin = np.nanmin(y) - ymax = np.nanmax(y) - - else: - # Use user specified plot limits - xmin = plotvars.xmin - xmax = plotvars.xmax - ymin = plotvars.ymin - ymax = plotvars.ymax - - ystep = 100 - myrange = abs(ymax - ymin) - - if myrange < 1: - ystep = abs(ymax - ymin) / 10.0 - if abs(ymax - ymin) > 1: - ystep = 1 - if abs(ymax - ymin) > 10: - ystep = 10 - if abs(ymax - ymin) > 100: - ystep = 100 - if abs(ymax - ymin) > 1000: - ystep = 200 - if abs(ymax - ymin) > 2000: - ystep = 500 - if abs(ymax - ymin) > 5000: - ystep = 1000 - if abs(ymax - ymin) > 15000: - ystep = 5000 - - # Work out ticks and tick labels - if ylog is False or ylog == 0: - heightticks = _gvals( - dmin=min(ymin, ymax), - dmax=max(ymin, ymax), - mystep=ystep, - mod=False, - )[0] - - if myrange < 1 and myrange > 0.1: - heightticks = np.arange(10) / 10.0 - else: - heightticks = [] - for tick in 1000, 100, 10, 1: - if tick >= min(ymin, ymax) and tick <= max(ymin, ymax): - heightticks.append(tick) - heightlabels = heightticks - - if axes: - if xaxis: - if xticks is not None: - if xticklabels is None: - xticklabels = xticks - else: - xticks = [100000000] - xticklabels = xticks - xlabel = "" - - if yaxis: - if yticks is not None: - heightticks = yticks - heightlabels = yticks - if yticklabels is not None: - heightlabels = yticklabels - else: - heightticks = [100000000] - ylabel = "" - - else: - xticks = [100000000] - xticklabels = xticks - heightticks = [100000000] - heightlabels = heightticks - xlabel = "" - ylabel = "" - - if yticks is None: - yticks = heightticks - yticklabels = heightlabels - - # Time - height contour plot - if ptype == 7: - if isinstance(f, cf.Field): - if plotvars.user_gset == 0: - tmin = f.construct("T").dtarray[0] - tmax = f.construct("T").dtarray[-1] - else: - # Use user set values if present - tmin = plotvars.xmin - tmax = plotvars.xmax - - ref_time = f.construct("T").units - ref_calendar = f.construct("T").calendar - time_units = cf.Units(ref_time, ref_calendar) - t = cf.Data(cf.dt(tmin), units=time_units) - xmin = t.array - t = cf.Data(cf.dt(tmax), units=time_units) - xmax = t.array - - if xticks is None and xaxis: - if ptype == 2: - xticks, xticklabels = _mapaxis( - min=xmin, max=xmax, type=2 - ) # lat-pressure - if ptype == 3: - xticks, xticklabels = _mapaxis( - min=xmin, max=xmax, type=1 - ) # lon-pressure - - if ptype == 7: - # time-pressure - if isinstance(f, cf.Field): - - # Change plotvars.xmin and plotvars.xmax from a date string - # to a number - ref_time = f.construct("T").units - ref_calendar = f.construct("T").calendar - time_units = cf.Units(ref_time, ref_calendar) - - t = cf.Data(cf.dt(tmin), units=time_units) - xmin = t.array - t = cf.Data(cf.dt(tmax), units=time_units) - xmax = t.array - - taxis = cf.Data( - [cf.dt(tmin), cf.dt(tmax)], units=time_units - ) - time_ticks, time_labels, tlabel = _timeaxis(taxis) - - # Use user supplied labels if present - if user_xlabel is None: - xlabel = tlabel - if xticks is None: - xticks = time_ticks - if xticklabels is None: - xticklabels = time_labels - - else: - errstr = ( - "\nNot a CF field\nPlease use ptype=0 and " - "specify axis labels manually\n" - ) - raise Warning(errstr) - - # Set plot limits - if ylog is False or ylog == 0: - gset( - xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, user_gset=user_gset - ) - else: - if ymax == 0: - ymax = 1 # Avoid zero in a log plot - gset( - xmin=xmin, - xmax=xmax, - ymin=ymin, - ymax=ymax, - ylog=True, - user_gset=user_gset, - ) +class XYContourRenderer(ContourRenderer): + """Cartesian renderer specialization for non-map contour plots. + + Handles ptypes 0, 2-7 (simple XY, lat-height, lon-height, Hovmuller, rotated). + """ - # Label axes - axes_plot( - xticks=xticks, - xticklabels=xticklabels, - yticks=heightticks, - yticklabels=heightlabels, - xlabel=xlabel, - ylabel=ylabel, + def render_filled( + self, alpha: float, zorder: int, transform_first: bool | None + ) -> None: + """Render filled contours in Cartesian space.""" + _ = transform_first + if self.data.x is None or self.data.y is None or self.data.levels is None: + return + + cmap = self.cs.get_cmap() + runtime = plotvars.runtime + scale = plotvars.scale + runtime.image = runtime.plot.contourf( + self.data.x, + self.data.y, + self.data.field * self.data.fmult, + self.data.levels, + extend=scale.levels_extend, + cmap=cmap, + norm=scale.norm, + alpha=alpha, + zorder=zorder, ) - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # from 1 to ncols-2. The colours for the colour bar extensions are - # then changed on the colorbar and plot after the plot is made - colmap = _cscale_get_map() + def render_blockfill( + self, fast: bool | None, alpha: float, zorder: int + ) -> None: + """Render block-filled contours in Cartesian space.""" + if self.data.x is None or self.data.y is None or self.data.levels is None: + return + + _bfill( + f=self.data.field * self.data.fmult, + x=self.data.x, + y=self.data.y, + clevs=self.data.levels, + bound=0, + alpha=alpha, + fast=fast, + zorder=zorder, + ) - # Filled contours - if fill: - colmap = _cscale_get_map() - cmap = matplotlib.colors.ListedColormap(colmap) - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "both" - ): - cmap.set_under(plotvars.cs[0]) - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "both" - ): - cmap.set_over(plotvars.cs[-1]) - - plotvars.image = plotvars.plot.contourf( - x, - y, - field * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, + def render_lines( + self, + colors: Any, + linewidths: Any, + linestyles: Any, + line_labels: bool, + zero_thick: bool | int, + zorder: int = 1, + ) -> None: + """Render contour lines in Cartesian space.""" + if self.data.x is None or self.data.y is None or self.data.levels is None: + return + + runtime = plotvars.runtime + dec = plotvars.decoration + cs = runtime.plot.contour( + self.data.x, + self.data.y, + self.data.field * self.data.fmult, + self.data.levels, + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + zorder=zorder, + ) + if line_labels and not isinstance(self.data.levels, int): + nd = utility.ndecs(self.data.levels) + fmt = "%d" + if nd != 0: + fmt = "%1." + str(nd) + "f" + runtime.plot.clabel( + cs, + fmt=fmt, + colors=colors, + fontsize=dec.text_fontsize, zorder=zorder, ) - # Block fill - if blockfill: - if isinstance(f, cf.Field): - - hasbounds = True - - if ptype == 2: - if f.coord("Y").has_bounds() and f.coord("Z").has_bounds(): - xpts = np.squeeze(f.coord("Y").bounds.array)[:, 0] - xpts = np.append( - xpts, f.coord("Y").bounds.array[-1, 1] - ) - ypts = np.squeeze(f.coord("Z").bounds.array)[:, 0] - ypts = np.append( - ypts, f.coord("Z").bounds.array[-1, 1] - ) - else: - hasbounds = False - - if ptype == 3: - if f.coord("X").has_bounds() and f.coord("Z").has_bounds(): - xpts = np.squeeze(f.coord("X").bounds.array)[:, 0] - xpts = np.append( - xpts, f.coord("X").bounds.array[-1, 1] - ) - # Use 'noqa' to prevent PEP8 E501 being raised due to - # line length being too long. Can't prevent this - # straightforwardly given function name of that length. - ypts = np.squeeAllTrop_UpStrat_Eq_Total_AllWN_Timeseries_2ze( # noqa: E501 - f.coord("Z").bounds.array - )[ - :, 0 - ] - ypts = np.append( - xpts, f.coord("Z").bounds.array[-1, 1] - ) - else: - hasbounds = False - - if ptype == 7: - if f.coord("T").has_bounds() and f.coord("Z").has_bounds(): - xpts = np.squeeze(f.coord("T").bounds.array)[:, 0] - xpts = np.append( - xpts, f.coord("T").bounds.array[-1, 1] - ) - ypts = np.squeeze(f.coord("Z").bounds.array)[:, 0] - ypts = np.append( - xpts, f.coord("Z").bounds.array[-1, 1] - ) - else: - hasbounds = False - - if hasbounds: - _bfill( - f=field_orig * fmult, - x=xpts, - y=ypts, - clevs=clevs, - lonlat=False, - bound=1, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - else: - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - - else: - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - - # Contour lines and labels - if lines: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - clevs, + if zero_thick: + runtime.plot.contour( + self.data.x, + self.data.y, + self.data.field * self.data.fmult, + [-1e-32, 0], colors=colors, - linewidths=linewidths, + linewidths=zero_thick, linestyles=linestyles, + alpha=1.0, zorder=zorder, ) - if line_labels and not isinstance(clevs, int): - nd = ndecs(clevs) - fmt = "%d" - if nd != 0: - fmt = "%1." + str(nd) + "f" - plotvars.plot.clabel( - cs, - fmt=fmt, - colors=colors, - zorder=zorder, - fontsize=text_fontsize, - ) - # Thick zero contour line - if zero_thick: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - [-1e-32, 0], - colors=colors, - linewidths=zero_thick, - linestyles=linestyles, - alpha=alpha, - zorder=zorder, - ) + def render_colorbar( + self, + orientation: str | None, + shrink: float | None, + position: list[float] | None, + fraction: float | None, + thick: float | None, + anchor: float | None, + fontsize: int | None, + fontweight: str | None, + text_up_down: bool, + text_down_up: bool, + drawedges: bool, + labels: list[str] | None = None, + title: str | None = None, + ) -> Any: + """Render colorbar for Cartesian contour plots.""" + if self.data.levels is None: + return None + + return cbar( + labels=labels, + orientation=orientation, + position=position, + shrink=shrink, + title=title or self.data.colorbar_title, + fontsize=fontsize, + fontweight=fontweight, + text_up_down=text_up_down, + text_down_up=text_down_up, + drawedges=drawedges, + fraction=fraction, + thick=thick, + levs=self.data.levels, + anchor=anchor, + ) - # Titles for dimensions - if titles: - _dim_titles(title=title_dims) - - # Color bar - if colorbar: - cbar( - labels=cbar_labels, - orientation=cb_orient, - position=colorbar_position, - shrink=colorbar_shrink, - title=colorbar_title, - fontsize=colorbar_fontsize, - fontweight=colorbar_fontweight, - text_up_down=colorbar_text_up_down, - text_down_up=colorbar_text_down_up, - drawedges=colorbar_drawedges, - fraction=colorbar_fraction, - thick=colorbar_thick, - levs=clevs, - anchor=colorbar_anchor, - verbose=verbose, - ) - # Title - plotvars.plot.set_title( - title, y=1.03, fontsize=title_fontsize, fontweight=title_fontweight +def levs(min=None, max=None, step=None, manual=None, extend="both"): + """Set or clear the contour levels stored in shared plotting state.""" + scale = plotvars.scale + runtime = plotvars.runtime + + if all(val is not None for val in [min, max]) and step is None: + print( + "\ncfp.levs error: when the min and max are specified " + "a step also needs to be specified\n" + ) + return + + if all(val is None for val in [min, max, step, manual]): + scale.levels = None + scale.levels_min = None + scale.levels_max = None + scale.levels_step = None + scale.levels_extend = "both" + scale.norm = None + runtime.user_levs = 0 + return + + if manual is not None: + scale.levels = np.array(manual) + scale.levels_min = None + scale.levels_max = None + scale.levels_step = None + ncolors = np.size(scale.levels) + if extend == "both" or extend == "max": + ncolors = ncolors - 1 + scale.norm = matplotlib.colors.BoundaryNorm( + boundaries=scale.levels, ncolors=ncolors ) + runtime.user_levs = 1 + else: + if all(val is not None for val in [min, max, step]): + scale.levels_min = min + scale.levels_max = max + scale.levels_step = step + scale.norm = None + if all(isinstance(item, int) for item in [min, max, step]): + lstep = step * 1e-10 + levs_arr = np.arange(min, max + lstep, step, dtype=np.float64) + levs_arr = ((levs_arr * 1e10).astype(np.int64)).astype(np.float64) + levs_arr = (levs_arr / 1e10).astype(np.int64) + scale.levels = levs_arr + else: + lstep = step * 1e-10 + levs_arr = np.arange(min, max + lstep, step, dtype=np.float64) + levs_arr = (levs_arr * 1e10).astype(np.int64).astype(np.float64) + levs_arr = levs_arr / 1e10 + scale.levels = levs_arr + runtime.user_levs = 1 + + for pt in np.arange(np.size(scale.levels)): + ndecs = str(scale.levels[pt])[::-1].find(".") + if ndecs > 7: + scale.levels[pt] = round(scale.levels[pt], 7) + + if step is not None and all(val is None for val in [min, max]): + runtime.user_levs = 0 + scale.levels = None + scale.levels_step = step + + if extend not in ["neither", "min", "max", "both"]: + errstr = "\n\n extend must be one of 'neither', 'min', 'max', 'both'\n" + raise TypeError(errstr) + scale.levels_extend = extend + + +def _can_use_new_xy_path(f: Any, kwargs: dict[str, Any]) -> bool: + """Return True when the new XY renderer can safely handle this call.""" + unsupported = ( + "irregular", + "orca", + "swap_axes", + "xlog", + ) + for key in unsupported: + if kwargs.get(key): + return False + + face_kwargs_present = any( + kwargs.get(key) is not None + for key in ("face_lons", "face_lats", "face_connectivity") + ) + if face_kwargs_present and not (isinstance(f, cf.Field) and kwargs.get("blockfill")): + return False + + ptype = kwargs.get("ptype", 0) + if not isinstance(f, cf.Field) and ptype not in (0, 1, None): + return False + + return True + + +def _clear_animation_artists(plotvars: Any) -> None: + """Remove artists from previous animation frame if present.""" + artists = getattr(plotvars.runtime, "_contour_animation_artists", None) + if not artists: + return + for artist in artists: + try: + artist.remove() + except Exception: + continue + plotvars.runtime._contour_animation_artists = [] + _clear_animation_title_artist(plotvars) + + +def _clear_animation_title_artist(plotvars: Any) -> None: + """Remove animation title artist from previous frame if present.""" + title_artist = getattr(plotvars.runtime, "_contour_animation_title_artist", None) + if title_artist is None: + return + try: + title_artist.remove() + except Exception: + pass + plotvars.runtime._contour_animation_title_artist = None + + +def _clear_animation_colorbar(plotvars: Any) -> None: + """Remove animation colorbar from previous frame if present.""" + colorbar_artist = getattr(plotvars.runtime, "_contour_animation_colorbar", None) + if colorbar_artist is None: + return + + try: + colorbar_artist.remove() + except Exception: + ax = getattr(colorbar_artist, "ax", None) + if ax is not None: + try: + ax.remove() + except Exception: + pass + + plotvars.runtime._contour_animation_colorbar = None + + +def _ptype_axes(ptype: int | None) -> set[str]: + """Return logical axes used by each contour plot type.""" + mapping: dict[int, set[str]] = { + 1: {"X", "Y"}, + 2: {"Y", "Z"}, + 3: {"X", "Z"}, + 4: {"X", "T"}, + 5: {"Y", "T"}, + 6: {"X", "Y"}, + 7: {"T", "Z"}, + } + if ptype is None: + return set() + return mapping.get(int(ptype), set()) + + +def _infer_animation_axis(f: Any, axis_spec: Any, ptype: int | None) -> str | None: + """Infer animation axis from a field and axis specification. + + Parameters + ---------- + f : Any + Input field. + axis_spec : Any + User axis selection. Supported values are "auto", "T", "Z", "Y", "X". + """ + if not isinstance(f, cf.Field): + return None + + if axis_spec is None: + return None + + axis_text = str(axis_spec).strip() + if axis_text == "": + return None + + axis_upper = axis_text.upper() + valid_axes = ("T", "Z", "Y", "X") + + if axis_upper != "AUTO": + if axis_upper in valid_axes and f.has_construct(axis_upper): + return axis_upper + return None + + try: + dims = utility.find_dim_names(f) + except Exception: + dims = [] + + ptype_axes = _ptype_axes(ptype) + + # For known non-zero ptypes, infer frame axis as a singleton axis that + # is not part of the selected ptype axes. + if ptype not in (None, 0) and ptype_axes: + for axis in ("T", "Z", "Y", "X"): + if axis not in dims or axis in ptype_axes: + continue + try: + values = np.asanyarray(f.construct(axis).array) + except Exception: + continue + if values.size == 1: + return axis + return None + + # ptype=0 fallback: prefer temporal slices first, then vertical, + # then horizontal singleton axes. + for axis in ("T", "Z", "Y", "X"): + if axis not in dims: + continue + try: + values = np.asanyarray(f.construct(axis).array) + except Exception: + continue + if values.size == 1: + return axis - # Reset plot limits to those supplied by the user - if user_gset == 1 and ptype == 7: - gset( - xmin=tmin, xmax=tmax, ymin=ymin, ymax=ymax, user_gset=user_gset - ) + return None - # reset plot limits if not a user plot - if plotvars.user_gset == 0: - gset() - - ######################## - # Hovmuller contour plot - ######################## - if ptype == 4 or ptype == 5: - if verbose: - print("con - making a Hovmuller plot") - yplotlabel = "Time" - if ptype == 4: - xplotlabel = "Longitude" - if ptype == 5: - xplotlabel = "Latitude" - user_gset = plotvars.user_gset - - # Time strings set to None initially - tmin = None - tmax = None - - # Set plot limits - if all( - val is not None - for val in [ - plotvars.xmin, - plotvars.xmax, - plotvars.ymin, - plotvars.ymax, - ] - ): - # Store time strings for later use - tmin = plotvars.ymin - tmax = plotvars.ymax - - # Check data has CF attributes needed - check_units = check_units = True - check_calendar = True - check_Units_reftime = True - if hasattr(f.construct("T"), "units") is False: - check_units = False - if hasattr(f.construct("T"), "calendar") is False: - check_calendar = False - if hasattr(f.construct("T"), "Units"): - if not hasattr(f.construct("T").Units, "reftime"): - check_Units_reftime = False - else: - check_Units_reftime = False - if False in [check_units, check_calendar, check_Units_reftime]: - print( - "\nThe required CF time information to make the plot " - "is not available please fix the following before " - "trying to plot again" - ) - if check_units is False: - print("Time axis missing: units") - if check_calendar is False: - print("Time axis missing: calendar") - if check_Units_reftime is False: - print("Time axis missing: Units.reftime") - return - # Change from date string in ymin and ymax to date as a float +def _animation_axis_value_text(f: cf.Field, axis: str) -> str | None: + """Return axis/value text used in animation titles.""" + axis_key = axis + if axis == "Z": + try: + axis_key = utility.find_z(f) + except Exception: + axis_key = axis - ref_time = f.construct("T").units - ref_calendar = f.construct("T").calendar + try: + construct = f.construct(axis_key) + except Exception: + return None - time_units = cf.Units(ref_time, ref_calendar) - t = cf.Data(cf.dt(plotvars.ymin), units=time_units) - ymin = t.array - t = cf.Data(cf.dt(plotvars.ymax), units=time_units) - ymax = t.array - xmin = plotvars.xmin - xmax = plotvars.xmax + try: + if axis == "T" and getattr(construct, "dtarray", None) is not None: + values = np.asanyarray(construct.dtarray) else: - xmin = np.nanmin(x) - xmax = np.nanmax(x) - ymin = np.nanmin(y) - ymax = np.nanmax(y) - - # Extract axis labels - if len(f.constructs("T")) > 1: - errstr = ( - "\n\nTime axis error - only one time axis allowed\n " - "Please list time axes with print(f.constructs())\n" - "and remove the ones not needed for a hovmuller plot \n" - "with f.del_construct('unwanted_time_axis')\n" - "before trying to plot again\n\n\n\n" + values = np.asanyarray(construct.array) + except Exception: + return None + + if values.size != 1: + return None + + value = values.reshape(-1)[0] + name, units = utility.cf_var_name_titles(f, axis_key) + axis_name = name or axis + units_text = f" {units}" if units else "" + + return f"{axis_name}: {value}{units_text}" + + +def _resolve_animation_title( + *, + f: Any, + base_title: str, + animation: bool, + animation_axis: Any, + ptype: int | None, + animation_title_template: str | None, +) -> str: + """Build final title text for animation frames.""" + if not animation: + return base_title + + axis = _infer_animation_axis(f, animation_axis, ptype) + if axis is None: + return base_title + + if not isinstance(f, cf.Field): + return base_title + + frame_text = _animation_axis_value_text(f, axis) + if not frame_text: + return base_title + + if animation_title_template: + try: + return str( + animation_title_template.format( + title=base_title, + frame=frame_text, + axis=axis, + ) ) - raise TypeError(errstr) - - time_ticks, time_labels, ylabel = _timeaxis(f.construct("T")) - - if ptype == 4: - lonlatticks, lonlatlabels = _mapaxis(min=xmin, max=xmax, type=1) - if ptype == 5: - lonlatticks, lonlatlabels = _mapaxis(min=xmin, max=xmax, type=2) - - if axes: - if xaxis: - if xticks is not None: - lonlatticks = xticks - lonlatlabels = xticks - if xticklabels is not None: - lonlatlabels = xticklabels - else: - lonlatticks = [100000000] - xlabel = "" - - if yaxis: - if yticks is not None: - timeticks = yticks - timelabels = yticks - if yticklabels is not None: - timelabels = yticklabels - else: - timeticks = [100000000] - ylabel = "" + except Exception: + pass - else: - timeticks = [100000000] - xplotlabel = "" - yplotlabel = "" - - if user_xlabel is not None: - xplotlabel = user_xlabel - if user_ylabel is not None: - yplotlabel = user_ylabel - - # Use the automatically generated labels if none are supplied - if ylabel is None: - yplotlabel = "time" - if np.size(time_ticks) > 0: - timeticks = time_ticks - if np.size(time_labels) > 0: - timelabels = time_labels - - # Swap axes if requested - if swap_axes: - x, y = y, x - field = np.flipud(np.rot90(field)) - xmin, ymin = ymin, xmin - xmax, ymax = ymax, xmax - xplotlabel, yplotlabel = yplotlabel, xplotlabel - lonlatticks, timeticks = timeticks, lonlatticks - lonlatlabels, timelabels = timelabels, lonlatlabels - - # Set plot limits - if plotvars.user_plot == 0: - gopen(user_plot=0) - gset(xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, user_gset=user_gset) - - # Revert to time strings if set - if all(val is not None for val in [tmin, tmax]): - plotvars.ymin = tmin - plotvars.ymax = tmax - - # Set and label axes - axes_plot( - xticks=lonlatticks, - xticklabels=lonlatlabels, - yticks=timeticks, - yticklabels=timelabels, - xlabel=xplotlabel, - ylabel=yplotlabel, - ) + if base_title: + return f"{base_title} | {frame_text}" + return frame_text - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # from 1 to ncols-2. The colours for the colour bar extensions are - # then changed on the colorbar and plot after the plot is made - colmap = _cscale_get_map() - # Filled contours - if fill: - colmap = _cscale_get_map() - cmap = matplotlib.colors.ListedColormap(colmap) - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "both" - ): - cmap.set_under(plotvars.cs[0]) - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "both" - ): - cmap.set_over(plotvars.cs[-1]) - - plotvars.image = plotvars.plot.contourf( - x, - y, - field * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, - zorder=zorder, - ) +def _field_has_ugrid_faces(f: cf.Field) -> bool: + """Return True when a CF field exposes face connectivity for UGRID plots.""" + try: + return bool(f.domain_topologies()) and f.domain_topology( + "cell:face", default=None + ) is not None + except Exception: + return False - # Block fill - if blockfill: - if isinstance(f, cf.Field): - if f.coord("X").has_bounds(): - if ptype == 4: - xpts = np.squeeze(f.coord("X").bounds.array)[:, 0] - xpts = np.append( - xpts, f.coord("X").bounds.array[-1, 1] - ) - if ptype == 5: - xpts = np.squeeze(f.coord("Y").bounds.array)[:, 0] - xpts = np.append( - xpts, f.coord("Y").bounds.array[-1, 1] - ) - ypts = np.squeeze(f.coord("T").bounds.array)[:, 0] - ypts = np.append(ypts, f.coord("T").bounds.array[-1, 1]) - if swap_axes: - xpts, ypts = ypts, xpts - field_orig = np.flipud(np.rot90(field_orig)) - - _bfill( - f=field_orig * fmult, - x=xpts, - y=ypts, - clevs=clevs, - lonlat=False, - bound=1, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - else: - if swap_axes: - x_orig, y_orig = y_orig, x_orig - field_orig = np.flipud(np.rot90(field_orig)) - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) - else: - if swap_axes: - x_orig, y_orig = y_orig, x_orig - field_orig = np.flipud(np.rot90(field_orig)) - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - ) +def _as_array(value: Any) -> np.ndarray: + """Convert a CF object or array-like to a NumPy array.""" + if isinstance(value, cf.Field): + return np.asanyarray(value.array) + return np.asanyarray(value) - # Contour lines and labels - if lines: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - clevs, - colors=colors, - linewidths=linewidths, - linestyles=linestyles, - alpha=alpha, - ) - if line_labels and not isinstance(clevs, int): - nd = ndecs(clevs) - fmt = "%d" - if nd != 0: - fmt = "%1." + str(nd) + "f" - plotvars.plot.clabel( - cs, - fmt=fmt, - colors=colors, - zorder=zorder, - fontsize=text_fontsize, - ) - # Thick zero contour line - if zero_thick: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - [-1e-32, 0], - colors=colors, - linewidths=zero_thick, - linestyles=linestyles, - alpha=alpha, - zorder=zorder, - ) - # Titles for dimensions - if titles: - _dim_titles(title=title_dims) - - # Color bar - if colorbar: - cbar( - labels=cbar_labels, - orientation=cb_orient, - position=colorbar_position, - shrink=colorbar_shrink, - title=colorbar_title, - fontsize=colorbar_fontsize, - fontweight=colorbar_fontweight, - text_up_down=colorbar_text_up_down, - text_down_up=colorbar_text_down_up, - drawedges=colorbar_drawedges, - fraction=colorbar_fraction, - thick=colorbar_thick, - levs=clevs, - anchor=colorbar_anchor, - verbose=verbose, - ) +def _face_vertex_array(face_values: Any, face_connectivity: Any) -> np.ndarray: + """Return per-face vertex coordinates from node coordinates and connectivity.""" + try: + bounds = getattr(face_values, "bounds", None) + if bounds is not None: + return np.asanyarray(bounds.array) + except Exception: + pass - # Title - plotvars.plot.set_title( - title, y=1.03, fontsize=title_fontsize, fontweight=title_fontweight - ) + values = _as_array(face_values) + connectivity = np.asanyarray(_as_array(face_connectivity), dtype=int) + if values.ndim == 1 and connectivity.ndim == 2: + if connectivity.size and connectivity.min() >= 0 and connectivity.max() < values.size: + return values[connectivity] + return values - # reset plot limits if not a user plot - if user_gset == 0: - gset() - ########################### - # Rotated pole contour plot - ########################### - if ptype == 6: +def _normalize_longitudes_for_map(lons: np.ndarray) -> np.ndarray: + """Shift longitudes into a continuous [-180, 180] range for map plots.""" + lons = np.asanyarray(lons, dtype=float) + if lons.ndim != 1 or lons.size == 0: + return lons - # Extract x and y grid points - if plotvars.proj == "cyl": - xpts = x - ypts = y - else: - xpts = np.arange(np.size(x)) - ypts = np.arange(np.size(y)) - - if verbose: - print("con - making a rotated pole plot") - user_gset = plotvars.user_gset - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Set plot limits - if plotvars.proj == "rotated": - plotargs = {} - gset( - xmin=0, - xmax=np.size(xpts) - 1, - ymin=0, - ymax=np.size(ypts) - 1, - user_gset=user_gset, - ) - plot = plotvars.plot + if np.nanmax(lons) > 180.0 and np.nanmin(lons) >= 0.0: + lons = np.where(lons > 180.0, lons - 360.0, lons) - # Set plot limits - if plotvars.proj == "UKCP": - plot = plotvars.plot - plotargs = {} + return lons - if plotvars.proj == "cyl": - rotated_pole = f.ref( - "grid_mapping_name:rotated_latitude_longitude" - ) - xpole = rotated_pole["grid_north_pole_longitude"] - ypole = rotated_pole["grid_north_pole_latitude"] - transform = ccrs.RotatedPole( - pole_latitude=ypole, pole_longitude=xpole - ) - plotargs = {"transform": transform} - if plotvars.user_mapset == 1: - _set_map() - else: - if np.ndim(xpts) == 1: - lonpts, latpts = np.meshgrid(xpts, ypts) - else: - lonpts = xpts - latpts = ypts - - points = ccrs.PlateCarree().transform_points( - transform, lonpts.flatten(), latpts.flatten() - ) - lons = np.array(points)[:, 0] - lats = np.array(points)[:, 1] - - mapset( - lonmin=np.min(lons), - lonmax=np.max(lons), - latmin=np.min(lats), - latmax=np.max(lats), - user_mapset=0, - resolution=resolution_orig, - ) - _set_map() +def _window_irregular_map_data( + field: np.ndarray, lons: np.ndarray, lats: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Wrap scattered lon/lat data to the current map window. - plotargs = {"transform": transform} - plot = plotvars.mymap + This mirrors the legacy irregular_window helper closely enough for the + global LFRic example: longitudes are shifted into the active map window, + then a seam column is interpolated at the left edge and duplicated at the + right edge for a full-globe plot. + """ + field_irregular = np.asarray(field, dtype=float).copy() + lons_irregular = np.asarray(lons, dtype=float).copy() + lats_irregular = np.asarray(lats, dtype=float).copy() + + lonmin = float(plotvars.lonmin) + lonmax = float(plotvars.lonmax) + + found_lon = False + lons_offset = 0.0 + for ilon in (-360.0, 0.0, 360.0): + lons_test = lons_irregular + ilon + if np.min(lons_test) <= lonmin: + found_lon = True + lons_offset = ilon + + if found_lon: + lons_irregular = lons_irregular + lons_offset + pts = np.where(lons_irregular < lonmin) + lons_irregular[pts] = lons_irregular[pts] + 360.0 + + # Build a seam line at the left edge of the plot by interpolating the + # wrapped points nearby in longitude/latitude space. + delta = 120.0 + pts_left = np.where(lons_irregular >= lonmin + 360.0 - delta) + lons_left = lons_irregular[pts_left] - 360.0 + lats_left = lats_irregular[pts_left] + field_left = field_irregular[pts_left] + + field_wrap = np.concatenate([field_irregular, field_left]) + lons_wrap = np.concatenate([lons_irregular, lons_left]) + lats_wrap = np.concatenate([lats_irregular, lats_left]) + + try: + from scipy.interpolate import griddata + except Exception: + return field_irregular, lons_irregular, lats_irregular + + lons_new = np.zeros(181) + lonmin + lats_new = np.arange(181) - 90.0 + field_new = griddata( + (lons_wrap, lats_wrap), + field_wrap, + (lons_new, lats_new), + method="linear", + ) + + pts = np.where(np.isfinite(field_new)) + field_new = field_new[pts] + lons_new = lons_new[pts] + lats_new = lats_new[pts] + + field_irregular = np.concatenate([field_irregular, field_new]) + lons_irregular = np.concatenate([lons_irregular, lons_new]) + lats_irregular = np.concatenate([lats_irregular, lats_new]) + + if lonmax - lonmin == 360.0: + field_irregular = np.concatenate([field_irregular, field_new]) + lons_irregular = np.concatenate([lons_irregular, lons_new + 359.95]) + lats_irregular = np.concatenate([lats_irregular, lats_new]) + + return field_irregular, lons_irregular, lats_irregular + + +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 + module-level independence while preserving current functionality. + """ + pv_map = plotvars.map + pv_axes = plotvars.axes + pv_dec = plotvars.decoration + pv_layout = plotvars.layout + pv_scale = plotvars.scale + pv_runtime = plotvars.runtime + pv_output = plotvars.output + + if isinstance(f, cf.Field) and (x is not None or y is not None): + field_arr = np.asanyarray(f.array) + x_arr = np.asarray(x.array) if isinstance(x, cf.Field) else x + y_arr = np.asarray(y.array) if isinstance(y, cf.Field) else y + data = ContourData.from_arrays( + field=field_arr, + x=None if x_arr is None else np.asarray(x_arr), + y=None if y_arr is None else np.asarray(y_arr), + ) + data = replace(data, ptype=kwargs.get("ptype", 0) or 0) + elif isinstance(f, cf.Field): + # Legacy parity: when mapset is user-defined for polar stereographic + # plots, subset latitude before data extraction and level generation. + if pv_runtime.user_mapset: + if pv_map.proj == "npstere": + f = f.subspace(Y=cf.wi(pv_map.boundinglat, 90.0)) + elif pv_map.proj == "spstere": + f = f.subspace(Y=cf.wi(-90.0, pv_map.boundinglat)) + + data = ContourData.from_cf_field( + f=f, + colorbar_title=kwargs.get("colorbar_title", None), + verbose=kwargs.get("verbose", None), + proj=pv_map.proj, + ) + # Implemented CF extraction targets include generic Cartesian (ptype 0), + # map, and selected non-map ptypes. + if data.ptype not in (0, 1, 2, 3, 4, 5, 6): + return False + else: + data = ContourData.from_arrays(field=np.asanyarray(f), x=x, y=y) + data = replace(data, ptype=kwargs.get("ptype", 0) or 0) + + if isinstance(f, cf.Field) and bool(kwargs.get("blockfill")) and _field_has_ugrid_faces(f): + # Prefer the face metadata embedded in the field, which is the legacy + # path and is more reliable than the auxiliary coordinate variables + # supplied by callers. + face_lons = f.aux("X").bounds.array + face_lats = f.aux("Y").bounds.array + face_connectivity = f.domain_topology("cell:face").array + + face_lons = _face_vertex_array(face_lons, face_connectivity) + face_lats = _face_vertex_array(face_lats, face_connectivity) + + data = replace( + data, + is_ugrid=True, + face_lons=face_lons, + face_lats=face_lats, + face_connectivity=face_connectivity, + ) - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # from 1 to ncols-2. The colours for the colour bar extensions are - # then changed on the colorbar and plot after the plot is made - colmap = _cscale_get_map() + # Keep legacy behavior for axis-routing logic by setting active plot type. + pv_runtime.plot_type = data.ptype - # Filled contours - if fill: - colmap = _cscale_get_map() - cmap = matplotlib.colors.ListedColormap(colmap) - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "both" - ): - cmap.set_under(plotvars.cs[0]) - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "both" - ): - cmap.set_over(plotvars.cs[-1]) - - plot.contourf( - xpts, - ypts, - field * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, - zorder=zorder, - **plotargs, - ) + fill = kwargs.get("fill", global_fill) + lines = kwargs.get("lines", global_lines) + blockfill = kwargs.get("blockfill", global_blockfill) + line_labels = kwargs.get("line_labels", True) + zero_thick = kwargs.get("zero_thick", False) + colors = kwargs.get("colors", "k") + linewidths = kwargs.get("linewidths", None) + linestyles = kwargs.get("linestyles", None) + alpha = kwargs.get("alpha", 1.0) + zorder = kwargs.get("zorder", 1) - # Block fill - if blockfill: - _bfill( - f=field_orig * fmult, - x=xpts, - y=ypts, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, - transform=transform, - ) + if blockfill: + fill = False - # Contour lines and labels - if lines: - cs = plot.contour( - xpts, - ypts, - field * fmult, - clevs, - colors=colors, - linewidths=linewidths, - linestyles=linestyles, - zorder=zorder, - **plotargs, - ) - if line_labels and not isinstance(clevs, int): - nd = ndecs(clevs) - fmt = "%d" - if nd != 0: - fmt = "%1." + str(nd) + "f" - plot.clabel( - cs, - fmt=fmt, - colors=colors, - zorder=zorder, - fontsize=text_fontsize, - ) + colorbar = kwargs.get("colorbar", True) + if not fill and not blockfill: + colorbar = False - # Thick zero contour line - if zero_thick: - cs = plot.contour( - xpts, - ypts, - field * fmult, - [-1e-32, 0], - colors=colors, - linewidths=zero_thick, - linestyles=linestyles, - alpha=alpha, - zorder=zorder, - **plotargs, - ) + if pv_scale.levels is None: + levels_field = data.field + if bool(kwargs.get("animation", False)) and kwargs.get("animation_reference") is not None: + ref = kwargs.get("animation_reference") + if isinstance(ref, cf.Field): + levels_field = np.asanyarray(ref.array) + else: + levels_field = np.asanyarray(ref) - # Titles for dimensions - if titles: - _dim_titles(title=title_dims) - - # Color bar - if colorbar: - cbar( - labels=cbar_labels, - orientation=cb_orient, - position=colorbar_position, - shrink=colorbar_shrink, - title=colorbar_title, - fontsize=colorbar_fontsize, - fontweight=colorbar_fontweight, - text_up_down=colorbar_text_up_down, - text_down_up=colorbar_text_down_up, - drawedges=colorbar_drawedges, - fraction=colorbar_fraction, - thick=colorbar_thick, - levs=clevs, - anchor=colorbar_anchor, - verbose=verbose, - ) + clevs, mult, fmult = utility.calculate_levels( + field=levels_field, + level_spacing=kwargs.get("level_spacing", "linear"), + levels_step=pv_scale.levels_step, + verbose=kwargs.get("verbose", None), + ) + else: + clevs = np.asarray(pv_scale.levels) + mult = 0 + fmult = 1 - if plotvars.proj == "rotated" or plotvars.proj == "UKCP": - # Remove Matplotlib default axis labels. Note we must do this - # before we add in our custom (rotated or UKCP) axes labels - # else they will also be wiped, along with the plot area! - axes_plot( - xticks=[100000000], - xticklabels=[""], - yticks=[100000000], - yticklabels=[""], - xlabel="", - ylabel="", - ) + cs = ColourScale(plotvars).fit_to_levels( + levels=np.asarray(clevs), + includes_zero=bool(np.any(np.asarray(clevs) == 0)), + levels_extend=pv_scale.levels_extend, + ) - # Rotated grid axes - if axes: - if plotvars.proj == "cyl": - _plot_map_axes( - axes=axes, - xaxis=xaxis, - yaxis=yaxis, - xticks=xticks, - xticklabels=xticklabels, - yticks=yticks, - yticklabels=yticklabels, - user_xlabel=user_xlabel, - user_ylabel=user_ylabel, - verbose=verbose, - ) - else: - rgaxes( - xpole=xpole, - ypole=ypole, - xvec=x, - yvec=y, - xticks=xticks, - xticklabels=xticklabels, - yticks=yticks, - yticklabels=yticklabels, - axes=axes, - xaxis=xaxis, - yaxis=yaxis, - xlabel=xlabel, - ylabel=ylabel, - ) + import matplotlib + matplotlib.rcParams["contour.negative_linestyle"] = "solid" - # Add title and coastlines for cylindrical projection - if plotvars.proj == "cyl": - # Coastlines - feature = cfeature.NaturalEarthFeature( - name="land", - category="physical", - scale=plotvars.resolution, - facecolor="none", - ) - plotvars.mymap.add_feature( - feature, - edgecolor=continent_color, - linewidth=continent_thickness, - linestyle=continent_linestyle, - zorder=zorder, - ) + _cb_orient = kwargs.get("colorbar_orientation", None) + if _cb_orient is None: + if data.ptype == 1 and pv_map.proj in ("npstere", "spstere"): + _cb_orient = "vertical" + else: + _cb_orient = "horizontal" + colorbar_orientation = _cb_orient + + clabels = cs.colourbar_labels( + levels=np.asarray(clevs), + orientation=colorbar_orientation, + n_columns=pv_layout.columns, + label_skip=kwargs.get("colorbar_label_skip", None), + custom_labels=kwargs.get("colorbar_labels"), + ) + cbar_labels = clabels + + colorbar_title = kwargs.get("colorbar_title", data.colorbar_title) + if mult != 0: + colorbar_title = f"{colorbar_title} *10^{{{mult}}}" + + resolved_title = _resolve_animation_title( + f=f, + base_title=kwargs.get("title", "") or "", + animation=bool(kwargs.get("animation", False)), + animation_axis=kwargs.get("animation_axis", "auto"), + ptype=data.ptype, + animation_title_template=kwargs.get("animation_title_template", None), + ) + + # ptype 6 has its own rendering/axes flow and must bypass generic XY + # layout to avoid non-map axes state assumptions. + if data.ptype == 6: + data = replace( + data, + levels=np.asarray(clevs), + mult=mult, + fmult=fmult, + fill=fill, + lines=lines, + blockfill=blockfill, + ) + return _render_ptype6_rotated_pole( + f=f, + data=data, + kwargs=kwargs, + clevs=np.asarray(clevs), + cs=cs, + cbar_labels=cbar_labels, + colorbar_title=colorbar_title, + fill=fill, + lines=lines, + blockfill=blockfill, + line_labels=line_labels, + zero_thick=zero_thick, + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + alpha=alpha, + zorder=zorder, + finalize_callback=maybe_autosave, + ) - # Title - if title != "": - _map_title(title) - - # Add title for native grid - if plotvars.proj == "rotated": - # Title - plotvars.plot.set_title( - title, - y=1.03, - fontsize=title_fontsize, - fontweight=title_fontweight, - ) + if pv_runtime.user_plot == 0: + ensure_xy_viewport() - # reset plot limits if not a user plot - if plotvars.user_gset == 0: - gset() - - ############# - # Other plots - ############# - if ptype == 0: - if verbose: - print("con - making an other plot") - if plotvars.user_plot == 0: - gopen(user_plot=0) - user_gset = plotvars.user_gset - - # Set axis labels to None - xplotlabel = None - yplotlabel = None - - cf_field = False - if f is not None: - if isinstance(f, cf.Field): - cf_field = True - f = f.squeeze() - - # Work out axes if none are supplied - if any( - val is None - for val in [ - plotvars.xmin, - plotvars.xmax, - plotvars.ymin, - plotvars.ymax, - ] - ): - xmin = np.nanmin(x) - xmax = np.nanmax(x) - ymin = np.nanmin(y) - ymax = np.nanmax(y) - else: - xmin = plotvars.xmin - xmax = plotvars.xmax - ymin = plotvars.ymin - ymax = plotvars.ymax + xmin = kwargs.get("xmin", float(np.nanmin(data.x))) + xmax = kwargs.get("xmax", float(np.nanmax(data.x))) + ymin = kwargs.get("ymin", float(np.nanmin(data.y))) + ymax = kwargs.get("ymax", float(np.nanmax(data.y))) - # Change from date string to a number if strings are passed - time_xstr = False - time_ystr = False + # Legacy parity for latitude/longitude/time-pressure plots: + # pressure-like coordinates are rendered with pressure decreasing upward. + if data.ptype in (2, 3, 7) and kwargs.get("user_gset", pv_runtime.user_gset) == 0: + positive = "down" + if isinstance(f, cf.Field): + myz = utility.find_z(f) + if myz is not None and hasattr(f.construct(myz), "positive"): + positive = f.construct(myz).positive + if "theta" in (data.ylabel or "").split(" "): + positive = "up" + if "height" in (data.ylabel or "").split(" "): + positive = "up" - try: - float(xmin) - except Exception: - time_xstr = True - try: - float(ymin) - except Exception: - time_ystr = True - - xaxisticks = None - yaxisticks = None - xtimeaxis = False - ytimeaxis = False - - if cf_field and f.has_construct("T"): - if np.size(f.construct("T").array) > 1: - - taxis = f.construct("T") - - data_axes = f.get_data_axes() - count = 1 - for d in data_axes: - i = f.constructs.domain_axis_identity(d) - try: - c = f.coordinate([i]) - if np.size(c.array) > 1: - test_for_time_axis = False - sn = getattr(c, "standard_name", "NoName") - an = c.get_property("axis", "NoName") - if sn == "time" or an == "T": - test_for_time_axis = True - - if count == 1: - if test_for_time_axis: - ytimeaxis = True - elif count == 2: - if test_for_time_axis: - xtimeaxis = True - count += 1 - except ValueError: - print("no sensible coordinates for this axis") - - if time_xstr or time_ystr: - ref_time = f.construct("T").units - ref_calendar = f.construct("T").calendar - time_units = cf.Units(ref_time, ref_calendar) - - if time_xstr: - t = cf.Data(cf.dt(xmin), units=time_units) - xmin = t.array - t = cf.Data(cf.dt(xmax), units=time_units) - xmax = t.array - taxis = cf.Data([xmin, xmax], units=time_units) - taxis.calendar = ref_calendar - - if time_ystr: - t = cf.Data(cf.dt(ymin), units=time_units) - ymin = t.array - t = cf.Data(cf.dt(ymax), units=time_units) - ymax = t.array - taxis = cf.Data([ymin, ymax], units=time_units) - taxis.calendar = ref_calendar - - if xtimeaxis: - xaxisticks, xaxislabels, xplotlabel = _timeaxis(taxis) - if ytimeaxis: - yaxisticks, yaxislabels, yplotlabel = _timeaxis(taxis) - - if cf_field: - coords = list(f.coords()) - mycoords = [] - for coord in coords: - if np.size(f.coord(coord).array) > 1: - mycoords.append(coord) - mycoords.reverse() - - for icoord in np.arange(len(mycoords)): - - myaxisticks = None - myaxislabels = None - mylabel = None - - if f.coord(mycoords[icoord]).X: - myaxisticks, myaxislabels = _mapaxis( - np.min(f.coord("X").array), - np.max(f.coord("X").array), - type=1, - ) - mylabel = "longitude" + if data.ptype == 2: + if xmin < -80 and xmin >= -90: + xmin = -90 + if xmax > 80 and xmax <= 90: + xmax = 90 - if f.coord(mycoords[icoord]).Y: - myaxisticks, myaxislabels = _mapaxis( - np.min(f.coord("Y").array), - np.max(f.coord("Y").array), - type=2, - ) - mylabel = "latitude" - - if myaxisticks is not None: - if icoord == 0: - xaxisticks, xaxislabels, xlabel = ( - myaxisticks, - myaxislabels, - mylabel, - ) - if icoord == 1: - yaxisticks, yaxislabels, ylabel = ( - myaxisticks, - myaxislabels, - mylabel, - ) - - if xaxisticks is None: - xaxisticks = _gvals(dmin=xmin, dmax=xmax, mod=False)[0] - xaxislabels = xaxisticks - - if yaxisticks is None: - yaxisticks = _gvals(dmin=ymax, dmax=ymin, mod=False)[0] - yaxislabels = yaxisticks - - if user_xlabel is not None: - xplotlabel = user_xlabel + if positive == "down": + ymin = float(np.nanmax(data.y)) + ymax = float(np.nanmin(data.y)) + if ymax < 10: + ymax = 0 else: - if xplotlabel is None: - xplotlabel = xlabel - if user_ylabel is not None: - yplotlabel = user_ylabel - else: - if yplotlabel is None: - yplotlabel = ylabel - - # Draw axes - if axes: - if xaxis: - if xticks is not None: - xaxisticks = xticks - xaxislabels = xticks - if xticklabels is not None: - xaxislabels = xticklabels - else: - xaxisticks = [100000000] - xlabel = "" - - if yaxis: - if yticks is not None: - yaxisticks = yticks - yaxislabels = yticks - if yticklabels is not None: - yaxislabels = yticklabels - else: - yaxisticks = [100000000] - ylabel = "" + ymin = float(np.nanmin(data.y)) + ymax = float(np.nanmax(data.y)) - else: - xaxisticks = [100000000] - yaxisticks = [100000000] - xlabel = "" - ylabel = "" - - # Swap axes if requested - if swap_axes: - x, y = y, x - field = np.flipud(np.rot90(field)) - xmin, ymin = ymin, xmin - xmax, ymax = ymax, xmax - xplotlabel, yplotlabel = yplotlabel, xplotlabel - xaxisticks, yaxisticks = yaxisticks, xaxisticks - xaxislabels, yaxislabels = yaxislabels, xaxislabels - - # Set plot limits and set default plot labels - gset(xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, user_gset=user_gset) - - # Draw axes - axes_plot( - xticks=xaxisticks, - xticklabels=xaxislabels, - yticks=yaxisticks, - yticklabels=yaxislabels, - xlabel=xplotlabel, - ylabel=yplotlabel, - ) + # Respect user gset for Hovmuller plots with date-string y limits. + tmin = None + tmax = None + if isinstance(f, cf.Field) and data.ptype in (4, 5): + if all( + val is not None + for val in [pv_axes.xmin, pv_axes.xmax, pv_axes.ymin, pv_axes.ymax] + ): + tmin = pv_axes.ymin + tmax = pv_axes.ymax + xmin = pv_axes.xmin + xmax = pv_axes.xmax - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # then from 1 to ncols-2. The colours for the colour bar extensions - # are changed on the colorbar and plot after the plot is made - colmap = _cscale_get_map() + ref_time = f.construct("T").units + ref_calendar = f.construct("T").calendar + time_units = cf.Units(ref_time, ref_calendar) + t = cf.Data(cf.dt(pv_axes.ymin), units=time_units) + ymin = t.array + t = cf.Data(cf.dt(pv_axes.ymax), units=time_units) + ymax = t.array - # Filled contours - if fill: - colmap = _cscale_get_map() - cmap = matplotlib.colors.ListedColormap(colmap) - if ( - plotvars.levels_extend == "min" - or plotvars.levels_extend == "both" - ): - cmap.set_under(plotvars.cs[0]) - if ( - plotvars.levels_extend == "max" - or plotvars.levels_extend == "both" - ): - cmap.set_over(plotvars.cs[-1]) - - plotvars.image = plotvars.plot.contourf( - x, - y, - field * fmult, - clevs, - extend=plotvars.levels_extend, - cmap=cmap, - norm=plotvars.norm, - alpha=alpha, - zorder=zorder, - ) + if kwargs.get("ylog", False) and ymax == 0: + ymax = 1 + set_plot_limits( + xmin=xmin, + xmax=xmax, + ymin=ymin, + ymax=ymax, + ylog=bool(kwargs.get("ylog", False)), + user_gset=kwargs.get("user_gset", pv_runtime.user_gset), + ) + + if tmin is not None and tmax is not None: + pv_axes.ymin = tmin + pv_axes.ymax = tmax + + xticks = kwargs.get("xticks", None) + yticks = kwargs.get("yticks", None) + xticklabels = kwargs.get("xticklabels", None) + yticklabels = kwargs.get("yticklabels", None) + + default_xlabel = data.xlabel or "" + default_ylabel = data.ylabel or "" + + if data.ptype == 1: + map_runtime = MapSet(plotvars) + + animation = bool(kwargs.get("animation", False)) + reuse_map_background = bool(kwargs.get("reuse_map_background", False)) + clear_previous_frame = bool(kwargs.get("clear_previous_frame", False)) + draw_static_map = not (animation and reuse_map_background) + + if clear_previous_frame: + _clear_animation_artists(plotvars) + + mylonmin = float(np.nanmin(data.x)) + mylonmax = float(np.nanmax(data.x)) + mylatmin = float(np.nanmin(data.y)) + mylatmax = float(np.nanmax(data.y)) + lonrange = mylonmax - mylonmin + latrange = mylatmax - mylatmin + if lonrange > 360.0: + mylonmax = mylonmin + 360.0 - # Block fill - if blockfill: - _bfill( - f=field_orig * fmult, - x=x_orig, - y=y_orig, - clevs=clevs, - lonlat=False, - bound=0, - alpha=alpha, - fast=blockfill_fast, - zorder=zorder, + if draw_static_map: + if not ((lonrange > 350 and latrange > 160) or pv_runtime.user_mapset == 1): + map_runtime.configure( + lonmin=mylonmin, + lonmax=mylonmax, + latmin=mylatmin, + latmax=mylatmax, + user_mapset=0, + resolution=pv_map.resolution, + ) + map_runtime.ensure_map_axes() + + # Add a cyclic longitude column when the grid is near-global but + # doesn't close on itself (no explicit bounds), to avoid a gap at + # the wrap-around seam in Cartopy. + # NOTE: For non-cyclic orthographic and polar stereographic grids this + # can introduce seam artefacts, so skip it there unless the grid is + # explicitly cyclic. + if ( + data.x is not None + and data.y is not None + and np.ndim(data.x) == 1 + and np.ndim(data.y) == 1 + and not data.irregular + and not blockfill + and ( + plotvars.proj not in ("ortho", "npstere", "spstere") + or data.x_is_cyclic ) - - # Contour lines and labels - if lines: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - clevs, - colors=colors, - linewidths=linewidths, - linestyles=linestyles, - zorder=zorder, + ): + lonrange_data = float(np.nanmax(data.x)) - float(np.nanmin(data.x)) + if 350.0 < lonrange_data < 360.0: + new_field, new_x = utility.add_cyclic(data.field, data.x) + data = replace(data, field=new_field, x=new_x) + + # For orthographic plots with a non-cyclic longitude grid, avoid a + # seam through the center of the visible hemisphere by rolling the + # array so it starts at ~-180° rather than at 0°. Cyclic grids + # (whose bounds close at 360°) are already seamless and must NOT be + # rolled, or a new artefact appears at ±180° near the poles. + # Assumes the longitude array is monotonically increasing (standard + # for CF-convention model output). + if ( + data.x is not None + and np.ndim(data.x) == 1 + and plotvars.proj == "ortho" + and not data.x_is_cyclic + and not data.irregular + ): + split = int(np.searchsorted(data.x, 180.0)) + wrapped_x = np.mod(data.x + 180.0, 360.0) - 180.0 + data = replace( + data, + x=np.roll(wrapped_x, -split), + field=np.roll(data.field, -split, axis=-1), ) - if line_labels and not isinstance(clevs, int): - nd = ndecs(clevs) - fmt = "%d" - if nd != 0: - fmt = "%1." + str(nd) + "f" - plotvars.plot.clabel( - cs, - fmt=fmt, - colors=colors, - zorder=zorder, - fontsize=text_fontsize, - ) - # Thick zero contour line - if zero_thick: - cs = plotvars.plot.contour( - x, - y, - field * fmult, - [-1e-32, 0], - colors=colors, - linewidths=zero_thick, - linestyles=linestyles, - alpha=alpha, - zorder=zorder, - ) + if not data.irregular and np.ndim(data.y) == 1 and data.y[0] > data.y[-1]: + data = replace(data, y=data.y[::-1], field=np.flipud(data.field)) + + xticks = kwargs.get("xticks", None) + yticks = kwargs.get("yticks", None) + xticklabels = kwargs.get("xticklabels", None) + yticklabels = kwargs.get("yticklabels", None) + + time_ticks = None + time_labels = None + time_label = None + if isinstance(f, cf.Field) and data.ptype in (4, 5): + time_ticks, time_labels, time_label = utility.timeaxis( + dtimes=f.construct("T"), + user_gset=pv_runtime.user_gset, + xmin=pv_axes.xmin, + xmax=pv_axes.xmax, + ymin=pv_axes.ymin, + ymax=pv_axes.ymax, + tspace_year=pv_output.tspace_year, + tspace_hour=pv_output.tspace_hour, + tspace_day=pv_output.tspace_day, + ) + xticks, yticks, xticklabels, yticklabels, default_xlabel, default_ylabel = ( + utility.compute_xy_ticks( + ptype=data.ptype, + xmin=xmin, + xmax=xmax, + ymin=ymin, + ymax=ymax, + ylog=bool(kwargs.get("ylog", False)), + degsym=pv_dec.degsym, + xticks=xticks, + yticks=yticks, + xticklabels=xticklabels, + yticklabels=yticklabels, + default_xlabel=default_xlabel, + default_ylabel=default_ylabel, + time_ticks=time_ticks, + time_labels=time_labels, + time_label=time_label, + ) + ) - # Titles for dimensions - if titles: - _dim_titles(title=title_dims) - - # Color bar - if colorbar: - cbar( - labels=cbar_labels, - orientation=cb_orient, - position=colorbar_position, - shrink=colorbar_shrink, - title=colorbar_title, - fontsize=colorbar_fontsize, - fontweight=colorbar_fontweight, - text_up_down=colorbar_text_up_down, - text_down_up=colorbar_text_down_up, - drawedges=colorbar_drawedges, - fraction=colorbar_fraction, - thick=colorbar_thick, - levs=clevs, - anchor=colorbar_anchor, - verbose=verbose, - ) + if data.ptype == 1: + layout = ContourLayout(plotvars).allocate_map_viewport( + colorbar_orientation=colorbar_orientation, + colorbar_position=kwargs.get("colorbar_position", None), + ) + else: + layout = ContourLayout(plotvars).allocate_xy_viewport( + colorbar_orientation=colorbar_orientation, + colorbar_position=kwargs.get("colorbar_position", None), + ) + layout.apply_axis_labels( + xlabel=kwargs.get("xlabel", default_xlabel), + ylabel=kwargs.get("ylabel", default_ylabel), + xticks=xticks, + yticks=yticks, + xticklabels=xticklabels, + yticklabels=yticklabels, + ) - # Title - plotvars.plot.set_title( - title, y=1.03, fontsize=title_fontsize, fontweight=title_fontweight + data = replace( + data, + levels=np.asarray(clevs), + mult=mult, + fmult=fmult, + fill=fill, + lines=lines, + blockfill=blockfill, + ) + if data.ptype == 1: + renderer = MapContourRenderer(layout=layout, data=data, colour_scale=cs) + else: + renderer = XYContourRenderer(layout=layout, data=data, colour_scale=cs) + + transform_first = kwargs.get("transform_first", None) + if data.ptype == 1 and plotvars.proj in ("npstere", "spstere"): + # Polar stereographic can show longitude striping when Cartopy + # pre-transforms dense regular lon/lat grids in data space. + # Rendering in map space is robust for both cyclic and non-cyclic data. + transform_first = False + elif data.ptype == 1 and plotvars.proj == "ortho" and not data.x_is_cyclic: + # Non-cyclic grids on ortho are prone to clipping artefacts with + # transform_first=True on near-global dense grids, so force it off. + # Cyclic grids use the default (True for 1-D arrays) which avoids a + # visible seam at the 0°/360° boundary. + transform_first = False + + if fill: + renderer.render_filled( + alpha=alpha, + zorder=zorder, + transform_first=transform_first, + ) + if blockfill: + renderer.render_blockfill( + fast=kwargs.get("blockfill_fast", None), alpha=alpha, zorder=zorder ) + if lines: + renderer.render_lines( + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + line_labels=line_labels, + zero_thick=zero_thick, + zorder=zorder, + ) + + if data.ptype == 1: + map_runtime = MapSet(plotvars) + + animation = bool(kwargs.get("animation", False)) + reuse_map_background = bool(kwargs.get("reuse_map_background", False)) + clear_previous_frame = bool(kwargs.get("clear_previous_frame", False)) + draw_static_map = not (animation and reuse_map_background) + + if animation and clear_previous_frame: + _clear_animation_title_artist(plotvars) + _clear_animation_colorbar(plotvars) + + if draw_static_map: + apply_axes( + plot_type=1, + xticks=kwargs.get("xticks", None), + yticks=kwargs.get("yticks", None), + xlabel=kwargs.get("xlabel", None), + ylabel=kwargs.get("ylabel", None), + xticklabels=kwargs.get("xticklabels", None), + yticklabels=kwargs.get("yticklabels", None), + ) - # reset plot limits if not a user plot - if plotvars.user_gset == 0: - gset() - - ############################ - # Set axis width if required - ############################ - if plotvars.axis_width is not None: - for axis in ["top", "bottom", "left", "right"]: - plotvars.plot.spines[axis].set_linewidth(plotvars.axis_width) - - ################################ - # Add a master title if reqested - ################################ - if plotvars.master_title is not None: - location = plotvars.master_title_location - plotvars.master_plot.text( - location[0], - location[1], - plotvars.master_title, - horizontalalignment="center", - fontweight=plotvars.master_title_fontweight, - fontsize=plotvars.master_title_fontsize, + _apply_map_features( + mymap=pv_runtime.mymap, + continent_color=pv_dec.continent_color or "k", + continent_thickness=pv_dec.continent_thickness or 1.5, + continent_linestyle=pv_dec.continent_linestyle or "solid", + kwargs=kwargs, + ) + if kwargs.get("grid", pv_dec.grid): + map_runtime.draw_grid() + + map_runtime.draw_polar_axes() + + # Persist only dynamic contour artists for animation updates. + pv_runtime._contour_animation_artists = list(renderer.frame_artists) + + if colorbar: + colorbar_artist = renderer.render_colorbar( + orientation=colorbar_orientation, + shrink=kwargs.get("colorbar_shrink", None), + position=kwargs.get("colorbar_position", None), + fraction=kwargs.get("colorbar_fraction", None), + thick=kwargs.get("colorbar_thick", None), + anchor=kwargs.get("colorbar_anchor", None), + fontsize=kwargs.get("colorbar_fontsize", None), + fontweight=kwargs.get("colorbar_fontweight", None), + text_up_down=kwargs.get("colorbar_text_up_down", False), + text_down_up=kwargs.get("colorbar_text_down_up", False), + drawedges=kwargs.get("colorbar_drawedges", True), + labels=list(cbar_labels), + title=colorbar_title, ) + if bool(kwargs.get("animation", False)): + pv_runtime._contour_animation_colorbar = colorbar_artist - # Reset map resolution - if plotvars.user_mapset == 0: - mapset() - mapset(resolution=resolution_orig) + if data.ptype == 1: + title = resolved_title + animation = bool(kwargs.get("animation", False)) - ################## - # Save or view plot - ################## + if title != "": + title_artist = _apply_map_title( + mymap=pv_runtime.mymap, + title=title, + proj=pv_map.proj, + boundinglat=pv_map.boundinglat, + lon_0=pv_map.lon_0, + lonmin=pv_map.lonmin, + lonmax=pv_map.lonmax, + latmin=pv_map.latmin, + latmax=pv_map.latmax, + title_fontsize=pv_dec.title_fontsize, + title_fontweight=pv_dec.title_fontweight, + ) + if animation: + pv_runtime._contour_animation_title_artist = title_artist + else: + layout.apply_title( + title=resolved_title, + dims_title=bool(kwargs.get("titles", False)), + fontsize=pv_dec.title_fontsize, + fontweight=pv_dec.title_fontweight, + ) + + maybe_autosave() + + return True + + +def con(f=None, x=None, y=None, **kwargs): + """Contour entrypoint coordinating through new object architecture. + + Gradually extracts logic from legacy _legacy_con into structured classes + while preserving behavior. Eventually rendering will be split into + MapContourRenderer and XYContourRenderer subclasses. + + For now, orchestration uses the new classes for data and styling, + then delegates to legacy renderer. + + Animation title options (map and non-map): + - animation: bool, enables animation-aware rendering hooks. + - animation_reference: cf.Field or array-like, optional reference data + used for automatic level generation across animation frames. + When supplied and levels are automatic, contour levels are computed + from this full reference rather than the current frame slice. + - animation_axis: str, one of "auto", "T", "Z", "Y", "X". + When "auto" and ptype != 0, the frame axis is inferred as a singleton + axis not used by that ptype. For ptype == 0, fallback preference is + singleton T, then Z, then Y, then X. + - animation_title_template: str, optional template used to construct + per-frame titles. Available fields are {title}, {frame}, and {axis}. + + Example: + cfp.con( + f, + animation=True, + reuse_map_background=True, + animation_axis="auto", + animation_title_template="{title} [{frame}]", + title="Air temperature", + ) + """ + # Refactor mode: unsupported cases should fail explicitly rather than + # silently routing through legacy code. + if not _can_use_new_xy_path(f=f, kwargs=kwargs): + raise NotImplementedError( + "Contour case not implemented in refactored renderer yet" + ) - if plotvars.user_plot == 0: - if verbose: - print("con - saving or viewing plot") + if _render_with_new_xy(f=f, x=x, y=y, kwargs=kwargs): + return None - np.seterr(**old_settings) # reset to default numpy error settings + raise NotImplementedError( + "Contour case not implemented in refactored renderer yet" + ) - gclose() diff --git a/cfplot/graphic/__init__.py b/cfplot/graphic/__init__.py deleted file mode 100644 index 7017876..0000000 --- a/cfplot/graphic/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .graphic import _which, gclose, gopen, gpos diff --git a/cfplot/graphic/graphic.py b/cfplot/graphic/graphic.py deleted file mode 100644 index 953273d..0000000 --- a/cfplot/graphic/graphic.py +++ /dev/null @@ -1,311 +0,0 @@ -import os -import subprocess - -import matplotlib -import matplotlib.pyplot as plot - -from ..parameters import levs, plotvars - - -def _which(program): - """Check if the ImageMagick display command is available.""" - - def is_exe(fpath): - """TODO DOCS.""" - return os.path.exists(fpath) and os.access(fpath, os.X_OK) - - def ext_candidates(fpath): - """TODO DOCS.""" - yield fpath - for ext in os.environ.get("PATHEXT", "").split(os.pathsep): - yield fpath + ext - - for path in os.environ["PATH"].split(os.pathsep): - exe_file = os.path.join(path, program) - for candidate in ext_candidates(exe_file): - if is_exe(candidate): - return candidate - - return None - - -def gopen( - rows=1, - columns=1, - user_plot=1, - file="cfplot.png", - orientation="landscape", - figsize=[11.7, 8.3], - left=None, - right=None, - top=None, - bottom=None, - wspace=None, - hspace=None, - dpi=None, - user_position=False, -): - """ - | Open a graphic file. - | - | rows=1 - number of plot rows on the page - | columns=1 - number of plot columns on the page - | user_plot=1 - internal plot variable - do not use. - | file='cfplot.png' - default file name - | orientation='landscape' - orientation - also takes 'portrait' - | figsize=[11.7, 8.3] - figure size in inches - | left=None - left margin in normalised coordinates - default=0.12 - | right=None - right margin in normalised coordinates - default=0.92 - | top=None - top margin in normalised coordinates - default=0.08 - | bottom=None - bottom margin in normalised coordinates - default=0.08 - | wspace=None - width reserved for blank space between - | subplots - default=0.2 - | hspace=None - height reserved for white space between - | subplots - default=0.2 - | dpi=None - resolution in dots per inch - | user_position=False - set to True to supply plot position via gpos - | xmin, xmax, ymin, ymax values - :Returns: - None - - """ - - # Set values in globals - plotvars.rows = rows - plotvars.columns = columns - if file != "cfplot.png": - plotvars.file = file - plotvars.orientation = orientation - plotvars.user_plot = user_plot - plotvars.gpos_called = False - - # Set user defined plot area to None - plotvars.plot_xmin = None - plotvars.plot_xmax = None - plotvars.plot_ymin = None - plotvars.plot_ymax = None - - if left is None: - left = 0.12 - if right is None: - right = 0.92 - if top is None: - top = 0.95 - if bottom is None: - bottom = 0.08 - if rows >= 3: - bottom = 0.1 - if wspace is None: - wspace = 0.2 - if hspace is None: - hspace = 0.2 - if rows >= 3: - hspace = 0.5 - - if orientation != "landscape": - if orientation != "portrait": - errstr = ( - "gopen error\n" - "orientation incorrectly set\n" - f"input value was {orientation}\n" - "Valid options are portrait or landscape\n" - ) - raise Warning(errstr) - - # Set master plot size - if orientation == "landscape": - plotvars.master_plot = plot.figure(figsize=(figsize[0], figsize[1])) - else: - plotvars.master_plot = plot.figure(figsize=(figsize[1], figsize[0])) - - # Set margins - plotvars.master_plot.subplots_adjust( - left=left, - right=right, - top=top, - bottom=bottom, - wspace=wspace, - hspace=hspace, - ) - - # Set initial subplot - if user_position is False and rows == 1 and columns == 1: - gpos(pos=1) - - # Change tick length for plots > 2x2 - if columns > 2 or rows > 2: - matplotlib.rcParams["xtick.major.size"] = 2 - matplotlib.rcParams["ytick.major.size"] = 2 - - # Set image resolution - if dpi is not None: - plotvars.dpi = dpi - - -def gclose(view=True): - """ - | Saves a graphics file. The default is to view the file as well - | but `view=False` can be used to turn this off. - - | view = True - view graphics file - :Returns: - None - - """ - - # Reset the user_plot variable to off - plotvars.user_plot = 0 - - # Test for python or ipython - interactive = False - try: - __IPYTHON__ - interactive = True - except NameError: - interactive = False - - if matplotlib.is_interactive(): - interactive = True - - # Remove whitespace if requested - saveargs = {} - if plotvars.tight: - saveargs = {"bbox_inches": "tight"} - - file = plotvars.file - if file is not None: - # Save a file - type = 1 - if file[-3:] == ".ps": - type = 1 - if file[-4:] == ".eps": - type = 1 - if file[-4:] == ".png": - type = 1 - if file[-4:] == ".pdf": - type = 1 - if type is None: - file = file + ".png" - plotvars.master_plot.savefig( - file, - orientation=plotvars.orientation, - dpi=plotvars.dpi, - **saveargs, - ) - plot.close() - else: - if plotvars.viewer == "display" and interactive is False: - # Use Imagemagick display command if this exists - disp = _which("display") - if disp is not None: - tfile = "cfplot.png" - plotvars.master_plot.savefig( - tfile, - orientation=plotvars.orientation, - dpi=plotvars.dpi, - **saveargs, - ) - matplotlib.pyplot.ioff() - subprocess.Popen([disp, tfile]) - else: - plotvars.viewer = "matplotlib" - if plotvars.viewer == "matplotlib" or interactive: - # Use Matplotlib viewer - matplotlib.pyplot.ion() - plot.show() - - # Reset plotting - plotvars.plot = None - plotvars.twinx = None - plotvars.twiny = None - plotvars.plot_xmin = None - plotvars.plot_xmax = None - plotvars.plot_ymin = None - plotvars.plot_ymax = None - plotvars.graph_xmin = None - plotvars.graph_xmax = None - plotvars.graph_ymin = None - plotvars.graph_ymax = None - plotvars.gpos_called = False - plotvars.mymap = None - plotvars.titles_con_called = False - - -def gpos(pos=1, xmin=None, xmax=None, ymin=None, ymax=None): - """ - | Set plot position. Plots start at top left and increase by one each plot - | to the right. When the end of the row has been reached then the next - | plot will be the leftmost plot on the next row down. - - | pos=pos - plot position - | - | The following four parameters are used to get full user control - | over the plot position. In addition to these cfp.gopen - | must have the user_position=True parameter set. - | xmin=None xmin in normalised coordinates - | xmax=None xmax in normalised coordinates - | ymin=None ymin in normalised coordinates - | ymax=None ymax in normalised coordinates - | - :Returns: - None - - """ - - # Reset mymap - plotvars.mymap = None - - # Check inputs are okay - if pos < 1 or pos > plotvars.rows * plotvars.columns: - errstr = ( - "pos error - pos out of range:\n range = 1 - " - f"{plotvars.rows * plotvars.columns}" - f"\n input pos was {pos}\n" - ) - raise Warning(errstr) - - user_pos = False - if all(val is not None for val in [xmin, xmax, ymin, ymax]): - user_pos = True - plotvars.plot_xmin = xmin - plotvars.plot_xmax = xmax - plotvars.plot_ymin = ymin - plotvars.plot_ymax = ymax - - # Reset any accumulated muliple graph limits - plotvars.graph_xmin = None - plotvars.graph_xmax = None - plotvars.graph_ymin = None - plotvars.graph_ymax = None - - # Set gpos_called - plotvars.gpos_called = True - - # Reset titles_con_called - plotvars.titles_con_called = False - - if user_pos is False: - plotvars.plot = plotvars.master_plot.add_subplot( - plotvars.rows, plotvars.columns, pos - ) - else: - delta_x = plotvars.plot_xmax - plotvars.plot_xmin - delta_y = plotvars.plot_ymax - plotvars.plot_ymin - - plotvars.plot = plotvars.master_plot.add_axes( - [plotvars.plot_xmin, plotvars.plot_ymin, delta_x, delta_y] - ) - - plotvars.plot.tick_params( - which="both", direction="out", right=True, top=True - ) - - # Set position in global variables - plotvars.pos = pos - - # Reset contour levels if they are not defined by the user - if plotvars.user_levs == 0: - if plotvars.levels_step is None: - levs() - else: - levs(step=plotvars.levels_step) diff --git a/cfplot/layout_runtime.py b/cfplot/layout_runtime.py new file mode 100644 index 0000000..12071cb --- /dev/null +++ b/cfplot/layout_runtime.py @@ -0,0 +1,601 @@ +"""Runtime layout/axes operations for refactored contour rendering. + +These helpers keep stateful calls to legacy cfplot plot orchestration out of +contour.py while preserving behaviour. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from typing import Any + +import matplotlib +import matplotlib.pyplot as plot + +from .state import plotvars +from .state import reset_runtime_state + + +def gopen( + rows: int = 1, + columns: int = 1, + user_plot: int = 1, + file: str = "cfplot.png", + orientation: str = "landscape", + figsize: list[float] | tuple[float, float] = (11.7, 8.3), + left: float | None = None, + right: float | None = None, + top: float | None = None, + bottom: float | None = None, + wspace: float | None = None, + hspace: float | None = None, + dpi: float | None = None, + user_position: bool = False, +) -> None: + """Open a contour-runtime graphics session compatible with cfplot.gopen.""" + plotvars.rows = rows + plotvars.columns = columns + if file != "cfplot.png": + plotvars.file = file + plotvars.orientation = orientation + + _open_figure( + user_plot=user_plot, + figsize=figsize, + orientation=orientation, + left=left, + right=right, + top=top, + bottom=bottom, + wspace=wspace, + hspace=hspace, + dpi=dpi, + user_position=user_position, + ) + + plotvars._contour_session_open = True + + +def gclose(view: bool = True) -> None: + """Close a contour-runtime graphics session and save/view output.""" + plotvars.user_plot = 0 + + interactive = bool(globals().get("__IPYTHON__", False)) + + if matplotlib.is_interactive(): + interactive = True + + saveargs = {} + if plotvars.tight: + saveargs = {"bbox_inches": "tight"} + + file = plotvars.file + figure = plotvars.master_plot or getattr(plotvars.plot, "figure", None) + if figure is not None and file is not None: + if os.path.splitext(file)[1].lower() not in (".ps", ".eps", ".png", ".pdf"): + file = file + ".png" + figure.savefig( + file, + orientation=plotvars.orientation, + dpi=plotvars.dpi, + **saveargs, + ) + plot.close(figure) + elif figure is not None: + if view and plotvars.viewer == "display" and not interactive: + disp = shutil.which("display") + if disp is not None: + tfile = "cfplot.png" + figure.savefig( + tfile, + orientation=plotvars.orientation, + dpi=plotvars.dpi, + **saveargs, + ) + matplotlib.pyplot.ioff() + subprocess.Popen([disp, tfile]) + else: + plotvars.viewer = "matplotlib" + + if view and (plotvars.viewer == "matplotlib" or interactive): + matplotlib.pyplot.ion() + plot.show() + elif not view: + plot.close(figure) + + reset_runtime_state() + + +def maybe_autosave() -> None: + """Auto-save/show and close when not inside an explicit gopen/gclose session.""" + session_open = bool(getattr(plotvars, "_contour_session_open", False)) + if not session_open: + gclose(view=True) + + +def set_axis_visibility( + axis: Any, + *, + axes: bool = True, + xaxis: bool = True, + yaxis: bool = True, +) -> None: + """Explicitly hide or show axis ticks and tick labels.""" + if axis is None: + return + + if not axes: + xaxis = False + yaxis = False + + if not xaxis: + axis.set_xticks([]) + axis.set_xticklabels([]) + axis.tick_params(bottom=False, top=False, labelbottom=False, labeltop=False) + + if not yaxis: + axis.set_yticks([]) + axis.set_yticklabels([]) + axis.tick_params(left=False, right=False, labelleft=False, labelright=False) + + +def ensure_runtime_session(pos: int = 1) -> bool: + """Ensure an implicit plotting session and default subplot when needed. + + Returns True when a session is implicitly managed by the caller + (i.e. `gopen` was auto-invoked because `plotvars.user_plot == 0`). + """ + auto_session = plotvars.user_plot == 0 + if auto_session: + gopen(user_plot=0) + + if plotvars.rows > 1 or plotvars.columns > 1: + if plotvars.gpos_called is False: + gpos(pos) + + return auto_session + + +def finalize_runtime_session( + *, + auto_session: bool, + reset_limits: bool = False, + reset_colour_scale: bool = False, + view: bool = True, +) -> None: + """Finalize an implicitly managed session. + + When `auto_session` is False this is a no-op so user-managed `gopen/gclose` + flows are preserved. + """ + if not auto_session: + return + + if reset_limits: + gset() + + if reset_colour_scale: + from .colour import cscale + + cscale() + + gclose(view=view) + + +def ensure_xy_viewport() -> None: + """Ensure a Cartesian viewport exists, matching legacy gopen/gpos behavior.""" + _reset_closed_figure_state() + + if plotvars.master_plot is None: + _open_figure(user_plot=0) + + if plotvars.plot is None or (plotvars.rows > 1 or plotvars.columns > 1): + if plotvars.gpos_called is False or plotvars.plot is None: + _select_position(1) + + +def set_plot_limits( + *, + xmin: float, + xmax: float, + ymin: float, + ymax: float, + ylog: bool, + user_gset: int, +) -> None: + """Apply graph limits through legacy gset interface.""" + plotvars.user_gset = user_gset + plotvars.xmin = xmin + plotvars.xmax = xmax + plotvars.ymin = ymin + plotvars.ymax = ymax + plotvars.ylog = ylog + + time_xstr = False + time_ystr = False + try: + float(xmin) + except Exception: + time_xstr = True + try: + float(ymin) + except Exception: + time_ystr = True + + if plotvars.plot is not None and not time_xstr and not time_ystr: + plotvars.plot.axis([xmin, xmax, ymin, ymax]) + + if ylog and plotvars.plot is not None: + plotvars.plot.set_yscale("log") + + +def gset( + xmin=None, + xmax=None, + ymin=None, + ymax=None, + xlog=False, + ylog=False, + user_gset=1, + twinx=None, + twiny=None, +): + """Set plot limits for non-longitude-latitude plots.""" + plotvars.user_gset = user_gset + + if all(val is None for val in [xmin, xmax, ymin, ymax]): + plotvars.xmin = None + plotvars.xmax = None + plotvars.ymin = None + plotvars.ymax = None + plotvars.xlog = False + plotvars.ylog = False + plotvars.twinx = False + plotvars.twiny = False + plotvars.user_gset = 0 + return + + bcount = 0 + for val in [xmin, xmax, ymin, ymax]: + if val is None: + bcount = bcount + 1 + + if bcount != 0 and bcount != 4: + errstr = ( + "gset error\n" + "xmin, xmax, ymin, ymax all need to be passed to gset\n" + "to set the plot limits\n" + ) + raise Warning(errstr) + + plotvars.xmin = xmin + plotvars.xmax = xmax + plotvars.ymin = ymin + plotvars.ymax = ymax + plotvars.xlog = xlog + plotvars.ylog = ylog + + time_xstr = False + time_ystr = False + try: + float(xmin) + except Exception: + time_xstr = True + try: + float(ymin) + except Exception: + time_ystr = True + + if plotvars.plot is not None and twinx is None and twiny is None: + if not time_xstr and not time_ystr: + plotvars.plot.axis( + [plotvars.xmin, plotvars.xmax, plotvars.ymin, plotvars.ymax] + ) + + if plotvars.xlog: + plotvars.plot.set_xscale("log") + if plotvars.ylog: + plotvars.plot.set_yscale("log") + + if twinx is not None: + plotvars.twinx = twinx + if twiny is not None: + plotvars.twiny = twiny + + +def apply_axes( + *, + plot_type: int, + xticks: Any, + yticks: Any, + xlabel: str | None, + ylabel: str | None, + xticklabels: Any | None, + yticklabels: Any | None, +) -> None: + """Apply axis labels/ticks through legacy axes handlers.""" + if plot_type == 1: + from .map_runtime import _apply_map_axes + + _apply_map_axes( + xticks=xticks, + yticks=yticks, + xlabel=xlabel, + ylabel=ylabel, + xticklabels=xticklabels, + yticklabels=yticklabels, + ) + else: + _apply_xy_axes( + xticks=xticks, + xticklabels=xticklabels, + yticks=yticks, + yticklabels=yticklabels, + xlabel=xlabel, + ylabel=ylabel, + ) + + +def axes( + xticks=None, + xticklabels=None, + yticks=None, + yticklabels=None, + xstep=None, + ystep=None, + xlabel=None, + ylabel=None, + title=None, +): + """Set axes plotting parameters in shared state.""" + if all( + val is None + for val in [ + xticks, + yticks, + xticklabels, + yticklabels, + xstep, + ystep, + xlabel, + ylabel, + title, + ] + ): + plotvars.xticks = None + plotvars.yticks = None + plotvars.xticklabels = None + plotvars.yticklabels = None + plotvars.xstep = None + plotvars.ystep = None + plotvars.xlabel = None + plotvars.ylabel = None + plotvars.title = None + return + + plotvars.xticks = xticks + plotvars.yticks = yticks + plotvars.xticklabels = xticklabels + plotvars.yticklabels = yticklabels + plotvars.xstep = xstep + plotvars.ystep = ystep + plotvars.xlabel = xlabel + plotvars.ylabel = ylabel + plotvars.title = title + + +def _open_figure( + user_plot: int = 0, + *, + figsize: list[float] | tuple[float, float] = (11.7, 8.3), + orientation: str | None = None, + left: float | None = None, + right: float | None = None, + top: float | None = None, + bottom: float | None = None, + wspace: float | None = None, + hspace: float | None = None, + dpi: float | None = None, + user_position: bool = False, +) -> None: + """Open a contour-owned figure and apply legacy-default layout.""" + if orientation is None: + orientation = plotvars.orientation + + if orientation == "portrait": + figshape = (figsize[1], figsize[0]) + elif orientation == "landscape": + figshape = (figsize[0], figsize[1]) + else: + raise Warning( + "gopen error\n" + "orientation incorrectly set\n" + f"input value was {orientation}\n" + "Valid options are portrait or landscape\n" + ) + + plotvars.master_plot = plot.figure(figsize=figshape) + + if left is None: + left = 0.12 + if right is None: + right = 0.92 + if top is None: + top = 0.95 + if bottom is None: + bottom = 0.1 if plotvars.rows >= 3 else 0.08 + if wspace is None: + wspace = 0.2 + if hspace is None: + hspace = 0.5 if plotvars.rows >= 3 else 0.2 + + plotvars.master_plot.subplots_adjust( + left=left, + right=right, + top=top, + bottom=bottom, + wspace=wspace, + hspace=hspace, + ) + + plotvars.user_plot = user_plot + plotvars.gpos_called = False + plotvars.plot_xmin = None + plotvars.plot_xmax = None + plotvars.plot_ymin = None + plotvars.plot_ymax = None + + if dpi is not None: + plotvars.dpi = dpi + + if not user_position and plotvars.rows == 1 and plotvars.columns == 1: + _select_position(1) + + if plotvars.columns > 2 or plotvars.rows > 2: + matplotlib.rcParams["xtick.major.size"] = 2 + matplotlib.rcParams["ytick.major.size"] = 2 + + +def _select_position(pos: int) -> None: + """Select subplot position in the contour-owned figure state.""" + _reset_closed_figure_state() + + if plotvars.master_plot is None: + _open_figure(user_plot=0) + + max_pos = plotvars.rows * plotvars.columns + if pos < 1 or pos > max_pos: + raise Warning( + "pos error - pos out of range:\n" + f" range = 1 - {max_pos}\n" + f" input pos was {pos}\n" + ) + + user_pos = all( + value is not None + for value in [ + plotvars.plot_xmin, + plotvars.plot_xmax, + plotvars.plot_ymin, + plotvars.plot_ymax, + ] + ) + + if not user_pos: + plotvars.plot = plotvars.master_plot.add_subplot( + plotvars.rows, plotvars.columns, pos + ) + else: + delta_x = plotvars.plot_xmax - plotvars.plot_xmin + delta_y = plotvars.plot_ymax - plotvars.plot_ymin + plotvars.plot = plotvars.master_plot.add_axes( + [plotvars.plot_xmin, plotvars.plot_ymin, delta_x, delta_y] + ) + + plotvars.plot.tick_params(which="both", direction="out", right=True, top=True) + plotvars.pos = pos + plotvars.gpos_called = True + plotvars.mymap = None + plotvars.graph_xmin = None + plotvars.graph_xmax = None + plotvars.graph_ymin = None + plotvars.graph_ymax = None + plotvars.titles_con_called = False + + if plotvars.user_levs == 0: + from .contour import levs + + if plotvars.levels_step is None: + levs() + else: + levs(step=plotvars.levels_step) + + +def _reset_closed_figure_state() -> None: + """Drop stale figure/axes handles after a window has been closed.""" + figure = plotvars.master_plot + if figure is None: + return + + try: + alive = matplotlib.pyplot.fignum_exists(figure.number) + except Exception: + alive = False + + if alive: + return + + plotvars.master_plot = None + plotvars.plot = None + plotvars.mymap = None + plotvars.gpos_called = False + + +def gpos( + pos: int = 1, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, +) -> None: + """Select a subplot position, matching the legacy cfplot.gpos API.""" + if all(val is not None for val in [xmin, xmax, ymin, ymax]): + plotvars.plot_xmin = xmin + plotvars.plot_xmax = xmax + plotvars.plot_ymin = ymin + plotvars.plot_ymax = ymax + + _select_position(pos) + + +def _apply_xy_axes( + *, + xticks: Any, + xticklabels: Any | None, + yticks: Any, + yticklabels: Any | None, + xlabel: str | None, + ylabel: str | None, +) -> None: + """Apply simple Cartesian axes labels and ticks.""" + if plotvars.plot is None: + return + + if xlabel: + plotvars.plot.set_xlabel( + xlabel, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) + if ylabel: + plotvars.plot.set_ylabel( + ylabel, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) + + if xticks is not None: + plotvars.plot.set_xticks(xticks) + plotvars.plot.set_xticklabels( + xticklabels if xticklabels is not None else xticks, + rotation=plotvars.xtick_label_rotation, + horizontalalignment=plotvars.xtick_label_align, + ) + + if yticks is not None: + plotvars.plot.set_yticks(yticks) + plotvars.plot.set_yticklabels( + yticklabels if yticklabels is not None else yticks, + rotation=plotvars.ytick_label_rotation, + horizontalalignment=plotvars.ytick_label_align, + ) + + for label in plotvars.plot.xaxis.get_ticklabels(): + label.set_fontsize(plotvars.axis_label_fontsize) + label.set_fontweight(plotvars.axis_label_fontweight) + for label in plotvars.plot.yaxis.get_ticklabels(): + label.set_fontsize(plotvars.axis_label_fontsize) + label.set_fontweight(plotvars.axis_label_fontweight) + diff --git a/cfplot/line.py b/cfplot/line.py index c23e19a..7634e57 100644 --- a/cfplot/line.py +++ b/cfplot/line.py @@ -1,18 +1,15 @@ import cf import numpy as np -from .graphic import gclose, gopen, gpos -from .mapping import _mapaxis -from .parameters import plotvars -from .utils import ( - _dim_titles, - _gvals, - _timeaxis, - cf_var_name, - find_z, - fix_floats, - generate_titles, +from .map_runtime import _apply_dim_titles +from .layout_runtime import ( + ensure_runtime_session, + finalize_runtime_session, + set_axis_visibility, ) +from .state import plotvars +from . import utility +from .utility import mapaxis def lineplot( @@ -127,15 +124,9 @@ def lineplot( return ################## - # Open a new plot is necessary + # Open a new plot if necessary ################## - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Call gpos(1) if not already called - if plotvars.rows > 1 or plotvars.columns > 1: - if plotvars.gpos_called is False: - gpos(1) + auto_session = ensure_runtime_session(pos=1) ################## # Extract required data @@ -192,7 +183,7 @@ def lineplot( # x label xlabel_units = str(getattr(f.construct(mydim), "Units", "")) plot_xlabel = ( - f"{cf_var_name(field=f, dim=mydim)} ({xlabel_units})" + f"{utility.cf_var_name(field=f, dim=mydim)} ({xlabel_units})" ) y = np.squeeze(f.array) @@ -262,7 +253,7 @@ def lineplot( if xlabel_units in ["meter", "metre", "m", "kilometer", "kilometre", "km"]: ztype = 2 - myz = find_z(f) + myz = utility.find_z(f) if cf_field and f.has_construct(myz): z_coord = f.construct(myz) if len(z_coord.array) > 1: @@ -402,19 +393,39 @@ def lineplot( if xticks is None: if plot_xlabel[0:3].lower() == "lon": - xticks, xticklabels = _mapaxis(minx, maxx, type=1) + xticks, xticklabels = mapaxis( + min_val=minx, + max_val=maxx, + axis_type=1, + degsym=bool(plotvars.degsym), + ) if plot_xlabel[0:3].lower() == "lat": - xticks, xticklabels = _mapaxis(minx, maxx, type=2) + xticks, xticklabels = mapaxis( + min_val=minx, + max_val=maxx, + axis_type=2, + degsym=bool(plotvars.degsym), + ) if cf_field: if xticks is None: if f.has_construct("T"): if np.size(f.construct("T").array) > 1: - xticks, xticklabels, plot_xlabel = _timeaxis(taxis) + xticks, xticklabels, plot_xlabel = utility.timeaxis( + taxis, + user_gset=plotvars.user_gset, + xmin=plotvars.xmin, + xmax=plotvars.xmax, + ymin=plotvars.ymin, + ymax=plotvars.ymax, + tspace_year=plotvars.tspace_year, + tspace_hour=plotvars.tspace_hour, + tspace_day=plotvars.tspace_day, + ) if xticks is None: - xticks, ymult = _gvals(dmin=minx, dmax=maxx, mod=mod) + xticks, ymult = utility.gvals(dmin=minx, dmax=maxx, mod=mod) # Fix long floating point numbers if necessary - fix_floats(xticks) + utility.fix_floats(xticks) xticklabels = xticks else: if xticklabels is None: @@ -425,15 +436,15 @@ def lineplot( if yticks is None: if abs(maxy - miny) > 1: if miny < maxy: - yticks, ymult = _gvals(dmin=miny, dmax=maxy, mod=mod) + yticks, ymult = utility.gvals(dmin=miny, dmax=maxy, mod=mod) if maxy < miny: - yticks, ymult = _gvals(dmin=maxy, dmax=miny, mod=mod) + yticks, ymult = utility.gvals(dmin=maxy, dmax=miny, mod=mod) else: - yticks, ymult = _gvals(dmin=miny, dmax=maxy, mod=mod) + yticks, ymult = utility.gvals(dmin=miny, dmax=maxy, mod=mod) # Fix long floating point numbers if necessary - fix_floats(yticks) + utility.fix_floats(yticks) if yticklabels is None: yticklabels = [] @@ -469,28 +480,18 @@ def lineplot( miny = plotvars.ymin maxy = plotvars.ymax - if axes: + if not axes: + plot_xlabel = "" + plot_ylabel = "" + else: if xaxis is not True: - xticks = [100000000] - xticklabels = xticks plot_xlabel = "" - if yaxis is not True: - yticks = [100000000] - yticklabels = yticks plot_ylabel = "" - else: - xticks = [100000000] - xticklabels = xticks - yticks = [100000000] - yticklabels = yticks - plot_xlabel = "" - plot_ylabel = "" - # Generate titles if requested if titles: - title_dims = generate_titles(f) + title_dims = utility.generate_titles(f) # Make graph if verbose: @@ -558,6 +559,8 @@ def lineplot( fontweight=plotvars.axis_label_fontweight, ) + set_axis_visibility(graph, axes=axes, xaxis=xaxis, yaxis=yaxis) + graph.plot( xpts, ypts, @@ -603,12 +606,23 @@ def lineplot( if titles: plotvars.plot = graph plotvars.plot_type = 0 - _dim_titles(title=title_dims) + _apply_dim_titles( + plot=plotvars.plot, + mymap=plotvars.mymap, + plot_type=plotvars.plot_type, + proj=plotvars.proj, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + axis_label_fontsize=plotvars.axis_label_fontsize, + axis_label_fontweight=plotvars.axis_label_fontweight, + title=title_dims, + ) ################## # Save or view plot ################## - if plotvars.user_plot == 0: - if verbose: - print("Saving or viewing plot") - gclose() + if auto_session and verbose: + print("Saving or viewing plot") + finalize_runtime_session(auto_session=auto_session, view=True) diff --git a/cfplot/map_runtime.py b/cfplot/map_runtime.py new file mode 100644 index 0000000..bd42d9a --- /dev/null +++ b/cfplot/map_runtime.py @@ -0,0 +1,1048 @@ +"""Contour-owned map setup/runtime helpers. + +This module contains the map-state and map-axes operations used by the +refactored contour renderer so it no longer needs map setup from cfplot.py. +""" + +from __future__ import annotations + +from typing import Any + +import cartopy.crs as ccrs +import numpy as np + +from . import utility +from .state import plotvars + + +def _apply_map_title( + *, + mymap: Any, + title: str, + proj: str, + boundinglat: float, + lon_0: float, + lonmin: float, + lonmax: float, + latmin: float, + latmax: float, + title_fontsize: int, + title_fontweight: str, +) -> Any: + """Draw a title on a map axes at the geographically correct position.""" + polar_range = 90 - abs(boundinglat) + myprojs = ["cyl", "robin", "moll", "merc"] + + if proj in myprojs: + lon_mid = lonmin + (lonmax - lonmin) / 2.0 + projs = [ccrs.PlateCarree, ccrs.Robinson, ccrs.Mollweide, ccrs.Mercator] + myind = myprojs.index(proj) + map_proj = projs[myind](central_longitude=lon_mid) + xpt, ypt = map_proj.transform_point(lon_mid, latmax, ccrs.PlateCarree()) + ypt = ypt + (latmax - latmin) / 40.0 + elif proj == "npstere": + mylon = lon_0 + 180 + mylat = boundinglat - polar_range / 15.0 + map_proj = ccrs.NorthPolarStereo(central_longitude=lon_0) + xpt, ypt = map_proj.transform_point(mylon, mylat, ccrs.PlateCarree()) + elif proj == "spstere": + mylon = lon_0 + mylat = boundinglat + polar_range / 15.0 + map_proj = ccrs.SouthPolarStereo(central_longitude=lon_0) + xpt, ypt = map_proj.transform_point(mylon, mylat, ccrs.PlateCarree()) + elif proj == "lcc": + lon_mid = lonmin + (lonmax - lonmin) / 2.0 + lat_0 = 40 + if latmin <= 0 and latmax <= 0: + lat_0 = 40 + map_proj = ccrs.LambertConformal( + central_longitude=lon_0, + central_latitude=lat_0, + cutoff=latmin, + ) + xpt, ypt = map_proj.transform_point(lon_mid, latmax, ccrs.PlateCarree()) + else: + return None + + return mymap.text( + xpt, + ypt, + title, + va="bottom", + ha="center", + rotation="horizontal", + rotation_mode="anchor", + fontsize=title_fontsize, + fontweight=title_fontweight, + ) + + +def _apply_dim_titles( + *, + plot: Any, + mymap: Any, + plot_type: int, + proj: str, + lonmin: float, + lonmax: float, + latmin: float, + latmax: float, + axis_label_fontsize: int, + axis_label_fontweight: str, + title: str | None = None, + title2: str | None = None, + title3: str | None = None, +) -> None: + """Draw a set of dimension titles on the active contour axes.""" + this_plot = mymap if plot_type == 1 else plot + + left, bottom, width, height = this_plot.get_position().bounds + valign = "bottom" + + if plot_type == 1 and proj != "cyl": + left -= 0.1 + myx = 1.25 + myy = 1.0 + valign = "top" + if title3 is None: + myx = 1.05 + elif plot_type == 1 and proj == "cyl": + lonrange = lonmax - lonmin + latrange = latmax - latmin + if (lonrange / latrange) > 1.5: + myx = 0.0 + myy = 1.02 + elif (lonrange / latrange) > 1.2: + myx = 0.0 + myy = 1.02 + height -= 0.015 + else: + left -= 0.1 + myx = 1.05 + myy = 1.0 + width -= 0.1 + valign = "top" + else: + height -= 0.1 + myx = 0.0 + myy = 1.02 + + if title3 is None: + this_plot.set_position([left, bottom, width, height]) + + xspacing = 0.3 + yspacing = 0.0 + if myx in (1.05, 1.25): + xspacing = 0.0 + yspacing = 0.2 + + if title is not None: + this_plot.text( + myx, + myy, + title, + va=valign, + ha="left", + fontsize=axis_label_fontsize, + fontweight=axis_label_fontweight, + transform=this_plot.transAxes, + ) + + if title2 is not None: + this_plot.text( + myx + xspacing, + myy - yspacing, + title2, + va=valign, + ha="left", + fontsize=axis_label_fontsize, + fontweight=axis_label_fontweight, + transform=this_plot.transAxes, + ) + + if title3 is not None: + this_plot.text( + myx + xspacing * 2, + myy - yspacing * 2, + title3, + va=valign, + ha="left", + fontsize=axis_label_fontsize, + fontweight=axis_label_fontweight, + transform=this_plot.transAxes, + ) + + +class MapSet: + """Stateful map setup for contour rendering.""" + + def __init__(self, pvars=plotvars): + self.plotvars = pvars + + def configure( + self, + *, + lonmin: float | None = None, + lonmax: float | None = None, + latmin: float | None = None, + latmax: float | None = None, + proj: str = "cyl", + boundinglat: float = 0, + lon_0: float = 0, + lat_0: float = 40, + resolution: str = "110m", + user_mapset: int = 1, + aspect: str | float | None = None, + ) -> None: + """Set map plotting parameters in shared state.""" + pv = self.plotvars + pv.resolution = resolution + + if ( + all(val is None for val in [lonmin, lonmax, latmin, latmax, aspect]) + and proj == "cyl" + ): + pv.lonmin = -180 + pv.lonmax = 180 + pv.latmin = -90 + pv.latmax = 90 + pv.proj = "cyl" + pv.user_mapset = 0 + pv.aspect = "equal" + pv.plot_xmin = None + pv.plot_xmax = None + pv.plot_ymin = None + pv.plot_ymax = None + return + + if aspect is None: + aspect = "equal" + pv.aspect = aspect + + if lonmin is None: + lonmin = -180 + if lonmax is None: + lonmax = 180 + if latmin is None: + latmin = -80 if proj == "merc" else -90 + if latmax is None: + latmax = 80 if proj == "merc" else 90 + + if proj == "moll": + lonmin = lon_0 - 180 + lonmax = lon_0 + 180 + + pv.lonmin = lonmin + pv.lonmax = lonmax + pv.latmin = latmin + pv.latmax = latmax + pv.proj = proj + pv.boundinglat = boundinglat + pv.lon_0 = lon_0 + pv.lat_0 = lat_0 + pv.user_mapset = user_mapset + + def ensure_map_axes(self) -> None: + """Create map axes in plotvars.mymap when not already present.""" + pv = self.plotvars + if pv.mymap is not None: + return + + if pv.plot is None: + ensure_map_viewport() + + extent = True + lonmin = pv.lonmin + lonmax = pv.lonmax + latmin = pv.latmin + latmax = pv.latmax + lon_diff = lonmax - lonmin + lon_mid = lonmin + lon_diff / 2.0 + + vproj = pv.proj + + if vproj in ("cyl", "merc") and lon_diff == 360.0: + lonmax += 0.01 + + if vproj == "cyl": + proj = ccrs.PlateCarree(central_longitude=lon_mid) + elif vproj == "merc": + min_latitude = -80.0 + if lonmin > min_latitude: + min_latitude = pv.lonmin + max_latitude = 84.0 + if lonmax < max_latitude: + max_latitude = pv.lonmax + proj = ccrs.Mercator( + central_longitude=pv.lon_0, + min_latitude=min_latitude, + max_latitude=max_latitude, + ) + elif vproj == "npstere": + proj = ccrs.NorthPolarStereo(central_longitude=pv.lon_0) + lonmin = pv.lon_0 - 180 + lonmax = pv.lon_0 + 180.01 + latmin = pv.boundinglat + latmax = 90 + elif vproj == "spstere": + proj = ccrs.SouthPolarStereo(central_longitude=pv.lon_0) + lonmin = pv.lon_0 - 180 + lonmax = pv.lon_0 + 180.01 + latmin = -90 + latmax = pv.boundinglat + elif vproj == "ortho": + proj = ccrs.Orthographic( + central_longitude=pv.lon_0, central_latitude=pv.lat_0 + ) + lonmin = pv.lon_0 - 180.0 + lonmax = pv.lon_0 + 180.01 + extent = False + elif vproj == "moll": + proj = ccrs.Mollweide(central_longitude=pv.lon_0) + lonmin = pv.lon_0 - 180.0 + lonmax = pv.lon_0 + 180.01 + extent = False + elif vproj == "robin": + proj = ccrs.Robinson(central_longitude=pv.lon_0) + elif vproj == "lcc": + lon_0 = lonmin + (lonmax - lonmin) / 2.0 + lat_0 = latmin + (latmax - latmin) / 2.0 + cutoff = -40 if lat_0 > 0 else 40 + standard_parallels = [33, 45] + if latmin <= 0 and latmax <= 0: + standard_parallels = [-45, -33] + proj = ccrs.LambertConformal( + central_longitude=lon_0, + central_latitude=lat_0, + cutoff=cutoff, + standard_parallels=standard_parallels, + ) + elif vproj == "rotated": + proj = ccrs.PlateCarree(central_longitude=lon_mid) + elif vproj == "OSGB": + proj = ccrs.OSGB() + elif vproj == "EuroPP": + proj = ccrs.EuroPP() + elif vproj == "UKCP": + proj = ccrs.TransverseMercator() + elif vproj == "TransverseMercator": + proj = ccrs.TransverseMercator() + lonmin = pv.lon_0 - 180.0 + lonmax = pv.lon_0 + 180.01 + extent = False + elif vproj == "LambertCylindrical": + proj = ccrs.LambertCylindrical() + else: + proj = ccrs.PlateCarree(central_longitude=lon_mid) + + if pv.plot_xmin: + delta_x = pv.plot_xmax - pv.plot_xmin + delta_y = pv.plot_ymax - pv.plot_ymin + mymap = pv.master_plot.add_axes( + [pv.plot_xmin, pv.plot_ymin, delta_x, delta_y], projection=proj + ) + else: + mymap = pv.master_plot.add_subplot( + pv.rows, pv.columns, pv.pos, projection=proj + ) + + set_extent = True + if vproj in ["OSGB", "EuroPP", "UKCP", "robin", "lcc"]: + set_extent = False + if extent and set_extent: + mymap.set_extent([lonmin, lonmax, latmin, latmax], crs=ccrs.PlateCarree()) + + if vproj == "cyl": + mymap.set_aspect(pv.aspect) + elif vproj == "lcc": + mymap.set_extent([lonmin, lonmax, latmin, latmax], crs=ccrs.PlateCarree()) + elif vproj == "UKCP": + mymap.set_extent([-11, 3, 49, 61], crs=ccrs.PlateCarree()) + elif vproj == "EuroPP": + mymap.set_extent([-12, 25, 30, 75], crs=ccrs.PlateCarree()) + + if pv.plot is not None: + pv.plot.set_frame_on(False) + pv.plot.set_xticks([]) + pv.plot.set_yticks([]) + + pv.mymap = mymap + + def draw_grid(self) -> None: + """Plot a graticule on the active map axes.""" + pv = self.plotvars + if pv.mymap is None: + return + if pv.proj != "cyl": + return + + lons = np.arange((360 / pv.grid_x_spacing) + 1) * pv.grid_x_spacing + lons = np.concatenate([lons - 360, lons]) + lats = np.arange((180 / pv.grid_y_spacing) + 1) * pv.grid_y_spacing - 90 + + pv.mymap.gridlines( + color=pv.grid_colour, + linewidth=pv.grid_thickness, + linestyle=pv.grid_linestyle, + xlocs=lons, + ylocs=lats, + zorder=pv.grid_zorder, + ) + + def draw_polar_axes(self) -> None: + """Draw graticule lines and longitude labels for polar stereographic plots. + + Replicates the legacy behaviour: latitude circles, longitude spokes, + and longitude text labels placed just outside the bounding latitude. + No latitude labels are drawn (matching legacy output). + """ + pv = self.plotvars + if pv.mymap is None: + return + vproj = pv.proj + if vproj not in ("npstere", "spstere"): + return + + mymap = pv.mymap + boundinglat = pv.boundinglat + lon_0 = pv.lon_0 + geodetic = ccrs.Geodetic() + + # --- latitude circles --- + latvals = np.arange(5) * 30 - 60 # [-60, -30, 0, 30, 60] + if vproj == "npstere": + latvals = latvals[latvals >= boundinglat] + else: + latvals = latvals[latvals <= boundinglat] + + for lat in latvals: + if abs(lat - boundinglat) > 1: + lons_line = np.arange(361, dtype=float) + lats_line = np.full(361, lat) + mymap.plot( + lons_line, lats_line, + color=pv.grid_colour, + linewidth=pv.grid_thickness, + linestyle=pv.grid_linestyle, + transform=geodetic, + ) + + # --- longitude spokes --- + lonvals = np.arange(7) * 60 # [0, 60, 120, 180, 240, 300, 360] + for lon in lonvals: + if vproj == "npstere": + lats_line = np.arange(90 - boundinglat) + boundinglat + else: + lats_line = np.arange(boundinglat + 91) - 90 + lons_line = np.full(lats_line.size, float(lon)) + mymap.plot( + lons_line, lats_line, + color=pv.grid_colour, + linewidth=pv.grid_thickness, + linestyle=pv.grid_linestyle, + transform=geodetic, + ) + + # --- longitude labels --- + if vproj == "npstere": + polar_proj = ccrs.NorthPolarStereo(central_longitude=lon_0) + latrange = 90 - abs(boundinglat) + latpt = boundinglat - latrange / 40.0 + else: + polar_proj = ccrs.SouthPolarStereo(central_longitude=lon_0) + latrange = 90 - abs(boundinglat) + latpt = boundinglat + latrange / 40.0 + + axis_label_fontsize = getattr(pv, "axis_label_fontsize", 11) + axis_label_fontweight = getattr(pv, "axis_label_fontweight", "normal") + + if axis_label_fontsize > 0: + for lon in lonvals: + # Build label the same way as legacy _mapaxis + lon2 = np.mod(lon + 180, 360) - 180 + degsym = r"$\degree$" if getattr(pv, "degsym", False) else "" + if lon2 < 0 and lon2 > -180: + label = str(abs(int(lon2))) + degsym + "W" + elif lon2 > 0 and lon2 <= 180: + label = str(int(lon2)) + degsym + "E" + elif lon2 == 0: + label = "0" + degsym + else: # 180 + label = "180" + degsym + + lonr, latr = polar_proj.transform_point( + lon, latpt, ccrs.PlateCarree() + ) + + v_align = "center" + if lonr < -1: + h_align = "right" + elif lonr > 1: + h_align = "left" + else: + h_align = "center" + if latr < 0: + v_align = "top" + else: + v_align = "bottom" + + mymap.text( + lonr, latr, label, + horizontalalignment=h_align, + verticalalignment=v_align, + fontsize=axis_label_fontsize, + fontweight=axis_label_fontweight, + zorder=101, + ) + + # Blank off corners to make the plot circular, then draw the bounding + # latitude circle and adjust axes limits (mirrors legacy behaviour). + lons_b = np.arange(360, dtype=float) + lats_b = np.full(360, float(boundinglat)) + device_coords = polar_proj.transform_points( + ccrs.PlateCarree(), lons_b, lats_b + ) + xmax_b = np.max(device_coords[:, 0]) + xmin_b = np.min(device_coords[:, 0]) + + pts = np.where(device_coords[:, 0] >= 0.0) + xpts = np.append( + device_coords[:, 0][pts], np.zeros(np.size(pts)) + xmax_b + ) + ypts = np.append( + device_coords[:, 1][pts], device_coords[:, 1][pts][::-1] + ) + mymap.fill(xpts, ypts, alpha=1.0, color="w", zorder=100) + + xpts = np.append( + np.zeros(np.size(pts)) + xmin_b, -1.0 * device_coords[:, 0][pts] + ) + ypts = np.append( + device_coords[:, 1][pts], device_coords[:, 1][pts][::-1] + ) + mymap.fill(xpts, ypts, alpha=1.0, color="w", zorder=100) + + mymap.set_frame_on(False) + + # Draw the bounding latitude circle + lons_circ = np.arange(361, dtype=float) + lats_circ = np.full(361, float(boundinglat)) + circle_coords = polar_proj.transform_points( + ccrs.PlateCarree(), lons_circ, lats_circ + ) + mymap.plot( + circle_coords[:, 0], circle_coords[:, 1], + color="k", zorder=100, clip_on=False, + ) + + # Expand axes limits slightly so labels are not clipped + xmax_ax = np.max(np.abs(mymap.set_xlim(None))) + mymap.set_xlim((-xmax_ax, xmax_ax), emit=False) + ymax_ax = np.max(np.abs(mymap.set_ylim(None))) + mymap.set_ylim((-ymax_ax, ymax_ax), emit=False) + + +def mapset( + lonmin: float | None = None, + lonmax: float | None = None, + latmin: float | None = None, + latmax: float | None = None, + proj: str = "cyl", + boundinglat: float = 0, + lon_0: float = 0, + lat_0: float = 40, + resolution: str = "110m", + user_mapset: int = 1, + aspect: str | float | None = None, +) -> None: + """Set map plotting parameters in shared state.""" + MapSet(plotvars).configure( + lonmin=lonmin, + lonmax=lonmax, + latmin=latmin, + latmax=latmax, + proj=proj, + boundinglat=boundinglat, + lon_0=lon_0, + lat_0=lat_0, + resolution=resolution, + user_mapset=user_mapset, + aspect=aspect, + ) + + +def ensure_map_viewport() -> None: + """Ensure a map viewport exists without resetting an existing map axes.""" + if plotvars.mymap is None: + from .layout_runtime import ( + _open_figure, + _reset_closed_figure_state, + _select_position, + ) + + _reset_closed_figure_state() + + if plotvars.master_plot is None: + _open_figure(user_plot=0) + + if plotvars.plot is None or (plotvars.rows > 1 or plotvars.columns > 1): + if plotvars.gpos_called is False or plotvars.plot is None: + _select_position(1) + + +def _ensure_map_axes() -> None: + """Ensure map axes exist on the active plot state.""" + MapSet(plotvars).ensure_map_axes() + + +def _apply_map_axes_with_toggles( + *, + axes: bool = True, + xaxis: bool = True, + yaxis: bool = True, + xticks=None, + xticklabels=None, + yticks=None, + yticklabels=None, + user_xlabel: str | None = None, + user_ylabel: str | None = None, +) -> None: + """Apply map axes while honoring axes/xaxis/yaxis visibility toggles.""" + xlabel = user_xlabel + ylabel = user_ylabel + map_xticks = xticks + map_yticks = yticks + map_xticklabels = xticklabels + map_yticklabels = yticklabels + + if not axes: + map_xticks = [] + map_yticks = [] + xlabel = "" + ylabel = "" + else: + if not xaxis: + map_xticks = [] + map_xticklabels = [] + xlabel = "" + if not yaxis: + map_yticks = [] + map_yticklabels = [] + ylabel = "" + + _apply_map_axes( + xticks=map_xticks, + yticks=map_yticks, + xlabel=xlabel, + ylabel=ylabel, + xticklabels=map_xticklabels, + yticklabels=map_yticklabels, + ) + + if plotvars.proj in ("npstere", "spstere"): + MapSet(plotvars).draw_polar_axes() + + +def _apply_current_map_title(title: str | None) -> None: + """Apply a title using the current global map state values.""" + if title is None or plotvars.mymap is None: + return + + _apply_map_title( + mymap=plotvars.mymap, + title=title, + proj=plotvars.proj, + boundinglat=plotvars.boundinglat, + lon_0=plotvars.lon_0, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + title_fontsize=plotvars.title_fontsize, + title_fontweight=plotvars.title_fontweight, + ) + + +def _apply_map_features( + *, + mymap: Any, + continent_color: str | None = None, + continent_thickness: float | None = None, + continent_linestyle: str | None = None, + kwargs: dict[str, Any] | None = None, +) -> None: + """Apply coastlines and ocean/land/lake feature colors to a map axes. + + This centralizes the common map feature colouring logic. + """ + if mymap is None: + return + + if kwargs is None: + kwargs = {} + + import cartopy.feature as cfeature + + feature = cfeature.NaturalEarthFeature( + name="land", + category="physical", + scale=plotvars.resolution, + facecolor="none", + ) + mymap.add_feature( + feature, + edgecolor=continent_color or "k", + linewidth=continent_thickness or 1.5, + linestyle=continent_linestyle or "solid", + zorder=kwargs.get("zorder", 1), + ) + + if plotvars.ocean_color is not None: + mymap.add_feature( + cfeature.OCEAN, + edgecolor="face", + facecolor=plotvars.ocean_color, + zorder=plotvars.feature_zorder, + ) + if plotvars.land_color is not None: + mymap.add_feature( + cfeature.LAND, + edgecolor="face", + facecolor=plotvars.land_color, + zorder=plotvars.feature_zorder, + ) + if plotvars.lake_color is not None: + mymap.add_feature( + cfeature.LAKES, + edgecolor="face", + facecolor=plotvars.lake_color, + zorder=plotvars.feature_zorder, + ) + + +def _apply_map_axes( + *, + xticks, + yticks, + xlabel, + ylabel, + xticklabels, + yticklabels, +) -> None: + """Apply map axes labels/ticks without using legacy cfplot map helpers.""" + map_ax = plotvars.mymap + if map_ax is None: + return + axes = True + xaxis = True + yaxis = True + + if plotvars.proj == "cyl": + lon_ticks = xticks + lon_labels = xticklabels + lat_ticks = yticks + lat_labels = yticklabels + lonrange = plotvars.lonmax - plotvars.lonmin + lon_mid = plotvars.lonmin + lonrange / 2.0 + xticklen = (plotvars.lonmax - plotvars.lonmin) * 0.007 + yticklen = (plotvars.latmax - plotvars.latmin) * 0.014 + + if lon_ticks is None: + lon_ticks, lon_labels = utility.mapaxis( + min_val=plotvars.lonmin, + max_val=plotvars.lonmax, + axis_type=1, + degsym=bool(plotvars.degsym), + ) + if lat_ticks is None: + lat_ticks, lat_labels = utility.mapaxis( + min_val=plotvars.latmin, + max_val=plotvars.latmax, + axis_type=2, + degsym=bool(plotvars.degsym), + ) + + if lon_ticks is not None: + lon_ticks_new = list(lon_ticks) + # Avoid wrapped endpoint labels overlapping for global ranges. + if lonrange >= 360 and len(lon_ticks_new) >= 2: + lon_ticks_new[0] = lon_ticks_new[0] + 0.01 + lon_ticks_new[-1] = lon_ticks_new[-1] - 0.01 + + map_ax.set_xticks(lon_ticks_new, crs=ccrs.PlateCarree()) + map_ax.set_xticklabels( + lon_labels if lon_labels is not None else lon_ticks_new, + rotation=plotvars.xtick_label_rotation, + horizontalalignment=plotvars.xtick_label_align, + ) + + # Match legacy behavior: draw top-edge major tick marks manually. + if plotvars.plot_type == 1: + proj = ccrs.PlateCarree(central_longitude=lon_mid) + for xval in lon_ticks_new: + xpt, ypt = proj.transform_point( + xval, plotvars.latmax, ccrs.PlateCarree() + ) + ypt2 = ypt + yticklen + map_ax.plot( + [xpt, xpt], + [ypt, ypt2], + color="k", + linewidth=0.8, + clip_on=False, + ) + if lat_ticks is not None: + map_ax.set_yticks(lat_ticks, crs=ccrs.PlateCarree()) + map_ax.set_yticklabels( + lat_labels if lat_labels is not None else lat_ticks, + rotation=plotvars.ytick_label_rotation, + horizontalalignment=plotvars.ytick_label_align, + ) + + # Match legacy behavior: draw right-edge major tick marks manually. + if plotvars.plot_type == 1: + proj = ccrs.PlateCarree(central_longitude=lon_mid) + for ytick in lat_ticks: + xpt, ypt = proj.transform_point( + plotvars.lonmax - 0.001, ytick, ccrs.PlateCarree() + ) + xpt2 = xpt + xticklen + map_ax.plot( + [xpt, xpt2], + [ypt, ypt], + color="k", + linewidth=0.8, + clip_on=False, + ) + + for label in map_ax.xaxis.get_ticklabels(): + label.set_fontsize(plotvars.axis_label_fontsize) + label.set_fontweight(plotvars.axis_label_fontweight) + for label in map_ax.yaxis.get_ticklabels(): + label.set_fontsize(plotvars.axis_label_fontsize) + label.set_fontweight(plotvars.axis_label_fontweight) + + if plotvars.proj == "lcc": + lonmin = plotvars.lonmin + lonmax = plotvars.lonmax + latmin = plotvars.latmin + latmax = plotvars.latmax + lon_0 = lonmin + (lonmax - lonmin) / 2.0 + lat_0 = latmin + (latmax - latmin) / 2.0 + standard_parallels = [33, 45] + if latmin <= 0 and latmax <= 0: + standard_parallels = [-45, -33] + + proj = ccrs.LambertConformal( + central_longitude=lon_0, + central_latitude=lat_0, + cutoff=40, + standard_parallels=standard_parallels, + ) + + ymin, ymax = map_ax.set_ylim(None) + map_ax.set_ylim(ymin * 1.05, ymax, emit=False) + map_ax.set_ylim(None) + + lons = np.arange(lonmax - lonmin + 1) + lonmin + lats = np.arange(latmax - latmin + 1) + latmin + + # Mask left and right of plot + lons_lr = np.zeros(np.size(lats)) + lonmin + device_coords = proj.transform_points(ccrs.PlateCarree(), lons_lr, lats) + xmin = np.min(device_coords[:, 0]) + xmax = np.max(device_coords[:, 0]) + if lat_0 > 0: + ymin_lr = np.min(device_coords[:, 1]) + ymax_lr = np.max(device_coords[:, 1]) + else: + ymin_lr = np.max(device_coords[:, 1]) + ymax_lr = np.min(device_coords[:, 1]) + + map_ax.fill( + [xmin, xmin, xmax, xmin], + [ymin_lr, ymax_lr, ymax_lr, ymin_lr], + alpha=1.0, + color="w", + zorder=100, + ) + map_ax.plot( + [xmin, xmax], [ymin_lr, ymax_lr], color="k", zorder=101, clip_on=False + ) + + map_ax.fill( + [-xmin, -xmin, -xmax, -xmin], + [ymin_lr, ymax_lr, ymax_lr, ymin_lr], + alpha=1.0, + color="w", + zorder=100, + ) + map_ax.plot( + [-xmin, -xmax], [ymin_lr, ymax_lr], color="k", zorder=101, clip_on=False + ) + + # Upper mask/boundary + lats_top = np.zeros(np.size(lons)) + latmax + device_coords = proj.transform_points(ccrs.PlateCarree(), lons, lats_top) + ymax_top = np.max(device_coords[:, 1]) + xpts = np.append(device_coords[:, 0], device_coords[:, 0][::-1]) + ypts = np.append(device_coords[:, 1], np.zeros(np.size(lons)) + ymax_top) + map_ax.fill(xpts, ypts, alpha=1.0, color="w", zorder=100) + map_ax.plot( + device_coords[:, 0], device_coords[:, 1], color="k", zorder=101, clip_on=False + ) + + # Lower mask/boundary + lats_bottom = np.zeros(np.size(lons)) + latmin + device_coords = proj.transform_points( + ccrs.PlateCarree(), lons, lats_bottom + ) + ymin_bottom = np.min(device_coords[:, 1]) * 1.05 + xpts = np.append(device_coords[:, 0], device_coords[:, 0][::-1]) + ypts = np.append(device_coords[:, 1], np.zeros(np.size(lons)) + ymin_bottom) + map_ax.fill(xpts, ypts, alpha=1.0, color="w", zorder=100) + map_ax.plot( + device_coords[:, 0], device_coords[:, 1], color="k", zorder=101, clip_on=False + ) + + map_ax.set_frame_on(False) + + if axes and xaxis: + if xticks is None: + map_xticks, map_xticklabels = utility.mapaxis( + min_val=plotvars.lonmin, + max_val=plotvars.lonmax, + axis_type=1, + degsym=bool(plotvars.degsym), + ) + else: + map_xticks = xticks + map_xticklabels = xticks if xticklabels is None else xticklabels + + lats_x = np.arange(latmax - latmin + 1) + latmin + for tick, tick_label in zip(map_xticks, map_xticklabels): + lons_x = np.zeros(np.size(lats_x)) + tick + device_coords = proj.transform_points( + ccrs.PlateCarree(), lons_x, lats_x + ) + map_ax.plot( + device_coords[:, 0], + device_coords[:, 1], + linewidth=plotvars.grid_thickness, + linestyle=plotvars.grid_linestyle, + color=plotvars.grid_colour, + zorder=101, + ) + + latpt = latmin - 3 + if lat_0 < 0: + latpt = latmax + 1 + dpt = proj.transform_point(tick, latpt, ccrs.PlateCarree()) + map_ax.text( + dpt[0], + dpt[1], + tick_label, + horizontalalignment="center", + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + zorder=101, + ) + + if axes and yaxis: + if yticks is None: + map_yticks, map_yticklabels = utility.mapaxis( + min_val=plotvars.latmin, + max_val=plotvars.latmax, + axis_type=2, + degsym=bool(plotvars.degsym), + ) + else: + map_yticks = yticks + map_yticklabels = yticks if yticklabels is None else yticklabels + + lons_y = np.arange(lonmax - lonmin + 1) + lonmin + for tick, tick_label in zip(map_yticks, map_yticklabels): + lats_y = np.zeros(np.size(lons_y)) + tick + device_coords = proj.transform_points( + ccrs.PlateCarree(), lons_y, lats_y + ) + map_ax.plot( + device_coords[:, 0], + device_coords[:, 1], + linewidth=plotvars.grid_thickness, + linestyle=plotvars.grid_linestyle, + color=plotvars.grid_colour, + zorder=101, + ) + + dpt_l = proj.transform_point(lonmin - 1, tick, ccrs.PlateCarree()) + map_ax.text( + dpt_l[0], + dpt_l[1], + tick_label, + horizontalalignment="right", + verticalalignment="center", + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + zorder=101, + ) + + dpt_r = proj.transform_point(lonmax + 1, tick, ccrs.PlateCarree()) + map_ax.text( + dpt_r[0], + dpt_r[1], + tick_label, + horizontalalignment="left", + verticalalignment="center", + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + zorder=101, + ) + + if plotvars.proj == "UKCP" and plotvars.grid: + lons = ( + np.arange((360 / plotvars.grid_x_spacing) + 1) + * plotvars.grid_x_spacing + ) + lons = np.concatenate([lons - 360, lons]) + lats = ( + np.arange((180 / plotvars.grid_y_spacing) + 1) + * plotvars.grid_y_spacing + - 90 + ) + + map_ax.gridlines( + color=plotvars.grid_colour, + linewidth=plotvars.grid_thickness, + linestyle=plotvars.grid_linestyle, + xlocs=lons, + ylocs=lats, + zorder=plotvars.grid_zorder, + ) + + if xlabel: + map_ax.text( + 0.5, + -0.10, + xlabel, + va="bottom", + ha="center", + rotation="horizontal", + rotation_mode="anchor", + transform=map_ax.transAxes, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) + if ylabel: + map_ax.text( + -0.05, + 0.50, + ylabel, + va="bottom", + ha="center", + rotation="vertical", + rotation_mode="anchor", + transform=map_ax.transAxes, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) diff --git a/cfplot/parameters/__init__.py b/cfplot/parameters/__init__.py deleted file mode 100644 index 8319564..0000000 --- a/cfplot/parameters/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .parameters import ( - allvars_defaults, - axes, - cscale, - cscale1, - global_blockfill, - global_fill, - global_lines, - gset, - levs, - mapset, - plotvars, - plotvars_defaults, - pvars, - reset, - setvars, - setvars_defaults, - viridis, -) diff --git a/cfplot/parameters/parameters.py b/cfplot/parameters/parameters.py deleted file mode 100644 index 965812a..0000000 --- a/cfplot/parameters/parameters.py +++ /dev/null @@ -1,1217 +0,0 @@ -import os -import sys - -import matplotlib -import numpy as np -from scipy import interpolate - -# TODO remove these and use the matploltib versions! Why on earth would -# they be hard-coded in cf-plot??? -# -# Default colour scales -# cscale1 is a differential data scale - blue to red -cscale1 = [ - "#0a3278", - "#0f4ba5", - "#1e6ec8", - "#3ca0f0", - "#50b4fa", - "#82d2ff", - "#a0f0ff", - "#c8faff", - "#e6ffff", - "#fffadc", - "#ffe878", - "#ffc03c", - "#ffa000", - "#ff6000", - "#ff3200", - "#e11400", - "#c00000", - "#a50000", -] -# viridis is a continuous data scale - blue, green, yellow -viridis = [ - "#440154", - "#440255", - "#440357", - "#450558", - "#45065a", - "#45085b", - "#46095c", - "#460b5e", - "#460c5f", - "#460e61", - "#470f62", - "#471163", - "#471265", - "#471466", - "#471567", - "#471669", - "#47186a", - "#48196b", - "#481a6c", - "#481c6e", - "#481d6f", - "#481e70", - "#482071", - "#482172", - "#482273", - "#482374", - "#472575", - "#472676", - "#472777", - "#472878", - "#472a79", - "#472b7a", - "#472c7b", - "#462d7c", - "#462f7c", - "#46307d", - "#46317e", - "#45327f", - "#45347f", - "#453580", - "#453681", - "#443781", - "#443982", - "#433a83", - "#433b83", - "#433c84", - "#423d84", - "#423e85", - "#424085", - "#414186", - "#414286", - "#404387", - "#404487", - "#3f4587", - "#3f4788", - "#3e4888", - "#3e4989", - "#3d4a89", - "#3d4b89", - "#3d4c89", - "#3c4d8a", - "#3c4e8a", - "#3b508a", - "#3b518a", - "#3a528b", - "#3a538b", - "#39548b", - "#39558b", - "#38568b", - "#38578c", - "#37588c", - "#37598c", - "#365a8c", - "#365b8c", - "#355c8c", - "#355d8c", - "#345e8d", - "#345f8d", - "#33608d", - "#33618d", - "#32628d", - "#32638d", - "#31648d", - "#31658d", - "#31668d", - "#30678d", - "#30688d", - "#2f698d", - "#2f6a8d", - "#2e6b8e", - "#2e6c8e", - "#2e6d8e", - "#2d6e8e", - "#2d6f8e", - "#2c708e", - "#2c718e", - "#2c728e", - "#2b738e", - "#2b748e", - "#2a758e", - "#2a768e", - "#2a778e", - "#29788e", - "#29798e", - "#287a8e", - "#287a8e", - "#287b8e", - "#277c8e", - "#277d8e", - "#277e8e", - "#267f8e", - "#26808e", - "#26818e", - "#25828e", - "#25838d", - "#24848d", - "#24858d", - "#24868d", - "#23878d", - "#23888d", - "#23898d", - "#22898d", - "#228a8d", - "#228b8d", - "#218c8d", - "#218d8c", - "#218e8c", - "#208f8c", - "#20908c", - "#20918c", - "#1f928c", - "#1f938b", - "#1f948b", - "#1f958b", - "#1f968b", - "#1e978a", - "#1e988a", - "#1e998a", - "#1e998a", - "#1e9a89", - "#1e9b89", - "#1e9c89", - "#1e9d88", - "#1e9e88", - "#1e9f88", - "#1ea087", - "#1fa187", - "#1fa286", - "#1fa386", - "#20a485", - "#20a585", - "#21a685", - "#21a784", - "#22a784", - "#23a883", - "#23a982", - "#24aa82", - "#25ab81", - "#26ac81", - "#27ad80", - "#28ae7f", - "#29af7f", - "#2ab07e", - "#2bb17d", - "#2cb17d", - "#2eb27c", - "#2fb37b", - "#30b47a", - "#32b57a", - "#33b679", - "#35b778", - "#36b877", - "#38b976", - "#39b976", - "#3bba75", - "#3dbb74", - "#3ebc73", - "#40bd72", - "#42be71", - "#44be70", - "#45bf6f", - "#47c06e", - "#49c16d", - "#4bc26c", - "#4dc26b", - "#4fc369", - "#51c468", - "#53c567", - "#55c666", - "#57c665", - "#59c764", - "#5bc862", - "#5ec961", - "#60c960", - "#62ca5f", - "#64cb5d", - "#67cc5c", - "#69cc5b", - "#6bcd59", - "#6dce58", - "#70ce56", - "#72cf55", - "#74d054", - "#77d052", - "#79d151", - "#7cd24f", - "#7ed24e", - "#81d34c", - "#83d34b", - "#86d449", - "#88d547", - "#8bd546", - "#8dd644", - "#90d643", - "#92d741", - "#95d73f", - "#97d83e", - "#9ad83c", - "#9dd93a", - "#9fd938", - "#a2da37", - "#a5da35", - "#a7db33", - "#aadb32", - "#addc30", - "#afdc2e", - "#b2dd2c", - "#b5dd2b", - "#b7dd29", - "#bade27", - "#bdde26", - "#bfdf24", - "#c2df22", - "#c5df21", - "#c7e01f", - "#cae01e", - "#cde01d", - "#cfe11c", - "#d2e11b", - "#d4e11a", - "#d7e219", - "#dae218", - "#dce218", - "#dfe318", - "#e1e318", - "#e4e318", - "#e7e419", - "#e9e419", - "#ece41a", - "#eee51b", - "#f1e51c", - "#f3e51e", - "#f6e61f", - "#f8e621", - "#fae622", - "#fde724", -] - - -# Read in defaults if they exist and overlay -# for contour options of fill, blockfill and lines -global_fill = True -global_lines = True -global_blockfill = False -global_degsym = False -global_viewer = "display" -defaults_file = os.path.expanduser("~") + "/.cfplot_defaults" -if os.path.exists(defaults_file): - with open(defaults_file) as file: - for line in file: - vals = line.split(" ") - com, val = vals - v = False - if val.strip() == "True": - v = True - if com == "blockfill": - global_blockfill = v - if com == "lines": - global_lines = v - if com == "fill": - global_fill = v - if com == "degsym": - global_degsym = v - if com == "viewer": - global_viewer = val.strip() - - -"""Global plotting variables.""" -# These are plotting variables that 'setvars' can adjust -setvars_defaults = { - # TODO check docstring defaults are correct to match these - # - # Output graphics file saving and viewing - "viewer": global_viewer, - "file": None, - # Output graphics file general config. - "dpi": None, - "tight": False, - # 'tspace' related - # TODO clarify what tspace is - "tspace_year": None, - "tspace_month": None, - "tspace_day": None, - "tspace_hour": None, - # 2. Tick labelling - "xtick_label_rotation": 0, - "xtick_label_align": "center", - "ytick_label_rotation": 0, - "ytick_label_align": "right", - # Font sizes - "text_fontsize": 11, - "axis_label_fontsize": 11, - "colorbar_fontsize": 11, - "title_fontsize": 15, - "master_title_fontsize": 30, - # TODO change for consistent name with the above, text_size -> fontsize - "legend_text_size": 11, - # Font weights - "fontweight": "normal", - "text_fontweight": "normal", - "axis_label_fontweight": "normal", - "colorbar_fontweight": "normal", - "title_fontweight": "normal", - "master_title_fontweight": "normal", - # TODO change for consistent name with the above, text_weight -> fontweight - "legend_text_weight": "normal", - # Master title - "master_title": None, - "master_title_location": [0.5, 0.95], - # Legend frame related - "legend_frame": True, - "legend_frame_edge_color": "k", - "legend_frame_face_color": None, - # Grid related - "grid": True, - "grid_x_spacing": 60, - "grid_y_spacing": 30, - "grid_zorder": 100, - "grid_colour": "k", # TODO API -> American spelling for consistency - "grid_linestyle": "--", - "grid_thickness": 1.0, - # Rotated grid related - # TODO SB why does rotated grid have separate config. like this? - "rotated_grid_spacing": 10, - "rotated_deg_spacing": 0.75, - "rotated_continents": True, - "rotated_grid": True, - "rotated_grid_thickness": 1.0, - "rotated_labels": True, - # Feature additions - "feature_zorder": 999, - "land_color": None, - "ocean_color": None, - "lake_color": None, - "continent_color": None, - # Continent feature related - "continent_thickness": None, - "continent_linestyle": None, - # Axis related - "axis_width": None, - "degsym": global_degsym, - # Contour plot only - "level_spacing": None, - # Misc. - "cs_uniform": True, # make a uniform differential colour scale -} -# These are further plotting variables that are set globally and cannot -# be set by 'setvars'. -plotvars_defaults = { - # 0. Top-level plotting config. eg. type, projection, - "global_viewer": global_viewer, - "plot_type": 1, - "master_plot": None, - "plot": None, - "mymap": None, - "proj": "cyl", - "resolution": "110m", - "norm": None, - # 1. Maxima and minima - # 1a) For lat and lon - "lonmin": -180, - "lonmax": 180, - "latmin": -90, - "latmax": 90, - # 1b) for x and y - "xmin": None, - "xmax": None, - "ymin": None, - "ymax": None, - # 1c) for the plot x and y boundaries - "plot_xmin": None, - "plot_xmax": None, - "plot_ymin": None, - "plot_ymax": None, - # 1d) for the graph x and y boundaries - "graph_xmin": None, - "graph_xmax": None, - "graph_ymin": None, - "graph_ymax": None, - # 2. Levels related - "levels": None, - "levels_min": None, - "levels_max": None, - "levels_step": None, - "levels_extend": "both", - # 3. Ticks and labels related - "xticks": None, - "xticklabels": None, - "xlabel": None, - "yticks": None, - "yticklabels": None, - "ylabel": None, - # 4. Colour scale related - "cs": cscale1, - "cscale_flag": 0, - # 5. Position related - "pos": 1, - "gpos_called": False, - # 6. Lat and lon boundaries and centering - "boundinglat": 0, - "lon_0": 0, - # 7. Log scale related - "xlog": None, - "ylog": None, - # 8. Twin related - "twinx": False, - "twiny": False, - # 9. Step related - "xstep": None, - "ystep": None, - # 10. 'User' settings - "user_mapset": 0, - "user_gset": 0, - "user_levs": 0, - "user_plot": 0, - "cs_user": "cscale1", # TODO rename to 'user_cs' for consistency? - # 11. Rows and columns - "rows": 1, - "columns": 1, - # 12. Titles - "title": None, - "titles_con_called": False, - # 13. General alignment - "orientation": "landscape", - "aspect": "equal", -} - - -class pvars: - """Stores plotting variables in `cfp.plotvars`.""" - - def __init__(self, **kwargs): - """Initialize a new Pvars instance.""" - for attr, value in kwargs.items(): - setattr(self, attr, value) - - def __str__(self): - """`x.__str__() <==> str(x)`""" - a = None - v = None - out = [f"{a} = {repr(v)}"] - for a, v in self.__dict__.items(): - return "\n".join(out) - - -allvars_defaults = {**setvars_defaults, **plotvars_defaults} -plotvars = pvars(**allvars_defaults) - -# Check for iPython notebook inline -# and set the viewer to None if found -is_inline = "inline" in matplotlib.get_backend() -if is_inline: - plotvars.viewer = None - -# Check for OSX and if so use matplotlib for for the viewer -# Not all users will have ImageMagick installed / XQuartz running -# Users can still select this with cfp.setvars(viewer='display') -if sys.platform == "darwin": - plotvars.global_viewer = "matplotlib" - plotvars.viewer = "matplotlib" - - -def setvars(**kwargs): - """ - | Set plotting variables and their defaults. - | - | file=None - output file name - | title_fontsize=None - title fontsize, default=15 - | title_fontweight='normal' - title fontweight - | text_fontsize='normal' - text font size, default=11 - | text_fontweight='normal' - text font weight - | axis_label_fontsize=None - axis label fontsize, default=11 - | axis_label_fontweight='normal' - axis font weight - | legend_text_size='11' - legend text size - | legend_text_weight='normal' - legend text weight - | colorbar_fontsize='11' - colorbar text size - | colorbar_fontweight='normal' - colorbar font weight - | legend_text_weight='normal' - legend text weight - | master_title_fontsize=30 - master title font size - | master_title_fontweight='normal' - master title font weight - | continent_thickness=1.5 - default=1.5 - | continent_color='k' - default='k' (black) - | continent_linestyle='solid' - default='k' (black) - | viewer='display' - use ImageMagick display program - | 'matplotlib' to use image widget to view the picture - | tspace_year=None - time axis spacing in years - | tspace_month=None - time axis spacing in months - | tspace_day=None - time axis spacing in days - | tspace_hour=None - time axis spacing in hours - | xtick_label_rotation=0 - rotation of xtick labels - | xtick_label_align='center' - alignment of xtick labels - | ytick_label_rotation=0 - rotation of ytick labels - | ytick_label_align='right' - alignment of ytick labels - - | cs_uniform=True - make a uniform differential colour scale - | master_title=None - master title text - | master_title_location=[0.5,0.95] - master title location - | dpi=None - dots per inch setting - | land_color=None - land colour - | ocean_color=None - ocean colour - | lake_color=None - lake colour - | feature_zorder - plotting zorder for above three features, default=999 - | rotated_grid_spacing=10 - rotated grid spacing in degrees - | rotated_deg_spacing=0.75 - rotated grid spacing between graticule dots - | rotated_deg_tkickness=1.0 - rotated grid thickness for longitude and - | latitude lines - | rotated_continents=True - draw rotated continents - | rotated_grid=True - draw rotated grid - | rotated_grid=1.0 - TODO, default 1.0 - | rotated_labels=True - draw rotated grid labels - | legend_frame=True - draw a frame around a lineplot legend - | legend_frame_edge_color='k' - color for the legend frame - | legend_frame_face_color=None - color for the legend background - | degsym=True - add degree symbol to longitude and latitude axis labels - | axis_width=None - width of line for the axes - | grid=True - draw grid - | grid_x_spacing=60 - grid longitude spacing in degrees - | grid_x_spacing=30 - grid latitude spacing in degrees - | grid_colour='k' - grid colour - | grid_linestyle='--' - grid line style - | grid_zorder=100 - plotting order for the grid lines - | grid_thickness=1.0 - grid thickness - | tight=False - remove whitespace around the plot - | level_spacing=None - default contour level spacing - takes 'linear', - | 'log', 'loglike', 'outlier' and 'inspect' - | - | Use setvars() to reset to the defaults - | - :Returns: - name - """ - # Set defaults first to ensure everything is assigned a valid value - for def_var, def_value in setvars_defaults.items(): - setattr(plotvars, def_var, def_value) - # Now override with anything the user specifies, which is unlikely to - # be a large listing relative to the amount set as defaults above - if kwargs: - # TODO eventually add kwarg value validation e.g. type checking? - for set_var, set_value in kwargs.items(): - - # First ensure a given kwarg is a valid cf-plot setvars input i.e. - # OK and meaningful to set on plotvars - if set_var not in setvars_defaults: - raise ValueError( - f"Unrecognised keyword argument for setvars: {set_var}" - ) - - setattr(plotvars, set_var, set_value) - - matplotlib.pyplot.ioff() - - -def mapset( - lonmin=None, - lonmax=None, - latmin=None, - latmax=None, - proj="cyl", - boundinglat=0, - lon_0=0, - lat_0=40, - resolution="110m", - user_mapset=1, - aspect=None, -): - """ - | Sets the mapping parameters. - | - | lonmin=lonmin - minimum longitude - | lonmax=lonmax - maximum longitude - | latmin=latmin - minimum latitude - | latmax=latmax - maximum latitude - | proj=proj - 'cyl' for cylindrical projection. 'npstere' or 'spstere' - | for northern hemisphere or southern hemisphere polar stereographic. - | ortho, merc, moll, robin and lcc are abreviations for orthographic, - | mercator, mollweide, robinson and lambert conformal projections - | 'rotated' for contour plots on the native rotated grid. - | - | boundinglat=boundinglat - edge of the viewable latitudes in a - | stereographic plot - | lon_0=0 - longitude centre of desired map domain in polar - | stereographic and orthogrphic plots - | lat_0=40 - latitude centre of desired map domain in orthogrphic plots - | resolution='110m' - the map resolution - can be one of '110m', - | '50m' or '10m'. '50m' means 1:50,000,000 and not 50 metre. - | user_mapset=user_mapset - variable to indicate whether a user call - | to mapset has been made. - | - | The default map plotting projection is the cyclindrical equidistant - | projection from -180 to 180 in longitude and -90 to 90 in latitude. - | To change the map view in this projection to over the United Kingdom, - | for example, you would use - | mapset(lonmin=-6, lonmax=3, latmin=50, latmax=60) - | or - | mapset(-6, 3, 50, 60) - | - | The limits are -360 to 720 in longitude so to look at the equatorial - | Pacific you could use - | mapset(lonmin=90, lonmax=300, latmin=-30, latmax=30) - | or - | mapset(lonmin=-270, lonmax=-60, latmin=-30, latmax=30) - | - | The default setting for the cylindrical projection is for 1 degree of - | longitude to have the same size as one degree of latitude. When plotting - | a smaller map setting aspect='auto' turns this off and the map fills the - | plot area. Setting aspect to a number a circle will be stretched such - | that the height is num times the width. aspect=1 is the same as - | aspect='equal'. - | - | The proj parameter accepts 'npstere' and 'spstere' for northern - | hemisphere or southern hemisphere polar stereographic projections. - | In addition to these the boundinglat parameter sets the edge of the - | viewable latitudes and lon_0 sets the centre of desired map domain. - | - | - | - | Map settings are persistent until a new call to mapset is made. To - | reset to the default map settings use mapset(). - - :Returns: - None - """ - - # Set the continent resolution - plotvars.resolution = resolution - - if ( - all(val is None for val in [lonmin, lonmax, latmin, latmax, aspect]) - and proj == "cyl" - ): - plotvars.lonmin = -180 - plotvars.lonmax = 180 - plotvars.latmin = -90 - plotvars.latmax = 90 - plotvars.proj = "cyl" - plotvars.user_mapset = 0 - plotvars.aspect = "equal" - - plotvars.plot_xmin = None - plotvars.plot_xmax = None - plotvars.plot_ymin = None - plotvars.plot_ymax = None - - return - - # Set the aspect ratio - if aspect is None: - aspect = "equal" - plotvars.aspect = aspect - - if lonmin is None: - lonmin = -180 - if lonmax is None: - lonmax = 180 - if latmin is None: - latmin = -90 - if proj == "merc": - latmin = -80 - if latmax is None: - latmax = 90 - if proj == "merc": - latmax = 80 - - if proj == "moll": - lonmin = lon_0 - 180 - lonmax = lon_0 + 180 - - plotvars.lonmin = lonmin - plotvars.lonmax = lonmax - plotvars.latmin = latmin - plotvars.latmax = latmax - plotvars.proj = proj - plotvars.boundinglat = boundinglat - plotvars.lon_0 = lon_0 - plotvars.lat_0 = lat_0 - plotvars.user_mapset = user_mapset - - -def levs(min=None, max=None, step=None, manual=None, extend="both"): - """ - | Manually sets the contour levels. - - | min=min - minimum level - | max=max - maximum level - | step=step - step between levels - | manual= manual - set levels manually - | extend='neither', 'both', 'min', or 'max' - colour bar limit extensions - - | Use the levs command when a predefined set of levels is required. The - | min, max and step parameters can be used to define a set of levels. - | These can take integer or floating point numbers. If the min and max - | are specified then a step also needs to be specified. - - | If just the step is specified then cf-plot will internally try to - | define a reasonable set of levels. - - | If colour filled contours are plotted then the default is to extend - | the minimum and maximum contours coloured for out of range values - | - extend='both'. - - | Once a user call is made to levs the levels are persistent. - | i.e. the next plot will use the same set of levels. - | Use levs() to reset to undefined levels. - - :Returns: - None - - """ - - if all(val is not None for val in [min, max]) and step is None: - print( - "\ncfp.levs error: when the min and max are specified " - "a step also needs to be specified\n" - ) - return - - if all(val is None for val in [min, max, step, manual]): - plotvars.levels = None - plotvars.levels_min = None - plotvars.levels_max = None - plotvars.levels_step = None - plotvars.levels_extend = "both" - plotvars.norm = None - plotvars.user_levs = 0 - return - - if manual is not None: - plotvars.levels = np.array(manual) - plotvars.levels_min = None - plotvars.levels_max = None - plotvars.levels_step = None - # Set the normalization object as we are using potentially unevenly - # spaced levels - ncolors = np.size(plotvars.levels) - if extend == "both" or extend == "max": - ncolors = ncolors - 1 - plotvars.norm = matplotlib.colors.BoundaryNorm( - boundaries=plotvars.levels, ncolors=ncolors - ) - plotvars.user_levs = 1 - else: - if all(val is not None for val in [min, max, step]): - plotvars.levels_min = min - plotvars.levels_max = max - plotvars.levels_step = step - plotvars.norm = None - if all(isinstance(item, int) for item in [min, max, step]): - lstep = step * 1e-10 - levs = np.arange(min, max + lstep, step, dtype=np.float64) - levs = ((levs * 1e10).astype(np.int64)).astype(np.float64) - levs = (levs / 1e10).astype(np.int64) - plotvars.levels = levs - else: - lstep = step * 1e-10 - levs = np.arange(min, max + lstep, step, dtype=np.float64) - levs = (levs * 1e10).astype(np.int64).astype(np.float64) - levs = levs / 1e10 - plotvars.levels = levs - plotvars.user_levs = 1 - - # Check for spurious decimal places due to numeric representation - # and fix if found - for pt in np.arange(np.size(plotvars.levels)): - ndecs = str(plotvars.levels[pt])[::-1].find(".") - if ndecs > 7: - plotvars.levels[pt] = round(plotvars.levels[pt], 7) - - # If step only is set then reset user_levs to zero - if step is not None and all(val is None for val in [min, max]): - plotvars.user_levs = 0 - plotvars.levels = None - plotvars.levels_step = step - - # Check extend has a proper value - if extend not in ["neither", "min", "max", "both"]: - errstr = "\n\n extend must be one of 'neither', 'min', 'max', 'both'\n" - raise TypeError(errstr) - plotvars.levels_extend = extend - - -def gset( - xmin=None, - xmax=None, - ymin=None, - ymax=None, - xlog=False, - ylog=False, - user_gset=1, - twinx=None, - twiny=None, -): - """ - | Set plot limits for all non-longitude-latitide plots. - | xmin, xmax, ymin, ymax are all needed to set the plot limits. - | Set xlog/ylog to True or 1 to get a log axis. - | - | xmin=None - x minimum - | xmax=None - x maximum - | ymin=None - y minimum - | ymax=None - y maximum - | xlog=False - log x - | ylog=False - log y - | twinx=None - set to True to make a twin y axis plot - | twiny=None - set to True to make a twin x axis plot - | - | Once a user call is made to gset the plot limits are persistent. - | i.e. the next plot will use the same set of plot limits. - | Use gset() to reset to undefined plot limits i.e. the full range - | of the data. - | - | To set date axes use date strings i.e. - | cfp.gset(xmin = '1970-1-1', xmax = '1999-12-31', ymin = 285, - | ymax = 295) - | - | Note the correct date format is 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' - | anything else will give unexpected results. - :Returns: - None - - """ - - plotvars.user_gset = user_gset - - if all(val is None for val in [xmin, xmax, ymin, ymax]): - plotvars.xmin = None - plotvars.xmax = None - plotvars.ymin = None - plotvars.ymax = None - plotvars.xlog = False - plotvars.ylog = False - plotvars.twinx = False - plotvars.twiny = False - plotvars.user_gset = 0 - return - - bcount = 0 - for val in [xmin, xmax, ymin, ymax]: - if val is None: - bcount = bcount + 1 - - if bcount != 0 and bcount != 4: - errstr = ( - "gset error\n" - "xmin, xmax, ymin, ymax all need to be passed to gset\n" - "to set the plot limits\n" - ) - raise Warning(errstr) - - plotvars.xmin = xmin - plotvars.xmax = xmax - plotvars.ymin = ymin - plotvars.ymax = ymax - plotvars.xlog = xlog - plotvars.ylog = ylog - - # Check if any axes are time strings - time_xstr = False - time_ystr = False - try: - float(xmin) - except Exception: - time_xstr = True - try: - float(ymin) - except Exception: - time_ystr = True - - # Set plot limits - if plotvars.plot is not None and twinx is None and twiny is None: - if not time_xstr and not time_ystr: - plotvars.plot.axis( - [plotvars.xmin, plotvars.xmax, plotvars.ymin, plotvars.ymax] - ) - - if plotvars.xlog: - plotvars.plot.set_xscale("log") - if plotvars.ylog: - plotvars.plot.set_yscale("log") - - # Set twinx or twiny if requested - if twinx is not None: - plotvars.twinx = twinx - if twiny is not None: - plotvars.twiny = twiny - - -# TODO move this to 'colour' module once set up. For now put here since -# it is called by 'reset' method. -def cscale( - scale=None, - ncols=None, - white=None, - below=None, - above=None, - reverse=False, - uniform=False, -): - """ - | Choose and manipulate colour maps. Around 200 colour scales are - | available (see the gallery section for more details). - | - | scale=None - name of colour map - | ncols=None - number of colours for colour map - | white=None - change these colours to be white - | below=None - change the number of colours below the mid point of - | the colour scale to be this - | above=None - change the number of colours above the mid point of - | the colour scale to be this - | reverse=False - reverse the colour scale - | uniform=False - produce a uniform colour scale. - | For example: if below=3 and above=10 are specified - | then initially below=10 and above=10 are used. The - | colour scale is then cropped to use scale colours - | 6 to 19. This produces a more uniform intensity colour - | scale than one where all the blues are compressed into - | 3 colours. - | - | - | Personal colour maps are available by saving the map as red green blue - | to a file with a set of values on each line. - | - | - | Use cscale() To reset to the default settings. - | - :Returns: - None - """ - - # If no map requested reset to default - if scale is None: - scale = "scale1" - plotvars.cscale_flag = 0 - return - else: - plotvars.cs_user = scale - plotvars.cscale_flag = 1 - vals = [ncols, white, below, above] - if any(val is not None for val in vals): - plotvars.cscale_flag = 2 - if reverse is not False or uniform is not False: - plotvars.cscale_flag = 2 - - if scale == "scale1" or scale == "": - if scale == "scale1": - myscale = cscale1 - if scale == "viridis": - myscale = viridis - # convert cscale1 or viridis from hex to rgb - r = [] - g = [] - b = [] - for myhex in myscale: - myhex = myhex.lstrip("#") - mylen = len(myhex) - rgb = tuple( - int(myhex[i : i + mylen // 3], 16) - for i in range(0, mylen, mylen // 3) - ) - r.append(rgb[0]) - g.append(rgb[1]) - b.append(rgb[2]) - else: - package_path = os.path.dirname(__file__) - # TODO improve path processing - eventually move to Pathlib - file = os.path.join( - package_path, "../colour/colourmaps/" + scale + ".rgb" - ) - if os.path.isfile(file) is False: - if os.path.isfile(scale) is False: - errstr = ( - "\ncscale error - colour scale not found:\n" - f"File {file} not found\n" - f"Scale {scale} not found\n" - ) - raise Warning(errstr) - else: - file = scale - - # Read in rgb values and convert to hex - with open(file, "r") as f: - lines = f.read() - lines = lines.splitlines() - r = [] - g = [] - b = [] - for line in lines: - vals = line.split() - r.append(int(vals[0])) - g.append(int(vals[1])) - b.append(int(vals[2])) - - # Reverse the colour scale if requested - if reverse: - r = r[::-1] - g = g[::-1] - b = b[::-1] - - # Interpolate to a new number of colours if requested - if ncols is not None: - x = np.arange(np.size(r)) - xnew = np.linspace(0, np.size(r) - 1, num=ncols, endpoint=True) - f_red = interpolate.interp1d(x, r) - f_green = interpolate.interp1d(x, g) - f_blue = interpolate.interp1d(x, b) - r = f_red(xnew) - g = f_green(xnew) - b = f_blue(xnew) - - # Change the number of colours below and above the mid-point if requested - if below is not None or above is not None: - - # Mid-point of colour scale - npoints = np.size(r) // 2 - - # Below mid point x locations - x_below = [] - lower = 0 - if below == 1: - x_below = 0 - if below is not None: - lower = below - if below is None: - lower = npoints - if below is not None and uniform: - lower = max(above, below) - if lower > 1: - x_below = ((npoints - 1) / float(lower - 1)) * np.arange(lower) - - # Above mid point x locations - x_above = [] - upper = 0 - if above == 1: - x_above = npoints * 2 - 1 - if above is not None: - upper = above - if above is None: - upper = npoints - if above is not None and uniform: - upper = max(above, below) - if upper > 1: - x_above = ((npoints - 1) / float(upper - 1)) * np.arange( - upper - ) + npoints - - # Append new colour positions - xnew = np.append(x_below, x_above) - - # Interpolate to new colour scale - xpts = np.arange(np.size(r)) - f_red = interpolate.interp1d(xpts, r) - f_green = interpolate.interp1d(xpts, g) - f_blue = interpolate.interp1d(xpts, b) - r = f_red(xnew) - g = f_green(xnew) - b = f_blue(xnew) - - # Reset colours if uniform is set - if uniform: - mid_pt = max(below, above) - r = r[mid_pt - below : mid_pt + above] - g = g[mid_pt - below : mid_pt + above] - b = b[mid_pt - below : mid_pt + above] - - # Convert to hex - hexarr = [] - for col in np.arange(np.size(r)): - hexarr.append(f"#{int(r[col]):02x}{int(g[col]):02x}{int(b[col]):02x}") - - # White requested colour positions - if white is not None: - if np.size(white) == 1: - hexarr[white] = "#ffffff" - else: - for col in white: - hexarr[col] = "#ffffff" - - # Set colour scale - plotvars.cs = hexarr - - -def axes( - xticks=None, - xticklabels=None, - yticks=None, - yticklabels=None, - xstep=None, - ystep=None, - xlabel=None, - ylabel=None, - title=None, -): - """ - | Set axes plotting parameters. The xstep and ystep - | parameters are used to label the axes starting at the left hand side and - | bottom of the plot respectively. For tighter control over labelling use - | xticks, yticks to specify the tick positions and xticklabels, - | yticklabels to specify the associated labels. - - | xstep=xstep - x axis step - | ystep=ystep - y axis step - | xlabel=xlabel - label for the x-axis - | ylabel=ylabel - label for the y-axis - | xticks=xticks - values for x ticks - | xticklabels=xticklabels - labels for x tick marks - | yticks=yticks - values for y ticks - | yticklabels=yticklabels - labels for y tick marks - | title=None - set title - | - | Use axes() to reset all the axes plotting attributes to the default. - - :Returns: - None - """ - - if all( - val is None - for val in [ - xticks, - yticks, - xticklabels, - yticklabels, - xstep, - ystep, - xlabel, - ylabel, - title, - ] - ): - plotvars.xticks = None - plotvars.yticks = None - plotvars.xticklabels = None - plotvars.yticklabels = None - plotvars.xstep = None - plotvars.ystep = None - plotvars.xlabel = None - plotvars.ylabel = None - plotvars.title = None - return - - plotvars.xticks = xticks - plotvars.yticks = yticks - plotvars.xticklabels = xticklabels - plotvars.yticklabels = yticklabels - plotvars.xstep = xstep - plotvars.ystep = ystep - plotvars.xlabel = xlabel - plotvars.ylabel = ylabel - plotvars.title = title - - -def reset(): - """ - | Reset all plotting variables. - | - :Returns: - name - """ - axes() - cscale() - levs() - gset() - mapset() - setvars() diff --git a/cfplot/rotated_runtime.py b/cfplot/rotated_runtime.py new file mode 100644 index 0000000..b29c7fd --- /dev/null +++ b/cfplot/rotated_runtime.py @@ -0,0 +1,523 @@ +"""Rotated-pole (ptype 6) rendering and grid axes helpers. + +This module encapsulates rotated-latitude-longitude coordinate system +rendering, including continent drawing via shapefile, rotated transforms, +and index-space grid line and axis label placement. +""" + +from __future__ import annotations + +from typing import Any + +import cartopy.crs as ccrs +import numpy as np + +from . import utility +from .blockfill import _bfill +from .colorbar import cbar +from .layout_runtime import apply_axes, ensure_xy_viewport, set_plot_limits +from .map_runtime import MapSet, _apply_map_title, _apply_map_features +from .state import plotvars + + +def _rotated_vloc( + *, lons: np.ndarray, lats: np.ndarray, xvec: np.ndarray, yvec: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """Map rotated-grid lon/lat points into plot coordinates. + + This mirrors the legacy location helper but uses safer index lookup so + empty intersections do not raise during rotated projection plotting. + """ + if any(val is None for val in [xvec, yvec, lons, lats]): + errstr = ( + "\nvloc error\n" + "xvec, yvec, lons, lats all need to be passed to vloc to\n" + "generate a set of location points\n" + ) + raise Warning(errstr) + + xvec = np.asarray(xvec, dtype=float).copy() + yvec = np.asarray(yvec, dtype=float).copy() + lons = np.asarray(lons, dtype=float).copy() + lats = np.asarray(lats, dtype=float).copy() + + xarr = np.zeros(np.size(lons)) + yarr = np.zeros(np.size(lats)) + + for i in np.arange(np.size(xvec)): + xvec[i] = ((xvec[i] + 180) % 360) - 180 + for i in np.arange(np.size(lons)): + lons[i] = ((lons[i] + 180) % 360) - 180 + + if np.nanmax(xvec) > 150: + for i in np.arange(np.size(xvec)): + xvec[i] = (xvec[i] + 360.0) % 360.0 + pts = np.where(xvec < 0.0) + xvec[pts] = xvec[pts] + 360.0 + for i in np.arange(np.size(lons)): + lons[i] = (lons[i] + 360.0) % 360.0 + pts = np.where(lons < 0.0) + lons[pts] = lons[pts] + 360.0 + + for i in np.arange(np.size(lons)): + if (lons[i] < np.min(xvec)) or (lons[i] > np.max(xvec)): + xarr[i] = np.nan + else: + xpt = int(np.searchsorted(xvec, lons[i], side="right") - 1) + xpt = max(0, min(xpt, np.size(xvec) - 2)) + xarr[i] = xpt + (lons[i] - xvec[xpt]) / (xvec[xpt + 1] - xvec[xpt]) + + if (lats[i] < np.min(yvec)) or (lats[i] > np.max(yvec)): + yarr[i] = np.nan + else: + ypt = int(np.searchsorted(yvec, lats[i], side="right") - 1) + ypt = max(0, min(ypt, np.size(yvec) - 2)) + yarr[i] = ypt + (lats[i] - yvec[ypt]) / (yvec[ypt + 1] - yvec[ypt]) + + return (xarr, yarr) + + +def _render_rotated_grid_axes( + *, + xpole: float, + ypole: float, + xvec: np.ndarray, + yvec: np.ndarray, + xticks: Any = None, + xticklabels: Any = None, + yticks: Any = None, + yticklabels: Any = None, + axes: bool = True, + xaxis: bool = True, + yaxis: bool = True, + xlabel: str | None = None, + ylabel: str | None = None, +) -> None: + """Draw rotated-grid axes and optional graticule labels.""" + import matplotlib.lines + + spacing = plotvars.rotated_grid_spacing + degspacing = plotvars.rotated_deg_spacing + continents = plotvars.rotated_continents + grid = plotvars.rotated_grid + labels = plotvars.rotated_labels + grid_thickness = plotvars.rotated_grid_thickness + + yvec = np.asarray(yvec) + xvec = np.asarray(xvec) + if yvec[0] > yvec[np.size(yvec) - 1]: + yvec = yvec[::-1] + + set_plot_limits( + xmin=0, + xmax=float(np.size(xvec) - 1), + ymin=0, + ymax=float(np.size(yvec) - 1), + ylog=False, + user_gset=0, + ) + + # Rotated-grid labels are drawn manually as text; hide the base + # index-space ticks/labels to avoid a duplicate numeric axis. + plotvars.plot.set_xticks([]) + plotvars.plot.set_yticks([]) + plotvars.plot.tick_params( + bottom=False, + top=False, + left=False, + right=False, + labelbottom=False, + labeltop=False, + labelleft=False, + labelright=False, + ) + + if continents: + import cartopy.io.shapereader as shpreader + import shapefile + + shpfilename = shpreader.natural_earth( + resolution=plotvars.resolution, + category="physical", + name="coastline", + ) + reader = shapefile.Reader(shpfilename) + shapes = [s.points for s in reader.shapes()] + for shape in shapes: + lons, lats = list(zip(*shape)) + lons = np.array(lons) + lats = np.array(lats) + rotated_transform = ccrs.RotatedPole( + pole_latitude=ypole, pole_longitude=xpole + ) + points = rotated_transform.transform_points( + ccrs.PlateCarree(), lons, lats + ) + xout = np.array(points)[:, 0] + yout = np.array(points)[:, 1] + xpts, ypts = _rotated_vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) + plotvars.plot.plot( + xpts, + ypts, + linewidth=plotvars.continent_thickness or 1.5, + color=plotvars.continent_color or "k", + ) + + if xticks is None: + lons = -180 + np.arange(360 / spacing + 1) * spacing + else: + lons = xticks + if yticks is None: + lats = -90 + np.arange(180 / spacing + 1) * spacing + else: + lats = yticks + + xlim = plotvars.plot.get_xlim() + spacing_x = (xlim[1] - xlim[0]) / 20 + ylim = plotvars.plot.get_ylim() + spacing_y = (ylim[1] - ylim[0]) / 20 + spacing = min(spacing_x, spacing_y) + + rotated_transform = ccrs.RotatedPole(pole_latitude=ypole, pole_longitude=xpole) + + if axes: + if xaxis: + for val in np.arange(np.size(lons)): + ipts = max(2, int(179.0 / degspacing)) + lona = np.zeros(ipts) + lons[val] + lata = -90 + np.arange(ipts) * degspacing + points = rotated_transform.transform_points( + ccrs.PlateCarree(), lona, lata + ) + xout = np.array(points)[:, 0] + yout = np.array(points)[:, 1] + xpts, ypts = _rotated_vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) + if grid: + plotvars.plot.plot( + xpts, ypts, ":", linewidth=grid_thickness, color="k" + ) + + if labels and np.size(ypts[5:]) > np.sum(np.isnan(ypts[5:])): + ymin = np.nanmin(ypts[5:]) + loc = np.where(ypts == ymin)[0] + if np.size(loc) > 1: + loc = loc[1] + if loc > 0 and np.isfinite(xpts[loc]): + xpos = float(np.asarray(xpts[loc]).reshape(-1)[0]) + line = matplotlib.lines.Line2D( + [xpos, xpos], [0, -spacing / 2], color="k" + ) + plotvars.plot.add_line(line) + line.set_clip_on(False) + xticklabel = ( + utility.mapaxis(lons[val], lons[val], axis_type=1, degsym=plotvars.degsym)[1][0] + if xticklabels is None + else xticklabels[val] + ) + plotvars.plot.text( + xpos, + -spacing, + xticklabel, + horizontalalignment="center", + verticalalignment="top", + fontsize=plotvars.text_fontsize, + fontweight=plotvars.text_fontweight, + ) + + if yaxis: + for val in np.arange(np.size(lats)): + ipts = max(2, int(359.0 / degspacing)) + lata = np.zeros(ipts) + lats[val] + lona = -180.0 + np.arange(ipts) * degspacing + points = rotated_transform.transform_points( + ccrs.PlateCarree(), lona, lata + ) + xout = np.array(points)[:, 0] + yout = np.array(points)[:, 1] + xpts, ypts = _rotated_vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) + + if grid: + plotvars.plot.plot( + xpts, ypts, ":", linewidth=grid_thickness, color="k" + ) + + if labels and np.size(xpts[5:]) > np.sum(np.isnan(xpts[5:])): + xmin = np.nanmin(xpts[5:]) + loc = np.where(xpts == xmin)[0] + if np.size(loc) == 1 and loc > 0 and np.isfinite(ypts[loc]): + ypos = float(np.asarray(ypts[loc]).reshape(-1)[0]) + line = matplotlib.lines.Line2D( + [0, -spacing / 2], [ypos, ypos], color="k" + ) + plotvars.plot.add_line(line) + line.set_clip_on(False) + yticklabel = ( + utility.mapaxis(lats[val], lats[val], axis_type=2, degsym=plotvars.degsym)[1][0] + if yticklabels is None + else yticklabels[val] + ) + plotvars.plot.text( + -spacing, + ypos, + yticklabel, + horizontalalignment="right", + verticalalignment="center", + fontsize=plotvars.text_fontsize, + fontweight=plotvars.text_fontweight, + ) + + if xlabel: + plotvars.plot.set_xlabel( + xlabel, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) + if ylabel: + plotvars.plot.set_ylabel( + ylabel, + fontsize=plotvars.axis_label_fontsize, + fontweight=plotvars.axis_label_fontweight, + ) + + +def _render_ptype6_rotated_pole( + *, + f: Any, + data: Any, + kwargs: dict[str, Any], + clevs: np.ndarray, + cs: Any, + cbar_labels: list[str] | Any, + colorbar_title: str, + fill: bool, + lines: bool, + blockfill: bool, + line_labels: bool, + zero_thick: bool | int, + colors: Any, + linewidths: Any, + linestyles: Any, + alpha: float, + zorder: int, + finalize_callback: Any, +) -> bool: + """Render ptype 6 (rotated pole) for cylindrical transformed-map mode.""" + + if data.x is None or data.y is None or data.levels is None: + return False + + if plotvars.user_plot == 0: + ensure_xy_viewport() + + rotated_pole = f.ref("grid_mapping_name:rotated_latitude_longitude", default=None) + xpole = ypole = None + transform = None + if rotated_pole: + xpole = utility.to_float_or_none(rotated_pole.get("grid_north_pole_longitude")) + ypole = utility.to_float_or_none(rotated_pole.get("grid_north_pole_latitude")) + + if plotvars.proj == "rotated": + xpts = np.arange(np.size(data.x)) + ypts = np.arange(np.size(data.y)) + plotargs: dict[str, Any] = {} + plot = plotvars.plot + set_plot_limits( + xmin=0, + xmax=float(np.size(xpts) - 1), + ymin=0, + ymax=float(np.size(ypts) - 1), + ylog=False, + user_gset=plotvars.user_gset, + ) + elif plotvars.proj == "cyl": + xpts = data.x + ypts = data.y + + if not rotated_pole: + return False + if xpole is None or ypole is None: + return False + + transform = ccrs.RotatedPole(pole_latitude=ypole, pole_longitude=xpole) + + map_runtime = MapSet(plotvars) + if plotvars.user_mapset != 1: + if np.ndim(xpts) == 1: + lonpts, latpts = np.meshgrid(xpts, ypts) + else: + lonpts = xpts + latpts = ypts + points = ccrs.PlateCarree().transform_points( + transform, lonpts.flatten(), latpts.flatten() + ) + lons = np.array(points)[:, 0] + lats = np.array(points)[:, 1] + + map_runtime.configure( + lonmin=float(np.min(lons)), + lonmax=float(np.max(lons)), + latmin=float(np.min(lats)), + latmax=float(np.max(lats)), + user_mapset=0, + resolution=plotvars.resolution, + ) + map_runtime.ensure_map_axes() + + plotargs = {"transform": transform} + plot = plotvars.mymap + else: + return False + + frame_artists: list[Any] = [] + + if fill: + cmap = cs.get_cmap() + cset = plot.contourf( + xpts, + ypts, + data.field * data.fmult, + clevs, + extend=plotvars.levels_extend, + cmap=cmap, + norm=plotvars.norm, + alpha=alpha, + zorder=zorder, + **plotargs, + ) + if hasattr(cset, "collections"): + frame_artists.extend(list(cset.collections)) + + if blockfill: + _bfill( + f=data.field * data.fmult, + x=xpts, + y=ypts, + clevs=clevs, + bound=0, + alpha=alpha, + fast=kwargs.get("blockfill_fast", None), + zorder=zorder, + transform=transform, + ) + + if lines: + cs_lines = plot.contour( + xpts, + ypts, + data.field * data.fmult, + clevs, + colors=colors, + linewidths=linewidths, + linestyles=linestyles, + zorder=zorder, + **plotargs, + ) + if hasattr(cs_lines, "collections"): + frame_artists.extend(list(cs_lines.collections)) + if line_labels and not isinstance(clevs, int): + nd = utility.ndecs(clevs) + fmt = "%d" + if nd != 0: + fmt = "%1." + str(nd) + "f" + plot.clabel( + cs_lines, + fmt=fmt, + colors=colors, + zorder=zorder, + fontsize=plotvars.text_fontsize, + ) + if zero_thick: + cs0 = plot.contour( + xpts, + ypts, + data.field * data.fmult, + [-1e-32, 0], + colors=colors, + linewidths=zero_thick, + linestyles=linestyles, + alpha=alpha, + zorder=zorder, + **plotargs, + ) + if hasattr(cs0, "collections"): + frame_artists.extend(list(cs0.collections)) + + if kwargs.get("axes", True): + if plotvars.proj == "cyl": + apply_axes( + plot_type=1, + xticks=kwargs.get("xticks", None), + yticks=kwargs.get("yticks", None), + xlabel=kwargs.get("xlabel", None), + ylabel=kwargs.get("ylabel", None), + xticklabels=kwargs.get("xticklabels", None), + yticklabels=kwargs.get("yticklabels", None), + ) + else: + _render_rotated_grid_axes( + xpole=xpole, + ypole=ypole, + xvec=data.x, + yvec=data.y, + xticks=kwargs.get("xticks", None), + xticklabels=kwargs.get("xticklabels", None), + yticks=kwargs.get("yticks", None), + yticklabels=kwargs.get("yticklabels", None), + axes=True, + xaxis=kwargs.get("xaxis", True), + yaxis=kwargs.get("yaxis", True), + xlabel=kwargs.get("xlabel", None), + ylabel=kwargs.get("ylabel", None), + ) + + if plotvars.proj == "cyl" and plotvars.mymap is not None: + _apply_map_features( + mymap=plotvars.mymap, + continent_color=plotvars.continent_color or "k", + continent_thickness=plotvars.continent_thickness or 1.5, + continent_linestyle=plotvars.continent_linestyle or "solid", + kwargs=kwargs, + ) + + if kwargs.get("grid", plotvars.grid): + MapSet(plotvars).draw_grid() + + if kwargs.get("colorbar", True) and (fill or blockfill): + cbar( + labels=cbar_labels, + orientation=kwargs.get("colorbar_orientation", None) or "horizontal", + position=kwargs.get("colorbar_position", None), + shrink=kwargs.get("colorbar_shrink", None), + title=colorbar_title, + fontsize=kwargs.get("colorbar_fontsize", None), + fontweight=kwargs.get("colorbar_fontweight", None), + text_up_down=kwargs.get("colorbar_text_up_down", False), + text_down_up=kwargs.get("colorbar_text_down_up", False), + drawedges=kwargs.get("colorbar_drawedges", True), + fraction=kwargs.get("colorbar_fraction", None), + thick=kwargs.get("colorbar_thick", None), + levs=clevs, + anchor=kwargs.get("colorbar_anchor", None), + verbose=kwargs.get("verbose", None), + ) + + title = kwargs.get("title", "") or "" + if title != "": + _apply_map_title( + mymap=plotvars.mymap, + title=title, + proj=plotvars.proj, + boundinglat=plotvars.boundinglat, + lon_0=plotvars.lon_0, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + title_fontsize=plotvars.title_fontsize, + title_fontweight=plotvars.title_fontweight, + ) + + finalize_callback() + + plotvars._contour_animation_artists = frame_artists + return True diff --git a/cfplot/state.py b/cfplot/state.py new file mode 100644 index 0000000..572d137 --- /dev/null +++ b/cfplot/state.py @@ -0,0 +1,428 @@ +"""Shared plotting state for cf-plot.""" + +from __future__ import annotations + +import os +import sys +from dataclasses import MISSING, dataclass, field, fields as dc_fields +from typing import Any, ClassVar + +import cartopy +import matplotlib +import matplotlib.pyplot as pyplot + +from .colour.colourmaps import cscale1 + + +class pvars: + """Stores plotting variables in `cfp.plotvars`.""" + + @dataclass + class MapState: + proj: str = "cyl" + resolution: str = "110m" + lonmin: float = -180 + lonmax: float = 180 + latmin: float = -90 + latmax: float = 90 + boundinglat: float = 0 + lon_0: float = 0 + lat_0: float = 40 + rotated_grid_spacing: float = 10 + rotated_deg_spacing: float = 0.75 + rotated_continents: bool = True + rotated_grid: bool = True + rotated_grid_thickness: float = 1.0 + rotated_labels: bool = True + + @dataclass + class AxesState: + xmin: Any = None + xmax: Any = None + ymin: Any = None + ymax: Any = None + plot_xmin: Any = None + plot_xmax: Any = None + plot_ymin: Any = None + plot_ymax: Any = None + graph_xmin: Any = None + graph_xmax: Any = None + graph_ymin: Any = None + graph_ymax: Any = None + xticks: Any = None + xticklabels: Any = None + xlabel: Any = None + yticks: Any = None + yticklabels: Any = None + ylabel: Any = None + xlog: Any = None + ylog: Any = None + xstep: Any = None + ystep: Any = None + twinx: Any = False + twiny: Any = False + xtick_label_rotation: int = 0 + xtick_label_align: str = "center" + ytick_label_rotation: int = 0 + ytick_label_align: str = "right" + axis_width: Any = None + + @dataclass + class DecorationState: + title: Any = None + master_title: Any = None + master_title_location: list[float] = field( + default_factory=lambda: [0.5, 0.95] + ) + text_fontsize: int = 11 + axis_label_fontsize: int = 11 + colorbar_fontsize: int = 11 + title_fontsize: int = 15 + master_title_fontsize: int = 30 + legend_text_size: int = 11 + fontweight: str = "normal" + text_fontweight: str = "normal" + axis_label_fontweight: str = "normal" + colorbar_fontweight: str = "normal" + title_fontweight: str = "normal" + master_title_fontweight: str = "normal" + legend_text_weight: str = "normal" + legend_frame: bool = True + legend_frame_edge_color: str = "k" + legend_frame_face_color: Any = None + grid: bool = False + grid_x_spacing: float = 60 + grid_y_spacing: float = 30 + grid_zorder: int = 100 + grid_colour: str = "k" + grid_linestyle: str = "--" + grid_thickness: float = 1.0 + feature_zorder: int = 999 + land_color: Any = None + ocean_color: Any = None + lake_color: Any = None + continent_color: Any = None + continent_thickness: Any = None + continent_linestyle: Any = None + degsym: bool = False + titles_con_called: bool = False + + @dataclass + class LayoutState: + rows: int = 1 + columns: int = 1 + pos: int = 1 + gpos_called: bool = False + orientation: str = "landscape" + aspect: str = "equal" + + @dataclass + class ScaleState: + levels: Any = None + levels_min: Any = None + levels_max: Any = None + levels_step: Any = None + levels_extend: str = "both" + level_spacing: Any = None + cs_uniform: bool = True + cs: Any = None + cscale_flag: int = 0 + cs_user: str = "cscale1" + norm: Any = None + + @dataclass + class RuntimeState: + plot_type: int = 1 + master_plot: Any = None + plot: Any = None + mymap: Any = None + image: Any = None + user_mapset: int = 0 + user_gset: int = 0 + user_levs: int = 0 + user_plot: int = 0 + _contour_session_open: bool = False + _contour_animation_artists: list[Any] = field(default_factory=list) + _contour_animation_title_artist: Any = None + _contour_animation_colorbar: Any = None + + @dataclass + class OutputState: + global_viewer: str = "display" + viewer: Any = "display" + file: Any = None + dpi: Any = None + tight: bool = False + tspace_year: Any = None + tspace_month: Any = None + tspace_day: Any = None + tspace_hour: Any = None + + _SECTION_TYPES: ClassVar[dict[str, type]] = { + "map": MapState, + "axes": AxesState, + "decoration": DecorationState, + "layout": LayoutState, + "scale": ScaleState, + "runtime": RuntimeState, + "output": OutputState, + } + _ATTR_TO_SECTION: ClassVar[dict[str, str]] = {} + + def __init__(self, **kwargs): + object.__setattr__(self, "map", self.MapState()) + object.__setattr__(self, "axes", self.AxesState()) + object.__setattr__(self, "decoration", self.DecorationState()) + object.__setattr__(self, "layout", self.LayoutState()) + object.__setattr__(self, "scale", self.ScaleState()) + object.__setattr__(self, "runtime", self.RuntimeState()) + object.__setattr__(self, "output", self.OutputState()) + + for attr, value in kwargs.items(): + if isinstance(value, (list, dict, set)): + value = value.copy() + setattr(self, attr, value) + + def __getattr__(self, attr: str) -> Any: + section_name = self._ATTR_TO_SECTION.get(attr) + if section_name is None: + raise AttributeError(attr) + return getattr(getattr(self, section_name), attr) + + def __setattr__(self, attr: str, value: Any) -> None: + section_name = self._ATTR_TO_SECTION.get(attr) + if section_name is not None: + setattr(getattr(self, section_name), attr, value) + return + + if attr in { + "map", + "axes", + "decoration", + "layout", + "scale", + "runtime", + "output", + } or attr.startswith("_"): + object.__setattr__(self, attr, value) + return + + # Preserve legacy extensibility for ad-hoc state attributes. + object.__setattr__(self, attr, value) + + def __str__(self): + out = [] + for a in sorted(self._ATTR_TO_SECTION): + v = getattr(self, a) + out.append(f"{a} = {repr(v)}") + + for a, v in self.__dict__.items(): + if a in { + "map", + "axes", + "decoration", + "layout", + "scale", + "runtime", + "output", + }: + continue + out.append(f"{a} = {repr(v)}") + + return "\n".join(out) + + +def reset_runtime_state() -> None: + """Reset shared plotting runtime state back to defaults.""" + plotvars.master_plot = None + plotvars.plot = None + plotvars.mymap = None + plotvars.norm = None + plotvars.image = None + plotvars.rows = 1 + plotvars.columns = 1 + plotvars.pos = 1 + plotvars.gpos_called = False + plotvars.user_plot = 0 + plotvars._contour_session_open = False + plotvars._contour_animation_artists = [] + plotvars._contour_animation_title_artist = None + plotvars._contour_animation_colorbar = None + plotvars.twinx = None + plotvars.twiny = None + plotvars.plot_xmin = None + plotvars.plot_xmax = None + plotvars.plot_ymin = None + plotvars.plot_ymax = None + plotvars.graph_xmin = None + plotvars.graph_xmax = None + plotvars.graph_ymin = None + plotvars.graph_ymax = None + plotvars.titles_con_called = False + + +try: + disp = os.environ["DISPLAY"] +except Exception: + matplotlib.use("Agg") + +try: + pre_existing_data_dir = os.environ["pre_existing_data_dir"] + cartopy.config["pre_existing_data_dir"] = pre_existing_data_dir +except KeyError: + pass + +global_fill = True +global_lines = True +global_blockfill = False +global_degsym = False +global_viewer = "display" + +defaults_file = os.path.expanduser("~") + "/.cfplot_defaults" +if os.path.exists(defaults_file): + with open(defaults_file) as file: + for line in file: + vals = line.split(" ") + com, val = vals + v = False + if val.strip() == "True": + v = True + if com == "blockfill": + global_blockfill = v + if com == "lines": + global_lines = v + if com == "fill": + global_fill = v + if com == "degsym": + global_degsym = v + if com == "viewer": + global_viewer = val.strip() + + +def _dataclass_defaults(dc_type: type) -> dict[str, Any]: + defaults: dict[str, Any] = {} + for fld in dc_fields(dc_type): + if fld.default_factory is not MISSING: + defaults[fld.name] = fld.default_factory() + else: + defaults[fld.name] = fld.default + return defaults + + +def _refresh_state_schemas() -> None: + attr_to_section: dict[str, str] = {} + for section, dc_type in pvars._SECTION_TYPES.items(): + for fld in dc_fields(dc_type): + attr_to_section[fld.name] = section + pvars._ATTR_TO_SECTION = attr_to_section + + +def _build_plotvars_defaults() -> dict[str, Any]: + defaults: dict[str, Any] = {} + for dc_type in pvars._SECTION_TYPES.values(): + defaults.update(_dataclass_defaults(dc_type)) + + # Environment and startup-specific overrides. + defaults["global_viewer"] = global_viewer + defaults["viewer"] = global_viewer + defaults["degsym"] = global_degsym + defaults["cs"] = cscale1 + return defaults + + +_refresh_state_schemas() +plotvars_defaults = _build_plotvars_defaults() + +_SETVARS_KEYS = [ + "viewer", + "file", + "dpi", + "tight", + "tspace_year", + "tspace_month", + "tspace_day", + "tspace_hour", + "xtick_label_rotation", + "xtick_label_align", + "ytick_label_rotation", + "ytick_label_align", + "text_fontsize", + "axis_label_fontsize", + "colorbar_fontsize", + "title_fontsize", + "master_title_fontsize", + "legend_text_size", + "fontweight", + "text_fontweight", + "axis_label_fontweight", + "colorbar_fontweight", + "title_fontweight", + "master_title_fontweight", + "legend_text_weight", + "master_title", + "master_title_location", + "legend_frame", + "legend_frame_edge_color", + "legend_frame_face_color", + "grid", + "grid_x_spacing", + "grid_y_spacing", + "grid_zorder", + "grid_colour", + "grid_linestyle", + "grid_thickness", + "rotated_grid_spacing", + "rotated_deg_spacing", + "rotated_continents", + "rotated_grid", + "rotated_grid_thickness", + "rotated_labels", + "feature_zorder", + "land_color", + "ocean_color", + "lake_color", + "continent_color", + "continent_thickness", + "continent_linestyle", + "axis_width", + "degsym", + "level_spacing", + "cs_uniform", +] +setvars_defaults = {key: plotvars_defaults[key] for key in _SETVARS_KEYS} + +allvars_defaults = {**setvars_defaults, **plotvars_defaults} +plotvars = pvars(**allvars_defaults) + +# Keep shared-state viewer defaults aligned with legacy startup behavior. +is_inline = "inline" in matplotlib.get_backend() +if is_inline: + plotvars.viewer = None + +if sys.platform == "darwin": + plotvars.global_viewer = "matplotlib" + plotvars.viewer = "matplotlib" + + +def setvars(**kwargs: Any) -> None: + """Set shared plotting variables from defaults plus explicit overrides.""" + for def_var, def_value in setvars_defaults.items(): + setattr(plotvars, def_var, def_value) + + for set_var, set_value in kwargs.items(): + if set_var not in setvars_defaults: + raise ValueError( + f"Unrecognised keyword argument for setvars: {set_var}" + ) + + setattr(plotvars, set_var, set_value) + + if "grid" not in kwargs and ( + "grid_x_spacing" in kwargs or "grid_y_spacing" in kwargs + ): + plotvars.grid = ( + plotvars.grid_x_spacing != setvars_defaults["grid_x_spacing"] + or plotvars.grid_y_spacing != setvars_defaults["grid_y_spacing"] + ) + + pyplot.ioff() diff --git a/cfplot/stipple.py b/cfplot/stipple.py index 1926a89..596fbb3 100644 --- a/cfplot/stipple.py +++ b/cfplot/stipple.py @@ -2,14 +2,8 @@ import cf import numpy as np -from .parameters import plotvars -from .utils import ( - _cf_data_assign, - add_cyclic, - polar_regular_grid, - regrid, - stipple_points, -) +from .state import plotvars +from . import utility from .validate import _check_data @@ -73,7 +67,7 @@ def stipple( ylabel, xpole, ypole, - ) = _cf_data_assign(f, colorbar_title) + ) = utility.cf_data_assign(f, colorbar_title, proj=plotvars.proj) elif isinstance(f, cf.FieldList): raise TypeError("Can't plot a field list") else: @@ -88,12 +82,12 @@ def stipple( lonrange = np.nanmax(xpts) - np.nanmin(xpts) if lonrange < 360: # field, xpts = cartopy_util.add_cyclic_point(field, xpts) - field, xpts = add_cyclic(field, xpts) + field, xpts = utility.add_cyclic(field, xpts) # if plotvars.proj == 'cyl': if plotvars.proj in ["cyl", "robin", "merc", "ortho", "moll"]: # Calculate interpolation points - xnew, ynew = stipple_points( + xnew, ynew = utility.stipple_points( xmin=np.nanmin(xpts), xmax=np.nanmax(xpts), ymin=np.nanmin(ypts), @@ -108,7 +102,12 @@ def stipple( if plotvars.proj == "npstere" or plotvars.proj == "spstere": # Calculate interpolation points - xnew, ynew, xnew_map, ynew_map = polar_regular_grid() + xnew, ynew, xnew_map, ynew_map = utility.polar_regular_grid( + pts=pts, + proj=plotvars.proj, + boundinglat=plotvars.boundinglat, + lon_0=plotvars.lon_0, + ) # Convert longitudes to be 0 to 360 # negative longitudes are incorrectly regridded in polar # stereographic projection @@ -129,7 +128,7 @@ def stipple( ymin = np.log10(ymin) ymax = np.log10(ymax) - xnew, ynew = stipple_points( + xnew, ynew = utility.stipple_points( xmin=np.nanmin(xpts), xmax=np.nanmax(xpts), ymin=ymin, @@ -142,7 +141,7 @@ def stipple( ynew = 10**ynew # Get values at the new points - vals = regrid(f=field, x=xpts, y=ypts, xnew=xnew, ynew=ynew) + vals = utility.regrid(f=field, x=xpts, y=ypts, xnew=xnew, ynew=ynew) # Work out which of the points are valid valid_points = np.array([], dtype="int64") diff --git a/cfplot/stream.py b/cfplot/stream.py new file mode 100644 index 0000000..b4f4951 --- /dev/null +++ b/cfplot/stream.py @@ -0,0 +1,235 @@ +from copy import deepcopy + +import cartopy.crs as ccrs +import cf +import numpy as np + +from .layout_runtime import ensure_runtime_session, finalize_runtime_session +from .map_runtime import ( + _apply_current_map_title, + _apply_map_axes_with_toggles, + _apply_map_features, + _ensure_map_axes, + mapset, +) +from .state import plotvars +from . import utility +from .validate import _check_data + + +def stream( + u=None, + v=None, + x=None, + y=None, + density=None, + linewidth=None, + color=None, + arrowsize=None, + arrowstyle=None, + minlength=None, + maxlength=None, + axes=True, + xaxis=True, + yaxis=True, + xticks=None, + xticklabels=None, + yticks=None, + yticklabels=None, + xlabel=None, + ylabel=None, + title=None, + zorder=None, +): + """Plot a streamplot to show fluid flow and 2D field gradients.""" + del zorder + + colorbar_title = "" + if title is None: + title = "" + + title_fontsize = plotvars.title_fontsize + if title_fontsize is None: + title_fontsize = 15 + + resolution_orig = plotvars.resolution + rotated_vect = False + + user_xlabel = xlabel + user_ylabel = ylabel + + plotargs = {} + if density is not None: + plotargs["density"] = density + if linewidth is not None: + plotargs["linewidth"] = linewidth + if color is not None: + plotargs["color"] = color + if arrowsize is not None: + plotargs["arrowsize"] = arrowsize + if arrowstyle is not None: + plotargs["arrowstyle"] = arrowstyle + if minlength is not None: + plotargs["minlength"] = minlength + if maxlength is not None: + plotargs["maxlength"] = maxlength + + if isinstance(u, cf.Field): + ndims = np.squeeze(u.data).ndim + if ndims != 2: + errstr = ( + "\n\ncfp.vect error need a 2 dimensonal u field to make vectors\n" + f"received {ndims}" + ) + if ndims == 1: + errstr += " dimension\n\n" + else: + errstr += " dimensions\n\n" + raise TypeError(errstr) + + ( + u_data, + u_x, + u_y, + ptype, + colorbar_title, + xlabel, + ylabel, + xpole, + ypole, + ) = utility.cf_data_assign( + u, colorbar_title, proj=("rotated" if rotated_vect else plotvars.proj) + ) + del xpole, ypole + elif isinstance(u, cf.FieldList): + raise TypeError("Can't plot a field list") + else: + _check_data(u, x, y) + u_data = deepcopy(u) + u_x = deepcopy(x) + u_y = deepcopy(y) + xlabel = "" + ylabel = "" + + if isinstance(v, cf.Field): + ndims = np.squeeze(v.data).ndim + if ndims != 2: + errstr = ( + "\n\ncfp.vect error need a 2 dimensonal v field to make vectors\n" + f"received {ndims}" + ) + if ndims == 1: + errstr += " dimension\n\n" + else: + errstr += " dimensions\n\n" + raise TypeError(errstr) + + ( + v_data, + v_x, + v_y, + ptype, + colorbar_title, + xlabel, + ylabel, + xpole, + ypole, + ) = utility.cf_data_assign( + v, colorbar_title, proj=("rotated" if rotated_vect else plotvars.proj) + ) + del v_x, v_y, xpole, ypole + elif isinstance(v, cf.FieldList): + raise TypeError("Can't plot a field list") + else: + _check_data(v, x, y) + v_data = deepcopy(v) + xlabel = "" + ylabel = "" + + if user_xlabel is not None: + xlabel = user_xlabel + if user_ylabel is not None: + ylabel = user_ylabel + + if xlabel == "" and plotvars.xlabel is not None: + xlabel = plotvars.xlabel + if ylabel == "" and plotvars.ylabel is not None: + ylabel = plotvars.ylabel + if xticks is None and plotvars.xticks is not None: + xticks = plotvars.xticks + if plotvars.xticklabels is not None: + xticklabels = plotvars.xticklabels + else: + xticklabels = list(map(str, xticks)) + if yticks is None and plotvars.yticks is not None: + yticks = plotvars.yticks + if plotvars.yticklabels is not None: + yticklabels = plotvars.yticklabels + else: + yticklabels = list(map(str, yticks)) + + auto_session = ensure_runtime_session(pos=1) + + if ptype is not None: + plotvars.plot_type = ptype + + lonrange = np.nanmax(u_x) - np.nanmin(u_x) + latrange = np.nanmax(u_y) - np.nanmin(u_y) + + if plotvars.plot_type == 1: + if (lonrange > 350 and latrange > 170) or plotvars.user_mapset == 1: + _ensure_map_axes() + else: + mapset( + lonmin=np.nanmin(u_x), + lonmax=np.nanmax(u_x), + latmin=np.nanmin(u_y), + latmax=np.nanmax(u_y), + user_mapset=0, + resolution=resolution_orig, + ) + _ensure_map_axes() + + mymap = plotvars.mymap + + mymap.streamplot( + u_x, + u_y, + u_data, + v_data, + transform=ccrs.PlateCarree(), + **plotargs, + ) + + _apply_map_axes_with_toggles( + axes=axes, + xaxis=xaxis, + yaxis=yaxis, + xticks=xticks, + xticklabels=xticklabels, + yticks=yticks, + yticklabels=yticklabels, + user_xlabel=user_xlabel, + user_ylabel=user_ylabel, + ) + + _apply_map_features( + mymap=mymap, + continent_color=plotvars.continent_color, + continent_thickness=plotvars.continent_thickness, + continent_linestyle=plotvars.continent_linestyle, + ) + + if title is not None: + _apply_current_map_title(title) + + finalize_runtime_session( + auto_session=auto_session, + reset_limits=True, + reset_colour_scale=True, + view=True, + ) + + if plotvars.user_mapset == 0: + mapset() + mapset(resolution=resolution_orig) diff --git a/cfplot/test/reference-example-images/ref_fig_19c.png b/cfplot/test/reference-example-images/ref_fig_19c.png deleted file mode 100644 index 3500410..0000000 Binary files a/cfplot/test/reference-example-images/ref_fig_19c.png and /dev/null differ diff --git a/cfplot/test/reference-example-images/ref_fig_42b.png b/cfplot/test/reference-example-images/ref_fig_42b.png deleted file mode 100644 index 5157e28..0000000 Binary files a/cfplot/test/reference-example-images/ref_fig_42b.png and /dev/null differ diff --git a/cfplot/test/test_examples.py b/cfplot/test/test_examples.py index b28640f..18a6e2d 100644 --- a/cfplot/test/test_examples.py +++ b/cfplot/test/test_examples.py @@ -10,7 +10,7 @@ import functools import hashlib import os -import unittest +from pathlib import Path from pprint import pformat import cartopy.crs as ccrs @@ -18,6 +18,7 @@ import coverage import matplotlib.testing.compare as mpl_compare import numpy as np +import pytest from netCDF4 import Dataset as ncfile from scipy.interpolate import griddata @@ -25,13 +26,11 @@ faulthandler.enable() # to debug seg faults and timeouts -# Must unzip data first from _downloads/example-datasets.zip to the source/ -# dir if this isn't already the case locally! -DATA_DIR = "../../docs/source/example-datasets" -TEST_REF_DIR = "./reference-example-images" -TEST_GEN_DIR = "./generated-example-images" -if not os.path.exists(TEST_GEN_DIR): - os.makedirs(TEST_GEN_DIR) +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA_DIR = REPO_ROOT / "docs" / "source" / "example-datasets" +TEST_REF_DIR = REPO_ROOT / "tests" / "reference-example-images" +TEST_GEN_DIR = REPO_ROOT / "generated-example-images" +TEST_GEN_DIR.mkdir(parents=True, exist_ok=True) def compare_plot_results(test_method): @@ -55,8 +54,8 @@ def wrapper(_self): print(f"___Comparing output images for {test_name}___") # TODO add underscore to ref_figX names for consistency image_cmp_result = mpl_compare.compare_images( - f"{TEST_REF_DIR}/ref_fig_{tid}.png", # expected (reference) plot - f"{TEST_GEN_DIR}/gen_fig_{tid}.png", # actual (generated) plot + str(TEST_REF_DIR / f"ref_fig_{tid}.png"), # expected plot + str(TEST_GEN_DIR / f"gen_fig_{tid}.png"), # actual plot tol=0.01, in_decorator=True, ) @@ -64,13 +63,14 @@ def wrapper(_self): # If the plot image comparison passed, image_cmp_result will be None # (see https://matplotlib.org/stable/api/ # testing_api.html#matplotlib.testing.compare.compare_images) - msg = f"\nPlot comparison shows differences, see result dict for details." - _self.assertIsNone(image_cmp_result, msg=msg) + msg = "\nPlot comparison shows differences, see result dict for details." + assert image_cmp_result is None, msg return wrapper -class ExamplesTest(unittest.TestCase): +@pytest.mark.integration +class TestExamples: """Run through gallery examples and compare to reference plots.""" data_dir = DATA_DIR @@ -78,12 +78,12 @@ class ExamplesTest(unittest.TestCase): ref_dir = TEST_REF_DIR test_id = None - def setUp(self): + def setup_method(self, method): """Preparations called immediately before each test method.""" # Get a filename fname with the ID of test_example_X component X - test_method_name = unittest.TestCase.id(self).split(".")[-1] + test_method_name = method.__name__ self.test_id = test_method_name.rsplit("test_example_")[1] - fname = f"{self.save_gen_dir}/" f"gen_fig_{self.test_id}.png" + fname = str(self.save_gen_dir / f"gen_fig_{self.test_id}.png") # At the moment there is no 'getvars' to access the plotting variables # defined (see Issue https://github.com/NCAS-CMS/cf-plot/issues/93) @@ -96,7 +96,7 @@ def setUp(self): } cfp.setvars(**self.setvars_dict) - def tearDown(self): + def teardown_method(self): """Preparations called immediately after each test method.""" cfp.reset() @@ -488,8 +488,8 @@ def test_example_21b(self): cfp.con(f) @compare_plot_results - def test_example_22(self): - """Test Example 22.""" + def test_example_22other(self): + """Test Example 22 (other, due to duplicate label of 22).""" f = cf.read(f"{self.data_dir}/rgp.nc")[0] cfp.cscale("plasma") @@ -1006,7 +1006,7 @@ def test_example_42b(self): colorbar_title="Relative Vorticity (Hz) * 1e5", ) - @unittest.skip + @pytest.mark.skip(reason="WRF test data not available") @compare_plot_results def test_example_43(self): """Test Example 43: plotting WRF data.""" @@ -1019,7 +1019,7 @@ def test_example_43(self): print("==================\nExamples Testing\n==================\n") cov = coverage.Coverage() cov.start() - unittest.main() + pytest.main(["-v", __file__]) cov.stop() cov.save() diff --git a/cfplot/trajectory.py b/cfplot/trajectory.py index 5c60f01..29d1f07 100644 --- a/cfplot/trajectory.py +++ b/cfplot/trajectory.py @@ -1,15 +1,20 @@ from copy import deepcopy import cartopy.crs as ccrs -import cartopy.feature as cfeature import cf import numpy as np from .colour import cbar -from .graphic import gclose, gopen, gpos -from .mapping import _map_title, _plot_map_axes, _set_map -from .parameters import cscale, gset, plotvars -from .utils import _gvals, _supscr, cf_var_name +from .colour import cscale +from .layout_runtime import ensure_runtime_session, finalize_runtime_session, gset +from .map_runtime import ( + _apply_current_map_title, + _apply_map_axes_with_toggles, + _apply_map_features, + _ensure_map_axes, +) +from .state import plotvars +from . import utility def traj( @@ -146,7 +151,7 @@ def traj( has_lons = False has_lats = False for mydim in list(f.auxiliary_coordinates()): - name = cf_var_name(field=f, dim=mydim) + name = utility.cf_var_name(field=f, dim=mydim) if name in ["longitude"]: lons = np.squeeze(f.construct(mydim).array) has_lons = True @@ -182,33 +187,13 @@ def traj( user_xlabel = xlabel user_ylabel = ylabel - user_xlabel = "" - user_ylabel = "" - # Set plotting parameters - continent_thickness = 1.5 - continent_color = "k" - continent_linestyle = "-" - if plotvars.continent_thickness is not None: - continent_thickness = plotvars.continent_thickness - if plotvars.continent_color is not None: - continent_color = plotvars.continent_color - if plotvars.continent_linestyle is not None: - continent_linestyle = plotvars.continent_linestyle - land_color = plotvars.land_color - ocean_color = plotvars.ocean_color - lake_color = plotvars.lake_color + continent_linestyle = plotvars.continent_linestyle or "-" ################## - # Open a new plot is necessary + # Open a new plot if necessary ################## - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Call gpos(1) if not already called - if plotvars.rows > 1 or plotvars.columns > 1: - if plotvars.gpos_called is False: - gpos(1) + auto_session = ensure_runtime_session(pos=1) # Set up mapping if plotvars.user_mapset == 0: @@ -217,7 +202,7 @@ def traj( plotvars.latmin = -90 plotvars.latmax = 90 - _set_map() + _ensure_map_axes() mymap = plotvars.mymap # Set the plot limits @@ -256,7 +241,7 @@ def traj( print("traj - generating automatic legend levels") dmin = np.nanmin(data) dmax = np.nanmax(data) - levs, mult = _gvals(dmin=dmin, dmax=dmax, mod=False) + levs, mult = utility.gvals(dmin=dmin, dmax=dmax, mod=False) # Add extend options to the levels if set if plotvars.levels_extend == "min" or plotvars.levels_extend == "both": @@ -441,7 +426,7 @@ def traj( ) # Axes - _plot_map_axes( + _apply_map_axes_with_toggles( axes=axes, xaxis=xaxis, yaxis=yaxis, @@ -451,49 +436,18 @@ def traj( yticklabels=yticklabels, user_xlabel=user_xlabel, user_ylabel=user_ylabel, - verbose=verbose, - ) - - # Coastlines - feature = cfeature.NaturalEarthFeature( - name="land", - category="physical", - scale=plotvars.resolution, - facecolor="none", ) - mymap.add_feature( - feature, - edgecolor=continent_color, - linewidth=continent_thickness, - linestyle=continent_linestyle, + _apply_map_features( + mymap=mymap, + continent_color=plotvars.continent_color, + continent_thickness=plotvars.continent_thickness, + continent_linestyle=continent_linestyle, ) - if ocean_color is not None: - mymap.add_feature( - cfeature.OCEAN, - edgecolor="face", - facecolor=ocean_color, - zorder=plotvars.feature_zorder, - ) - if land_color is not None: - mymap.add_feature( - cfeature.LAND, - edgecolor="face", - facecolor=land_color, - zorder=plotvars.feature_zorder, - ) - if lake_color is not None: - mymap.add_feature( - cfeature.LAKES, - edgecolor="face", - facecolor=lake_color, - zorder=plotvars.feature_zorder, - ) - # Title if title is not None: - _map_title(title) + _apply_current_map_title(title) # Color bar plot_colorbar = False @@ -523,7 +477,7 @@ def traj( if str(f.Units) == "": colorbar_title += "" else: - colorbar_title += f"({_supscr(str(f.Units))})" + colorbar_title += f"({utility._supscr(str(f.Units))})" levs = plotvars.levels if colorbar_labels is not None: @@ -547,5 +501,4 @@ def traj( ########## # Save plot ########## - if plotvars.user_plot == 0: - gclose() + finalize_runtime_session(auto_session=auto_session, view=True) diff --git a/cfplot/utility.py b/cfplot/utility.py new file mode 100644 index 0000000..eee964b --- /dev/null +++ b/cfplot/utility.py @@ -0,0 +1,1547 @@ +"""Utility functions for plotting. + +Pure utility functions with no global state dependencies. +These are designed to be used by any plotting module. +""" + +from __future__ import annotations + +import os +from copy import deepcopy +from typing import Any + +import cartopy.util as cartopy_util +import numpy as np + + +def to_float_or_none(value: Any) -> float | None: + """Convert numeric-like metadata values to float, else return None.""" + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def resolve_colour_scale_file(scale: str) -> str: + """Resolve a named colour scale or explicit file path.""" + package_path = os.path.dirname(__file__) + file_path = os.path.join(package_path, "colour", "colourmaps", f"{scale}.rgb") + if os.path.isfile(file_path): + return file_path + if os.path.isfile(scale): + return scale + + errstr = ( + "\ncscale error - colour scale not found:\n" + f"File {file_path} not found\n" + f"Scale {scale} not found\n" + ) + raise Warning(errstr) + + +def load_colour_scale_rgb(scale: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Load RGB channels for a colour scale.""" + with open(resolve_colour_scale_file(scale), "r", encoding="ascii") as handle: + lines = handle.read().splitlines() + + red: list[int] = [] + green: list[int] = [] + blue: list[int] = [] + for line in lines: + vals = line.split() + red.append(int(vals[0])) + green.append(int(vals[1])) + blue.append(int(vals[2])) + + return ( + np.asarray(red, dtype=float), + np.asarray(green, dtype=float), + np.asarray(blue, dtype=float), + ) + + +def interpolate_colour_channels( + red: np.ndarray, + green: np.ndarray, + blue: np.ndarray, + positions: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Interpolate RGB channels to the requested positions.""" + xpts = np.arange(np.size(red), dtype=float) + return ( + np.interp(positions, xpts, red), + np.interp(positions, xpts, green), + np.interp(positions, xpts, blue), + ) + + +def ndecs(data: np.ndarray | list) -> int: + """Find the maximum number of decimal places in an array. + + Data with more decimal places will determine the result. + Used to format colorbar and line labels consistently. + + Parameters + ---------- + data : array-like + Input array of numeric values + + Returns + ------- + int + Maximum number of decimal places found + """ + maxdecs = 0 + for value in data: + parts = str(value).split(".") + if len(parts) == 2: + number_decs = len(parts[1]) + if number_decs > maxdecs: + maxdecs = number_decs + return maxdecs + + +def gvals( + dmin: float | None = None, + dmax: float | None = None, + mystep: float | None = None, + mod: bool = True, +) -> tuple[np.ndarray, int]: + """Generate sensible tick values between two limits. + + Works out appropriate step size and generates values, + optionally scaling with a power-of-10 multiplier. + Used for contour levels and axis labelling. + + Parameters + ---------- + dmin : float + Minimum value + dmax : float + Maximum value + mystep : float, optional + Use this step instead of auto-calculating + mod : bool + If True, apply multiplier for small/large ranges + + Returns + ------- + vals : ndarray + Array of tick values + mult : int + Multiplier exponent (10^mult) applied to values + """ + # Copies of inputs as these might be changed + dmin1 = deepcopy(dmin) + dmax1 = deepcopy(dmax) + + # Swap if dmin1 > dmax1 + if dmax1 < dmin1: + dmin1, dmax1 = dmax1, dmin1 + + # Data range + data_range = dmax1 - dmin1 + + # field multiplier + mult = 0 + vals = None + + # Return some values if dmin1 = dmax1 + if dmin1 == dmax1: + vals = np.array([dmin1 - 1, dmin1, dmin1 + 1]) + mult = 0 + return vals, mult + + # Modify if requested or if out of range 0.001 to 2000000 + if data_range < 0.001: + while dmax1 <= 3: + dmin1 = dmin1 * 10.0 + dmax1 = dmax1 * 10.0 + data_range = dmax1 - dmin1 + mult = mult - 1 + + if data_range > 2000000: + while dmax1 > 10: + dmin1 = dmin1 / 10.0 + dmax1 = dmax1 / 10.0 + data_range = dmax1 - dmin1 + mult = mult + 1 + + if data_range >= 0.001 and data_range <= 2000000: + # Calculate an appropriate step + step = None + test_steps = [ + 0.0001, + 0.0002, + 0.0005, + 0.001, + 0.002, + 0.005, + 0.01, + 0.02, + 0.05, + 0.1, + 0.2, + 0.5, + 1, + 2, + 5, + 10, + 20, + 50, + 100, + 200, + 500, + 1000, + 2000, + 5000, + 10000, + 20000, + 50000, + 100000, + ] + + if mystep is not None: + step = mystep + else: + for val in test_steps: + nvals = data_range / val + + if val < 1: + if nvals > 8: + step = val + else: + if nvals > 11: + step = val + + # Return an error if no step found + if step is None: + errstr = "\n\n cfp.gvals - no valid step values found \n\n" + errstr += "cfp.gvals(" + str(dmin1) + "," + str(dmax1) + ")\n\n" + raise ValueError(errstr) + + # values < 0.0 + vals = None + vals1 = None + if dmin1 < 0.0: + vals1 = (np.arange(-dmin1 / step) * -step)[::-1] - step + + # values >= 0.0 + vals2 = None + if dmax1 >= 0.0: + vals2 = np.arange(dmax1 / step + 1) * step + + if vals1 is not None and vals2 is None: + vals = vals1 + if vals2 is not None and vals1 is None: + vals = vals2 + if vals1 is not None and vals2 is not None: + vals = np.concatenate((vals1, vals2)) + + # Round off decimal numbers + if step < 1: + vals = vals.round(6) + + # Change values to integers for values >= 1 + if step >= 1: + vals = vals.astype(int) + + pts = np.where(np.logical_and(vals >= dmin1, vals <= dmax1)) + if np.min(pts) > -1: + vals = vals[pts] + + if mod is False: + vals = vals * 10**mult + mult = 0 + + # Catch if no values have been defined + if vals is None: + vals = np.array([dmin, dmax]) + + return (vals, mult) + + +def mapaxis( + min_val: float | None = None, + max_val: float | None = None, + axis_type: int | None = None, + degsym: bool = True, +) -> tuple[list, list]: + """Generate longitude or latitude axis ticks and labels. + + Works out sensible tick marks and labels for geographic axes. + + Parameters + ---------- + min_val : float + Minimum axis value + max_val : float + Maximum axis value + axis_type : int + 1 = longitude, 2 = latitude + degsym : bool + If True, use degree symbol in labels + + Returns + ------- + ticks : list + Tick positions + labels : list + Tick labels + """ + degsym_str = r"$\degree$" if degsym else "" + + if axis_type == 1: + # Longitude + lonmin = min_val + lonmax = max_val + lonrange = lonmax - lonmin + lonstep = 60 + if lonrange <= 180: + lonstep = 30 + if lonrange <= 90: + lonstep = 10 + if lonrange <= 30: + lonstep = 5 + if lonrange <= 10: + lonstep = 2 + if lonrange <= 5: + lonstep = 1 + + lons = np.arange(-720, 720 + lonstep, lonstep) + lonticks = [] + for lon in lons: + if lon >= lonmin and lon <= lonmax: + lonticks.append(lon) + + lonlabels = [] + for lon in lonticks: + lon2 = np.mod(lon + 180, 360) - 180 + if lon2 < 0 and lon2 > -180: + if lon != 180: + lonlabels.append(str(abs(int(lon2))) + degsym_str + "W") + if lon2 > 0 and lon2 <= 180: + lonlabels.append(str(int(lon2)) + degsym_str + "E") + if lon2 == 0: + lonlabels.append("0" + degsym_str) + if lon == 180 or lon == -180: + lonlabels.append("180" + degsym_str) + + return (lonticks, lonlabels) + + if axis_type == 2: + # Latitude + latmin = min_val + latmax = max_val + latrange = latmax - latmin + latstep = 30 + if latrange <= 90: + latstep = 10 + if latrange <= 30: + latstep = 5 + if latrange <= 10: + latstep = 2 + if latrange <= 5: + latstep = 1 + + lats = np.arange(-90, 90 + latstep, latstep) + latticks = [] + for lat in lats: + if lat >= latmin and lat <= latmax: + latticks.append(lat) + + latlabels = [] + for lat in latticks: + if lat < 0: + latlabels.append(str(abs(int(lat))) + degsym_str + "S") + if lat > 0: + latlabels.append(str(int(lat)) + degsym_str + "N") + if lat == 0: + latlabels.append("0" + degsym_str) + + return (latticks, latlabels) + + return ([], []) + + +def fix_floats(data: list) -> list: + """Fix numpy rounding issues where e.g. 0.4 becomes 0.3999999999. + + Returns data unchanged if any values contain an exponent ('e'). + """ + has_e = any("e" in str(val) for val in data) + if has_e: + return data + + data_ndecs = np.zeros(len(data)) + for i in np.arange(len(data)): + data_ndecs[i] = len(str(float(data[i])).split(".")[1]) + + if max(data_ndecs) >= 10: + if min(data_ndecs) < 10: + pts = np.where(data_ndecs >= 10) + data_ndecs[pts] = 0 + ndecs_max = int(max(data_ndecs)) + for i in np.arange(len(data)): + data[i] = round(data[i], ndecs_max) + else: + nd = 2 + data_range = 0.0 + data_temp = data + while data_range == 0.0: + data_temp = deepcopy(data) + for i in np.arange(len(data_temp)): + data_temp[i] = round(data_temp[i], nd) + data_range = np.max(data_temp) - np.min(data_temp) + nd = nd + 1 + data = data_temp + + return data + + +def calculate_levels( + field: np.ndarray, + level_spacing: str = "linear", + levels_step: Any | None = None, + verbose: bool | None = None, +) -> tuple[np.ndarray, int, float]: + """Calculate contour levels automatically from field data. + + Parameters + ---------- + field : ndarray + The data field to generate levels for. + level_spacing : str + One of 'linear', 'outlier', 'inspect', 'log', 'loglike'. + levels_step : scalar or None + If given, generate levels with this step size instead of auto. + verbose : bool or None + If True, print diagnostic messages. + + Returns + ------- + clevs : ndarray + Array of contour levels. + mult : int + Multiplier exponent applied (10 ** mult). + fmult : float + Inverse multiplier (10 ** -mult). + """ + dmin = np.nanmin(field) + dmax = np.nanmax(field) + + tight = True + field2 = deepcopy(field) + mult = 0 + fmult = 1.0 + clevs: Any = [] + + if levels_step is None: + if verbose: + print("calculate_levels - generating automatic contour levels") + + if level_spacing in ("outlier", "inspect"): + hist = np.histogram(field, 100)[0] + pts_arr = np.size(field) + rate = 0.01 + + if sum(hist[1:-2]) == 0: + if hist[0] / hist[-1] < rate: + pts = np.where(field == dmin) + field2[pts] = dmax + dmin = np.nanmin(field2) + if hist[-1] / hist[0] < rate: + pts = np.where(field == dmax) + field2[pts] = dmin + dmax = np.nanmax(field2) + + clevs, mult = gvals(dmin=dmin, dmax=dmax) + fmult = 10**-mult + tight = False + + if level_spacing == "linear": + if isinstance(np.ma.min(dmin), np.ma.core.MaskedConstant) or isinstance( + np.ma.min(dmax), np.ma.core.MaskedConstant + ): + if verbose: + print( + "calculate_levels warning - data is entirely masked; " + "setting levels to 0 and 0.1" + ) + dmin = 0.0 + dmax = 0.1 + + clevs, mult = gvals(dmin=dmin, dmax=dmax) + fmult = 10**-mult + tight = False + + if level_spacing in ("log", "loglike"): + if dmin < 0.0 and dmax < 0.0: + dmin1 = abs(dmax) + dmax1 = abs(dmin) + elif dmin > 0.0 and dmax > 0.0: + dmin1 = abs(dmin) + dmax1 = abs(dmax) + else: + dmax1 = max(abs(dmin), dmax) + pts_neg = np.where(field < 0.0) + close_below = np.max(field[pts_neg]) + pts_pos = np.where(field > 0.0) + close_above = np.min(field[pts_pos]) + dmin1 = min(abs(close_below), close_above) + + if level_spacing == "log": + clevs = [] + for i in np.arange(31): + val = 10 ** (i - 30.0) + clevs.append("{:.0e}".format(val)) + else: + clevs = [] + for i in np.arange(61): + val = 10 ** (i - 30.0) + clevs.append("{:.0e}".format(val)) + clevs.append("{:.0e}".format(val * 2)) + clevs.append("{:.0e}".format(val * 5)) + + clevs = np.float64(clevs) + pts = np.where(np.logical_and(clevs >= abs(dmin1), clevs <= abs(dmax1))) + clevs = clevs[pts] + + if dmin < 0.0 and dmax < 0.0: + clevs = -1.0 * clevs[::-1] + if dmin <= 0.0 and dmax >= 0.0: + clevs = np.concatenate([-1.0 * clevs[::-1], [0.0], clevs]) + + else: + if verbose: + print("calculate_levels - using specified step to generate contour levels") + + step = levels_step + if isinstance(step, int): + dmin = int(dmin) + dmax = int(dmax) + + clevs_list = [] + if dmin < 0: + clevs_list = list((np.arange(-1 * dmin / step + 1) * -step)[::-1]) + if dmax > 0: + pos = list(np.arange(dmax / step + 1) * step) + if len(clevs_list) > 0: + clevs_list = list(clevs_list[:-1]) + pos + else: + clevs_list = pos + clevs = np.array(clevs_list) + if isinstance(step, int): + clevs = clevs.astype(int) + + # Remove out-of-range values if tight mode + if tight: + pts = np.where(np.logical_and(clevs >= dmin, clevs <= dmax)) + clevs = clevs[pts] + + # Ensure at least two levels + clevs = list(clevs) + if len(clevs) < 2: + clevs.append(clevs[0] + 0.001 if clevs else 0.001) + + # Fix floating-point rounding noise + if isinstance(clevs[0], float): + clevs = fix_floats(clevs) + + return (np.asarray(clevs), mult, fmult) + + +def timeaxis( + dtimes: Any, + user_gset: int = 0, + xmin: Any = None, + xmax: Any = None, + ymin: Any = None, + ymax: Any = None, + tspace_year: int | None = None, + tspace_hour: int | None = None, + tspace_day: int | None = None, +) -> tuple[list, list, str]: + """Calculate time axis ticks and labels for a CF time coordinate. + + Parameters + ---------- + dtimes : cf time coordinate + The time dimension of the CF field. + user_gset : int + Non-zero if the user has set axis limits via gset. + xmin, xmax, ymin, ymax : scalar or str or None + User-specified axis limits (possibly date strings). + tspace_year, tspace_hour, tspace_day : int or None + Override auto-calculated spacing for year/hour/day axes. + + Returns + ------- + time_ticks : list + time_labels : list + axis_label : str + """ + import cf as _cf + + time_units = dtimes.Units + time_ticks: list = [] + time_labels: list = [] + axis_label = "Time" + + yearmin = min(dtimes.year.array) + yearmax = max(dtimes.year.array) + tmin = min(dtimes.dtarray) + tmax = max(dtimes.dtarray) + calendar = getattr(dtimes, "calendar", "standard") + + if user_gset != 0: + if isinstance(xmin, str): + t = _cf.Data(_cf.dt(xmin), units=time_units, calendar=calendar) + yearmin = int(t.year) + t = _cf.Data(_cf.dt(xmax), units=time_units, calendar=calendar) + yearmax = int(t.year) + tmin = _cf.dt(xmin, calendar=calendar) + tmax = _cf.dt(xmax, calendar=calendar) + if isinstance(ymin, str): + t = _cf.Data(_cf.dt(ymin), units=time_units, calendar=calendar) + yearmin = int(t.year) + t = _cf.Data(_cf.dt(ymax), units=time_units, calendar=calendar) + yearmax = int(t.year) + tmin = _cf.dt(ymin, calendar=calendar) + tmax = _cf.dt(ymax, calendar=calendar) + + # Years + span = yearmax - yearmin + if span > 4 and span < 3000: + axis_label = "Time (year)" + if span <= 15: + step = 1 + elif span <= 30: + step = 2 + elif span <= 60: + step = 5 + elif span <= 160: + step = 10 + elif span <= 300: + step = 20 + elif span <= 600: + step = 50 + elif span <= 1300: + step = 100 + else: + step = 200 + + if tspace_year is not None: + step = tspace_year + + years = np.arange(yearmax / step + 2) * step + tvals = years[np.where((years >= yearmin) & (years <= yearmax))] + + if np.size(tvals) < 2: + tvals = gvals(dmin=yearmin, dmax=yearmax)[0] + + for year in tvals: + time_ticks.append( + np.min( + _cf.Data( + _cf.dt(f"{int(year)}-01-01 00:00:00"), + units=time_units, + calendar=calendar, + ).array + ) + ) + time_labels.append(str(int(year))) + + # Months + if yearmax - yearmin <= 4: + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + tsteps = 0 + for year in np.arange(yearmax - yearmin + 1) + yearmin: + for month in np.arange(12): + mytime = _cf.dt(f"{year}-{month + 1}-01 00:00:00", calendar=calendar) + if mytime >= tmin and mytime <= tmax: + tsteps += 1 + + mvals = np.arange(12) if tsteps < 17 else np.arange(4) * 3 + + for year in np.arange(yearmax - yearmin + 1) + yearmin: + for month in mvals: + mytime = _cf.dt(f"{year}-{month + 1}-01 00:00:00", calendar=calendar) + if mytime >= tmin and mytime <= tmax: + time_ticks.append( + np.min( + _cf.Data(mytime, units=time_units, calendar=calendar).array + ) + ) + time_labels.append(str(months[month]) + " " + str(int(year))) + + # Days and hours + if np.size(time_ticks) <= 2: + myday = _cf.dt(int(tmin.year), int(tmin.month), int(tmin.day), calendar=calendar) + not_found = 0 + hour_counter = 0 + span = 0 + while not_found <= 48: + mydate = _cf.Data(myday, dtimes.Units) + _cf.Data(hour_counter, "hour") + if mydate >= tmin and mydate <= tmax: + span += 1 + else: + not_found += 1 + hour_counter += 1 + + step = 1 + if span > 13: + step = 1 + if span > 13: + step = 4 + if span > 25: + step = 6 + if span > 100: + step = 12 + if span > 200: + step = 24 + if span > 400: + step = 48 + if span > 800: + step = 96 + if tspace_hour is not None: + step = tspace_hour + if tspace_day is not None: + step = tspace_day * 24 + + not_found = 0 + hour_counter = 0 + axis_label = "Time (hour)" + if span >= 24: + axis_label = "Time" + time_ticks = [] + time_labels = [] + + while not_found <= 48: + mytime = _cf.Data(myday, dtimes.Units) + _cf.Data(hour_counter, "hour") + if mytime >= tmin and mytime <= tmax: + time_ticks.append(np.min(mytime.array)) + label = f"{mytime.year}-{mytime.month}-{mytime.day}" + if hour_counter / 24 != int(hour_counter / 24): + label += f" {mytime.hour}:00:00" + time_labels.append(label) + else: + not_found += 1 + hour_counter += step + + return (time_ticks, time_labels, axis_label) + + +def _pressure_axis_ticks(ymin: float, ymax: float, ylog: bool) -> list[float] | np.ndarray: + """Generate pressure-like Y ticks used by ptypes 2 and 3.""" + if ylog: + ylo = min(ymin, ymax) + yhi = max(ymin, ymax) + return [tick for tick in (1000, 100, 10, 1) if ylo <= tick <= yhi] + + ystep = 100.0 + yrange = abs(ymax - ymin) + if yrange < 1: + ystep = yrange / 10.0 if yrange != 0 else 0.1 + if yrange > 1: + ystep = 1.0 + if yrange > 10: + ystep = 10.0 + if yrange > 100: + ystep = 100.0 + if yrange > 1000: + ystep = 200.0 + if yrange > 2000: + ystep = 500.0 + if yrange > 5000: + ystep = 1000.0 + if yrange > 15000: + ystep = 5000.0 + + return gvals( + dmin=min(ymin, ymax), + dmax=max(ymin, ymax), + mystep=ystep, + mod=False, + )[0] + + +def compute_xy_ticks( + *, + ptype: int, + xmin: float, + xmax: float, + ymin: float, + ymax: float, + ylog: bool, + degsym: bool, + xticks: Any, + yticks: Any, + xticklabels: Any, + yticklabels: Any, + default_xlabel: str, + default_ylabel: str, + time_ticks: list | None = None, + time_labels: list | None = None, + time_label: str | None = None, +) -> tuple[Any, Any, Any, Any, str, str]: + """Compute non-map axis ticks/labels for refactored contour rendering. + + Handles ptypes 2-5 plus generic Cartesian fallback used by ptypes 0/7. + """ + if ptype in (4, 5) and time_ticks is not None and time_labels is not None: + if ptype == 4: + lonlat_ticks, lonlat_labels = mapaxis( + min_val=xmin, max_val=xmax, axis_type=1, degsym=degsym + ) + default_xlabel = default_xlabel or "Longitude" + else: + lonlat_ticks, lonlat_labels = mapaxis( + min_val=xmin, max_val=xmax, axis_type=2, degsym=degsym + ) + default_xlabel = default_xlabel or "Latitude" + + default_ylabel = time_label or default_ylabel or "time" + + if xticks is None: + xticks = lonlat_ticks + xticklabels = lonlat_labels + if yticks is None: + yticks = time_ticks + yticklabels = time_labels + + return xticks, yticks, xticklabels, yticklabels, default_xlabel, default_ylabel + + if ptype == 2: + if xticks is None: + xticks, xticklabels = mapaxis( + min_val=xmin, + max_val=xmax, + axis_type=2, + degsym=degsym, + ) + if yticks is None: + yticks = _pressure_axis_ticks(ymin=ymin, ymax=ymax, ylog=ylog) + elif ptype == 3: + if xticks is None: + xticks, xticklabels = mapaxis( + min_val=xmin, + max_val=xmax, + axis_type=1, + degsym=degsym, + ) + if yticks is None: + yticks = _pressure_axis_ticks(ymin=ymin, ymax=ymax, ylog=ylog) + else: + if xticks is None: + xticks = gvals(dmin=xmin, dmax=xmax, mod=False)[0] + if yticks is None: + yticks = gvals(dmin=ymax, dmax=ymin, mod=False)[0] + + return xticks, yticks, xticklabels, yticklabels, default_xlabel, default_ylabel + + +# --------------------------------------------------------------------------- +# CF field extraction helpers +# --------------------------------------------------------------------------- + +def _supscr(text: str) -> str: + """Format superscript notation for units strings (``**`` and ``^``).""" + tform = "" + sup = 0 + for i in text: + if i == "^": + sup = 2 + if i == "*": + sup = sup + 1 + if sup == 0: + tform = tform + i + if sup == 1: + if i not in "*": + tform = tform + "*" + i + sup = 0 + if sup == 3: + if i in "-0123456789": + tform = tform + i + else: + tform = tform + "}$" + i + sup = 0 + if sup == 2: + tform = tform + "$^{" + sup = 3 + if sup == 3: + tform = tform + "}$" + + tform = tform.replace("m2", "m$^{2}$") + tform = tform.replace("m3", "m$^{3}$") + tform = tform.replace("m-2", "m$^{-2}$") + tform = tform.replace("m-3", "m$^{-3}$") + tform = tform.replace("s-1", "s$^{-1}$") + tform = tform.replace("s-2", "s$^{-2}$") + return tform + + +def cf_var_name(field: Any, dim: str) -> str: + """Return the best available name for a CF field dimension coordinate. + + Names are checked in priority order: ncvar, short_name, long_name, + standard_name. + """ + # If multiple Z coordinates exist, use the last one + if dim == "Z": + z_names = [ + mycoord + for mycoord in list(field.coords()) + if field.coord(mycoord).Z + ] + if len(z_names) > 1: + dim = z_names[-1] + + construct = field.construct(dim) + id_ = getattr(construct, "id", False) + ncvar = construct.nc_get_variable(False) + short_name = getattr(construct, "short_name", False) + long_name = getattr(construct, "long_name", False) + standard_name = getattr(construct, "standard_name", False) + + name = "No Name" + if id_: + name = id_ + if ncvar: + name = ncvar + if short_name: + name = short_name + if long_name: + name = long_name + if standard_name: + name = standard_name + return name + + +def cf_var_name_titles(field: Any, dim: str) -> tuple[str | None, str | None]: + """Return preferred coordinate name/units for dimension-title rendering.""" + name = None + units = None + if field.has_construct(dim): + construct = field.construct(dim) + id_ = getattr(construct, "id", False) + ncvar = construct.nc_get_variable(False) + short_name = getattr(construct, "short_name", False) + long_name = getattr(construct, "long_name", False) + standard_name = getattr(construct, "standard_name", False) + + if id_: + name = id_ + if ncvar: + name = ncvar + if short_name: + name = short_name + if long_name: + name = long_name + if standard_name: + name = standard_name + + units = getattr(construct, "units", "") + if len(units) > 0: + units = f"({units})" + return name, units + + +def generate_titles(f: Any = None) -> str: + """Generate dimension/cell-method title text for plot annotation.""" + import cf + + from .validate import check_well_formed + + mycoords = find_dim_names(f) + # Preserve legacy side effect/validation behavior. + check_well_formed(f) + + title_dims = "" + if isinstance(f, cf.Field): + for idim in np.arange(len(mycoords)): + mycoord = mycoords[idim] + if mycoord == "Z": + mycoord = find_z(f) + + title, units = cf_var_name_titles(f, mycoord) + if not f.coord(mycoord).T: + values = f.construct(mycoord).array + if len(values) > 1: + value = "" + else: + value = str(values) + title_dims += f"{mycoord}: {title} {value} {units}\n" + + else: + values = f.construct(mycoord).dtarray + if len(values) > 1: + value = "" + else: + value = str(cf.Data(values).datetime_as_string) + title_dims += f"{mycoord}: {title} {value}\n" + + if len(f.cell_methods()) > 0: + title_dims += "cell_methods: " + i = 0 + + for method in f.cell_methods(): + if len(f.cell_methods()[method].get_axes()) > 0: + axis = f.cell_methods()[method].get_axes()[0] + try: + myid = f.constructs.domain_axis_identity(axis) + except ValueError: + myid = axis + + value = "" + if f.cell_methods()[method].has_method(): + value = f.cell_methods()[method].get_method() + + qualifiers = f.cell_methods()[method].qualifiers() + qualifier_text = "" + if len(qualifiers) > 0: + qualifier_text = str(qualifiers) + + if i > 0: + title_dims += ", " + + title_dims += f"{myid}: {value} {qualifier_text}" + i += 1 + + return title_dims + + +def find_dim_names(field: Any) -> list: + """Return dimension coordinate names in [X, Y, Z, T] order. + + Ignores auxiliary coordinates unless no dimension coordinate is available + for a given axis. + """ + daxes = list(field.get_data_axes()) + dcoords = list(field.coords()) + + nx = ny = nz = nt = 0 + for coord in dcoords: + c = field.coord(coord) + if c.X: + nx += 1 + if c.Y: + ny += 1 + if c.Z: + nz += 1 + if c.T: + nt += 1 + + coords = [] + for axis in daxes: + chosen = None + for coord in dcoords: + try: + caxes = field.get_data_axes(coord) + except Exception: + continue + if not caxes or caxes[0] != axis: + continue + if not str(coord).startswith("auxiliarycoordinate"): + chosen = coord + break + if chosen is None: + chosen = coord + if chosen is not None: + coords.append(chosen) + + mycoords = deepcopy(coords) + for i in np.arange(len(coords)): + c = field.coord(coords[i]) + if c.X and nx == 1: + mycoords[i] = "X" + if c.Y and ny == 1: + mycoords[i] = "Y" + if c.Z and nz == 1: + mycoords[i] = "Z" + if c.T and nt == 1: + mycoords[i] = "T" + + mycoords.reverse() + return mycoords + + +def find_z(field: Any) -> str | None: + """Return the key for the Z coordinate of a CF field, or None.""" + if field is None: + return None + mycoords = find_dim_names(field) + myz = None + for mycoord in mycoords: + if field.coord(mycoord).Z: + myz = mycoord + return myz + + +def cf_data_assign( + f: Any, + colorbar_title: str | None = None, + verbose: bool | None = None, + proj: str = "cyl", +) -> tuple: + """Extract arrays and metadata from a CF field for contouring. + + This is the refactored, pure version of the legacy ``_cf_data_assign`` + function. It has no dependency on ``plotvars`` global state; the one + piece of state it previously read (``plotvars.proj``) is passed explicitly + via the *proj* parameter. + + Parameters + ---------- + f : cf.Field + Input CF field. + colorbar_title : str or None + Override colorbar title; if None, derived from field metadata. + verbose : bool or None + Print diagnostic information when True. + proj : str + Current map projection (default ``'cyl'``). Used to decide whether + to look for auxiliary lon/lat coordinates on rotated-pole fields. + + Returns + ------- + field, x, y, ptype, colorbar_title, xlabel, ylabel, xpole, ypole + """ + import cf as _cf + import cartopy.crs as _ccrs + + # Check input data has the correct number of dimensions. + # Rotated-pole fields may legitimately have extra dimensions. + ndim = len(f.domain_axes().filter_by_size(_cf.gt(1))) + if f.ref("grid_mapping_name:rotated_latitude_longitude", default=False) is False: + if ndim > 2 or ndim < 1: + if ndim > 2: + errstr = "cf_data_assign error - data has too many dimensions" + else: + errstr = "cf_data_assign error - data has too few dimensions" + errstr += "\n cf-plot requires one or two dimensional data\n" + for mydim in list(f.dimension_coordinates()): + sn = getattr(f.construct(mydim), "standard_name", False) + ln = getattr(f.construct(mydim), "long_name", False) + if sn: + errstr += f"{mydim},{sn},{f.construct(mydim).size}\n" + elif ln: + errstr += f"{mydim},{ln},{f.construct(mydim).size}\n" + raise Warning(errstr) + + lons = lats = height = time = None + has_lons = has_lats = has_height = has_time = False + xlabel = ylabel = "" + xpole = ypole = None + ptype = None + x = y = None + + myz = find_z(f) + + for mycoord in f.coords(): + c = f.coord(mycoord) + if c.X: + lons = np.squeeze(f.construct(mycoord).array) + if verbose: + print("lons -", lons) + if np.size(lons) > 1: + has_lons = True + if c.Y: + lats = np.squeeze(f.construct(mycoord).array) + if verbose: + print("lats -", lats) + if np.size(lats) > 1: + has_lats = True + if c.Z: + height = np.squeeze(f.construct(mycoord).array) + if verbose: + print("height -", height) + if np.size(height) > 1: + has_height = True + if c.T: + time = np.squeeze(f.construct(mycoord).array) + if verbose: + print("time -", time) + if np.size(time) > 1: + has_time = True + + field = np.squeeze(f.array) + + if str(f.dtype) == "bool": + print("\n\n\n Warning - boolean data found - converting to integers\n\n\n") + g = deepcopy(f) + g.dtype = int + field = np.squeeze(g.array) + + if has_lons and has_lats: + ptype = 1 + x = lons + y = lats + + if has_lats and has_height: + ptype = 2 + x = lats + y = height + xname = cf_var_name(field=f, dim="Y") + xunits = str(getattr(f.construct("Y"), "Units", "")) + if xunits == "degrees_north": + xunits = "degrees" + xlabel = f"{xname} ({xunits})" if xunits else xname + yname = cf_var_name(field=f, dim=myz) + yunits = str(getattr(f.construct(myz), "Units", "")) + ylabel = f"{yname} ({yunits})" if yunits else yname + + if has_lons and has_height: + ptype = 3 + x = lons + y = height + xname = cf_var_name(field=f, dim="X") + xunits = str(getattr(f.construct("X"), "Units", "")) + if xunits == "degrees_east": + xunits = "degrees" + xlabel = f"{xname} ({xunits})" if xunits else xname + yname = cf_var_name(field=f, dim=myz) + yunits = str(getattr(f.construct(myz), "Units", "")) + ylabel = f"{yname} ({yunits})" if yunits else yname + + if has_lons and has_time: + ptype = 4 + x = lons + y = time + xname = cf_var_name(field=f, dim="X") + xunits = str(getattr(f.construct("X"), "Units", "")) + if xunits == "degrees_east": + xunits = "degrees" + xlabel = f"{xname} ({xunits})" if xunits else xname + yname = cf_var_name(field=f, dim="T") + yunits = str(getattr(f.construct("T"), "Units", "")) + ylabel = f"{yname} ({yunits})" if yunits else yname + + if has_lats and has_time: + ptype = 5 + x = lats + y = time + xname = cf_var_name(field=f, dim="Y") + xunits = str(getattr(f.construct("Y"), "Units", "")) + if xunits == "degrees_north": + xunits = "degrees" + xlabel = f"{xname} ({xunits})" if xunits else xname + yname = cf_var_name(field=f, dim="T") + yunits = str(getattr(f.construct("T"), "Units", "")) + ylabel = f"{yname} ({yunits})" if yunits else yname + + if has_height and has_time: + ptype = 7 + x = time + y = height + xname = cf_var_name(field=f, dim="T") + xunits = str(getattr(f.construct("T"), "Units", "")) + xlabel = f"{xname} ({xunits})" if xunits else xname + yname = cf_var_name(field=f, dim="Z") + yunits = str(getattr(f.construct("Z"), "Units", "")) + ylabel = f"{yname} ({yunits})" if yunits else yname + field = np.flipud(np.rot90(field)) + + # Rotated pole + if f.ref("grid_mapping_name:rotated_latitude_longitude", default=False): + ptype = 6 + rotated_pole = f.ref("grid_mapping_name:rotated_latitude_longitude") + xpole = rotated_pole["grid_north_pole_longitude"] + ypole = rotated_pole["grid_north_pole_latitude"] + for mydim in list(f.dimension_coordinates()): + name = cf_var_name(field=f, dim=mydim) + if name in ["grid_longitude", "longitude", "x"]: + x = np.squeeze(f.construct(mydim).array) + xunits = str(getattr(f.construct(mydim), "units", "")) + xlabel = cf_var_name(field=f, dim=mydim) + if name in ["grid_latitude", "latitude", "y"]: + y = np.squeeze(f.construct(mydim).array) + if y[0] > y[-1]: + y = y[::-1] + field = np.flipud(field) + yunits = str(getattr(f.construct(mydim), "Units", "")) + ylabel = cf_var_name(field=f, dim=mydim) + yunits + + # Auxiliary lon/lat (e.g. ORCA, UGRID) + if ptype == 1 or ptype is None: + if proj != "rotated": + aux_lons = aux_lats = False + xpts = ypts = None + for mydim in list(f.auxiliary_coordinates()): + name = cf_var_name(field=f, dim=mydim) + if name in ["longitude"]: + xpts = np.squeeze(f.construct(mydim).array) + aux_lons = True + if name in ["latitude"]: + ypts = np.squeeze(f.construct(mydim).array) + aux_lats = True + if aux_lons and aux_lats: + x = xpts + y = ypts + ptype = 1 + + # UKCP transverse mercator + if f.ref("grid_mapping_name:transverse_mercator", default=False): + ptype = 1 + field = np.squeeze(f.array) + has_lons = has_lats = False + for mydim in list(f.auxiliary_coordinates()): + name = cf_var_name(field=f, dim=mydim) + if name in ["longitude"]: + x = np.squeeze(f.construct(mydim).array) + has_lons = True + if name in ["latitude"]: + y = np.squeeze(f.construct(mydim).array) + has_lats = True + if not has_lons or not has_lats: + xpts = f.construct("X").array + ypts = f.construct("Y").array + field = np.squeeze(f.array) + ref = f.ref("grid_mapping_name:transverse_mercator") + transform = _ccrs.TransverseMercator( + false_easting=ref["false_easting"], + false_northing=ref["false_northing"], + central_longitude=ref["longitude_of_central_meridian"], + central_latitude=ref["latitude_of_projection_origin"], + scale_factor=ref["scale_factor_at_central_meridian"], + ) + xvals, yvals = np.meshgrid(xpts, ypts) + points = _ccrs.PlateCarree().transform_points(transform, xvals, yvals) + x = np.array(points)[:, :, 0] + y = np.array(points)[:, :, 1] + + # None of the above — fall back to ptype 0 + if ptype is None: + ptype = 0 + data_axes = f.get_data_axes() + count = 1 + for d in data_axes: + try: + c = f.coordinate(filter_by_axis=[d]) + if np.size(c.array) > 1: + if count == 1: + y = c + mycoord = "dimensioncoordinate" + str(d[-1]) + yunits = str(getattr(f.coord(mycoord), "Units", "")) + yunits_str = f"({yunits})" if yunits else "" + ylabel = cf_var_name(field=f, dim=mycoord) + yunits_str + elif count == 2: + x = c + mycoord = "dimensioncoordinate" + str(d[-1]) + xunits = str(getattr(f.coord(mycoord), "units", "")) + xunits_str = f"({xunits})" if xunits else "" + xlabel = cf_var_name(field=f, dim=mycoord) + xunits_str + count += 1 + except ValueError: + errstr = ( + "\n\ncf_data_assign - cannot find data to return\n\n" + f"{f.constructs.domain_axis_identity(d)}\n\n" + ) + raise Warning(errstr) + + # Derive colorbar title from field metadata + if colorbar_title is None: + colorbar_title = "No Name" + if hasattr(f, "id"): + colorbar_title = f.id + nc = f.nc_get_variable(None) + if nc: + colorbar_title = f.nc_get_variable() + if hasattr(f, "short_name"): + colorbar_title = f.short_name + if hasattr(f, "long_name"): + colorbar_title = f.long_name + if hasattr(f, "standard_name"): + colorbar_title = f.standard_name + if hasattr(f, "Units"): + units_str = str(f.Units) + if units_str: + colorbar_title = f"{colorbar_title} ({_supscr(units_str)})" + + return (field, x, y, ptype, colorbar_title, xlabel, ylabel, xpole, ypole) + + +def add_cyclic(field: np.ndarray, lons: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Add a cyclic longitude column if the grid doesn't span the full 360°. + + Wraps cartopy_util.add_cyclic_point with float-rounding fallback for + uneven longitude spacing due to numpy precision. + """ + try: + return cartopy_util.add_cyclic_point(field, lons) + except Exception: + # Check if spacing is nearly uniform (floating-point precision issue) + # rather than genuinely unequal. Float32 data often has ~1e-5 artifacts. + diffs = np.diff(lons) + mean_diff = np.mean(diffs) + max_deviation = np.max(np.abs(diffs - mean_diff)) + # If max deviation is < 0.01% of mean spacing, treat as floating-point + # precision artifact and reconstruct with linspace. + if max_deviation < 1e-4 * mean_diff: + lons_reconstructed = np.linspace( + float(lons[0]), float(lons[-1]), len(lons) + ) + return cartopy_util.add_cyclic_point(field, lons_reconstructed) + else: + # Spacing is genuinely unequal, re-raise the original error + raise + + +def stipple_points( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + pts: int | list | np.ndarray, + stype: int, +) -> tuple[np.ndarray, np.ndarray]: + """Generate regular or offset sampling points over a rectangular domain. + + Parameters + ---------- + xmin, xmax, ymin, ymax : float + Domain bounds. + pts : int or length-2 sequence + Number of points in x and y directions. + stype : int + 1 for regular grid rows, 2 for alternating offset rows. + """ + if np.size(pts) == 1: + pts_x = int(pts) + pts_y = int(pts) + else: + pts_x = int(pts[0]) + pts_y = int(pts[1]) + + xstep = (xmax - xmin) / float(pts_x) + x1 = [xmin + xstep / 4] + while (np.nanmax(x1) + xstep) < xmax - xstep / 10: + x1 = np.append(x1, np.nanmax(x1) + xstep) + + x2 = [xmin + xstep * 3 / 4] + while (np.nanmax(x2) + xstep) < xmax - xstep / 10: + x2 = np.append(x2, np.nanmax(x2) + xstep) + + ystep = (ymax - ymin) / float(pts_y) + y1 = [ymin + ystep / 2] + while (np.nanmax(y1) + ystep) < ymax - ystep / 10: + y1 = np.append(y1, np.nanmax(y1) + ystep) + + xnew: list[float] | np.ndarray = [] + ynew: list[float] | np.ndarray = [] + iy = 0 + for y in y1: + iy += 1 + if stype == 1: + xnew = np.append(xnew, x1) + y2 = np.zeros(np.size(x1)) + y2.fill(y) + ynew = np.append(ynew, y2) + if stype == 2: + if iy % 2 == 0: + xnew = np.append(xnew, x1) + y2 = np.zeros(np.size(x1)) + y2.fill(y) + ynew = np.append(ynew, y2) + if iy % 2 == 1: + xnew = np.append(xnew, x2) + y2 = np.zeros(np.size(x2)) + y2.fill(y) + ynew = np.append(ynew, y2) + + return np.asarray(xnew), np.asarray(ynew) + + +def _find_pos_in_array(vals: np.ndarray, val: float) -> int: + """Return lower-bracketing index for *val* in monotonic coordinate *vals*.""" + pos = int(np.searchsorted(vals, val, side="right") - 1) + return max(0, min(pos, np.size(vals) - 2)) + + +def regrid( + f: np.ndarray, + x: np.ndarray, + y: np.ndarray, + xnew: np.ndarray, + ynew: np.ndarray, +) -> np.ndarray: + """Bilinearly interpolate a regular 2D field onto scattered target points.""" + regrid_f = deepcopy(f) + regrid_x = deepcopy(x) + regrid_y = deepcopy(y) + + if regrid_x[0] > regrid_x[-1]: + regrid_x = regrid_x[::-1] + regrid_f = np.fliplr(regrid_f) + + if regrid_y[0] > regrid_y[-1]: + regrid_y = regrid_y[::-1] + regrid_f = np.flipud(regrid_f) + + out = np.array([], dtype=float) + for i in np.arange(np.size(xnew)): + xval = xnew[i] + yval = ynew[i] + + ix = _find_pos_in_array(regrid_x, xval) + iy = _find_pos_in_array(regrid_y, yval) + ix2 = ix + 1 + iy2 = iy + 1 + + dx = regrid_x[ix2] - regrid_x[ix] + dy = regrid_y[iy2] - regrid_y[iy] + alpha_x = (xval - regrid_x[ix]) / (dx if dx != 0 else 1e-30) + alpha_y = (yval - regrid_y[iy]) / (dy if dy != 0 else 1e-30) + + v1 = regrid_f[iy, ix] - (regrid_f[iy, ix] - regrid_f[iy, ix2]) * alpha_x + v2 = regrid_f[iy2, ix] - (regrid_f[iy2, ix] - regrid_f[iy2, ix2]) * alpha_x + newval = v1 - (v1 - v2) * alpha_y + + out = np.append(out, newval) + + return out + + +def polar_regular_grid( + pts: int = 50, + proj: str = "npstere", + boundinglat: float = 0, + lon_0: float = 0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return lon/lat and projected x/y points over a polar stereographic cap.""" + import cartopy.crs as ccrs + + if proj == "npstere": + thisproj = ccrs.NorthPolarStereo(central_longitude=lon_0) + else: + thisproj = ccrs.SouthPolarStereo(central_longitude=lon_0) + + lons = np.array([lon_0 - 90, lon_0, lon_0 + 90, lon_0 + 180]) + lats = np.array([boundinglat, boundinglat, boundinglat, boundinglat]) + extent = thisproj.transform_points(ccrs.PlateCarree(), lons, lats) + + xmin = np.min(extent[:, 0]) + xmax = np.max(extent[:, 0]) + ymin = np.min(extent[:, 1]) + ymax = np.max(extent[:, 1]) + + points_device = stipple_points( + xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, pts=pts, stype=2 + ) + xnew = np.array(points_device)[0, :] + ynew = np.array(points_device)[1, :] + + points_polar = ccrs.PlateCarree().transform_points(thisproj, xnew, ynew) + lons = np.array(points_polar)[:, 0] + lats = np.array(points_polar)[:, 1] + + if proj == "npstere": + valid = np.where(lats >= boundinglat) + else: + valid = np.where(lats <= boundinglat) + + return lons[valid], lats[valid], xnew[valid], ynew[valid] diff --git a/cfplot/utils/__init__.py b/cfplot/utils/__init__.py deleted file mode 100644 index 2d05bc7..0000000 --- a/cfplot/utils/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from .utils import ( - _bfill, - _bfill_ugrid, - _cf_data_assign, - _dim_titles, - _gvals, - _supscr, - _timeaxis, - add_cyclic, - cf_var_name, - cf_var_name_titles, - find_dim_names, - find_pos_in_array, - find_z, - fix_floats, - generate_titles, - irregular_window, - max_ndecs_data, - ndecs, - pcon, - polar_regular_grid, - regrid, - rgaxes, - stipple_points, - vloc, -) diff --git a/cfplot/utils/utils.py b/cfplot/utils/utils.py deleted file mode 100644 index 7de26b9..0000000 --- a/cfplot/utils/utils.py +++ /dev/null @@ -1,2519 +0,0 @@ -from copy import deepcopy - -import cartopy.crs as ccrs -import cartopy.util as cartopy_util -import cf -import matplotlib -import matplotlib.patches as mpatches -import numpy as np -import shapely.geometry as sgeom -from matplotlib.collections import PolyCollection -from scipy.interpolate import griddata - -from ..colour import _cscale_get_map -from ..mapping import _mapaxis -from ..parameters import gset, plotvars -from ..validate import check_well_formed - - -def cf_var_name(field=None, dim=None): - """ - | Return the name from a supplied dimension in order. - | - | Names are returned in the following order: - | * ncvar - | * short_name - | * long_name - | * standard_name - | - | field=None - field - | dim=None - dimension required - 'dim0', 'dim1' etc. - | - :Returns: - name - """ - - # Check for multiple Z coordinates - # Adjust dim if necessary - if dim == "Z": - z_count = 0 - z_names = [] - for mycoord in list(field.coords()): - if field.coord(mycoord).Z: - z_count += 1 - z_names.append(mycoord) - - if z_count > 1: - dim = z_names[-1] - - id = getattr(field.construct(dim), "id", False) - ncvar = field.construct(dim).nc_get_variable(False) - short_name = getattr(field.construct(dim), "short_name", False) - long_name = getattr(field.construct(dim), "long_name", False) - standard_name = getattr(field.construct(dim), "standard_name", False) - - name = "No Name" - if id: - name = id - if ncvar: - name = ncvar - if short_name: - name = short_name - if long_name: - name = long_name - if standard_name: - name = standard_name - - return name - - -def find_dim_names(field): - """Find the field dimension coordinate names. - Ignores auxiliary coordinates (for now). - - returns: - coordinates in the order [T, X, Y, Z] - """ - - # Get the field domain axes - daxes = list(field.get_data_axes()) - - # Get the field coordinates - dcoords = list(field.coords()) - - # Calculate the number of coordinates of type X, Y, Z and T - nx = 0 - ny = 0 - nz = 0 - nt = 0 - for i in np.arange(len(dcoords)): - if field.coord(dcoords[i]).X: - nx += 1 - if field.coord(dcoords[i]).Y: - ny += 1 - if field.coord(dcoords[i]).Z: - nz += 1 - if field.coord(dcoords[i]).T: - nt += 1 - - # New test - remove_aux = False - - # Strip out any auxiliary coordinates if the field is not a - # trajectory field - if remove_aux: - for i in np.arange(len(dcoords)): - if dcoords[i][:-1] == "auxiliarycoordinate": - dcoords[i] = "aux" - dcoords = list(filter(("aux").__ne__, dcoords)) - - # Convert these into corresponding dimension coordinates - if remove_aux: - coords = [] - for i in np.arange(len(daxes)): - val = daxes[i] - coord = None - for j in np.arange(len(dcoords)): - if val == field.get_data_axes(dcoords[j])[0]: - coord = dcoords[j] - - if coord is not None: - coords.append(coord) - else: - errstr = ( - "find_data_names error - cannot find a coordinate for " - f"{val}\nin the data\n" - ) - raise Warning(errstr) - else: - coords = dcoords - - # Make a copy of coords in mycoords - mycoords = deepcopy(coords) - - # Convert to X, Y, Z, T if coordinate is one of these - # If the number of coordinates of this type is greater than 1 then don't - # do this as f.coord('Z') gives an - # error as there is more than one coordinate to return - for i in np.arange(len(daxes)): - if len(coords) - 1 < i: - break - if field.coord(coords[i]).X: - if nx == 1: - mycoords[i] = "X" - if field.coord(coords[i]).Y: - if ny == 1: - mycoords[i] = "Y" - if field.coord(coords[i]).Z: - if nz == 1: - mycoords[i] = "Z" - if field.coord(coords[i]).T: - if nt == 1: - mycoords[i] = "T" - - # Return the reverse of the coordinates so that they are in the - # order [X, Y, Z, T] - mycoords.reverse() - - return mycoords - - -def find_z(f): - """Find the Z coordinate if it exists.""" - - # Return if f is undefined - if f is None: - return None - - myz = "Z" - mycoords = find_dim_names(f) - myz = None - for mycoord in mycoords: - if f.coord(mycoord).Z: - myz = mycoord - - return myz - - -def _dim_titles(title=None, title2=None, title3=None): - """ - | An internal routine to draw a set of dimension titles on a plot. - | - | title=None - title to put on the plot - | title2=None - additional title - | title3=None - additional title - | - """ - - # Logic for the supplied titles - # if just title is supplied: - # title - contour or line title - # - # if both title and title2 are supplied: - # title u component of title - # title2 v component of the title - # - # if title2 and title3 are supplied: - # title2 u component of title - # title3 v component of title - # - # move the plot around if title3 is None - - # Get plot position - if plotvars.plot_type == 1: - this_plot = plotvars.mymap - else: - this_plot = plotvars.plot - - left, bottom, width, height = this_plot.get_position().bounds - - valign = "bottom" - - # Shift down if a cylindrical projection plot else to the left - if plotvars.plot_type == 1 and plotvars.proj != "cyl": - left -= 0.1 - myx = 1.25 - myy = 1.0 - valign = "top" - if title3 is None: - myx = 1.05 - elif plotvars.plot_type == 1 and plotvars.proj == "cyl": - lonrange = plotvars.lonmax - plotvars.lonmin - latrange = plotvars.latmax - plotvars.latmin - if (lonrange / latrange) > 1.5: - myx = 0.0 - myy = 1.02 - - if (lonrange / latrange) > 1.2 and (lonrange / latrange) <= 1.5: - myx = 0.0 - myy = 1.02 - height -= 0.015 - - if (lonrange / latrange) <= 1.2: - left -= 0.1 - # if title2 is not None: - # l = l - 0.1 - myx = 1.05 - myy = 1.0 - width -= 0.1 - valign = "top" - else: - height -= 0.1 - myx = 0.0 - myy = 1.02 - - # Change the plot position if title3 is None - if title3 is None: - this_plot.set_position([left, bottom, width, height]) - - # Set x and y spacing depending on the label location - xspacing = 0.3 - yspacing = 0.0 - if myx == 1.05 or myx == 1.25: - xspacing = 0.0 - yspacing = 0.2 - - if title is not None: - this_plot.text( - myx, - myy, - title, - va=valign, - ha="left", - fontsize=plotvars.axis_label_fontsize, - fontweight=plotvars.axis_label_fontweight, - transform=this_plot.transAxes, - ) - - if title2 is not None: - this_plot.text( - myx + xspacing, - myy - yspacing, - title2, - va=valign, - ha="left", - fontsize=plotvars.axis_label_fontsize, - fontweight=plotvars.axis_label_fontweight, - transform=this_plot.transAxes, - ) - - if title3 is not None: - this_plot.text( - myx + xspacing * 2, - myy - yspacing * 2, - title3, - va=valign, - ha="left", - fontsize=plotvars.axis_label_fontsize, - fontweight=plotvars.axis_label_fontweight, - transform=this_plot.transAxes, - ) - - -def _bfill_ugrid( - f=None, - face_lons=None, - face_lats=None, - face_connectivity=None, - clevs=None, - alpha=None, - zorder=None, -): - """ - | Block fill a irregular field with colour rectangles. - | This is an internal routine and is not generally used by the user. - | - | f=None - field - | face_lons=None - longitude points for face vertices - | face_lats=None - latitude points for face verticies - | face_connectivity=None - connectivity for face verticies - | clevs=None - levels for filling - | lonlat=False - lonlat data - | bound=False - x and y are cf data boundaries - | alpha=alpha - transparency setting 0 to 1 - | zorder=None - plotting order - | - :Returns: - None - | - """ - - # Colour faces according to value - # Set faces to white initially - cols = ["#000000" for x in range(len(face_connectivity))] - - levs = deepcopy(np.array(clevs)) - - if plotvars.levels_extend == "min" or plotvars.levels_extend == "both": - levs = np.concatenate([[-1e20], levs]) - ilevs_max = np.size(levs) - if plotvars.levels_extend == "max" or plotvars.levels_extend == "both": - levs = np.concatenate([levs, [1e20]]) - else: - ilevs_max = ilevs_max - 1 - - for ilev in np.arange(ilevs_max): - lev = levs[ilev] - col = plotvars.cs[ilev] - pts = np.where(f.squeeze() >= lev)[0] - - if len(pts) > 0: - if np.min(pts) >= 0: - for val in np.arange(np.size(pts)): - pt = pts[val] - cols[pt] = col - - plotargs = {"transform": ccrs.PlateCarree()} - - coords_all = [] - - nfaces = np.shape(face_connectivity)[0] - - coords_all = [] - for iface in np.arange(nfaces): - lons = face_lons[iface, :] - lats = face_lats[iface, :] - - # Wrapping in longitude - if (np.max(lons) - np.min(lons)) > 100: - if np.max(lons) > 180: - for j in np.arange(len(lons)): - lons[j] = (lons[j] + 180) % 360 - 180 - else: - for j in np.arange(len(lons)): - lons[j] = lons[j] % 360 - - nverts = len(lons) - - # Add extra verticies if any of the points are at the north or - # south pole - if np.max(lats) == 90 or np.min(lats) == -90: - - geom = sgeom.Polygon( - [(lons[k], lats[k]) for k in np.arange(nverts)] - ) - geom_cyl = ccrs.PlateCarree().project_geometry( - geom, ccrs.Geodetic() - ) - - # New method for shapely 2.0 + - poly_mapped = sgeom.mapping(geom_cyl.geoms[0]) - - coords = list(poly_mapped["coordinates"][0]) - else: - coords = [(lons[k], lats[k]) for k in np.arange(nverts)] - - coords_all.append(coords) - - plotvars.mymap.add_collection( - PolyCollection( - coords_all, - facecolors=cols, - edgecolors=None, - alpha=alpha, - zorder=zorder, - **plotargs, - ) - ) - - -def _timeaxis(dtimes=None): - """ - | Work out a sensible set of time labels and tick - | marks given a time span. This is an internal routine and is not used - | by the user. - - | dtimes=None - data times as a CF variable - - :Returns: - time ticks and labels - | - """ - - time_units = dtimes.Units - time_ticks = [] - time_labels = [] - axis_label = "Time" - - yearmin = min(dtimes.year.array) - yearmax = max(dtimes.year.array) - tmin = min(dtimes.dtarray) - tmax = max(dtimes.dtarray) - if hasattr(dtimes, "calendar"): - calendar = dtimes.calendar - else: - calendar = "standard" - - if plotvars.user_gset != 0: - if isinstance(plotvars.xmin, str): - t = cf.Data( - cf.dt(plotvars.xmin), units=time_units, calendar=calendar - ) - yearmin = int(t.year) - t = cf.Data( - cf.dt(plotvars.xmax), units=time_units, calendar=calendar - ) - yearmax = int(t.year) - tmin = cf.dt(plotvars.xmin, calendar=calendar) - tmax = cf.dt(plotvars.xmax, calendar=calendar) - if isinstance(plotvars.ymin, str): - t = cf.Data( - cf.dt(plotvars.ymin), units=time_units, calendar=calendar - ) - yearmin = int(t.year) - t = cf.Data( - cf.dt(plotvars.ymax), units=time_units, calendar=calendar - ) - yearmax = int(t.year) - tmin = cf.dt(plotvars.ymin, calendar=calendar) - tmax = cf.dt(plotvars.ymax, calendar=calendar) - - # Years - span = yearmax - yearmin - if span > 4 and span < 3000: - axis_label = "Time (year)" - tvals = [] - if span <= 15: - step = 1 - if span > 15: - step = 2 - if span > 30: - step = 5 - if span > 60: - step = 10 - if span > 160: - step = 20 - if span > 300: - step = 50 - if span > 600: - step = 100 - if span > 1300: - step = 200 - - if plotvars.tspace_year is not None: - step = plotvars.tspace_year - - years = np.arange(yearmax / step + 2) * step - tvals = years[np.where((years >= yearmin) & (years <= yearmax))] - - # Catch tvals if not properly defined and use gvals to generate some - # year tick marks - if np.size(tvals) < 2: - tvals = _gvals(dmin=yearmin, dmax=yearmax)[0] - - for year in tvals: - time_ticks.append( - np.min( - cf.Data( - cf.dt(f"{int(year)}-01-01 00:00:00"), - units=time_units, - calendar=calendar, - ).array - ) - ) - time_labels.append(str(int(year))) - - # Months - if yearmax - yearmin <= 4: - months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ] - - # Check number of labels with 1 month steps - tsteps = 0 - for year in np.arange(yearmax - yearmin + 1) + yearmin: - for month in np.arange(12): - mytime = cf.dt( - f"{year}-{month + 1}-01 00:00:00", - calendar=calendar, - ) - if mytime >= tmin and mytime <= tmax: - tsteps = tsteps + 1 - - if tsteps < 17: - mvals = np.arange(12) - if tsteps >= 17: - mvals = np.arange(4) * 3 - - for year in np.arange(yearmax - yearmin + 1) + yearmin: - for month in mvals: - mytime = cf.dt( - f"{year}-{month + 1}-01 00:00:00", - calendar=calendar, - ) - if mytime >= tmin and mytime <= tmax: - time_ticks.append( - np.min( - cf.Data( - mytime, units=time_units, calendar=calendar - ).array - ) - ) - time_labels.append( - str(months[month]) + " " + str(int(year)) - ) - - # Days and hours - if np.size(time_ticks) <= 2: - myday = cf.dt( - int(tmin.year), int(tmin.month), int(tmin.day), calendar=calendar - ) - not_found = 0 - hour_counter = 0 - span = 0 - while not_found <= 48: - mydate = cf.Data(myday, dtimes.Units) + cf.Data( - hour_counter, "hour" - ) - if mydate >= tmin and mydate <= tmax: - span = span + 1 - else: - not_found = not_found + 1 - - hour_counter = hour_counter + 1 - - step = 1 - if span > 13: - step = 1 - if span > 13: - step = 4 - if span > 25: - step = 6 - if span > 100: - step = 12 - if span > 200: - step = 24 - if span > 400: - step = 48 - if span > 800: - step = 96 - if plotvars.tspace_hour is not None: - step = plotvars.tspace_hour - if plotvars.tspace_day is not None: - step = plotvars.tspace_day * 24 - - not_found = 0 - hour_counter = 0 - axis_label = "Time (hour)" - if span >= 24: - axis_label = "Time" - time_ticks = [] - time_labels = [] - - while not_found <= 48: - mytime = cf.Data(myday, dtimes.Units) + cf.Data( - hour_counter, "hour" - ) - if mytime >= tmin and mytime <= tmax: - time_ticks.append(np.min(mytime.array)) - label = f"{mytime.year}-{mytime.month}-{mytime.day}" - if hour_counter / 24 != int(hour_counter / 24): - label += f" {mytime.hour}:00:00" - time_labels.append(label) - else: - not_found = not_found + 1 - - hour_counter = hour_counter + step - - return (time_ticks, time_labels, axis_label) - - -def _supscr(text=None): - """ - | Add superscript text formatting for `**` and `^`. - | This is an internal routine used in titles and colour bars - | and not used by the user. - | - | text=None - input text - - :Returns: - Formatted text - | - """ - - if text is None: - errstr = "\n _supscr error - _supscr must have text input\n" - raise Warning(errstr) - - tform = "" - - sup = 0 - for i in text: - if i == "^": - sup = 2 - if i == "*": - sup = sup + 1 - - if sup == 0: - tform = tform + i - if sup == 1: - if i not in "*": - tform = tform + "*" + i - sup = 0 - if sup == 3: - if i in "-0123456789": - tform = tform + i - else: - tform = tform + "}$" + i - sup = 0 - if sup == 2: - tform = tform + "$^{" - sup = 3 - - if sup == 3: - tform = tform + "}$" - - tform = tform.replace("m2", "m$^{2}$") - tform = tform.replace("m3", "m$^{3}$") - tform = tform.replace("m-2", "m$^{-2}$") - tform = tform.replace("m-3", "m$^{-3}$") - tform = tform.replace("s-1", "s$^{-1}$") - tform = tform.replace("s-2", "s$^{-2}$") - - return tform - - -def _gvals(dmin=None, dmax=None, mystep=None, mod=True): - """ - | Work out a sensible set of values between two limits. - | This is an internal routine used for contour levels and axis - | labelling and is not generally used by the user. - - | dmin = None - minimum - | dmax = None - maximum - | mystep = None - use this step - | mod = True - modify data to make use of a multipler - | - """ - - # Copies of inputs as these might be changed - dmin1 = deepcopy(dmin) - dmax1 = deepcopy(dmax) - - # Swap values if dmin1 > dmax1 - if dmax1 < dmin1: - dmin1, dmax1 = dmax1, dmin1 - - # Data range - data_range = dmax1 - dmin1 - - # field multiplier - mult = 0 - vals = None - - # Return some values if dmin1 = dmax1 - if dmin1 == dmax1: - vals = np.array([dmin1 - 1, dmin1, dmin1 + 1]) - mult = 0 - return vals, mult - - # Modify if requested or if out of range 0.001 to 2000000 - if data_range < 0.001: - while dmax1 <= 3: - dmin1 = dmin1 * 10.0 - dmax1 = dmax1 * 10.0 - data_range = dmax1 - dmin1 - mult = mult - 1 - - if data_range > 2000000: - while dmax1 > 10: - dmin1 = dmin1 / 10.0 - dmax1 = dmax1 / 10.0 - data_range = dmax1 - dmin1 - mult = mult + 1 - - if data_range >= 0.001 and data_range <= 2000000: - - # Calculate an appropriate step - step = None - test_steps = [ - 0.0001, - 0.0002, - 0.0005, - 0.001, - 0.002, - 0.005, - 0.01, - 0.02, - 0.05, - 0.1, - 0.2, - 0.5, - 1, - 2, - 5, - 10, - 20, - 50, - 100, - 200, - 500, - 1000, - 2000, - 5000, - 10000, - 20000, - 50000, - 100000, - ] - - if mystep is not None: - step = mystep - else: - for val in test_steps: - nvals = data_range / val - - if val < 1: - if nvals > 8: - step = val - else: - if nvals > 11: - step = val - - # Return an error if no step found - if step is None: - errstr = "\n\n cfp._gvals - no valid step values found \n\n" - errstr += "cfp._gvals(" + str(dmin1) + "," + str(dmax1) + ")\n\n" - raise Warning(errstr) - - # values < 0.0 - vals = None - vals1 = None - if dmin1 < 0.0: - vals1 = (np.arange(-dmin1 / step) * -step)[::-1] - step - - # values >= 0.0 - vals2 = None - if dmax1 >= 0.0: - vals2 = np.arange(dmax1 / step + 1) * step - - if vals1 is not None and vals2 is None: - vals = vals1 - if vals2 is not None and vals1 is None: - vals = vals2 - if vals1 is not None and vals2 is not None: - vals = np.concatenate((vals1, vals2)) - - # Round off decimal numbers so that - # (np.arange(4) * -0.1)[3] = -0.30000000000000004 gives -0.3 - # as expected - if step < 1: - vals = vals.round(6) - - # Change values to integers for values >= 1 - if step >= 1: - vals = vals.astype(int) - - pts = np.where(np.logical_and(vals >= dmin1, vals <= dmax1)) - if np.min(pts) > -1: - vals = vals[pts] - - if mod is False: - vals = vals * 10**mult - mult = 0 - - # Catch if no values have been defined - if vals is None: - vals = np.array([dmin, dmax]) - - return (vals, mult) - - -def _cf_data_assign( - f=None, colorbar_title=None, verbose=None, rotated_vect=False -): - """ - | Check cf input data is okay and return data for contour plot. - | This is an internal routine not used by the user. - | - | f=None - input cf field - | colorbar_title=None - input colour bar title - | rotated vect=False - return 1D x and y for rotated plot vectors - | verbose=None - set to 1 to get a verbose idea of what the - | _cf_data_assign is doing - - :Returns: - | f - data for contouring - | x - x coordinates of data (optional) - | y - y coordinates of data (optional) - | ptype - plot type - | colorbar_title - colour bar title - | xlabel - x label for plot - | ylabel - y label for plot - | - """ - - # Check input data has the correct number of dimensions - # Take into account rotated pole fields having extra dimensions - ndim = len(f.domain_axes().filter_by_size(cf.gt(1))) - if ( - f.ref("grid_mapping_name:rotated_latitude_longitude", default=False) - is False - ): - if ndim > 2 or ndim < 1: - print("") - if ndim > 2: - errstr = "_cf_data_assign error - data has too many dimensions" - if ndim < 1: - errstr = "_cf_data_assign error - data has too few dimensions" - errstr += "\n cf-plot requires one or two dimensional data\n" - for mydim in list(f.dimension_coordinates()): - sn = getattr(f.construct(mydim), "standard_name", False) - ln = getattr(f.construct(mydim), "long_name", False) - if sn: - errstr += f"{mydim},{sn},{f.construct(mydim).size}\n" - else: - if ln: - errstr += f"{mydim},{ln},{f.construct(mydim).size}\n" - raise Warning(errstr) - - # Set up data arrays and variables - lons = None - lats = None - height = None - time = None - xlabel = "" - ylabel = "" - has_lons = False - has_lats = False - has_height = False - has_time = False - xpole = None - ypole = None - ptype = None - field = None - x = None - y = None - - # Check for multiple Z coordinates - myz = find_z(f) - - # Extract coordinate data if a matching CF standard_name or axis is found - for mycoord in f.coords(): - c = f.coord(mycoord) - if c.X: - lons = np.squeeze(f.construct(mycoord).array) - if verbose: - print("lons -", lons) - if np.size(lons) > 1: - has_lons = True - - if c.Y: - lats = np.squeeze(f.construct(mycoord).array) - if verbose: - print("lats -", lats) - if np.size(lats) > 1: - has_lats = True - - if c.Z: - height = np.squeeze(f.construct(mycoord).array) - if verbose: - print("height -", height) - if np.size(height) > 1: - has_height = True - - if c.T: - time = np.squeeze(f.construct(mycoord).array) - if verbose: - print("time -", time) - if np.size(time) > 1: - has_time = True - - # assign field data - field = np.squeeze(f.array) - - # Change Boolean data to integer - if str(f.dtype) == "bool": - warnstr = ( - "\n\n\n Warning - boolean data found - converting to " - "integers\n\n\n" - ) - print(warnstr) - g = deepcopy(f) - g.dtype = int - field = np.squeeze(g.array) - - # Check what plot type is required. - # 0=simple contour plot, 1=map plot, 2=latitude-height plot, - # 3=longitude-time plot, 4=latitude-time plot. - if has_lons and has_lats: - ptype = 1 - x = lons - y = lats - - if has_lats and has_height: - ptype = 2 - x = lats - y = height - - xname = cf_var_name(field=f, dim="Y") - xunits = str(getattr(f.construct("Y"), "Units", "")) - if xunits == "degrees_north": - xunits = "degrees" - if xunits != "": - xlabel = f"{xname} ({xunits})" - else: - xlabel = xname - - yname = cf_var_name(field=f, dim=myz) - yunits = str(getattr(f.construct(myz), "Units", "")) - if yunits != "": - ylabel = f"{yname} ({yunits})" - else: - ylabel = yname - - if has_lons and has_height: - ptype = 3 - x = lons - y = height - - xname = cf_var_name(field=f, dim="X") - xunits = str(getattr(f.construct("X"), "Units", "")) - if xunits == "degrees_east": - xunits = "degrees" - if xunits != "": - xlabel = f"{xname} ({xunits})" - else: - xlabel = xname - - yname = cf_var_name(field=f, dim=myz) - yunits = str(getattr(f.construct(myz), "Units", "")) - if yunits != "": - ylabel = f"{yname} ({yunits})" - else: - ylabel = yname - - if has_lons and has_time: - ptype = 4 - x = lons - y = time - - xname = cf_var_name(field=f, dim="X") - xunits = str(getattr(f.construct("X"), "Units", "")) - if xunits == "degrees_east": - xunits = "degrees" - if xunits != "": - xlabel = f"{xname} ({xunits})" - else: - xlabel = xname - - yname = cf_var_name(field=f, dim="T") - yunits = str(getattr(f.construct("T"), "Units", "")) - if yunits != "": - ylabel = f"{yname} ({yunits})" - else: - ylabel = yname - - if has_lats and has_time: - ptype = 5 - x = lats - y = time - - xname = cf_var_name(field=f, dim="Y") - xunits = str(getattr(f.construct("Y"), "Units", "")) - if xunits == "degrees_north": - xunits = "degrees" - if xunits != "": - xlabel = f"{xname} ({xunits})" - else: - xlabel = xname - - yname = cf_var_name(field=f, dim="T") - yunits = str(getattr(f.construct("T"), "Units", "")) - if yunits != "": - ylabel = f"{yname} ({yunits})" - else: - ylabel = yname - - # time height plot - if has_height and has_time: - ptype = 7 - x = time - y = height - - xname = cf_var_name(field=f, dim="T") - xunits = str(getattr(f.construct("T"), "Units", "")) - if xunits != "": - xlabel = f"{xname} ({xunits})" - else: - xlabel = xname - - yname = cf_var_name(field=f, dim="Z") - yunits = str(getattr(f.construct("Z"), "Units", "")) - if yunits != "": - ylabel = f"{yname} ({yunits})" - else: - ylabel = yname - - # Rotate array to get it as time vs height - field = np.rot90(field) - field = np.flipud(field) - - # Rotated pole - if f.ref("grid_mapping_name:rotated_latitude_longitude", default=False): - ptype = 6 - - rotated_pole = f.ref("grid_mapping_name:rotated_latitude_longitude") - xpole = rotated_pole["grid_north_pole_longitude"] - ypole = rotated_pole["grid_north_pole_latitude"] - - # Extract grid x and y coordinates - for mydim in list(f.dimension_coordinates()): - name = cf_var_name(field=f, dim=mydim) - - if name in ["grid_longitude", "longitude", "x"]: - x = np.squeeze(f.construct(mydim).array) - xunits = str(getattr(f.construct(mydim), "units", "")) - xlabel = cf_var_name(field=f, dim=mydim) - - if name in ["grid_latitude", "latitude", "y"]: - y = np.squeeze(f.construct(mydim).array) - # Flip y and data if reversed - if y[0] > y[-1]: - y = y[::-1] - field = np.flipud(field) - yunits = str(getattr(f.construct(mydim), "Units", "")) - ylabel = cf_var_name(field=f, dim=mydim) + yunits - - # Extract auxiliary lons and lats if they exist - if ptype == 1 or ptype is None: - if plotvars.proj != "rotated" and not rotated_vect: - aux_lons = False - aux_lats = False - for mydim in list(f.auxiliary_coordinates()): - name = cf_var_name(field=f, dim=mydim) - if name in ["longitude"]: - xpts = np.squeeze(f.construct(mydim).array) - aux_lons = True - if name in ["latitude"]: - ypts = np.squeeze(f.construct(mydim).array) - aux_lats = True - - if aux_lons and aux_lats: - x = xpts - y = ypts - ptype = 1 - - # UKCP grid - if f.ref("grid_mapping_name:transverse_mercator", default=False): - ptype = 1 - field = np.squeeze(f.array) - - # Find the auxiliary lons and lats if provided - has_lons = False - has_lats = False - for mydim in list(f.auxiliary_coordinates()): - name = cf_var_name(field=f, dim=mydim) - if name in ["longitude"]: - x = np.squeeze(f.construct(mydim).array) - has_lons = True - if name in ["latitude"]: - y = np.squeeze(f.construct(mydim).array) - has_lats = True - - # Calculate lons and lats if no auxiliary data for these - if not has_lons or not has_lats: - xpts = f.construct("X").array - ypts = f.construct("Y").array - field = np.squeeze(f.array) - - ref = f.ref("grid_mapping_name:transverse_mercator") - false_easting = ref["false_easting"] - false_northing = ref["false_northing"] - central_longitude = ref["longitude_of_central_meridian"] - central_latitude = ref["latitude_of_projection_origin"] - scale_factor = ref["scale_factor_at_central_meridian"] - - # Set the transform - transform = ccrs.TransverseMercator( - false_easting=false_easting, - false_northing=false_northing, - central_longitude=central_longitude, - central_latitude=central_latitude, - scale_factor=scale_factor, - ) - - # Calculate the longitude and latitude points - xvals, yvals = np.meshgrid(xpts, ypts) - points = ccrs.PlateCarree().transform_points( - transform, xvals, yvals - ) - x = np.array(points)[:, :, 0] - y = np.array(points)[:, :, 1] - - # None of the above - if ptype is None: - ptype = 0 - - data_axes = f.get_data_axes() - count = 1 - for d in data_axes: - try: - c = f.coordinate(filter_by_axis=[d]) - if np.size(c.array) > 1: - if count == 1: - - y = c - mycoord = "dimensioncoordinate" + str(d[-1]) - yunits = str(getattr(f.coord(mycoord), "Units", "")) - if yunits != "": - yunits = f"({yunits})" - ylabel = cf_var_name(field=f, dim=mycoord) + yunits - elif count == 2: - x = c - mycoord = "dimensioncoordinate" + str(d[-1]) - xunits = str(getattr(f.coord(mycoord), "units", "")) - if xunits != "": - xunits = f"({xunits})" - xlabel = cf_var_name(field=f, dim=mycoord) + xunits - count += 1 - except ValueError: - errstr = ( - "\n\n_cf_data_assign - cannot find data to return\n\n" - f"{f.constructs.domain_axis_identity(d)}\n\n" - ) - raise Warning(errstr) - - # Assign colorbar_title - if colorbar_title is None: - colorbar_title = "No Name" - if hasattr(f, "id"): - colorbar_title = f.id - nc = f.nc_get_variable(None) - if nc: - colorbar_title = f.nc_get_variable() - if hasattr(f, "short_name"): - colorbar_title = f.short_name - if hasattr(f, "long_name"): - colorbar_title = f.long_name - if hasattr(f, "standard_name"): - colorbar_title = f.standard_name - - if hasattr(f, "Units"): - if str(f.Units) == "": - colorbar_title = colorbar_title - else: - colorbar_title = f"{colorbar_title} ({_supscr(str(f.Units))})" - - # Return data - return (field, x, y, ptype, colorbar_title, xlabel, ylabel, xpole, ypole) - - -def _bfill( - f=None, - x=None, - y=None, - clevs=False, - lonlat=None, - bound=False, - alpha=1.0, - single_fill_color=None, - white=True, - zorder=4, - fast=None, - transform=False, - orca=False, -): - """ - | Block fill a field with colour rectangles. - | This is an internal routine and is not generally used by the user. - | - | f=None - field - | x=None - x points for field - | y=None - y points for field - | clevs=None - levels for filling - | lonlat=None - longitude and latitude data - | bound=False - x and y are cf data boundaries - | alpha=alpha - transparency setting 0 to 1 - | white=True - colour unplotted areas white - | single_fill_color=None - colour for a blockfill between two levels - | - makes maplotlib named colours or - | - hexadecimal notation - '#d3d3d3' for grey - | zorder=4 - plotting order - | fast=None - use fast plotting with pcolormesh which is useful for - | larger datasets - | transform=False - map transform supplied by calling routine - | orca=False - data is orca data - | - :Returns: - None - | - """ - - # Set lonlat if not specified - lonlat = False - if plotvars.plot_type == 1: - lonlat = True - - # If single_fill_color is defined then turn off whiting out the background. - if single_fill_color is not None: - white = False - - # Set 2D lon lat if data is that format - two_d = False - if not isinstance(f, cf.Field): - if np.ndim(x) == 2 and np.ndim(x) == 2: - two_d = True - - # Set the default map coordinates for the data to be PlateCarree - plotargs = {} - if lonlat: - plotargs = {"transform": ccrs.PlateCarree()} - - # Set the field - if isinstance(f, cf.Field): - field = f.array - else: - field = f - - # Get colour scale for use in contouring - # If colour bar extensions are enabled then the colour map goes - # from 1 to ncols-2. The colours for the colour bar extensions - # are then changed on the colorbar and plot after the plot is made - ncols_addition = 0 - if single_fill_color is None: - colmap = _cscale_get_map() - cmap = matplotlib.colors.ListedColormap(colmap) - if plotvars.levels_extend in ["min", "both"]: - cmap.set_under(plotvars.cs[0]) - clevs = np.append(-1e-30, clevs) - ncols_addition += 1 - if plotvars.levels_extend in ["max", "both"]: - cmap.set_over(plotvars.cs[-1]) - clevs = np.append(clevs, 1e30) - ncols_addition += 1 - else: - cols = single_fill_color - cmap = matplotlib.colors.ListedColormap(cols) - - levels = np.array(deepcopy(clevs)).astype("float") - - # Colour array for storing the cell colour - colarr = np.zeros([np.shape(field)[0], np.shape(field)[1]]) - for i in np.arange(np.size(levels) - 1): - lev = levels[i] - pts = np.where(np.logical_and(field >= lev, field < levels[i + 1])) - colarr[pts] = int(i) - - # Change points that are masked back to -1 - if isinstance(field, np.ma.MaskedArray): - pts = np.ma.where(field.mask) - if np.size(pts) > 0: - colarr[pts] = -1 - - norm = matplotlib.colors.BoundaryNorm(levels, cmap.N + ncols_addition) - - if isinstance(f, cf.Field): - if f.ref("grid_mapping_name:transverse_mercator", default=False): - lonlat = True - - # Case of transverse mercator of which UKCP is an example - ref = f.ref("grid_mapping_name:transverse_mercator") - false_easting = ref["false_easting"] - false_northing = ref["false_northing"] - central_longitude = ref["longitude_of_central_meridian"] - central_latitude = ref["latitude_of_projection_origin"] - scale_factor = ref["scale_factor_at_central_meridian"] - - transform = ccrs.TransverseMercator( - false_easting=false_easting, - false_northing=false_northing, - central_longitude=central_longitude, - central_latitude=central_latitude, - scale_factor=scale_factor, - ) - - # Extract the axes and data - xpts = np.append( - f.dim("X").bounds.array[:, 0], f.dim("X").bounds.array[-1, 1] - ) - ypts = np.append( - f.dim("Y").bounds.array[:, 0], f.dim("Y").bounds.array[-1, 1] - ) - field = np.squeeze(f.array) - plotargs = {"transform": transform} - - else: - - if two_d is False: - if bound: - xpts = x - ypts = y - else: - # Find x box boundaries - xpts = x[0] - (x[1] - x[0]) / 2.0 - for ix in np.arange(np.size(x) - 1): - xpts = np.append(xpts, x[ix] + (x[ix + 1] - x[ix]) / 2.0) - xpts = np.append(xpts, x[ix + 1] + (x[ix + 1] - x[ix]) / 2.0) - - # Find y box boundaries - ypts = y[0] - (y[1] - y[0]) / 2.0 - for iy in np.arange(np.size(y) - 1): - ypts = np.append(ypts, y[iy] + (y[iy + 1] - y[iy]) / 2.0) - ypts = np.append(ypts, y[iy + 1] + (y[iy + 1] - y[iy]) / 2.0) - - # Shift lon grid if needed - if lonlat: - # Extract upper bound and original rhs of box longitude - # bounding points - upper_bound = ypts[-1] - - # Reduce xpts and ypts by 1 or shifting of grid fails - # The last points are the right / upper bounds for the - # last data box - xpts = xpts[0:-1] - ypts = ypts[0:-1] - - if plotvars.lonmin < np.nanmin(xpts): - xpts = xpts - 360 - if plotvars.lonmin > np.nanmax(xpts): - xpts = xpts + 360 - - # Add cyclic information if missing. - lonrange = np.nanmax(xpts) - np.nanmin(xpts) - if lonrange < 360 and lonrange > 350: - # field, xpts = cartopy_util.add_cyclic_point(field, xpts) - field, xpts = add_cyclic(field, xpts) - - right_bound = xpts[-1] + (xpts[-1] - xpts[-2]) - - # Add end x and y end points - xpts = np.append(xpts, right_bound) - ypts = np.append(ypts, upper_bound) - - if two_d: - # 2D lons and lats code - if fast: - xpts = x - ypts = y - else: - nx = np.shape(x)[1] - ny = np.shape(x)[0] - - for ix in np.arange(nx): - for iy in np.arange(ny): - - # Calculate the local size difference and set the - # square points - if ix < nx - 2: - xdiff = (x[iy, ix + 1] - x[iy, ix]) / 2 - else: - xdiff = (x[iy, ix] - x[iy, ix - 1]) / 2 - - if iy < ny - 2: - ydiff = (y[iy + 1, ix] - y[iy, ix]) / 2 - else: - ydiff = (y[iy, ix] - y[iy - 1, ix]) / 2 - - xpts = [ - x[iy, ix] - xdiff, - x[iy, ix] + xdiff, - x[iy, ix] + xdiff, - x[iy, ix] - xdiff, - x[iy, ix] - xdiff, - ] - ypts = [ - y[iy, ix] - ydiff, - y[iy, ix] - ydiff, - y[iy, ix] + ydiff, - y[iy, ix] + ydiff, - y[iy, ix] - ydiff, - ] - - # Plot the square - plotvars.mymap.add_patch( - mpatches.Polygon( - [ - [xpts[0], ypts[0]], - [xpts[1], ypts[1]], - [xpts[2], ypts[2]], - [xpts[3], ypts[3]], - [xpts[4], ypts[4]], - ], - facecolor=plotvars.cs[int(colarr[iy, ix])], - zorder=zorder, - transform=ccrs.PlateCarree(), - ) - ) - - return - - # Polar stereographic - # Set points past plotting limb to be plotvars.boundinglat - # Also set any lats past the pole to be the pole - if plotvars.proj == "npstere": - pts = np.where(ypts < plotvars.boundinglat) - if np.size(pts) > 0: - ypts[pts] = plotvars.boundinglat - pts = np.where(ypts > 90.0) - if np.size(pts) > 0: - ypts[pts] = 90.0 - - if plotvars.proj == "spstere": - pts = np.where(ypts > plotvars.boundinglat) - if np.size(pts) > 0: - ypts[pts] = plotvars.boundinglat - pts = np.where(ypts < -90.0) - if np.size(pts) > 0: - ypts[pts] = -90.0 - - # Set the transform if not supplied to _bfill - if transform: - lonlat = True - else: - transform = ccrs.PlateCarree() - - if fast: - if isinstance(clevs, int): - norm = False - - if two_d: - # Plot using pcolormesh if a 2D grid - # field = f - fixed_x = x.copy() - for i, start in enumerate( - np.argmax(np.abs(np.diff(x)) > 180, axis=1) - ): - fixed_x[i, start + 1 :] += 360 - plotvars.image = plotvars.mymap.pcolormesh( - fixed_x, y, field, cmap=cmap, transform=transform, norm=norm - ) - - else: - if lonlat: - for offset in [0, 360.0]: - if isinstance(clevs, int): - plotvars.image = plotvars.mymap.pcolormesh( - xpts + offset, - ypts, - field, - transform=transform, - cmap=cmap, - ) - else: - plotvars.image = plotvars.mymap.pcolormesh( - xpts + offset, - ypts, - field, - transform=transform, - cmap=cmap, - norm=norm, - ) - - else: - if isinstance(clevs, int): - plotvars.image = plotvars.plot.pcolormesh( - xpts, ypts, field, cmap=cmap - ) - else: - plotvars.image = plotvars.plot.pcolormesh( - xpts, ypts, field, cmap=cmap, norm=norm - ) - - else: - - if plotvars.plot_type == 1 and plotvars.proj != "cyl": - - for i in np.arange(np.size(levels) - 1): - allverts = [] - xy_stack = np.column_stack(np.where(colarr == i)) - - for pt in np.arange(np.shape(xy_stack)[0]): - ix = xy_stack[pt][1] - iy = xy_stack[pt][0] - lons = [ - xpts[ix], - xpts[ix + 1], - xpts[ix + 1], - xpts[ix], - xpts[ix], - ] - lats = [ - ypts[iy], - ypts[iy], - ypts[iy + 1], - ypts[iy + 1], - ypts[iy], - ] - - txpts, typts = lons, lats - verts = [ - (txpts[0], typts[0]), - (txpts[1], typts[1]), - (txpts[2], typts[2]), - (txpts[3], typts[3]), - (txpts[4], typts[4]), - ] - - allverts.append(verts) - - # Make the collection and add it to the plot. - if single_fill_color is None: - color = plotvars.cs[i] - else: - color = single_fill_color - coll = PolyCollection( - allverts, - facecolor=color, - edgecolors=color, - alpha=alpha, - zorder=zorder, - **plotargs, - ) - - if lonlat: - plotvars.mymap.add_collection(coll) - else: - plotvars.plot.add_collection(coll) - else: - for i in np.arange(np.size(levels) - 1): - - allverts = [] - xy_stack = np.column_stack(np.where(colarr == i)) - for pt in np.arange(np.shape(xy_stack)[0]): - ix = xy_stack[pt][1] - iy = xy_stack[pt][0] - verts = [ - (xpts[ix], ypts[iy]), - (xpts[ix + 1], ypts[iy]), - (xpts[ix + 1], ypts[iy + 1]), - (xpts[ix], ypts[iy + 1]), - (xpts[ix], ypts[iy]), - ] - - allverts.append(verts) - - # Make the collection and add it to the plot. - if single_fill_color is None: - color = plotvars.cs[i] - else: - color = single_fill_color - - coll = PolyCollection( - allverts, - facecolor=color, - edgecolors=color, - alpha=alpha, - zorder=zorder, - **plotargs, - ) - - if lonlat: - plotvars.mymap.add_collection(coll) - else: - plotvars.plot.add_collection(coll) - - # Add white for undefined areas - if white: - allverts = [] - xy_stack = np.column_stack(np.where(colarr == -1)) - for pt in np.arange(np.shape(xy_stack)[0]): - ix = xy_stack[pt][1] - iy = xy_stack[pt][0] - - verts = [ - (xpts[ix], ypts[iy]), - (xpts[ix + 1], ypts[iy]), - (xpts[ix + 1], ypts[iy + 1]), - (xpts[ix], ypts[iy + 1]), - (xpts[ix], ypts[iy]), - ] - - allverts.append(verts) - - # Make the collection and add it to the plot. - color = plotvars.cs[i] - coll = PolyCollection( - allverts, - facecolor="#ffffff", - edgecolors="#ffffff", - alpha=alpha, - zorder=zorder, - **plotargs, - ) - - if lonlat: - plotvars.mymap.add_collection(coll) - else: - plotvars.plot.add_collection(coll) - - -def add_cyclic(field, lons): - """ - | A wrapper for `cartopy_util.add_cyclic_point(field, lons)`. - | - | This is needed for the case of when the longitudes are not evenly spaced - | due to numpy rounding which causes an error from the cartopy wrapping - | routine. In this case the longitudes are promoted to 64 bit and then - | rounded to an appropriate number of decimal places before passing to - | the cartopy add_cyclic routine. - """ - - try: - field, lons = cartopy_util.add_cyclic_point(field, lons) - except Exception: - ndecs_max = max_ndecs_data(lons) - lons = np.float64(lons).round(ndecs_max) - field, lons = cartopy_util.add_cyclic_point(field, lons) - - return field, lons - - -def cf_var_name_titles(field=None, dim=None): - """ - | Return the name from a supplied dimension in order. - | - | Names are returned in the following order: - | * standard_name - | * long_name - | * short_name - | * ncvar - - | field=None - field - | dim=None - dimension required - 'dim0', 'dim1' etc. - | - :Returns: - name - """ - # TODO SLB: combine with 'cf_var_name', which does reverse - - name = None - units = None - if field.has_construct(dim): - - id = getattr(field.construct(dim), "id", False) - ncvar = field.construct(dim).nc_get_variable(False) - short_name = getattr(field.construct(dim), "short_name", False) - long_name = getattr(field.construct(dim), "long_name", False) - standard_name = getattr(field.construct(dim), "standard_name", False) - - # name = 'No Name' - if id: - name = id - if ncvar: - name = ncvar - if short_name: - name = short_name - if long_name: - name = long_name - if standard_name: - name = standard_name - - units = getattr(field.construct(dim), "units", "") - if len(units) > 0: - units = f"({units})" - return name, units - - -def find_pos_in_array(vals=None, val=None, above=False): - """ - | Find the position of a point in an array. - | - | vals - array values - | val - value to find position of - | - :Returns: - position in array - | - """ - - pos = -1 - if above is False: - for myval in vals: - if val > myval: - pos = pos + 1 - - if above: - for myval in vals: - if val >= myval: - pos = pos + 1 - - if np.size(vals) - 1 > pos: - pos = pos + 1 - - return pos - - -def generate_titles(f=None): - """Generate a set of title dims to put at the top of plots.""" - - mycoords = find_dim_names(f) - # TODO SLB, see 'well_formed' dead code below in case this is important. - # For now, us 'noqa' to prevent PEP8 F841 being raised due to lack of use. - well_formed = check_well_formed(f) # noqa: F841 - - title_dims = "" - if isinstance(f, cf.Field): - for idim in np.arange(len(mycoords)): - mycoord = mycoords[idim] - if mycoord == "Z": - mycoord = find_z(f) - - title, units = cf_var_name_titles(f, mycoord) - if not f.coord(mycoord).T: - values = f.construct(mycoord).array - if len(values) > 1: - value = "" - else: - value = str(values) - title_dims += f"{mycoord}: {title} {value} {units}\n" - - else: - values = f.construct(mycoord).dtarray - - if len(values) > 1: - value = "" - else: - value = str(cf.Data(values).datetime_as_string) - title_dims += f"{mycoord}: {title} {value}\n" - - if len(f.cell_methods()) > 0: - title_dims += "cell_methods: " - i = 0 - - for method in f.cell_methods(): - if len(f.cell_methods()[method].get_axes()) > 0: - axis = f.cell_methods()[method].get_axes()[0] - try: - # Change domainaxis0 etc to an axis - myid = f.constructs.domain_axis_identity(axis) - except ValueError: - myid = axis - - value = "" - if f.cell_methods()[method].has_method(): - value = f.cell_methods()[method].get_method() - - qualifiers = f.cell_methods()[method].qualifiers() - qualifier_text = "" - if len(qualifiers) > 0: - qualifier_text = str(qualifiers) - - if i > 0: - title_dims += ", " - - title_dims += f"{myid}: {value} {qualifier_text}" - - i += 1 - - return title_dims - - -def irregular_window(field, lons, lats): - """TODO DOCS.""" - - field_irregular = deepcopy(field) - lons_irregular = deepcopy(lons) - lats_irregular = deepcopy(lats) - - # Fix longitudes to be -180 to 180 - # lons_irregular = ( - # (lons_irregular + plotvars.lonmin) % 360) + plotvars.lonmin - - # Test data to get appropiate longitude offset to perform remapping - found_lon = False - for ilon in [-360, 0, 360]: - lons_test = lons_irregular + ilon - if np.min(lons_test) <= plotvars.lonmin: - found_lon = True - lons_offset = ilon - - if found_lon: - lons_irregular = lons_irregular + lons_offset - pts = np.where(lons_irregular < plotvars.lonmin) - lons_irregular[pts] = lons_irregular[pts] + 360.0 - else: - errstr = ( - "\n\ncf-plot error - cannot determine grid offset in " - "add_cyclic_irregular\n\n" - ) - raise Warning(errstr) - - field_wrap = deepcopy(field_irregular) - lons_wrap = deepcopy(lons_irregular) - lats_wrap = deepcopy(lats_irregular) - delta = 120.0 - - pts_left = np.where(lons_wrap >= plotvars.lonmin + 360 - delta) - lons_left = lons_wrap[pts_left] - 360.0 - lats_left = lats_wrap[pts_left] - field_left = field_wrap[pts_left] - - field_wrap = np.concatenate([field_wrap, field_left]) - lons_wrap = np.concatenate([lons_wrap, lons_left]) - lats_wrap = np.concatenate([lats_wrap, lats_left]) - - # Make a line of interpolated data on left hand side of plot and insert - # this into the data on both the left and the right before contouring - lons_new = np.zeros(181) + plotvars.lonmin - lats_new = np.arange(181) - 90 - field_new = griddata( - (lons_wrap, lats_wrap), - field_wrap, - (lons_new, lats_new), - method="linear", - ) - - # Remove any non finite points in the interpolated data - pts = np.where(np.isfinite(field_new)) - field_new = field_new[pts] - lons_new = lons_new[pts] - lats_new = lats_new[pts] - - # Add the interpolated data to the left - field_irregular = np.concatenate([field_irregular, field_new]) - lons_irregular = np.concatenate([lons_irregular, lons_new]) - lats_irregular = np.concatenate([lats_irregular, lats_new]) - - # Add to the right if a full globe is being plotted - # The 359.99 here is needed or Cartopy will map 360 back to 0 - - if plotvars.lonmax - plotvars.lonmin == 360: - field_irregular = np.concatenate([field_irregular, field_new]) - lons_irregular = np.concatenate([lons_irregular, lons_new + 359.95]) - lats_irregular = np.concatenate([lats_irregular, lats_new]) - else: - lons_new2 = np.zeros(181) + plotvars.lonmax - lats_new2 = np.arange(181) - 90 - field_new2 = griddata( - (lons_wrap, lats_wrap), - field_wrap, - (lons_new2, lats_new2), - method="linear", - ) - - # Remove any non finite points in the interpolated data - pts = np.where(np.isfinite(field_new2)) - field_new2 = field_new2[pts] - lons_new2 = lons_new2[pts] - lats_new2 = lats_new2[pts] - - # Add the interpolated data to the right - field_irregular = np.concatenate([field_irregular, field_new2]) - lons_irregular = np.concatenate([lons_irregular, lons_new2]) - lats_irregular = np.concatenate([lats_irregular, lats_new2]) - - # Finally remove any point off to the right of plotvars.lonmax - pts = np.where(lons_irregular <= plotvars.lonmax) - if np.size(pts) > 0: - field_irregular = field_irregular[pts] - lons_irregular = lons_irregular[pts] - lats_irregular = lats_irregular[pts] - - return field_irregular, lons_irregular, lats_irregular - - -def fix_floats(data): - """ - Fixes numpy rounding issues where 0.4 becomes 0.399999999999999999999. - """ - - # Return unchecked if any values have an e in them, for example 7.85e-8 - has_e = False - for val in data: - if "e" in str(val): - has_e = True - if has_e: - return data - - data_ndecs = np.zeros(len(data)) - for i in np.arange(len(data)): - data_ndecs[i] = len(str(float(data[i])).split(".")[1]) - - if max(data_ndecs) >= 10: - # Reset large decimal vales to zero - if min(data_ndecs) < 10: - pts = np.where(data_ndecs >= 10) - data_ndecs[pts] = 0 - ndecs_max = int(max(data_ndecs)) - # Reset to new ndecs_max decimal places - for i in np.arange(len(data)): - data[i] = round(data[i], ndecs_max) - else: - # fix to two or more decimal places - nd = 2 - data_range = 0.0 - data_temp = data - - while data_range == 0.0: - data_temp = deepcopy(data) - - for i in np.arange(len(data_temp)): - data_temp[i] = round(data_temp[i], nd) - - data_range = np.max(data_temp) - np.min(data_temp) - nd = nd + 1 - - data = data_temp - - return data - - -def max_ndecs_data(data): - """TODO DOCS.""" - - ndecs_max = 1 - data_ndecs = np.zeros(len(data)) - for i in np.arange(len(data)): - data_ndecs[i] = len(str(data[i]).split(".")[1]) - - if max(data_ndecs) >= ndecs_max: - # Reset large decimal vales to zero - if min(data_ndecs) < 10: - pts = np.where(data_ndecs >= 10) - data_ndecs[pts] = 0 - ndecs_max = int(max(data_ndecs)) - - return ndecs_max - - -def ndecs(data=None): - """ - | Finds the number of decimal places in an array. - | Needed to make the colour bar match the contour line labelling. - - | data=data - input array of values - - :Returns: - | maximum number of necimal places - | - """ - - maxdecs = 0 - - for i in range(len(data)): - number = data[i] - a = str(number).split(".") - if np.size(a) == 2: - number_decs = len(a[1]) - if number_decs > maxdecs: - maxdecs = number_decs - - return maxdecs - - -def pcon(mb=None, km=None, h=7.0, p0=1000): - """ - | Convert pressure to height in kilometers and vice-versa. - | This function uses the equation P=P0exp(-z/H) to translate - | between pressure and height. In pcon the surface pressure P0 is set to - | 1000.0mb and the scale height H is set to 7.0. The value of H can vary - | from 6.0 in the polar regions to 8.5 in the tropics as well as - | seasonally. The value of P0 could also be said to be 1013.25mb rather - | than 1000.0mb. - - | As this relationship is approximate: - | (i) Only use this for making the axis labels on y axis pressure plots - | (ii) Put the converted axis on the right hand side to indicate that - | this isn't the primary unit of measure - - | print cfp.pcon(mb=[1000, 300, 100, 30, 10, 3, 1, 0.3]) - | [0. 8.42780963 16.11809565 24.54590528 32.2361913 - | 40.66400093 48.35428695, 56.78209658] - - | mb=None - input pressure - | km=None - input height - | h=7.0 - default value for h - | p0=1000 - default value for p0 - - :Returns: - | pressure(mb) if height(km) input, - | height(km) if pressure(mb) input - """ - - if all(val is None for val in [mb, km]) == 2: - errstr = "pcon error - pcon must have mb or km input\n" - raise Warning(errstr) - - if mb is not None: - return h * (np.log(p0) - np.log(mb)) - if km is not None: - return np.exp(-1.0 * (np.array(km) / h - np.log(p0))) - - -def polar_regular_grid(pts=50): - """ - | Return a regular grid over a polar stereographic area. - | - | pts=50 - number of grid points in the x and y directions - | - :Returns: - lons, lats of grid in degrees - x, y locations of lons and lats - """ - - boundinglat = plotvars.boundinglat - lon_0 = plotvars.lon_0 - - if plotvars.proj == "npstere": - thisproj = ccrs.NorthPolarStereo(central_longitude=lon_0) - else: - thisproj = ccrs.SouthPolarStereo(central_longitude=lon_0) - - # Find min and max of plotting region in device coordinates - lons = np.array([lon_0 - 90, lon_0, lon_0 + 90, lon_0 + 180]) - lats = np.array([boundinglat, boundinglat, boundinglat, boundinglat]) - extent = thisproj.transform_points(ccrs.PlateCarree(), lons, lats) - - xmin = np.min(extent[:, 0]) - xmax = np.max(extent[:, 0]) - ymin = np.min(extent[:, 1]) - ymax = np.max(extent[:, 1]) - - # Make up a stipple of points for cover the pole - points_device = stipple_points( - xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, pts=pts, stype=2 - ) - - xnew = np.array(points_device)[0, :] - ynew = np.array(points_device)[1, :] - - points_polar = ccrs.PlateCarree().transform_points(thisproj, xnew, ynew) - - lons = np.array(points_polar)[:, 0] - lats = np.array(points_polar)[:, 1] - - if plotvars.proj == "npstere": - valid = np.where(lats >= boundinglat) - else: - valid = np.where(lats <= boundinglat) - - return lons[valid], lats[valid], xnew[valid], ynew[valid] - - -def regrid(f=None, x=None, y=None, xnew=None, ynew=None): - """ - | Bilinear interpolation of a grid to new grid locations. - | - | f=None - original field - | x=None - original field x values - | y=None - original field y values - | xnew=None - new x points - | ynew=None - new y points - | - :Returns: - field values at requested locations - | - """ - # TODO SLB: what is this method for and why named/described as such??? - - # Copy input arrays - regrid_f = deepcopy(f) - regrid_x = deepcopy(x) - regrid_y = deepcopy(y) - fieldout = [] - - # Reverse xpts and field if necessary - if regrid_x[0] > regrid_x[-1]: - regrid_x = regrid_x[::-1] - regrid_f = np.fliplr(regrid_f) - - # Reverse ypts and field if necessary - if regrid_y[0] > regrid_y[-1]: - regrid_y = regrid_y[::-1] - regrid_f = np.flipud(regrid_f) - - # Iterate over the new grid to get the new grid values. - for i in np.arange(np.size(xnew)): - - xval = xnew[i] - yval = ynew[i] - - # Find position of new grid point in the x and y arrays - myxpos = find_pos_in_array(vals=regrid_x, val=xval) - myypos = find_pos_in_array(vals=regrid_y, val=yval) - - myxpos2 = myxpos + 1 - myypos2 = myypos + 1 - - if myxpos2 != myxpos: - alpha = (xnew[i] - regrid_x[myxpos]) / ( - regrid_x[myxpos2] - regrid_x[myxpos] - ) - else: - alpha = (xnew[i] - regrid_x[myxpos]) / 1e-30 - - newval1 = regrid_f[myypos, myxpos] - regrid_f[myypos, myxpos2] - newval1 = newval1 * alpha - newval1 = regrid_f[myypos, myxpos] - newval1 - - newval2 = regrid_f[myypos2, myxpos] - regrid_f[myypos2, myxpos2] - newval2 = newval2 * alpha - newval2 = regrid_f[myypos2, myxpos] - newval2 - - if myypos2 != myypos: - alpha2 = ynew[i] - regrid_y[myypos] - alpha2 = alpha2 / (regrid_y[myypos2] - regrid_y[myypos]) - else: - alpha2 = (ynew[i] - regrid_y[myypos]) / 1e-30 - - newval3 = newval1 - (newval1 - newval2) * alpha2 - - fieldout = np.append(fieldout, newval3) - - return fieldout - - -def rgaxes( - xpole=None, - ypole=None, - xvec=None, - yvec=None, - xticks=None, - xticklabels=None, - yticks=None, - yticklabels=None, - axes=None, - xaxis=None, - yaxis=None, - xlabel=None, - ylabel=None, -): - """ - | Label rotated grid plots. - | - | xpole=None - location of xpole in degrees - | ypole=None - location of ypole in degrees - | xvec=None - location of x grid points - | yvec=None - location of y grid points - | - | axes=True - plot x and y axes - | xaxis=True - plot xaxis - | yaxis=True - plot y axis - | xticks=None - xtick positions - | xticklabels=None - xtick labels - | yticks=None - y tick positions - | yticklabels=None - ytick labels - | xlabel=None - label for x axis - | ylabel=None - label for y axis - | - :Returns: - name - """ - - spacing = plotvars.rotated_grid_spacing - degspacing = plotvars.rotated_deg_spacing - continents = plotvars.rotated_continents - grid = plotvars.rotated_grid - labels = plotvars.rotated_labels - grid_thickness = plotvars.rotated_grid_thickness - - # Invert y array if going from north to south - # Otherwise this gives nans for all output - yvec_orig = yvec - if yvec[0] > yvec[np.size(yvec) - 1]: - yvec = yvec[::-1] - - gset( - xmin=0, - xmax=np.size(xvec) - 1, - ymin=0, - ymax=np.size(yvec) - 1, - user_gset=0, - ) - - # Set continent thickness and color if not already set - if plotvars.continent_thickness is None: - continent_thickness = 1.5 - if plotvars.continent_color is None: - continent_color = "k" - - # Draw continents - if continents: - - import cartopy.io.shapereader as shpreader - import shapefile - - shpfilename = shpreader.natural_earth( - resolution=plotvars.resolution, - category="physical", - name="coastline", - ) - reader = shapefile.Reader(shpfilename) - shapes = [s.points for s in reader.shapes()] - for shape in shapes: - lons, lats = list(zip(*shape)) - lons = np.array(lons) - lats = np.array(lats) - - rotated_transform = ccrs.RotatedPole( - pole_latitude=ypole, pole_longitude=xpole - ) - points = rotated_transform.transform_points( - ccrs.PlateCarree(), lons, lats - ) - xout = np.array(points)[:, 0] - yout = np.array(points)[:, 1] - - xpts, ypts = vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) - plotvars.plot.plot( - xpts, - ypts, - linewidth=continent_thickness, - color=continent_color, - ) - - if xticks is None: - lons = -180 + np.arange(360 / spacing + 1) * spacing - else: - lons = xticks - if yticks is None: - lats = -90 + np.arange(180 / spacing + 1) * spacing - else: - lats = yticks - - # Work out how far from plot to plot the longitude and latitude labels - xlim = plotvars.plot.get_xlim() - spacing_x = (xlim[1] - xlim[0]) / 20 - ylim = plotvars.plot.get_ylim() - spacing_y = (ylim[1] - ylim[0]) / 20 - spacing = min(spacing_x, spacing_y) - - # Draw lines along a longitude - if axes: - if xaxis: - for val in np.arange(np.size(lons)): - ipts = 179.0 / degspacing - lona = np.zeros(int(ipts)) + lons[val] - lata = -90 + np.arange(ipts - 1) * degspacing - - rotated_transform = ccrs.RotatedPole( - pole_latitude=ypole, pole_longitude=xpole - ) - points = rotated_transform.transform_points( - ccrs.PlateCarree(), lona, lata - ) - xout = np.array(points)[:, 0] - yout = np.array(points)[:, 1] - - xpts, ypts = vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) - if grid: - plotvars.plot.plot( - xpts, ypts, ":", linewidth=grid_thickness, color="k" - ) - - if labels: - # Make a label unless the axis is all Nans - if np.size(ypts[5:]) > np.sum(np.isnan(ypts[5:])): - ymin = np.nanmin(ypts[5:]) - loc = np.where(ypts == ymin)[0] - if np.size(loc) > 1: - loc = loc[1] - - if loc > 0: - if np.isfinite(xpts[loc]): - line = matplotlib.lines.Line2D( - [xpts[loc], xpts[loc]], - [0, -spacing / 2], - color="k", - ) - plotvars.plot.add_line(line) - line.set_clip_on(False) - fw = plotvars.text_fontweight - if xticklabels is None: - xticklabel = _mapaxis( - lons[val], lons[val], type=1 - )[1][0] - else: - xticklabel = xticks[val] - - plotvars.plot.text( - xpts[loc], - -spacing, - xticklabel, - horizontalalignment="center", - verticalalignment="top", - fontsize=plotvars.text_fontsize, - fontweight=fw, - ) - - # Draw lines along a latitude - if axes: - if yaxis: - for val in np.arange(np.size(lats)): - ipts = 359.0 / degspacing - lata = np.zeros(int(ipts)) + lats[val] - lona = -180.0 + np.arange(ipts - 1) * degspacing - - rotated_transform = ccrs.RotatedPole( - pole_latitude=ypole, pole_longitude=xpole - ) - points = rotated_transform.transform_points( - ccrs.PlateCarree(), lona, lata - ) - xout = np.array(points)[:, 0] - yout = np.array(points)[:, 1] - xpts, ypts = vloc(lons=xout, lats=yout, xvec=xvec, yvec=yvec) - - if grid: - plotvars.plot.plot( - xpts, ypts, ":", linewidth=grid_thickness, color="k" - ) - - if labels: - # Make a label unless the axis is all Nans - if np.size(xpts[5:]) > np.sum(np.isnan(xpts[5:])): - xmin = np.nanmin(xpts[5:]) - loc = np.where(xpts == xmin)[0] - if np.size(loc) == 1: - if loc > 0: - if np.isfinite(ypts[loc]): - line = matplotlib.lines.Line2D( - [0, -spacing / 2], - [ypts[loc], ypts[loc]], - color="k", - ) - plotvars.plot.add_line(line) - line.set_clip_on(False) - fw = plotvars.text_fontweight - if yticklabels is None: - yticklabel = _mapaxis( - lats[val], lats[val], type=2 - )[1][0] - else: - yticklabel = yticks[val] - - plotvars.plot.text( - -spacing, - ypts[loc], - yticklabel, - horizontalalignment="right", - verticalalignment="center", - fontsize=plotvars.text_fontsize, - fontweight=fw, - ) - - # Reset yvec - yvec = yvec_orig - - -def stipple_points( - xmin=None, xmax=None, ymin=None, ymax=None, pts=None, stype=None -): - """ - | Calculate interpolation points. - | - | xmin=None - plot x minimum - | ymax=None - plot x maximum - | ymin=None - plot y minimum - | ymax=None - plot x maximum - | pts=None - number of points in the x and y directions - | one number gives the same in both directions - | - | stype=None - type of grid. 1=regular, 2=offset - | - :Returns: - stipple locations in x and y - | - """ - - # Work out number of points in x and y directions - if np.size(pts) == 1: - pts_x = pts - pts_y = pts - if np.size(pts) == 2: - pts_x = pts[0] - pts_y = pts[1] - - # Create regularly spaced points - xstep = (xmax - xmin) / float(pts_x) - x1 = [xmin + xstep / 4] - while (np.nanmax(x1) + xstep) < xmax - xstep / 10: - x1 = np.append(x1, np.nanmax(x1) + xstep) - - x2 = [xmin + xstep * 3 / 4] - while (np.nanmax(x2) + xstep) < xmax - xstep / 10: - x2 = np.append(x2, np.nanmax(x2) + xstep) - - ystep = (ymax - ymin) / float(pts_y) - y1 = [ymin + ystep / 2] - while (np.nanmax(y1) + ystep) < ymax - ystep / 10: - y1 = np.append(y1, np.nanmax(y1) + ystep) - - # Create interpolation points - xnew = [] - ynew = [] - iy = 0 - - for y in y1: - iy = iy + 1 - if stype == 1: - xnew = np.append(xnew, x1) - y2 = np.zeros(np.size(x1)) - y2.fill(y) - ynew = np.append(ynew, y2) - - if stype == 2: - if iy % 2 == 0: - xnew = np.append(xnew, x1) - y2 = np.zeros(np.size(x1)) - y2.fill(y) - ynew = np.append(ynew, y2) - if iy % 2 == 1: - xnew = np.append(xnew, x2) - y2 = np.zeros(np.size(x2)) - y2.fill(y) - ynew = np.append(ynew, y2) - - return xnew, ynew - - -def vloc(xvec=None, yvec=None, lons=None, lats=None): - """ - | Locate the positions of a set of points in a vector. - | - | xvec=None - data longitudes - | yvec=None - data latitudes - | lons=None - required longitude positions - | lats=None - required latitude positions - - :Returns: - locations of user points in the longitude and latitude points - """ - - # Check input parameters - if any(val is None for val in [xvec, yvec, lons, lats]): - errstr = ( - "\nvloc error\n" - "xvec, yvec, lons, lats all need to be passed to vloc to\n" - "generate a set of location points\n" - ) - raise Warning(errstr) - - xarr = np.zeros(np.size(lons)) - yarr = np.zeros(np.size(lats)) - - # Convert longitudes to -180 to 180. - for i in np.arange(np.size(xvec)): - xvec[i] = ((xvec[i] + 180) % 360) - 180 - for i in np.arange(np.size(lons)): - lons[i] = ((lons[i] + 180) % 360) - 180 - - # Centre around 180 degrees longitude if needed. - if max(xvec) > 150: - for i in np.arange(np.size(xvec)): - xvec[i] = (xvec[i] + 360.0) % 360.0 - pts = np.where(xvec < 0.0) - xvec[pts] = xvec[pts] + 360.0 - for i in np.arange(np.size(lons)): - lons[i] = (lons[i] + 360.0) % 360.0 - pts = np.where(lons < 0.0) - lons[pts] = lons[pts] + 360.0 - - # Find position in array - for i in np.arange(np.size(lons)): - - if (lons[i] < min(xvec)) or (lons[i] > max(xvec)): - xpt = -1 - else: - xpts = np.where(lons[i] >= xvec) - xpt = np.nanmax(xpts) - - if (lats[i] < min(yvec)) or (lats[i] > max(yvec)): - ypt = -1 - else: - ypts = np.where(lats[i] >= yvec) - ypt = np.nanmax(ypts) - - if xpt >= 0: - xarr[i] = xpt + (lons[i] - xvec[xpt]) / (xvec[xpt + 1] - xvec[xpt]) - else: - xarr[i] = None - - if (ypt >= 0) and ypt <= np.size(yvec) - 2: - yarr[i] = ypt + (lats[i] - yvec[ypt]) / (yvec[ypt + 1] - yvec[ypt]) - else: - yarr[i] = None - - return (xarr, yarr) diff --git a/cfplot/vector.py b/cfplot/vector.py index e107b81..19906c4 100644 --- a/cfplot/vector.py +++ b/cfplot/vector.py @@ -1,27 +1,80 @@ from copy import deepcopy import cartopy.crs as ccrs -import cartopy.feature as cfeature import cf import numpy as np -from .graphic import gclose, gopen, gpos -from .mapping import _map_title, _mapaxis, _plot_map_axes, _set_map, axes_plot -from .parameters import cscale, gset, mapset, plotvars -from .utils import ( - _cf_data_assign, - _dim_titles, - _gvals, - _supscr, - add_cyclic, - generate_titles, - regrid, - rgaxes, - stipple_points, +from .layout_runtime import ( + apply_axes, + ensure_runtime_session, + finalize_runtime_session, + gset, + set_axis_visibility, ) +from .map_runtime import ( + _apply_current_map_title, + _apply_dim_titles, + _apply_map_axes_with_toggles, + _apply_map_features, + _ensure_map_axes, + mapset, +) +from .rotated_runtime import _render_rotated_grid_axes +from .state import plotvars +from . import utility +from .utility import mapaxis from .validate import _check_data +def _mapaxis(min=None, max=None, type=None): + return mapaxis( + min_val=min, + max_val=max, + axis_type=type, + degsym=bool(plotvars.degsym), + ) + + + + +def axes_plot( + xticks=None, + xticklabels=None, + yticks=None, + yticklabels=None, + xlabel=None, + ylabel=None, + title=None, + axes=True, + xaxis=True, + yaxis=True, +): + apply_axes( + plot_type=plotvars.plot_type, + xticks=xticks, + yticks=yticks, + xlabel=xlabel, + ylabel=ylabel, + xticklabels=xticklabels, + yticklabels=yticklabels, + ) + + if title is not None and plotvars.plot is not None: + plotvars.plot.set_title( + title, + y=1.03, + fontsize=plotvars.title_fontsize, + fontweight=plotvars.title_fontweight, + ) + + set_axis_visibility( + plotvars.plot, + axes=axes, + xaxis=xaxis, + yaxis=yaxis, + ) + + def vect( u=None, v=None, @@ -184,7 +237,9 @@ def vect( ylabel, xpole, ypole, - ) = _cf_data_assign(u, colorbar_title, rotated_vect=rotated_vect) + ) = utility.cf_data_assign( + u, colorbar_title, proj=("rotated" if rotated_vect else plotvars.proj) + ) elif isinstance(u, cf.FieldList): raise TypeError("Can't plot a field list") else: @@ -222,7 +277,9 @@ def vect( ylabel, xpole, ypole, - ) = _cf_data_assign(v, colorbar_title, rotated_vect=rotated_vect) + ) = utility.cf_data_assign( + v, colorbar_title, proj=("rotated" if rotated_vect else plotvars.proj) + ) elif isinstance(v, cf.FieldList): raise TypeError("Can't plot a field list") else: @@ -273,19 +330,13 @@ def vect( # Calculate a set of dimension titles if requested if titles: - title_dims = generate_titles(u) + title_dims = utility.generate_titles(u) title_dims = f"u\n{title_dims}" - title_dims2 = generate_titles(v) + title_dims2 = utility.generate_titles(v) title_dims2 = f"v\n{title_dims2}" # Open a new plot if necessary - if plotvars.user_plot == 0: - gopen(user_plot=0) - - # Call gpos(1) if not already called - if plotvars.rows > 1 or plotvars.columns > 1: - if plotvars.gpos_called is False: - gpos(1) + auto_session = ensure_runtime_session(pos=1) # Set plot type if user specified if ptype is not None: @@ -297,7 +348,7 @@ def vect( if plotvars.plot_type == 1: # Set up mapping if (lonrange > 350 and latrange > 170) or plotvars.user_mapset == 1: - _set_map() + _ensure_map_axes() else: mapset( lonmin=np.nanmin(u_x), @@ -307,14 +358,14 @@ def vect( user_mapset=0, resolution=resolution_orig, ) - _set_map() + _ensure_map_axes() mymap = plotvars.mymap # u_data, u_x = cartopy_util.add_cyclic_point(u_data, u_x) - u_data, u_x = add_cyclic(u_data, u_x) + u_data, u_x = utility.add_cyclic(u_data, u_x) # v_data, v_x = cartopy_util.add_cyclic_point(v_data, v_x) - v_data, v_x = add_cyclic(v_data, v_x) + v_data, v_x = utility.add_cyclic(v_data, v_x) # stride data points to reduce vector density if stride is not None: @@ -396,7 +447,7 @@ def vect( if key_label is None: key_label = str(key_length) if isinstance(u, cf.Field): - key_label = _supscr(key_label + u.units) + key_label = utility._supscr(key_label + u.units) if key_show: plotvars.mymap.quiverkey( quiv, @@ -411,7 +462,7 @@ def vect( ) # axes - _plot_map_axes( + _apply_map_axes_with_toggles( axes=axes, xaxis=xaxis, yaxis=yaxis, @@ -421,43 +472,51 @@ def vect( yticklabels=yticklabels, user_xlabel=user_xlabel, user_ylabel=user_ylabel, - verbose=False, ) - # Coastlines - continent_thickness = plotvars.continent_thickness - continent_color = plotvars.continent_color - continent_linestyle = plotvars.continent_linestyle - if continent_thickness is None: - continent_thickness = 1.5 - if continent_color is None: - continent_color = "k" - if continent_linestyle is None: - continent_linestyle = "solid" - - feature = cfeature.NaturalEarthFeature( - name="land", - category="physical", - scale=plotvars.resolution, - facecolor="none", - ) - mymap.add_feature( - feature, - edgecolor=continent_color, - linewidth=continent_thickness, - linestyle=continent_linestyle, + _apply_map_features( + mymap=mymap, + continent_color=plotvars.continent_color, + continent_thickness=plotvars.continent_thickness, + continent_linestyle=plotvars.continent_linestyle, ) # Title if title is not None: - _map_title(title) + _apply_current_map_title(title) # Titles for dimensions if titles: if plotvars.titles_con_called is False: - _dim_titles(title=title_dims, title2=title_dims2) + _apply_dim_titles( + plot=plotvars.plot, + mymap=plotvars.mymap, + plot_type=plotvars.plot_type, + proj=plotvars.proj, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + axis_label_fontsize=plotvars.axis_label_fontsize, + axis_label_fontweight=plotvars.axis_label_fontweight, + title=title_dims, + title2=title_dims2, + ) else: - _dim_titles(title2=title_dims, title3=title_dims2) + _apply_dim_titles( + plot=plotvars.plot, + mymap=plotvars.mymap, + plot_type=plotvars.plot_type, + proj=plotvars.proj, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + axis_label_fontsize=plotvars.axis_label_fontsize, + axis_label_fontweight=plotvars.axis_label_fontweight, + title2=title_dims, + title3=title_dims2, + ) if plotvars.plot_type == 6: if u.ref("grid_mapping_name:rotated_latitude_longitude", False): @@ -467,7 +526,7 @@ def vect( if ( lonrange > 350 and latrange > 170 ) or plotvars.user_mapset == 1: - _set_map() + _ensure_map_axes() else: mapset( @@ -478,7 +537,7 @@ def vect( user_mapset=0, resolution=resolution_orig, ) - _set_map() + _ensure_map_axes() quiv = plotvars.mymap.quiver( u_x, @@ -502,7 +561,7 @@ def vect( if key_label is None: key_label = str(key_length) if isinstance(u, cf.Field): - key_label = _supscr(key_label + u.units) + key_label = utility._supscr(key_label + u.units) if key_show: plotvars.mymap.quiverkey( @@ -519,11 +578,11 @@ def vect( # Axes on the native grid if plotvars.plot == "rotated": - rgaxes( + _render_rotated_grid_axes( xpole=xpole, ypole=ypole, - xvec=x, - yvec=y, + xvec=u_x, + yvec=u_y, xticks=xticks, xticklabels=xticklabels, yticks=yticks, @@ -536,7 +595,7 @@ def vect( ) if plotvars.plot == "cyl": - _plot_map_axes( + _apply_map_axes_with_toggles( axes=axes, xaxis=xaxis, yaxis=yaxis, @@ -546,16 +605,28 @@ def vect( yticklabels=yticklabels, user_xlabel=user_xlabel, user_ylabel=user_ylabel, - verbose=False, ) # Title if title is not None: - _map_title(title) + _apply_current_map_title(title) # Titles for dimensions if titles: - _dim_titles(title=title_dims, titles2=title_dims2) + _apply_dim_titles( + plot=plotvars.plot, + mymap=plotvars.mymap, + plot_type=plotvars.plot_type, + proj=plotvars.proj, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + axis_label_fontsize=plotvars.axis_label_fontsize, + axis_label_fontweight=plotvars.axis_label_fontweight, + title=title_dims, + title2=title_dims2, + ) ###################################### # Latitude or longitude vs height plot @@ -631,7 +702,7 @@ def vect( lltype = 2 llticks, lllabels = _mapaxis(min=xmin, max=xmax, type=lltype) - heightticks = _gvals( + heightticks = utility.gvals( dmin=ymin, dmax=ymax, mystep=ystep, mod=False )[0] heightlabels = heightticks @@ -644,7 +715,6 @@ def vect( if xticklabels is not None: lllabels = xticklabels else: - llticks = [100000000] xlabel = "" if yaxis: @@ -654,12 +724,9 @@ def vect( if yticklabels is not None: heightlabels = yticklabels else: - heightticks = [100000000] ylabel = "" else: - llticks = [100000000] - heightticks = [100000000] xlabel = "" ylabel = "" @@ -670,6 +737,9 @@ def vect( yticklabels=heightlabels, xlabel=xlabel, ylabel=ylabel, + axes=axes, + xaxis=xaxis, + yaxis=yaxis, ) # Log y axis @@ -696,7 +766,6 @@ def vect( if xticklabels is not None: lllabels = xticklabels else: - llticks = [100000000] xlabel = "" if yaxis: @@ -706,7 +775,6 @@ def vect( if yticklabels is not None: heightlabels = yticklabels else: - heightticks = [100000000] ylabel = "" if yticks is None: @@ -715,6 +783,9 @@ def vect( xticklabels=lllabels, xlabel=xlabel, ylabel=ylabel, + axes=axes, + xaxis=xaxis, + yaxis=yaxis, ) else: axes_plot( @@ -724,12 +795,15 @@ def vect( yticklabels=heightlabels, xlabel=xlabel, ylabel=ylabel, + axes=axes, + xaxis=xaxis, + yaxis=yaxis, ) # Regrid the data if requested if pts is not None: - xnew, ynew = stipple_points( + xnew, ynew = utility.stipple_points( xmin=np.min(u_x), xmax=np.max(u_x), ymin=np.min(u_y), @@ -741,14 +815,14 @@ def vect( if ytype == 0: # Make y interpolation in log space as we have a pressure # coordinate - u_vals = regrid( + u_vals = utility.regrid( f=u_data, x=u_x, y=np.log10(u_y), xnew=xnew, ynew=np.log10(ynew), ) - v_vals = regrid( + v_vals = utility.regrid( f=v_data, x=u_x, y=np.log10(u_y), @@ -756,8 +830,12 @@ def vect( ynew=np.log10(ynew), ) else: - u_vals = regrid(f=u_data, x=u_x, y=u_y, xnew=xnew, ynew=ynew) - v_vals = regrid(f=v_data, x=u_x, y=u_y, xnew=xnew, ynew=ynew) + u_vals = utility.regrid( + f=u_data, x=u_x, y=u_y, xnew=xnew, ynew=ynew + ) + v_vals = utility.regrid( + f=v_data, x=u_x, y=u_y, xnew=xnew, ynew=ynew + ) u_x = xnew u_y = ynew @@ -804,7 +882,7 @@ def vect( if key_label is None: key_label_u = str(key_length_u) if isinstance(u, cf.Field): - key_label_u = _supscr(f"{key_label_u} ({u.units})") + key_label_u = utility._supscr(f"{key_label_u} ({u.units})") else: key_label_u = key_label[0] if key_show: @@ -841,12 +919,12 @@ def vect( key_label_u = str(key_length_u) key_label_v = str(key_length_v) if isinstance(u, cf.Field): - key_label_u = _supscr(f"{key_label_u} ({u.units})") + key_label_u = utility._supscr(f"{key_label_u} ({u.units})") if isinstance(v, cf.Field): - key_label_v = _supscr(f"{key_label_v} ({v.units})") + key_label_v = utility._supscr(f"{key_label_v} ({v.units})") else: - key_label_u = _supscr(key_label[0]) - key_label_v = _supscr(key_label[1]) + key_label_u = utility._supscr(key_label[0]) + key_label_v = utility._supscr(key_label[1]) # Plot reference vectors and keys if key_show: @@ -907,15 +985,30 @@ def vect( # Titles for dimensions if titles: - _dim_titles(title=title_dims, titles2=title_dims2) + _apply_dim_titles( + plot=plotvars.plot, + mymap=plotvars.mymap, + plot_type=plotvars.plot_type, + proj=plotvars.proj, + lonmin=plotvars.lonmin, + lonmax=plotvars.lonmax, + latmin=plotvars.latmin, + latmax=plotvars.latmax, + axis_label_fontsize=plotvars.axis_label_fontsize, + axis_label_fontweight=plotvars.axis_label_fontweight, + title=title_dims, + title2=title_dims2, + ) ########## # Save plot ########## - if plotvars.user_plot == 0: - gset() - cscale() - gclose() + finalize_runtime_session( + auto_session=auto_session, + reset_limits=True, + reset_colour_scale=True, + view=True, + ) if plotvars.user_mapset == 0: mapset() diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 0000000..f0f4c0c --- /dev/null +++ b/docs/dev/architecture.md @@ -0,0 +1,449 @@ +# cf-plot Contour Subsystem — Architecture & Refactoring Analysis + +*Generated 2026-05-16. Covers the refactored contour path only (`contour.py` and its +direct dependencies). The legacy monolith `cfplot.py` is explicitly out of scope.* + +--- + +## 1. Package Layout + +``` +cfplot/ +├── __init__.py # Public API surface +├── cfplot.py # Legacy monolith (out of scope) +├── contour.py # Refactored contour rendering +├── colorbar.py # Colorbar rendering helper +├── blockfill.py # Block-fill rendering helper +├── layout_runtime.py # Figure/viewport lifecycle (no map knowledge) +├── map_runtime.py # Map setup, axes, and graticule +├── state.py # Global plotvars singleton + colour scale state +└── utility.py # Pure stateless utilities +``` + +--- + +## 2. Module Responsibilities + +### `state.py` +Owns the single shared `plotvars` instance and the two colour-scale operations +that mutate it (`apply_colour_scale`, `get_colour_scale_map`). Everything else that +reads or writes plotting state goes through this object. + +**Imports:** `utility` (for `load_colour_scale_rgb`, `interpolate_colour_channels`). +This is the only outward dependency; `state` does not import any rendering module. + +### `utility.py` +Pure functions with no global state. Acts as the maths and data-extraction layer: +`gvals`, `mapaxis`, `ndecs`, `calculate_levels`, `cf_data_assign`, `timeaxis`, +`find_z`, `load_colour_scale_rgb`, `interpolate_colour_channels`, etc. + +**Imports:** nothing from the package. + +### `layout_runtime.py` +Figure lifecycle and Cartesian viewport management. Contains: + +| Symbol | Role | +|--------|------| +| `gopen` / `gclose` | Session open/close, figure save/show | +| `ensure_xy_viewport` | Lazy figure + subplot creation for Cartesian plots | +| `set_plot_limits` | Forward axis limits to the active `plotvars.plot` | +| `apply_axes` | Dispatcher: routes to `_apply_xy_axes` or (local import) `map_runtime._apply_map_axes` | +| `_open_figure` | Private: creates the Matplotlib figure, applies subplots_adjust | +| `_select_position` | Private: creates one subplot or user-positioned axes | +| `_apply_xy_axes` | Private: labels and ticks for Cartesian axes | + +**Imports:** `matplotlib`, `state`. No Cartopy, no NumPy. + +### `map_runtime.py` +Everything required to create and decorate a Cartopy map axes. Contains: + +| Symbol | Role | +|--------|------| +| `MapSet` class | Stateful map configuration + axes creation | +| `MapSet.configure` | Write map extent/projection parameters into `plotvars` | +| `MapSet.ensure_map_axes` | Create the Cartopy `GeoAxes` subplot | +| `MapSet.draw_grid` | Draw a graticule on the active map | +| `MapSet.draw_polar_axes` | Graticule + longitude labels for polar stereographic | +| `_apply_map_title` | Places a title on map axes using Cartopy transforms | +| `_apply_dim_titles` | Places dimension-string annotation beside plot | +| `ensure_map_viewport` | Module-level: lazy figure creation before map axes | +| `_apply_map_axes` | Module-level: lon/lat ticks for cyl and lcc projections | + +**Imports:** `cartopy.crs`, `numpy`, `utility`, `state`. Uses a *local* import of +`_open_figure`/`_select_position` from `layout_runtime` inside `ensure_map_viewport` +to avoid a circular module-level dependency. + +### `colorbar.py` +Single public function `cbar()` that draws a `ColorbarBase` onto a newly-added axes +inset below or beside the active plot. Reads `plotvars` directly for defaults. + +**Imports:** `matplotlib`, `numpy`, `state`. + +### `blockfill.py` +Single public function `_bfill()` that fills contour bands with solid-coloured +rectangles or polygons. Handles both Cartesian and map (lon/lat) modes. + +**Imports:** `cartopy.crs`, `cartopy.util`, `cf`, `matplotlib`, `numpy`, `state`. + +### `contour.py` +The main rendering module. Provides `con()` as its sole public entry point. + +**Key objects:** + +| Object | Role | +|--------|------| +| `ContourData` | Frozen dataclass: extracted, validated field + coordinate arrays | +| `ContourLayout` | Allocates viewport and applies titles/labels | +| `ColourScale` | Fits a colormap to contour levels | +| `ContourRenderer` | Abstract base: `render_filled`, `render_lines`, `render_blockfill`, `render_colorbar` | +| `MapContourRenderer` | Concrete: Cartopy-aware contour drawing (ptype 1) | +| `XYContourRenderer` | Concrete: Cartesian contour drawing (ptypes 0, 2–5) | + +**Key private functions:** + +| Function | Role | +|----------|------| +| `con()` | Public entry point; guards unsupported cases, delegates to `_render_with_new_xy` | +| `_render_with_new_xy` | Orchestrates: data extraction → levels → colour → layout → render → colorbar → title | +| `_add_cyclic` | Adds a cyclic longitude column using `cartopy_util` | +| `_clear_animation_artists` | Removes artists from the previous animation frame | +| `_can_use_new_xy_path` | Guards entry to the new renderer path | + +**Imports:** `cf`, `cartopy.crs`, `cartopy.feature`, `matplotlib`, `numpy`, +`utility`, `blockfill._bfill`, `colorbar.cbar`, `layout_runtime`, `map_runtime`, +`state`. + +--- + +## 3. Dependency Diagram + +```plantuml +@startuml cf-plot-contour-dependencies +skinparam packageStyle rectangle +skinparam ArrowColor #444444 +skinparam componentStyle uml2 + +package "External" { + [cf] + [cartopy] + [matplotlib] + [numpy] +} + +package "cfplot" { + [utility] + [state] + [layout_runtime] + [map_runtime] + [colorbar] + [blockfill] + [contour] +} + +' Pure utilities layer +[state] --> [utility] + +' Layout layer (no Cartopy) +[layout_runtime] --> [state] +[layout_runtime] ..> [map_runtime] : local import\n(apply_axes branch) + +' Map layer +[map_runtime] --> [state] +[map_runtime] --> [utility] +[map_runtime] ..> [layout_runtime] : local import\n(ensure_map_viewport) +[map_runtime] --> [cartopy] +[map_runtime] --> [numpy] + +' Rendering helpers +[colorbar] --> [state] +[colorbar] --> [matplotlib] +[colorbar] --> [numpy] + +[blockfill] --> [state] +[blockfill] --> [cartopy] +[blockfill] --> [numpy] +[blockfill] --> [cf] + +' Main module +[contour] --> [state] +[contour] --> [utility] +[contour] --> [layout_runtime] +[contour] --> [map_runtime] +[contour] --> [colorbar] +[contour] --> [blockfill] +[contour] --> [cartopy] +[contour] --> [matplotlib] +[contour] --> [numpy] +[contour] --> [cf] +@enduml +``` + +--- + +## 4. Call Flow: a typical `con(f)` invocation + +```plantuml +@startuml cf-plot-con-callflow +skinparam sequenceArrowThickness 1.2 +skinparam actorBackgroundColor #eee + +actor Caller + +Caller -> contour : con(f, **kwargs) +contour -> contour : _can_use_new_xy_path() +contour -> contour : _render_with_new_xy() + +group Data extraction + contour -> utility : cf_data_assign(f) + utility --> contour : field, x, y, ptype, labels + contour -> contour : ContourData.from_cf_field() +end + +group Levels & colour scale + contour -> utility : calculate_levels(field) + utility --> contour : clevs, mult, fmult + contour -> state : ColourScale.fit_to_levels() + state -> utility : apply_colour_scale → load_colour_scale_rgb +end + +group Viewport allocation (ptype == 1 shown) + contour -> map_runtime : MapSet.configure() + map_runtime -> state : write lonmin/lonmax/proj/… + contour -> map_runtime : MapSet.ensure_map_axes() + map_runtime -> layout_runtime : ensure_map_viewport() [local import] + layout_runtime -> state : _open_figure / _select_position + map_runtime -> state : write plotvars.mymap + contour -> contour : ContourLayout.allocate_map_viewport() +end + +group Rendering + contour -> contour : MapContourRenderer.render_filled() + note right: reads plotvars.mymap, plotvars.norm + contour -> contour : MapContourRenderer.render_lines() + contour -> map_runtime : apply_axes (via layout_runtime dispatcher) + map_runtime -> utility : mapaxis() + contour -> map_runtime : MapSet.draw_grid() / draw_polar_axes() + contour -> cartopy : add_feature (coastlines etc.) +end + +group Colorbar + title + contour -> colorbar : cbar(labels, levs, …) + colorbar -> state : get_colour_scale_map() + contour -> map_runtime : _apply_map_title() +end + +group Optional file save + contour -> matplotlib : figure.savefig() +end + +contour --> Caller : None (success) +@enduml +``` + +--- + +## 5. Semantic Leakage Analysis + +The following issues are places where a module does work that belongs to a +different layer of the architecture. They are ordered roughly by severity and +ease of fixing. + +--- + +### L1 — `_apply_map_title` and `_apply_dim_titles` moved to `map_runtime.py` (completed) + +**Status (2026-05-16):** Completed. Both helpers now live in `map_runtime.py` and +`contour.py` calls them from there. + +**What:** Two functions that place annotated text on map axes using Cartopy +coordinate transforms (`ccrs.PlateCarree`, `ccrs.Robinson`, etc.) are defined +as module-level privates in `contour.py`. + +**Why it matters:** These are map annotation concerns. They have nothing to do +with the contour rendering strategy (levels, colourmaps, fill/lines). Their +natural home is `map_runtime.py`, alongside `draw_grid` and `draw_polar_axes`. + +**Symptom in `ContourLayout`:** `ContourLayout.apply_title` branches on +`plot_type == 1` and calls `_apply_map_title`. This forces the *layout* class +to have knowledge of map projections — exactly the concern `map_runtime` is +supposed to own. + +**Result:** Done. Ownership is now aligned: map annotations are in `map_runtime.py`. + +--- + +### `rotated_runtime.py` +Rotated-pole (ptype 6) rendering and grid axes helpers. Encapsulates rotated-latitude-longitude +coordinate system rendering, including continent drawing via shapefile, rotated transforms, +and index-space grid lines/axis labels. + +**Key functions:** + +| Function | Role | +|----------|------| +| `_rotated_vloc` | Maps geographic lon/lat points into rotated-grid index space | +| `_render_rotated_grid_axes` | Draws rotated-pole graticule + continent lines in index space | +| `_render_ptype6_rotated_pole` | Handles rotated-pole plots (ptype 6) orchestration | + +**Imports:** `cartopy.crs`, `cartopy.feature`, `numpy`, `utility`, `blockfill._bfill`, +`colorbar.cbar`, `layout_runtime`, `map_runtime`, `state`. + +--- + +### L2 — `_render_rotated_grid_axes` and `_render_ptype6_rotated_pole` moved to `rotated_runtime.py` (completed) + +**Status (2026-05-17):** Extraction complete. Both functions moved to `rotated_runtime.py`. +Post-move simplification: extracted duplicated map feature decoration code into a +centralized `_apply_map_features()` helper in `map_runtime.py`, eliminating ~35 lines +of duplicate code in both `contour.py` and `rotated_runtime.py`. + +**Result:** Done. Ptype-6 rendering has its own module with reduced duplication. Map +decoration logic is now centralized. + +--- + +### L3 — `ColourScale.colourbar_labels` now the single source of truth (completed) + +**Status (completed):** The ~50-line inline colourbar label logic in +`_render_with_new_xy` has been removed and replaced with a call to +`cs.colourbar_labels()`. The method itself was fixed: the dead-code second +`if label_skip is None:` guard was removed so the horizontal skip heuristic +now runs correctly. + +**What was done:** +- Renamed `colorbar_labels()` to `colourbar_labels()` on `ColourScale` +- Fixed dead-code bug in method: removed early `label_skip = 1` that + prevented the horizontal skip heuristic from ever executing +- Replaced ~50 lines of inline label-skip/level-selection logic in + `_render_with_new_xy` with a single call to `cs.colourbar_labels()` +- All 112 tests passing + +**Result:** ~50 lines of duplicate logic removed. `ColourScale.colourbar_labels` +is now the canonical implementation. + +--- + +### L4 — `_add_cyclic` consolidated into `utility.py` + +**Status (completed):** Duplicate implementations from `contour.py` and `blockfill.py` +merged into single canonical function `utility.add_cyclic()` with unified error +handling for cyclic longitude grids. Both modules now import and call `utility.add_cyclic()`. + +**What was done:** +- Added `cartopy.util as cartopy_util` import to `utility.py` +- Created canonical `add_cyclic()` function combining error handling from both sources +- Updated `contour.py` line 1164: changed from local `_add_cyclic()` to `utility.add_cyclic()` +- Updated `blockfill.py` line 202: changed from local `_add_cyclic()` to `utility.add_cyclic()` +- Removed duplicate `_add_cyclic()` and `_max_ndecs_data()` definitions +- Removed now-unused `cartopy.util` imports from both modules +- All 112 tests passing + +**Result:** ~25 lines of duplicate code removed. Cyclic longitude handling centralized in utility layer. + +--- + +### L5 — Renderer classes (`MapContourRenderer`, `XYContourRenderer`) bypass their injected dependencies and read `plotvars` directly + +**What:** Both subclasses receive `layout`, `data`, and `colour_scale` in their +constructor, yet render methods reach back to `plotvars.mymap`, `plotvars.image`, +`plotvars.norm`, `plotvars.levels_extend`, etc. + +**Why it matters:** The object design suggests the renderers are self-contained, but +at runtime they depend on global state being in the right shape. This makes testing +them in isolation hard and means the class boundaries are more cosmetic than real. + +**Note:** This is a known cost of incremental refactoring. Fully fixing it requires +threading `mymap`, `norm`, etc. through the renderer interface, which is a larger +change. It should be tracked as a future goal rather than fixed immediately. + +--- + +### L6 — Tick generation for ptypes 2–5 extracted from `_render_with_new_xy` (completed) + +**Status (completed):** The inline ptype tick-generation block was moved out of +`contour._render_with_new_xy` and consolidated in `utility.compute_xy_ticks()`. + +**What was done:** +- Added `utility.compute_xy_ticks()` as the canonical non-map tick/label helper + for ptypes 2-5, including default axis-label resolution. +- Added `utility._pressure_axis_ticks()` to keep pressure/log-pressure Y tick + logic shared and centralized. +- Replaced the inline ptype-dispatch tick block in + `contour._render_with_new_xy` with one call to `utility.compute_xy_ticks()`. +- Kept behaviour parity for Hovmuller time-axis handling by passing precomputed + `time_ticks`, `time_labels`, and `time_label` through the new helper. +- Verified with focused unit and integration tests. + +**Result:** Tick-generation concerns are now owned by the utility layer, +reducing orchestration complexity in `contour.py` and making axis logic easier +to reuse and test. + +--- + +### L7 — `maybe_autosave` extracted to `layout_runtime` (completed) + +**Status (completed):** `_finalize_non_session_plot()` (previously a private +function in `contour.py`) has been moved to `layout_runtime` as the public +`maybe_autosave()` function. Both `_render_with_new_xy` (direct call) and +`_render_ptype6_rotated_pole` (via `finalize_callback` argument) now use it. + +**What was done:** +- Added `maybe_autosave()` to `layout_runtime.py` +- Removed `_finalize_non_session_plot()` from `contour.py` +- Updated `contour.py` import to include `maybe_autosave` +- Updated direct call site in `_render_with_new_xy` +- Updated `finalize_callback=maybe_autosave` passed to `_render_ptype6_rotated_pole` +- All 112 tests passing + +**Result:** File-save/close responsibility correctly owned by `layout_runtime`. + +--- + +### L8 — `colorbar.py` reads `plotvars` extensively for defaults + +**What:** `cbar()` currently reads at least fifteen `plotvars` attributes to fill in +unset parameters (`rows`, `plot_type`, `proj`, `levels_extend`, `cs`, `norm`, +`image`, `master_plot`, etc.). + +**Why it matters:** This makes `colorbar.py` tightly coupled to the state object +and hard to unit-test. It is the deepest legacy coupling left in the refactored +modules. + +**Note:** Fixing this requires threading all the required values as explicit +arguments. It is the right long-term direction but is a substantial interface +change. For now the coupling is contained within `colorbar.py` and nowhere +else imports `colorbar` except through `contour.py`. + +--- + +### L9 — `lonlat` removed from the refactored `blockfill._bfill` API (completed) + +**Status (completed):** The refactored `_bfill` implementation no longer accepts +the misleading `lonlat` keyword. Runtime behaviour is unchanged: `_bfill` still +derives lon/lat handling from `plotvars.plot_type == 1`, but the fake override +hook is no longer exposed in the function signature. + +**What was done:** +- Removed `lonlat` from the refactored `blockfill._bfill` signature +- Updated refactored call sites in `contour.py` and `rotated_runtime.py` +- Kept existing behaviour by computing `lonlat` internally from `plotvars.plot_type` +- Added a unit test to prevent `lonlat` from reappearing in the refactored API + +**Result:** The refactored internal API is now honest about what it controls, +without changing rendering behaviour. + +--- + +## 6. Suggested Refactoring Order + +| Priority | Item | Module(s) affected | +|----------|------|--------------------| +| 1 (done) | Fix L4: merge `_add_cyclic` into `utility` | `contour`, `blockfill`, `utility` | +| 2 (done) | Fix L7: extract `maybe_autosave` to `layout_runtime` | `layout_runtime`, `contour` | +| 3 (done) | Fix L3: replace inline label logic with `colourbar_labels()` | `contour` | +| 4 (done) | Fix L1: move `_apply_map_title` / `_apply_dim_titles` to `map_runtime` | `contour`, `map_runtime` | +| 5 (done) | Fix L2: extract rotated-pole path to `rotated_runtime` | `contour`, `rotated_runtime` | +| 6 (done) | Fix L6: extract tick logic to utility helper(s) | `contour`, `utility` | +| 7 (done) | Fix L9: remove misleading `lonlat` parameter from `_bfill` | `blockfill` | +| 8 (future) | Fix L8: thread colorbar params explicitly | `colorbar`, `contour` | +| 9 (future) | Fix L5: decouple renderers from global `plotvars` | `contour` | diff --git a/docs/dev/merge_instructions.md b/docs/dev/merge_instructions.md new file mode 100644 index 0000000..28fa97e --- /dev/null +++ b/docs/dev/merge_instructions.md @@ -0,0 +1,40 @@ +I want to merge as much as possible from main onto this branch, recognsing that this branch diverged a long time back in the history of main. + +Goals are to: preserve the history from main, but prefer the refactoring done here. + +Main has has a minor refactor, and in particular broke the original cfplot into several modules. I think we can bring the + - stipple.py + - trajectory.py + - line.py + - vect.py + +code in carefully, recognising that they may refer to plotvars and utilities such as gclos which we have refactored. + +Please investigate the feasibility but don't do anything until we have discussed. + +--- + +Feasibility looks good for a staged merge, but not as a single direct “take everything” merge. + +What I found: +1. Divergence is large: main is far ahead while this branch also has many unique commits (about 268 vs 47 from the merge base). +2. The four modules you called out exist on main (except vect.py, which appears to be vector.py on main), and are absent on this branch. +3. Main’s versions of those modules depend on modular internals such as parameters, graphic, and utils; this branch has equivalent responsibilities reorganized around state/runtime files. +4. This branch centralizes shared plotting state in state.py, with runtime orchestration in layout_runtime.py, map_runtime.py, and contour.py. +5. gclose entry points already exist here in layout_runtime.py and legacy compatibility still exists in cfplot.py. + +Risk hotspots to expect: +1. API mismatch: main modules expect old modular imports and old plotvars ownership; this branch uses the refactored state/runtime model. +2. Duplicate control flow risk if trajectory/line/vector logic is brought in without routing through current runtime/state paths. +3. Semantic conflicts will be higher than textual conflicts, even if git reports few raw conflict markers. + +Recommended merge strategy to preserve main history while preferring this refactor: +1. Merge main into a dedicated integration branch from refactor26, but do not resolve by taking old structure wholesale. +2. Keep this branch’s state/runtime architecture as the source of truth. +3. Replay the four target modules from main one by one, adapting imports and state access to current abstractions. +4. For each module: port, run focused tests/examples, then proceed to the next. +5. Keep a thin compatibility layer in cfplot.py during transition to reduce breakage. + +No code changes were made. + +If you want, next I can prepare a concrete per-module integration checklist (expected import rewrites, likely function shims, and test targets) before we touch the branch. diff --git a/docs/dev/merge_staged_playbook.md b/docs/dev/merge_staged_playbook.md new file mode 100644 index 0000000..96a070e --- /dev/null +++ b/docs/dev/merge_staged_playbook.md @@ -0,0 +1,244 @@ +# Staged Merge Playbook: main -> refactor26 + +This playbook is a command-level draft for merging as much as possible from `main` while preferring the refactor architecture on `refactor26`. + +The intent is to: +- preserve ancestry/history from `main` +- keep the `refactor26` state/runtime architecture as the source of truth +- integrate selected modules from `main` in controlled stages + +No command below is executed by default. Work through sections one-by-one. + +## 0) Pre-flight and safety checkpoints + +Run from repo root. + +```bash +git status --short -b +``` + +If not clean, either commit or stash first. + +Create explicit safety refs: + +```bash +git checkout refactor26 +git pull --ff-only + +# Safety tag on current refactor tip +git tag -a safety/refactor26-pre-main-merge-$(date +%Y%m%d-%H%M) -m "Safety tag before staged main merge" + +# Optional backup branch +git branch backup/refactor26-pre-main-merge +``` + +## 1) Create dedicated integration branch + +This keeps `refactor26` clean while integrating. + +```bash +git checkout -b integration/main-into-refactor26 +``` + +## 2) Bring in main history (single merge commit) + +Start a non-committing merge so conflicts can be resolved deliberately. + +```bash +git fetch origin +git merge --no-commit --no-ff main +``` + +### Conflict resolution policy + +Use these rules consistently: +- default conflict preference: keep refactor architecture +- accept `main` where change is orthogonal and low-risk +- never re-introduce old structure if equivalent refactor code already exists + +Useful helpers: + +```bash +# List unresolved files +git diff --name-only --diff-filter=U + +# Inspect each conflict +git checkout --ours # keep integration branch version (refactor26 side) +git checkout --theirs # take main version +git add +``` + +When all conflicts are resolved: + +```bash +git commit -m "merge: main into integration branch (prefer refactor26 architecture)" +``` + +## 3) Immediate post-merge sanity pass + +```bash +git status --short -b +pytest -q tests/unit +``` + +If tests fail broadly, stop and create a fix commit before module stages. + +Suggested commit message: + +```text +fix: restore baseline after main merge +``` + +## 4) Stage plan for target modules + +Main appears to use `vector.py` (not `vect.py`). + +Stages: +1. `stipple` +2. `trajectory` +3. `line` +4. `vector` + +For each stage, do the same workflow below. + +--- + +## 5) Per-module workflow template + +Use `` as one of: `stipple`, `trajectory`, `line`, `vector`. + +### A) Inspect source on main and current branch + +```bash +git show main:cfplot/.py | sed -n '1,220p' +rg -n "|plotvars|gclose|gopen|gpos|state|utility" cfplot +``` + +### B) Port with adaptation, not blind copy + +Guidance: +- translate imports from old module layout to refactor layout +- route global state reads/writes through `cfplot/state.py` (`plotvars`) +- route graphics lifecycle calls through refactor runtime entrypoints +- avoid duplicating logic that already exists in runtime modules + +### C) Validate stage + +```bash +pytest -q tests/unit +pytest -q tests/integration +``` + +If image regression workflow is available locally, run focused checks for module behavior. + +### D) Commit stage + +```bash +git add -A +git commit -m "merge-stage: integrate from main into refactor runtime" +``` + +### E) Stage exit criteria + +- module imports cleanly +- no broad regressions in unit/integration tests +- no duplicate competing implementation paths introduced + +--- + +## 6) Suggested conflict defaults by area + +These are starting defaults, not strict rules: + +- Keep refactor side by default: + - `cfplot/state.py` + - `cfplot/layout_runtime.py` + - `cfplot/map_runtime.py` + - `cfplot/rotated_runtime.py` + - `cfplot/utility.py` (unless cherry-picked utility fixes are clearly better) + +- Evaluate carefully / case-by-case: + - `cfplot/cfplot.py` (compatibility surface, likely heavy conflict zone) + - `cfplot/contour.py` (high behavior impact) + +- Bring from main with adaptation: + - `cfplot/stipple.py` + - `cfplot/trajectory.py` + - `cfplot/line.py` + - `cfplot/vector.py` + +## 7) Optional: isolate each module on micro-branches + +If you want very fine rollback points: + +```bash +git checkout integration/main-into-refactor26 +git checkout -b integration/stage-stipple +# integrate stipple, commit +git checkout integration/main-into-refactor26 +git merge --no-ff integration/stage-stipple +``` + +Repeat for each module. + +## 8) Final hardening before merging back + +```bash +pytest -q +``` + +If you have example/image reference checks, run full suite here. + +Then compare against `refactor26`: + +```bash +git log --oneline --decorate --graph refactor26..integration/main-into-refactor26 +git diff --stat refactor26...integration/main-into-refactor26 +``` + +If satisfied: + +```bash +git checkout refactor26 +git merge --ff-only integration/main-into-refactor26 +``` + +If fast-forward is not possible and you want to preserve branch topology: + +```bash +git checkout refactor26 +git merge --no-ff integration/main-into-refactor26 +``` + +## 9) Rollback recipes + +Abort in-progress merge: + +```bash +git merge --abort +``` + +Reset integration branch to pre-merge safety ref: + +```bash +git reset --hard safety/refactor26-pre-main-merge- +``` + +Restore refactor baseline quickly: + +```bash +git checkout refactor26 +git reset --hard backup/refactor26-pre-main-merge +``` + +Only use hard reset when you intentionally want to discard uncommitted work. + +## 10) Suggested commit message scheme + +- `merge: main into integration branch (prefer refactor26 architecture)` +- `fix: restore baseline after main merge` +- `merge-stage: integrate stipple from main into refactor runtime` +- `merge-stage: integrate trajectory from main into refactor runtime` +- `merge-stage: integrate line from main into refactor runtime` +- `merge-stage: integrate vector from main into refactor runtime` +- `test: update expectations for merged behavior` diff --git a/docs/dev/simplification_plan.md b/docs/dev/simplification_plan.md new file mode 100644 index 0000000..2055741 --- /dev/null +++ b/docs/dev/simplification_plan.md @@ -0,0 +1,160 @@ +# cf-plot Maintainability Simplification Plan (No Public API Change) + +*Created 2026-05-23. Companion to architecture.md. This file tracks post-merge +simplifications that reduce duplication and improve readability while preserving +all public API behaviour.* + +--- + +## 1. Scope And Constraints + +### In scope +- Internal refactoring and deduplication in runtime and plotting modules. +- Bug fixes that restore expected behaviour without changing public signatures. +- Test additions/updates needed to lock in current API behaviour. + +### Out of scope +- Renaming/removing public symbols from `cfplot.__init__`. +- Changing function call signatures users rely on. +- Visual style or plotting-output changes unless fixing a clear bug. + +### Hard constraint +- Keep public API stable. + +--- + +## 2. Issue Register + +Legend: +- Priority: P0 (critical), P1 (high), P2 (medium), P3 (low) +- Effort: S (small), M (medium), L (large) +- Status: Not Started, In Progress, Done, Deferred + +| ID | Priority | Effort | Status | Issue | Why it matters | Candidate files | +|----|----------|--------|--------|-------|----------------|-----------------| +| S1 | P0 | S | Done | Trajectory label regression (`user_xlabel`/`user_ylabel` overwritten) | User input can be ignored; blocks confidence in subsequent refactors | `cfplot/trajectory.py` | +| S2 | P1 | M | Done | Duplicate map helper trio (`_set_map`, `_plot_map_axes`, `_map_title`) across modules | Same logic in three places increases drift risk | `cfplot/stream.py`, `cfplot/trajectory.py`, `cfplot/vector.py`, `cfplot/map_runtime.py` | +| S3 | P1 | M | Done | Coastline/feature rendering duplicated despite shared helper | Styling and behaviour can diverge by plot type | `cfplot/map_runtime.py`, `cfplot/stream.py`, `cfplot/trajectory.py`, `cfplot/vector.py` | +| S4 | P1 | M | Done | Repeated graphics session lifecycle open/gpos/close patterns | Boilerplate hides intent and makes fixes repetitive | `cfplot/layout_runtime.py`, `cfplot/line.py`, `cfplot/stream.py`, `cfplot/trajectory.py`, `cfplot/vector.py`, `cfplot/rotated_runtime.py` | +| S5 | P2 | M | Done | Axis hiding uses sentinel numeric ticks (`100000000`) | Non-obvious and brittle behaviour | `cfplot/line.py`, `cfplot/vector.py` | +| S6 | P2 | M | Done | Shared-state reset logic duplicated | High chance of reset drift and latent state bugs | `cfplot/__init__.py`, `cfplot/layout_runtime.py`, `cfplot/state.py` | +| S7 | P3 | S | Done | Executable lookup duplicated (`_which` and `shutil.which`) | Minor maintenance overhead and inconsistency | `cfplot/__init__.py`, `cfplot/layout_runtime.py` | +| S8 | P3 | S | Done | `pvars.__str__` implementation is non-informative | Debugging shared state is harder than necessary | `cfplot/state.py` | + +--- + +## 3. Phased Implementation Plan + +### Phase 1 - Correctness Baseline (S1) +Goal: Fix known regression before broader refactors. + +Steps: +- [x] Fix label clobbering in `traj()` so user-provided labels are preserved. +- [x] Add/adjust focused tests for trajectory label overrides. +- [x] Run trajectory-related tests. + +Exit criteria: +- User labels are respected when supplied. +- No public API or signature change. + +--- + +### Phase 2 - Map Helper Consolidation (S2, S3) +Goal: Remove repeated map orchestration logic. + +Steps: +- [x] Introduce/expand shared private helpers in `map_runtime.py` for map setup, axes application, and title application. +- [x] Replace duplicated helper definitions in `stream.py`, `trajectory.py`, and `vector.py` with imports from `map_runtime.py`. +- [x] Route map feature/coastline code paths through `_apply_map_features` where applicable. +- [x] Validate map plot types (cylindrical, polar stereographic, lcc where relevant). + +Exit criteria: +- No duplicated helper trio remains in stream/trajectory/vector. +- Map feature behaviour remains equivalent for existing tests/examples. + +--- + +### Phase 3 - Session Lifecycle Consolidation (S4) +Goal: Centralize figure/session management boilerplate. + +Steps: +- [x] Add internal helper(s) for common open/gpos/close flow in `layout_runtime.py`. +- [x] Migrate line/stream/trajectory/vector/rotated paths incrementally. +- [x] Keep module-level public functions unchanged. +- [x] Run regression tests for each migrated module. + +Exit criteria: +- Repeated lifecycle pattern replaced by shared internal helper(s). +- Behaviour remains stable under multi-panel and user-managed sessions. + +--- + +### Phase 4 - Axis And State Hygiene (S5, S6) +Goal: Improve clarity and reduce state drift risk. + +Steps: +- [x] Replace sentinel tick suppression with explicit axis-hiding helper logic. +- [x] Introduce a single internal state-reset helper in `state.py` (or equivalent central location). +- [x] Make `cfplot.reset()` and runtime close/reset paths delegate to shared reset logic. + +Exit criteria: +- No sentinel tick hacks in active code paths. +- Reset behaviour remains equivalent but centralized. + +--- + +### Phase 5 - Low-Risk Cleanup (S7, S8) +Goal: Finish small consistency improvements. + +Steps: +- [x] Unify executable lookup usage while preserving exported compatibility wrappers. +- [x] Fix `pvars.__str__` to return meaningful state output for debugging. + +Exit criteria: +- Utility duplication removed or delegated. +- Shared state object is easier to inspect. + +--- + +## 4. Execution Checklist + +Use this as the day-to-day tracker. + +- [ ] Phase 1 complete (S1) +- [x] Phase 1 complete (S1) +- [x] Phase 2 complete (S2, S3) +- [x] Phase 3 complete (S4) +- [x] Phase 4 complete (S5, S6) +- [x] Phase 5 complete (S7, S8) +- [ ] Full regression pass complete +- [ ] Plan reviewed and archived + +--- + +## 5. Validation Strategy Per Phase + +- Prefer targeted tests first, then broader integration tests. +- Keep at least one representative run for each major plotting path: + - line + - contour + - vector + - stream + - trajectory + - rotated-pole path +- Compare generated images only where behavioural parity is uncertain. +- Record any intentional behaviour adjustments explicitly in this file. + +--- + +## 6. Change Log + +| Date | Author | Change | +|------|--------|--------| +| 2026-05-23 | Copilot | Initial issue register and phased implementation plan created | +| 2026-05-23 | Copilot | Completed S1: fixed trajectory label clobbering and added focused unit coverage | +| 2026-05-23 | Copilot | Phase 2 partial: consolidated stream/vector/trajectory map helpers to map_runtime and routed shared map features; targeted vector/stream/trajectory tests passed | +| 2026-05-23 | Copilot | Completed Phase 2 validation: targeted projection tests (including LCC-related path) passed; no image-diff failures in executed set | +| 2026-05-23 | Copilot | Phase 3 in progress: introduced shared runtime session helpers and migrated line/stream/trajectory/vector; targeted runs show image-comparison differences in selected advanced examples pending user decision | +| 2026-05-23 | Copilot | Completed Phase 3: shared runtime session helpers validated with targeted regression tests across line, vector, stream, trajectory, and rotated paths | +| 2026-05-23 | Copilot | Completed Phase 4: replaced sentinel axis hiding with explicit helper logic and centralized runtime reset behavior; targeted runtime and lineplot validation passed | +| 2026-05-23 | Copilot | Completed Phase 5: removed private _which helper from __init__.py and fixed state object string output; targeted unit validation passed | diff --git a/docs/dev/uml/cfplot-runtime-simplified.png b/docs/dev/uml/cfplot-runtime-simplified.png new file mode 100644 index 0000000..8769f13 Binary files /dev/null and b/docs/dev/uml/cfplot-runtime-simplified.png differ diff --git a/docs/dev/uml/cfplot-runtime-simplified.pu b/docs/dev/uml/cfplot-runtime-simplified.pu new file mode 100644 index 0000000..19d146b --- /dev/null +++ b/docs/dev/uml/cfplot-runtime-simplified.pu @@ -0,0 +1,25 @@ +@startuml cfplot-runtime-simplified +skinparam packageStyle rectangle +skinparam ArrowColor #444444 +skinparam componentStyle uml2 + +rectangle "API Facade\n(__init__.py)" as API +rectangle "Plot APIs\n(contour, line, vector, stream, trajectory, stipple)" as PlotAPIs +rectangle "Runtime\n(layout_runtime, map_runtime, rotated_runtime)" as Runtime +rectangle "Shared Core\n(state, utility, validate)" as Core +rectangle "Render Helpers\n(colorbar, blockfill)" as Helpers +rectangle "External Libs\n(cf, numpy, matplotlib, cartopy)" as External + +API --> PlotAPIs +PlotAPIs --> Runtime +PlotAPIs --> Core +PlotAPIs --> Helpers + +Runtime --> Core +Helpers --> Core + +PlotAPIs --> External +Runtime --> External +Helpers --> External +Core --> External +@enduml diff --git a/docs/dev/uml/contour.puml b/docs/dev/uml/contour.puml new file mode 100644 index 0000000..fbd3257 --- /dev/null +++ b/docs/dev/uml/contour.puml @@ -0,0 +1,107 @@ +@startuml contour_module + +skinparam classAttributeIconSize 0 +hide empty members + +title cfplot.contour module - class diagram + +class ContourData <> { + +field: ndarray + +x: ndarray | None + +y: ndarray | None + +ptype: int + +colorbar_title: str + +xlabel: str + +ylabel: str + +levels: ndarray | None + +fmult: float + +fill: bool + +lines: bool + +blockfill: bool + +xpole: float | None + +ypole: float | None + +x_is_cyclic: bool + -- + +from_cf_field(f, colorbar_title, verbose, proj): ContourData + +from_arrays(field, x, y): ContourData +} + +class ContourLayout { + -_plotvars: Any + +viewport: Axes | None + +colorbar_ax: Axes | None + +title_ax: Axes | None + +colorbar_orientation: str + +colorbar_position: list[float] | None + -- + +allocate_xy_viewport(colorbar_orientation, colorbar_position): ContourLayout + +allocate_map_viewport(colorbar_orientation, colorbar_position): ContourLayout + +allocate(colorbar_orientation, colorbar_position): ContourLayout + +apply_title(title, dims_title, fontsize, fontweight): None + +apply_axis_labels(xlabel, ylabel, xticks, yticks, xticklabels, yticklabels): None +} + +class ColourScale { + -_plotvars: Any + -_levels: ndarray | None + -_includes_zero: bool + -_levels_extend: str + -- + +fit_to_levels(levels, includes_zero, levels_extend): ColourScale + +get_cmap(): ListedColormap + +colourbar_labels(levels, orientation, n_columns, label_skip, custom_labels): list[str] + {_static} +_expand_skipped_labels(labels, label_skip): list[str] +} + +class ContourRenderer { + +layout: ContourLayout + +data: ContourData + +cs: ColourScale + +frame_artists: list[Any] + -- + +render_filled(alpha, zorder, transform_first): None + +render_blockfill(fast, alpha, zorder): None + +render_lines(colors, linewidths, linestyles, line_labels, zero_thick, zorder): None + +render_colorbar(orientation, shrink, position, fraction, thick, anchor, fontsize, fontweight, text_up_down, text_down_up, drawedges, labels, title): None +} + +class MapContourRenderer { + +render_filled(alpha, zorder, transform_first): None + +render_blockfill(fast, alpha, zorder): None + +render_lines(colors, linewidths, linestyles, line_labels, zero_thick, zorder): None + +render_colorbar(orientation, shrink, position, fraction, thick, anchor, fontsize, fontweight, text_up_down, text_down_up, drawedges, labels, title): None +} + +class XYContourRenderer { + +render_filled(alpha, zorder, transform_first): None + +render_blockfill(fast, alpha, zorder): None + +render_lines(colors, linewidths, linestyles, line_labels, zero_thick, zorder): None + +render_colorbar(orientation, shrink, position, fraction, thick, anchor, fontsize, fontweight, text_up_down, text_down_up, drawedges, labels, title): None +} + +MapContourRenderer --|> ContourRenderer +XYContourRenderer --|> ContourRenderer + +ContourRenderer *-- ContourLayout : layout +ContourRenderer *-- ContourData : data +ContourRenderer *-- ColourScale : colour scale + +note right of ContourData +Read-only plotting inputs and metadata, +created from CF field extraction or raw arrays. +end note + +note bottom of ContourRenderer +Strategy base class. Concrete subclasses specialize +map and Cartesian plotting paths. +end note + +class PlotState <> +class Utility <> +class RuntimeHelpers <> + +ContourData ..> Utility : cf_data_assign(), to_float_or_none() +ContourLayout ..> RuntimeHelpers : ensure_*_viewport(), apply_axes() +ContourRenderer ..> PlotState : plotvars runtime/scale/decoration + +@enduml diff --git a/docs/dev/updated_architecture.md b/docs/dev/updated_architecture.md new file mode 100644 index 0000000..06c7396 --- /dev/null +++ b/docs/dev/updated_architecture.md @@ -0,0 +1,210 @@ +# cf-plot Runtime Architecture (Updated) + +*Generated 2026-05-24. Captures the current modular plotting runtime after the +main/refactor26 merge and post-merge simplification phases.* + +--- + +## 1. Scope + +This document describes the current runtime architecture for plotting modules in +cfplot, including shared state, runtime/session orchestration, map runtime, and +plot-type modules: + +- contour +- line +- vector +- stream +- trajectory +- stipple +- rotated runtime path for ptype 6 + +The legacy monolith file still exists in the repository but is not the primary +runtime path for the modules documented here. + +--- + +## 2. Package Layout (Current) + +```text +cfplot/ +├── __init__.py # Public API exports and reset orchestration +├── contour.py # Main contour pipeline and renderer classes +├── layout_runtime.py # Figure/session lifecycle and Cartesian axes helpers +├── map_runtime.py # Projection/map setup, map axes, map features/titles +├── rotated_runtime.py # Rotated-pole rendering and rotated grid axes +├── line.py # lineplot API +├── vector.py # vect API +├── stream.py # stream API +├── trajectory.py # traj API +├── stipple.py # stipple overlay API +├── colorbar.py # Colorbar drawing helper +├── blockfill.py # Filled block rendering helper +├── state.py # Shared plotvars + runtime reset state +├── utility.py # Pure data/axis/colour utilities +├── validate.py # Input shape and grid checks +├── colour/ # Colour scale loading and helpers +└── cfplot.py # Legacy monolith (compatibility/legacy path) +``` + +--- + +## 3. Module Responsibilities + +| Module | Responsibility | +|---|---| +| `__init__.py` | Public API surface and reset orchestration (`reset`, exports) | +| `state.py` | Single global shared state object (`plotvars`), defaults, runtime reset | +| `layout_runtime.py` | Open/close figures, implicit session management, Cartesian axis visibility | +| `map_runtime.py` | Projection configuration, map axes creation, map feature/axes/title helpers | +| `rotated_runtime.py` | Rotated pole rendering path and custom rotated grid axes drawing | +| `contour.py` | Main contour orchestration, contour renderer strategies, ptype routing | +| `line.py` | Line plotting and Cartesian axis setup | +| `vector.py` | Vector plotting over map/cartesian/rotated paths | +| `stream.py` | Streamline plotting on map path | +| `trajectory.py` | Trajectory plotting with optional legend/colorbar/vector segments | +| `stipple.py` | Overlay stippling for thresholded values | +| `utility.py` | Stateless helpers: levels, mapaxis, data extraction, interpolation, regrid | +| `validate.py` | Input-shape/grid validation checks | +| `colorbar.py` | Shared colorbar rendering | +| `blockfill.py` | Shared block-fill rendering | + +--- + +## 4. Dependency Diagram (Current) + +This simplified view shows the architectural layers and primary dependency +directions. Detailed per-module imports are intentionally omitted for +readability. + +Source file: [docs/dev/uml/cfplot-runtime-simplified.pu](docs/dev/uml/cfplot-runtime-simplified.pu) + +```plantuml +@startuml cfplot-runtime-simplified +skinparam packageStyle rectangle +skinparam ArrowColor #444444 +skinparam componentStyle uml2 + +rectangle "API Facade\n(__init__.py)" as API +rectangle "Plot APIs\n(contour, line, vector, stream, trajectory, stipple)" as PlotAPIs +rectangle "Runtime\n(layout_runtime, map_runtime, rotated_runtime)" as Runtime +rectangle "Shared Core\n(state, utility, validate)" as Core +rectangle "Render Helpers\n(colorbar, blockfill)" as Helpers +rectangle "External Libs\n(cf, numpy, matplotlib, cartopy)" as External + +API --> PlotAPIs +PlotAPIs --> Runtime +PlotAPIs --> Core +PlotAPIs --> Helpers + +Runtime --> Core +Helpers --> Core + +PlotAPIs --> External +Runtime --> External +Helpers --> External +Core --> External +@enduml +``` + +Legend: + +- `API` is the stable public surface. +- `Plot APIs` coordinate user-facing plotting operations. +- `Runtime` provides shared session and map setup behavior. +- `Shared Core` holds state and stateless utility/validation logic. +- `Render Helpers` provide reusable rendering primitives. +- `External Libs` are foundational third-party dependencies. + +--- + +## 5. Runtime Session Lifecycle (Current) + +The plotting modules now use shared implicit session orchestration from +`layout_runtime`: + +- `ensure_runtime_session(pos=1)` +- `finalize_runtime_session(...)` + +```plantuml +@startuml runtime-session-lifecycle +skinparam sequenceArrowThickness 1.2 +actor Caller +participant "line/vector/stream/traj" as PlotAPI +participant "layout_runtime" as Runtime +participant "plotvars(state)" as State +participant "matplotlib" as MPL + +Caller -> PlotAPI : plot function(...) +PlotAPI -> Runtime : ensure_runtime_session(pos=1) +Runtime -> State : check user_plot +alt implicit session (user_plot == 0) + Runtime -> Runtime : gopen(user_plot=0) + Runtime -> MPL : create figure/subplot +else explicit session already open + Runtime --> PlotAPI : no-op open +end + +PlotAPI -> PlotAPI : render data / map / axes + +PlotAPI -> Runtime : finalize_runtime_session(...) +alt implicit session + Runtime -> Runtime : optional gset() + Runtime -> Runtime : optional cscale() + Runtime -> Runtime : gclose(view=True) + Runtime -> MPL : save/show/close + Runtime -> State : reset_runtime_state() +else explicit session + Runtime --> PlotAPI : no-op close +end +@enduml +``` + +--- + +## 6. Contour Call Flow (Current) + +```plantuml +@startuml contour-current-flow +skinparam sequenceArrowThickness 1.2 +actor Caller +participant contour +participant utility +participant "layout_runtime" as layout +participant "map_runtime" as map +participant "rotated_runtime" as rotated +participant colorbar +participant state + +Caller -> contour : con(f, **kwargs) +contour -> contour : _render_with_new_xy(...) +contour -> utility : cf_data_assign / calculate_levels +contour -> state : apply_colour_scale(...) + +alt ptype == 6 + contour -> rotated : _render_ptype6_rotated_pole(...) +else map/cartesian + contour -> layout : ensure_xy_viewport() or ensure_map_viewport() + contour -> map : MapSet.configure/ensure_map_axes (map path) + contour -> contour : render filled/lines/blockfill + contour -> layout : apply_axes(...) + contour -> map : _apply_map_features/_apply_map_title +end + +contour -> colorbar : cbar(...) +contour -> layout : maybe_autosave() +@enduml +``` + +--- + +## 7. Notes On Current State + +1. Shared runtime/session orchestration is now centralized in `layout_runtime`. +2. Map setup/axes/title/features are centralized in `map_runtime` and consumed + by vector/stream/trajectory/contour paths. +3. Rotated pole rendering is isolated in `rotated_runtime` and called from + contour/vector flows as required. +4. Runtime state reset is centralized through `state.reset_runtime_state()` and + used by both package-level `reset()` and runtime `gclose()`. +5. Public API remains stable via exports in `__init__.py`. diff --git a/pyproject.toml b/pyproject.toml index 8537e00..7dc9b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,38 @@ -# This file is currently used only to configure the black Python code -# formatter, rather than provide project metadata (see -# https://www.python.org/dev/peps/pep-0621/) which can be found instead -# in the setup.py file. +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cf-plot" +version = "3.5.0" +description = "Climate contour, vector and line plots in Python" +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE.txt" } +authors = [{ name = "Andy Heaps", email = "andy.heaps@ncas.ac.uk" }] +maintainers = [ + { name = "Sadie Bartholomew", email = "sadie.bartholomew@ncas.ac.uk" }, +] +dependencies = [ + "matplotlib>=3.1.0", + "cf-python>=3.9.0", + "scipy>=1.4.0", + "cartopy>=0.17.0", + "packaging>=21", +] + +[project.urls] +Homepage = "http://ajheaps.github.io/cf-plot" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["cfplot*"] + +[tool.setuptools.package-data] +cfplot = ["colour/colourmaps/*"] # To run the black checker with this configuration, execute 'black .' in the # root of this repository. See https://black.readthedocs.io/en/stable/ for diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..de53aed --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -ra -m "not xfail" +markers = + integration: tests requiring external or reference datasets + image: tests that compare rendered figures \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index cbcda7b..0000000 --- a/setup.py +++ /dev/null @@ -1,43 +0,0 @@ -from setuptools import find_packages, setup -import os -import fnmatch -import sys -import importlib -import subprocess - - -def find_package_data_files(directory): - for root, dirs, files in os.walk(directory): - for basename in files: - if fnmatch.fnmatch(basename, "*"): - filename = os.path.join(root, basename) - yield filename.replace("cfplot/", "", 1) - - -package_data = [ - f for f in find_package_data_files("cfplot/colour/colourmaps") -] - -setup( - name="cf-plot", - version="3.4.0", - author="Andy Heaps", - author_email="andy.heaps@ncas.ac.uk", - maintainer="Sadie Bartholomew", - maintainer_email="sadie.bartholomew@ncas.ac.uk", - packages=find_packages(), - package_dir={"cfplot": "cfplot"}, - package_data={"cfplot": package_data}, - include_package_data=True, - install_requires=[ - # "matplotlib >=3.1.0", # already a requirement for Cartopy - "cf-python >= 3.17.0", - # "scipy >= 1.4.0", # already a requirement for cf-python - "cartopy >= 0.22.0", - ], - url="https://ncas-cms.github.io/cf-plot/", - license="LICENSE.txt", - description="Code-light plotting for earth science and aligned research", - long_description=open("README.md").read(), - long_description_content_type="text/markdown", -) diff --git a/test_two_plots_old.png b/test_two_plots_old.png new file mode 100644 index 0000000..7f9803f Binary files /dev/null and b/test_two_plots_old.png differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6b17e3b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Pytest bootstrap for local repository imports.""" + +from pathlib import Path +import sys + + +# Ensure tests import the current checkout before any site-packages install. +REPO_ROOT = Path(__file__).resolve().parents[1] +repo_root_str = str(REPO_ROOT) +if sys.path[0] != repo_root_str: + sys.path.insert(0, repo_root_str) diff --git a/tests/data/bnl_tmp_NAEW.nc b/tests/data/bnl_tmp_NAEW.nc new file mode 100644 index 0000000..472ffe3 Binary files /dev/null and b/tests/data/bnl_tmp_NAEW.nc differ diff --git a/tests/data/da193_example.nc b/tests/data/da193_example.nc new file mode 100644 index 0000000..ca14609 Binary files /dev/null and b/tests/data/da193_example.nc differ diff --git a/tests/data/example_ortho_fail.nc b/tests/data/example_ortho_fail.nc new file mode 100644 index 0000000..07ccbc0 Binary files /dev/null and b/tests/data/example_ortho_fail.nc differ diff --git a/tests/integration/test_advanced_plot_examples.py b/tests/integration/test_advanced_plot_examples.py new file mode 100644 index 0000000..c800063 --- /dev/null +++ b/tests/integration/test_advanced_plot_examples.py @@ -0,0 +1,663 @@ +"""Integration tests for advanced plot examples (vector, stipple, trajectory, line). + +Migrated from cfplot/test/test_examples.py::ExamplesTest +These tests verify that vect(), stipple(), traj(), and lineplot() functions work with real data. +""" + +from pathlib import Path +import shutil + +import cf +import matplotlib.pyplot as plt +import matplotlib.testing.compare as mpl_compare +import numpy as np +import pytest + +import cfplot as cfp + + +# Path to test data +DATA_DIR = Path(__file__).parent.parent.parent / "docs" / "source" / "example-datasets" +CANARI_DATA_FILE = Path(__file__).parent.parent / "data" / "bnl_tmp_NAEW.nc" +TEST_GEN_DIR = Path(__file__).parent.parent.parent / "generated-example-images" +REF_IMAGE_DIR = Path(__file__).parent.parent / "new_reference-example-images" +#REF_IMAGE_DIR = Path(__file__).parent.parent / "reference-example-images" +TEST_GEN_DIR.mkdir(parents=True, exist_ok=True) + + +def _is_targeted_example_run(pytestconfig: pytest.Config) -> bool: + """Return True when pytest was invoked for a narrow subset of tests.""" + args = tuple(str(arg) for arg in pytestconfig.invocation_params.args) + if any("::" in arg for arg in args): + return True + + for index, arg in enumerate(args): + if arg == "-k": + return index + 1 < len(args) and bool(args[index + 1].strip()) + if arg.startswith("-k"): + expression = arg[2:] + return expression.startswith("=") or bool(expression.strip()) + + return False + + +@pytest.fixture(scope="session", autouse=True) +def clean_generated_example_dir(pytestconfig: pytest.Config): + """Remove stale generated images for broad runs, but preserve targeted runs.""" + if _is_targeted_example_run(pytestconfig): + return + + for path in TEST_GEN_DIR.iterdir(): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +def _configure_example_output(example_id: str) -> None: + """Route plot output to the expected generated example filename.""" + fname = str(TEST_GEN_DIR / f"gen_fig_{example_id}.png") + cfp.setvars(file=fname, viewer="matplotlib") + + +def _assert_reference_match(example_id: str) -> None: + """Compare generated plot against legacy reference image.""" + ref = REF_IMAGE_DIR / f"ref_fig_{example_id}.png" + gen = TEST_GEN_DIR / f"gen_fig_{example_id}.png" + if not ref.exists(): + pytest.skip(f"Missing reference image: {ref}") + if not gen.exists(): + pytest.fail(f"Generated image missing for example {example_id}: {gen}") + result = mpl_compare.compare_images( + str(ref), + str(gen), + tol=0.01, + in_decorator=True, + ) + if result is not None: + pytest.fail(f"Image mismatch for example {example_id}: {result}") + + +@pytest.fixture(autouse=True) +def setup_cfplot(): + """Set up cfplot for each test.""" + plt.close("all") + cfp.reset() + yield + cfp.reset() + plt.close("all") + + +@pytest.fixture +def ggap_file(): + """Return GGAP fields keyed by identity.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + flds = cf.read(str(DATA_DIR / "ggap.nc")) + fdict = {f.identity(): f for f in flds} + return fdict + + +# ============================================================================ +# Vector plot tests (Examples 13-16c) +# ============================================================================ + +@pytest.mark.integration +def test_example_13_basic_vector_plot(ggap_file): + """Test Example 13: basic vector plot.""" + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + + # Subspace to get values for a specified pressure, here 500 mbar + u = u.subspace(pressure=500) + v = v.subspace(pressure=500) + + _configure_example_output("13") + cfp.vect(u=u, v=v, key_length=10, scale=100, stride=5) + _assert_reference_match("13") + + +@pytest.mark.integration +def test_example_14_vector_with_colour_contour(ggap_file): + """Test Example 14: vector plot with colour contour map.""" + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + t = ggap_file["air_temperature"] + + # Subspace to get values for a specified pressure, here 500 mbar + u = u.subspace(pressure=500) + v = v.subspace(pressure=500) + t = t.subspace(pressure=500) + + _configure_example_output("14") + cfp.gopen() + cfp.mapset(lonmin=10, lonmax=120, latmin=-30, latmax=30) + cfp.levs(min=254, max=270, step=1) + cfp.con(t) + cfp.vect(u=u, v=v, key_length=10, scale=50, stride=2) + cfp.gclose() + + _assert_reference_match("14") + + +@pytest.mark.integration +def test_example_15_polar_vector_plot(ggap_file): + """Test Example 15: polar vector plot.""" + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + u = u.subspace(Z=500) + v = v.subspace(Z=500) + + _configure_example_output("15") + cfp.mapset(proj="npstere") + + cfp.gopen(columns=2) + cfp.vect( + u=u, + v=v, + key_length=10, + scale=100, + stride=4, + title="Polar plot using data grid", + ) + cfp.gpos(2) + cfp.vect( + u=u, + v=v, + key_length=10, + scale=100, + pts=40, + title="Polar plot with regular point distribution", + ) + cfp.gclose() + _assert_reference_match("15") + + +@pytest.mark.integration +def test_example_16_zonal_vector_plot_on_contour(ggap_file): + """Test Example 16a: zonal vector plot.""" + # TODO: The vectors look wrong. Needs investigation and a a comparison file + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + + u = u.collapse("X: mean") + v = v.collapse("X: mean") + + _configure_example_output("16") + cfp.gopen() + cfp.levs(min=-15, max=25, step=5) + cfp.con(u) + cfp.vect(u=u, v=v, scale=100, key_length=5, stride=1) + cfp.gclose() + _assert_reference_match("16") + + + +@pytest.mark.integration +def test_example_16a_zonal_vector_plot(ggap_file): + + c = cf.read(str(DATA_DIR / "vaAMIPlcd_DJF.nc"))[0] + c = c.subspace(Y=cf.wi(-60, 60)) + c = c.subspace(X=cf.wi(80, 160)) + c = c.collapse("T: mean X: mean") + + g = cf.read(str(DATA_DIR / "wapAMIPlcd_DJF.nc"))[0] + g = g.subspace(Y=cf.wi(-60, 60)) + g = g.subspace(X=cf.wi(80, 160)) + g = g.collapse("T: mean X: mean") + + # To avoid a cf-python field bug which would appear if we instead + # did v = -g, see cf-python Issue #797: + # https://github.com/NCAS-CMS/cf-python/issues/797 + v = -1 * g + + _configure_example_output("16a") + cfp.vect( + u=c, + v=v, + key_length=[5, 0.05], + scale=[20, 0.2], + title="DJF", + key_location=[0.95, -0.05], + ) + _assert_reference_match("16a") + +@pytest.mark.integration +def test_example_16b_basic_stream_plot(ggap_file): + """Test Example 16b: basic stream plot.""" + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + + u = u.subspace(pressure=500) + v = v.subspace(pressure=500) + + u = u.anchor("X", -180) + v = v.anchor("X", -180) + + _configure_example_output("16b") + cfp.stream(u=u, v=v, density=2) + _assert_reference_match("16b") + + +@pytest.mark.integration +def test_example_16c_enhanced_stream_plot(ggap_file): + """Test Example 16c: enhanced stream plot.""" + u = ggap_file["eastward_wind"] + v = ggap_file["northward_wind"] + + u = u.subspace(pressure=500) + v = v.subspace(pressure=500) + + u = u.anchor("X", -180) + v = v.anchor("X", -180) + + magnitude = (u**2 + v**2) ** 0.5 + mag = np.squeeze(magnitude.array) + + _configure_example_output("16c") + cfp.levs(0, 60, 5, extend="max") + cfp.cscale("viridis", ncols=13) + cfp.gopen() + cfp.stream(u=u, v=v, density=2, color=mag) + cfp.cbar( + levs=cfp.plotvars.levels, + position=[0.12, 0.12, 0.8, 0.02], + title="Wind magnitude", + ) + cfp.gclose() + + _assert_reference_match("16c") + + +# ============================================================================ +# Stipple plot tests (Examples 17-18) +# ============================================================================ + +@pytest.mark.integration +def test_example_17_basic_stipple_plot(): + """Test Example 17: basic stipple plot.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + g = f.subspace(time=15) + + _configure_example_output("17") + cfp.gopen() + cfp.cscale("magma") + cfp.con(g) + cfp.stipple(f=g, min=220, max=260, size=100, color="#00ff00") + cfp.stipple(f=g, min=300, max=330, size=50, color="#0000ff", marker="s") + cfp.gclose() + + _assert_reference_match("17") + + +@pytest.mark.integration +def test_example_18_polar_stipple_plot(): + """Test Example 18: polar stipple plot.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + g = f.subspace(time=15) + + _configure_example_output("18") + cfp.gopen() + + cfp.cscale("magma") + cfp.mapset(proj="npstere") + cfp.con(g) + cfp.stipple(f=g, min=265, max=295, size=100, color="#00ff00") + cfp.gclose() + + _assert_reference_match("18") + + +# ============================================================================ +# Unstructured grid tests (Examples 24a-24c) +# ============================================================================ + + +@pytest.mark.integration +def test_canari_1(): + """Regression test for CANARI rotated-grid plotting case.""" + if not CANARI_DATA_FILE.exists(): + pytest.skip(f"Missing integration dataset: {CANARI_DATA_FILE}") + + _configure_example_output("canari_1") + flds = cf.read(str(CANARI_DATA_FILE)) + f = flds[0] + field = f[0, 3, :, :] + cfp.mapset(proj="rotated") + cfp.con(field, lines=False) + _assert_reference_match("canari_1") + +@pytest.mark.integration +def test_example_24a_unstructured_grid_basic(): + """Test Example 24a. + + Test example for unstructured grids: LFRic example 1, now + numbered to become the missing example 24, part (a). + + NOTE, TODO: relative to example from docs, have added + 'blockfill=True' to get well-defined edges on faces, otherwise + looks very similar to 'gen_fig_unstructured_lfric_3' plot, with + edges all blurred together. + """ + if not (DATA_DIR / "lfric_initial.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'lfric_initial .nc'}") + f = cf.read(str(DATA_DIR / "lfric_initial.nc")) + + # Select the relevant fields for the objects required for the plot, + # taking the air potential temperature as a variable to choose to view. + pot = f.select_by_identity("air_potential_temperature")[0] + lats = f.select_by_identity("latitude")[0] + lons = f.select_by_identity("longitude")[0] + faces = f.select_by_identity("cf_role=face_edge_connectivity")[0] + + # Reduce the variable to match the shapes + pot = pot[4, :] + _configure_example_output("24a") + + cfp.levs(240, 310, 5) + + cfp.con( + f=pot, + face_lons=lons, + face_lats=lats, + face_connectivity=faces, + lines=False, + blockfill=True, + ) + _assert_reference_match("24a") + + +@pytest.mark.integration +def test_example_24b_unstructured_grid_blockfill(): + + """Test Example 24b. + + Test example for unstructured grids: LFRic example 2, now + numbered to become the missing example 24, part (b). + + NOTE, TODO: there are 3 'sides' of missing data in the cubed-sphere + grid, a clear issue. For now the reference plot has this in. An + issue will be raised to note this and eventually fix it. + """ + if not (DATA_DIR / "lfric_initial.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'lfric_initial .nc'}") + f = cf.read(str(DATA_DIR / "lfric_initial.nc")) + + # Select the relevant fields for the objects required for the plot, + # taking the air potential temperature as a variable to choose to view. + pot = f.select_by_identity("air_potential_temperature")[0] + lats = f.select_by_identity("latitude")[0] + lons = f.select_by_identity("longitude")[0] + faces = f.select_by_identity("cf_role=face_edge_connectivity")[0] + + # Reduce the variable to match the shapes + pot = pot[4, :] + + _configure_example_output("24b") + cfp.levs(240, 310, 5) + + # This time set the projection to a polar one for a different view + cfp.mapset(proj="npstere") + cfp.con( + f=pot, + face_lons=lons, + face_lats=lats, + face_connectivity=faces, + lines=False, + blockfill=True, + ) + _assert_reference_match("24b") + + +@pytest.mark.integration +def test_example_24c_unstructured_grid_version3(): + """Test Example 24c. + + Test example for unstructured grids: LFRic example 3, now + numbered to become the missing example 24, part (c). + + #TODO Convince ourself this is right. There were some strange polar + #artifacts in the refactor transition, and it's not obvious what is right or wrong. + + """ + if not (DATA_DIR / "lfric_initial.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'lfric_initial .nc'}") + f = cf.read(str(DATA_DIR / "lfric_initial.nc")) + + pot = f.select_by_identity("air_potential_temperature")[0] + + g = pot[0,:] + _configure_example_output("24c") + cfp.con(g, lines=False) + _assert_reference_match("24c") + + + + +# ============================================================================ +# Line/Graph plot tests (Examples 27-30) +# ============================================================================ + +@pytest.mark.integration +def test_example_27_basic_graph_plot(ggap_file): + """Test Example 27: basic graph plot.""" + f = ggap_file["eastward_wind"] + g = f.collapse("X: mean") + + _configure_example_output("27") + cfp.gopen() + cfp.lineplot( + g.subspace(pressure=100), + marker="o", + color="blue", + title="Zonal mean zonal wind at 100mb", + ) + cfp.gclose() + _assert_reference_match("27") + + +@pytest.mark.integration +def test_example_28_line_and_legend_plot(ggap_file): + """Test Example 28: line and legend plot.""" + f = ggap_file["eastward_wind"] + g = f.collapse("X: mean") + + xticks = [-90, -75, -60, -45, -30, -15, 0, 15, 30, 45, 60, 75, 90] + xticklabels = [ + "90S", "75S", "60S", "45S", "30S", "15S", "0", + "15N", "30N", "45N", "60N", "75N", "90N", + ] + xpts = [-30, 30, 30, -30, -30] + ypts = [-8, -8, 5, 5, -8] + + _configure_example_output("28") + cfp.gset(xmin=-90, xmax=90, ymin=-10, ymax=50) + + cfp.gopen() + cfp.lineplot( + g.subspace(pressure=100), + marker="o", + color="blue", + title="Zonal mean zonal wind", + label="100mb", + ) + cfp.lineplot( + g.subspace(pressure=200), + marker="D", + color="red", + label="200mb", + xticks=xticks, + xticklabels=xticklabels, + legend_location="upper right", + ) + cfp.plotvars.plot.plot(xpts, ypts, linewidth=3.0, color="green") + cfp.plotvars.plot.text(35, -2, "Region of interest", horizontalalignment="left") + cfp.gclose() + _assert_reference_match("28") + + +@pytest.mark.integration +def test_example_29_global_average_annual_temperature(): + """Test Example 29: global average annual temperature.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + + temp = f.subspace(time=cf.wi(cf.dt("1900-01-01"), cf.dt("1980-01-01"))) + temp_annual = temp.collapse("T: mean", group=cf.Y()) + temp_annual_global = temp_annual.collapse("area: mean", weights="area") + temp_annual_global.Units -= 273.15 + + _configure_example_output("29") + cfp.lineplot( + temp_annual_global, + title="Global average annual temperature", + color="blue", + ) + _assert_reference_match("29") + + +@pytest.mark.integration +def test_example_30_two_axis_plotting(ggap_file): + """Test Example 30: two axis plotting.""" + f_u = ggap_file["eastward_wind"] + f_t = ggap_file["air_temperature"] + + u = f_u.collapse("X: mean") + u1 = u.subspace(Y=cf.isclose(-61.12099075)) + u2 = u.subspace(Y=cf.isclose(0.56074494)) + + t = f_t.collapse("X: mean") + t1 = t.subspace(Y=cf.isclose(-61.12099075)) + t2 = t.subspace(Y=cf.isclose(0.56074494)) + + _configure_example_output("30") + cfp.gopen() + cfp.gset(-30, 30, 1000, 0) + cfp.lineplot(u1, color="r") + cfp.lineplot(u2, color="r") + cfp.gset(190, 300, 1000, 0, twiny=True) + cfp.lineplot(t1, color="b") + cfp.lineplot(t2, color="b") + cfp.gclose() + _assert_reference_match("30") + + +# ============================================================================ +# Trajectory plot tests (Examples 39-42b) +# ============================================================================ + +@pytest.mark.integration +def test_example_39_basic_track_plotting_trajectory(): + """Test Example 39: basic track plotting trajectory.""" + if not (DATA_DIR / "ff_trs_pos.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ff_trs_pos.nc'}") + + f = cf.read(str(DATA_DIR / "ff_trs_pos.nc"))[0] + + _configure_example_output("39") + cfp.traj(f) + _assert_reference_match("39") + + +@pytest.mark.integration +def test_example_39b_single_dsg_trajectory_no_dimension(): + """Test Example 39b: single DSG with no trajectory dimension (1D).""" + if not (DATA_DIR / "dsg_trajectory.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'dsg_trajectory.nc'}") + + f = cf.read(str(DATA_DIR / "dsg_trajectory.nc"))[0] + + # This is over a small part of France so focus in on that area + cfp.mapset(lonmin=3, lonmax=6, latmin=51, latmax=54, resolution="10m") + + _configure_example_output("39b") + cfp.traj(f) + _assert_reference_match("39b") + + +@pytest.mark.integration +def test_example_40_tracks_polar_stereographic(): + """Test Example 40: tracks in the polar stereographic projection.""" + if not (DATA_DIR / "ff_trs_pos.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ff_trs_pos.nc'}") + + f = cf.read(str(DATA_DIR / "ff_trs_pos.nc"))[0] + + cfp.mapset(proj="npstere") + + _configure_example_output("40") + cfp.traj(f) + _assert_reference_match("40") + + +@pytest.mark.integration +def test_example_41_feature_propagation(): + """Test Example 41: feature propagation over Europe.""" + if not (DATA_DIR / "ff_trs_pos.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ff_trs_pos.nc'}") + + f = cf.read(str(DATA_DIR / "ff_trs_pos.nc"))[0] + + cfp.mapset(lonmin=-20, lonmax=20, latmin=30, latmax=70) + + _configure_example_output("41") + cfp.traj(f, vector=True, markersize=0.0, fc="b", ec="b") + _assert_reference_match("41") + + +@pytest.mark.integration +def test_example_42a_intensity_legend(): + """Test Example 42a: intensity legend.""" + if not (DATA_DIR / "ff_trs_pos.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ff_trs_pos.nc'}") + + f = cf.read(str(DATA_DIR / "ff_trs_pos.nc"))[0] + + cfp.mapset(lonmin=-50, lonmax=50, latmin=20, latmax=80) + g = f.subspace(time=cf.wi(cf.dt("1979-12-01"), cf.dt("1979-12-10"))) + g = g * 1e5 + cfp.levs(0, 12, 1, extend="max") + cfp.cscale("scale1", below=0, above=13) + + _configure_example_output("42a") + cfp.traj( + g, + legend=True, + markersize=40.0, + colorbar_title="Relative Vorticity (Hz) * 1e5", + ) + _assert_reference_match("42a") + + +@pytest.mark.integration +def test_example_42b_intensity_legend_with_lines(): + """Test Example 42b: intensity legend with lines.""" + # TODO I think this is right, even though it is different from legacy. + # Legacy has 42a and 42b the same, which can't be right. + if not (DATA_DIR / "ff_trs_pos.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ff_trs_pos.nc'}") + + f = cf.read(str(DATA_DIR / "ff_trs_pos.nc"))[0] + + cfp.mapset(lonmin=-50, lonmax=50, latmin=20, latmax=80) + g = f.subspace(time=cf.wi(cf.dt("1979-12-01"), cf.dt("1979-12-10"))) + g = g * 1e5 + cfp.levs(0, 12, 1, extend="max") + cfp.cscale("scale1", below=0, above=13) + + _configure_example_output("42b") + cfp.traj( + g, + legend_lines=True, + linewidth=2, + colorbar_title="Relative Vorticity (Hz) * 1e5", + ) + _assert_reference_match("42b") diff --git a/tests/integration/test_contour_animation_examples.py b/tests/integration/test_contour_animation_examples.py new file mode 100644 index 0000000..4fa26f8 --- /dev/null +++ b/tests/integration/test_contour_animation_examples.py @@ -0,0 +1,136 @@ +"""Integration tests for contour animation behavior with real datasets.""" + +from pathlib import Path + +import cf +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import cfplot as cfp + + +DATA_DIR = Path(__file__).parent.parent.parent / "docs" / "source" / "example-datasets" +TEST_GEN_DIR = Path(__file__).parent.parent.parent / "generated-example-images" +ANIM_GEN_DIR = TEST_GEN_DIR / "animation" + + +@pytest.fixture(autouse=True) +def setup_cfplot(): + """Reset plotting state around each integration test.""" + plt.close("all") + cfp.reset() + yield + cfp.reset() + plt.close("all") + + +@pytest.mark.integration +def test_ptype1_animation_first_five_tas_timesteps_updates_titles(): + """Animate first five tas_A1 frames and verify per-frame title updates.""" + data_file = DATA_DIR / "tas_A1.nc" + if not data_file.exists(): + pytest.skip(f"Missing test data: {data_file}") + + f = cf.read(str(data_file))[0] + frame_titles: list[str] = [] + + cfp.gopen() + try: + for frame_idx in range(5): + frame = f[frame_idx, :, :] + reuse_map_background = frame_idx > 0 + + cfp.con( + frame, + ptype=1, + animation=True, + reuse_map_background=reuse_map_background, + clear_previous_frame=reuse_map_background, + title="tas", + animation_axis="auto", + animation_title_template="{title} [{frame}]", + lines=False, + ) + + if reuse_map_background: + title_artist = cfp.plotvars.runtime._contour_animation_title_artist + assert title_artist is not None + frame_title = title_artist.get_text() + assert frame_title.startswith("tas [") + assert "time:" in frame_title.lower() + frame_titles.append(frame_title) + + # Frames 1-4 should each have a distinct time-stamped title. + assert len(frame_titles) == 4 + assert len(set(frame_titles)) == 4 + finally: + cfp.gclose(view=False) + + +@pytest.mark.integration +def test_ptype1_animation_first_five_tas_timesteps_writes_png_frames(): + """Render five animation frames and save each frame as a PNG file.""" + data_file = DATA_DIR / "tas_A1.nc" + if not data_file.exists(): + pytest.skip(f"Missing test data: {data_file}") + + f = cf.read(str(data_file))[0] + ANIM_GEN_DIR.mkdir(parents=True, exist_ok=True) + + for old_frame in ANIM_GEN_DIR.glob("tas_ptype1_frame_*.png"): + old_frame.unlink() + + written_frames: list[Path] = [] + expected_axes_count: int | None = None + expected_boundaries: np.ndarray | None = None + + cfp.gopen() + try: + for frame_idx in range(5): + frame = f[frame_idx, :, :] + # For exported standalone frames, redraw the map each frame so + # each PNG contains complete map context on its own. + reuse_map_background = False + + cfp.con( + frame, + ptype=1, + animation=True, + animation_reference=f, + reuse_map_background=reuse_map_background, + clear_previous_frame=True, + title="tas", + animation_axis="auto", + animation_title_template="{title} [{frame}]", + lines=False, + ) + + fig = cfp.plotvars.runtime.master_plot + if fig is None and cfp.plotvars.runtime.plot is not None: + fig = cfp.plotvars.runtime.plot.figure + assert fig is not None + + if expected_axes_count is None: + expected_axes_count = len(fig.axes) + else: + assert len(fig.axes) == expected_axes_count + + colorbar = cfp.plotvars.runtime._contour_animation_colorbar + assert colorbar is not None + boundaries = np.asarray(colorbar.boundaries, dtype=float) + if expected_boundaries is None: + expected_boundaries = boundaries + else: + assert np.allclose(boundaries, expected_boundaries) + + frame_path = ANIM_GEN_DIR / f"tas_ptype1_frame_{frame_idx:02d}.png" + fig.savefig(frame_path, dpi=100) + written_frames.append(frame_path) + + assert len(written_frames) == 5 + for frame_path in written_frames: + assert frame_path.exists() + assert frame_path.stat().st_size > 0 + finally: + cfp.gclose(view=False) diff --git a/tests/integration/test_contour_examples.py b/tests/integration/test_contour_examples.py new file mode 100644 index 0000000..0a36372 --- /dev/null +++ b/tests/integration/test_contour_examples.py @@ -0,0 +1,124 @@ +"""Integration tests for contour plotting with reference data.""" + +from pathlib import Path + +import cf +import matplotlib.pyplot as plt +import pytest +from netCDF4 import Dataset as ncfile +import cfplot as cfp + + +# Path to test data +DATA_DIR = Path(__file__).parent.parent.parent / "docs" / "source" / "example-datasets" +REF_IMAGE_DIR = Path(__file__).parent.parent / "reference-example-images" + + +@pytest.fixture(autouse=True) +def setup_cfplot(): + """Reset cfplot and close figures around each integration test.""" + plt.close("all") + cfp.reset() + yield + cfp.reset() + plt.close("all") + + +@pytest.mark.integration +def test_contour_basic_tas(): + """Test basic contour plot with temperature data.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + flds = cf.read(str(DATA_DIR / "tas_A1.nc")) + f = flds[0] + + # Match the known-good example slice for this dataset. + f_2d = f.subspace(time=15) + + # This should not raise + cfp.con(f_2d) + + +@pytest.mark.integration +def test_contour_ggap_data(): + """Test contour plot with GGAP dataset.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + flds = cf.read(str(DATA_DIR / "ggap.nc")) + + # The first two GGAP fields are pressure-level data; take a 2D slice. + for idx in [0, 1]: + if idx < len(flds): + f = flds[idx] + f_2d = f.subspace(pressure=500) if f.has_construct("Z") else f + cfp.con(f_2d) + + +@pytest.mark.integration +def test_orca_direct(): + + if not (DATA_DIR / "orca2.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'orca2.nc'}") + + # Use native 2D arrays to exercise map contouring from numpy inputs. + with ncfile(str(DATA_DIR / "orca2.nc")) as nc: + lons = nc.variables["longitude"][:] + lats = nc.variables["latitude"][:] + temp = nc.variables["sst"][:] + + cfp.con(x=lons, y=lats, f=temp, ptype=1) + + +@pytest.mark.integration +def test_contour_orca_grid(): + """Test contour plot with ORCA grid (irregular/tripolar).""" + if not (DATA_DIR / "orca2.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'orca2.nc'}") + + flds = cf.read(str(DATA_DIR / "orca2.nc")) + lons = flds.select_by_identity("ncvar%longitude")[0] + lats = flds.select_by_identity("ncvar%latitude")[0] + temp = flds.select_by_identity("ncvar%sst")[0] + + cfp.con(x=lons, y=lats, f=temp, ptype=1) + + +@pytest.mark.integration +def test_contour_orca_cf_metadata_only(): + """Test ORCA contouring directly from CF metadata (no explicit lon/lat).""" + if not (DATA_DIR / "orca2.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'orca2.nc'}") + + flds = cf.read(str(DATA_DIR / "orca2.nc")) + temp = flds.select_by_identity("ncvar%sst")[0].copy() + lons = flds.select_by_identity("ncvar%longitude")[0] + lats = flds.select_by_identity("ncvar%latitude")[0] + + # This dataset stores lon/lat as separate fields rather than attached + # coordinates on sst. Attach them as auxiliary coordinates for CF plotting. + axes = temp.get_data_axes() + lon_coord = cf.AuxiliaryCoordinate( + properties={"standard_name": "longitude", "units": "degrees_east"}, + data=lons.array, + ) + lat_coord = cf.AuxiliaryCoordinate( + properties={"standard_name": "latitude", "units": "degrees_north"}, + data=lats.array, + ) + temp.set_construct(lon_coord, axes=axes) + temp.set_construct(lat_coord, axes=axes) + + # Plot using the CF field only; metadata is now attached to temp. + cfp.con(temp) + + +@pytest.mark.integration +def test_contour_rotated_pole(): + """Test contour plot with rotated pole grid.""" + if not (DATA_DIR / "rgp.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'rgp.nc'}") + + f = cf.read(str(DATA_DIR / "rgp.nc"))[0] + cfp.con(f) \ No newline at end of file diff --git a/tests/integration/test_contour_plot_examples.py b/tests/integration/test_contour_plot_examples.py new file mode 100644 index 0000000..e7df1c7 --- /dev/null +++ b/tests/integration/test_contour_plot_examples.py @@ -0,0 +1,608 @@ +"""Integration tests for contour plot examples. + +Migrated from cfplot/test/test_examples.py::ExamplesTest +These tests verify that con() plotting functions work with real data. +""" + +from pathlib import Path +import shutil + +import cf +import matplotlib.pyplot as plt +import matplotlib.testing.compare as mpl_compare +import numpy as np +import pytest + +import cfplot as cfp +import cfplot.layout_runtime as layout_runtime + + +# Path to test data +DATA_DIR = Path(__file__).parent.parent.parent / "docs" / "source" / "example-datasets" +TEST_GEN_DIR = Path(__file__).parent.parent.parent / "generated-example-images" +#REF_IMAGE_DIR = Path(__file__).parent.parent / "reference-example-images" +REF_IMAGE_DIR = Path(__file__).parent.parent / "new_reference-example-images" +TEST_GEN_DIR.mkdir(parents=True, exist_ok=True) + + +def _is_targeted_example_run(pytestconfig: pytest.Config) -> bool: + """Return True when pytest was invoked for a narrow subset of tests.""" + args = tuple(str(arg) for arg in pytestconfig.invocation_params.args) + if any("::" in arg for arg in args): + return True + + for index, arg in enumerate(args): + if arg == "-k": + return index + 1 < len(args) and bool(args[index + 1].strip()) + if arg.startswith("-k"): + expression = arg[2:] + return expression.startswith("=") or bool(expression.strip()) + + return False + + +@pytest.fixture(scope="session", autouse=True) +def clean_generated_example_dir(pytestconfig: pytest.Config): + """Remove stale generated images for broad runs, but preserve targeted runs.""" + if _is_targeted_example_run(pytestconfig): + return + + for path in TEST_GEN_DIR.iterdir(): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +@pytest.fixture +def ggap_file(): + """Return GGAP fields keyed by identity.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + flds = cf.read(str(DATA_DIR / "ggap.nc")) + fdict = {f.identity(): f for f in flds} + return fdict + +def _configure_example_output(example_id: str) -> None: + """Route plot output to the expected generated example filename.""" + fname = str(TEST_GEN_DIR / f"gen_fig_{example_id}.png") + cfp.setvars(file=fname, viewer="matplotlib") + + +def _assert_reference_match(example_id: str) -> None: + """Compare generated plot against legacy reference image.""" + ref = REF_IMAGE_DIR / f"ref_fig_{example_id}.png" + gen = TEST_GEN_DIR / f"gen_fig_{example_id}.png" + if not ref.exists(): + pytest.skip(f"Missing reference image: {ref}") + if not gen.exists(): + pytest.fail(f"Generated image missing for example {example_id}: {gen}") + result = mpl_compare.compare_images( + str(ref), + str(gen), + tol=0.01, + in_decorator=True, + ) + if result is not None: + pytest.fail(f"Image mismatch for example {example_id}: {result}") + + +@pytest.fixture(autouse=True) +def setup_cfplot(): + """Set up cfplot for each test.""" + plt.close("all") + cfp.reset() + yield + cfp.reset() + plt.close("all") + + +@pytest.mark.integration +def test_gclose_view_false_does_not_launch_viewer(monkeypatch): + """Ensure gclose(view=False) does not trigger display or matplotlib viewer.""" + calls = {"show": 0, "popen": 0} + + def fake_show(*args, **kwargs): + calls["show"] += 1 + + def fake_popen(*args, **kwargs): + calls["popen"] += 1 + return None + + monkeypatch.setattr(plt, "show", fake_show) + monkeypatch.setattr(layout_runtime.subprocess, "Popen", fake_popen) + + cfp.setvars(file=None, viewer="display") + cfp.gopen() + cfp.gclose(view=False) + + assert calls["show"] == 0 + assert calls["popen"] == 0 + + +@pytest.mark.integration +def test_example_1_basic_cylindrical(): + """Test Example 1: a basic cylindrical projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("1") + + # This should not raise + cfp.con(f.subspace(time=15)) + _assert_reference_match("1") + + +@pytest.mark.integration +def test_example_2_blockfill(): + """Test Example 2: cylindrical projection with blockfill.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("2") + + # This should not raise (blockfill with filled colors) + cfp.con(f.subspace(time=15), blockfill=True, lines=False) + _assert_reference_match("2") + + +@pytest.mark.integration +def test_example_3_map_limits_and_levels(): + """Test Example 3: altering the map limits and contour levels.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("3") + + cfp.mapset(lonmin=-15, lonmax=3, latmin=48, latmax=60) + cfp.levs(min=265, max=285, step=1) + cfp.con(f.subspace(time=15)) + _assert_reference_match("3") + + +@pytest.mark.integration +def test_example_4_north_pole_stereographic(ggap_file): + """Test Example 4: north pole polar stereographic projection.""" + + f = ggap_file["eastward_wind"] + assert f.identity() == "eastward_wind" + _configure_example_output("4") + + cfp.mapset(proj="npstere") + cfp.con(f.subspace(pressure=500)) + _assert_reference_match("4") + + +@pytest.mark.integration +def test_example_5_south_pole_with_boundary(ggap_file): + """Test Example 5: south pole with bounding latitude.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + f = ggap_file["eastward_wind"] + _configure_example_output("5") + + cfp.mapset(proj="spstere", boundinglat=-30, lon_0=180) + cfp.con(f.subspace(pressure=500)) + _assert_reference_match("5") + + +@pytest.mark.integration +def test_example_6_latitude_pressure_plot(ggap_file): + """Test Example 6: latitude-pressure plot.""" + + f = ggap_file["geopotential"] + _configure_example_output("6") + + cfp.con(f.subspace(longitude=0)) + _assert_reference_match("6") + + +@pytest.mark.integration +def test_example_7_lat_pressure_zonal_mean(ggap_file): + """Test Example 7: latitude-pressure plot of a zonal mean.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + f = ggap_file["eastward_wind"] + _configure_example_output("7") + + cfp.con(f.collapse("mean", "longitude")) + _assert_reference_match("7") + + +@pytest.mark.integration +def test_example_8_log_scale_pressure(ggap_file): + """Test Example 8: plot showing latitude against log-scale pressure.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + f = ggap_file["eastward_wind"] + _configure_example_output("8") + + cfp.con(f.collapse("mean", "longitude"), ylog=1) + _assert_reference_match("8") + + +@pytest.mark.integration +#@pytest.mark.xfail(reason="cf-python issue #799") +def test_example_9_longitude_pressure_plot(ggap_file): + """Test Example 9: longitude-pressure plot.""" + + f = ggap_file["air_temperature"] + _configure_example_output("9") + + cfp.con(f.collapse("mean", "latitude")) + _assert_reference_match("9") + + +@pytest.mark.integration +def test_example_10_latitude_time_hovmuller(): + """Test Example 10: latitude-time Hovmuller plot.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("10") + + cfp.cscale("plasma") + cfp.con(f.subspace(longitude=0), lines=0) + _assert_reference_match("10") + + +@pytest.mark.integration +def test_example_11_latitude_time_subset_hovmuller(): + """Test Example 11: latitude-time subset Hovmuller plot.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("11") + + cfp.gset(-30, 30, "1960-1-1", "1980-1-1") + cfp.levs(min=280, max=305, step=1) + cfp.cscale("plasma") + cfp.con(f.subspace(longitude=0), lines=0) + _assert_reference_match("11") + + +@pytest.mark.integration +def test_example_12_longitude_time_hovmuller(): + """Test Example 12: longitude-time Hovmuller plot.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("12") + + cfp.cscale("plasma") + cfp.con(f.subspace(latitude=0), lines=0) + _assert_reference_match("12") + + +@pytest.mark.integration +def test_example_19_multiple_subplots(ggap_file): + """Test Example 19: multiple plots as subplots.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + f = ggap_file["eastward_wind"] + _configure_example_output("19") + + cfp.gopen(rows=2, columns=2, bottom=0.2) + cfp.gpos(1) + cfp.con(f.subspace(pressure=500), colorbar=None) + cfp.gpos(2) + cfp.mapset(proj="moll") + cfp.con(f.subspace(pressure=500), colorbar=None) + cfp.gpos(3) + cfp.mapset(proj="npstere", boundinglat=30, lon_0=180) + cfp.con(f.subspace(pressure=500), colorbar=None) + cfp.gpos(4) + cfp.mapset(proj="spstere", boundinglat=-30, lon_0=180) + cfp.con( + f.subspace(pressure=500), + colorbar_position=[0.1, 0.1, 0.8, 0.02], + colorbar_orientation="horizontal", + ) + cfp.gclose() + _assert_reference_match("19") + + +@pytest.mark.integration +def test_example_19a_user_positioned_subplots(ggap_file): + """Test Example 19a: user specified subplot positions.""" + if not (DATA_DIR / "ggap.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'ggap.nc'}") + + f = ggap_file["eastward_wind"] + _configure_example_output("19a") + + cfp.gopen(user_position=True) + cfp.gpos(xmin=0.1, xmax=0.5, ymin=0.55, ymax=1.0) + cfp.con(f.subspace(Z=500), title="500mb", lines=False) + cfp.gpos(xmin=0.55, xmax=0.95, ymin=0.55, ymax=1.0) + cfp.con(f.subspace(Z=100), title="100mb", lines=False) + cfp.gpos(xmin=0.3, xmax=0.7, ymin=0.1, ymax=0.55) + cfp.con(f.subspace(Z=10), title="10mb", lines=False) + cfp.gclose() + _assert_reference_match("19a") + + +@pytest.mark.integration +def test_example_20_rotated_pole_data(): + """Test Example 20: user labelling of axes with rotated pole data.""" + if not (DATA_DIR / "Geostropic_Adjustment.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'Geostropic_Adjustment.nc'}") + + flds = cf.read(str(DATA_DIR / "Geostropic_Adjustment.nc")) + f = {f.identity(): f for f in flds}["ncvar%v"] + _configure_example_output("20") + + # Legacy examples contour a 2D slice of this rotated-pole field. + cfp.con(f.subspace[9]) + _assert_reference_match("20") + + +@pytest.mark.integration +def test_example_21_rotated_pole_custom_ticks(): + """Test Example 21: rotated pole data plot with custom ticks.""" + if not (DATA_DIR / "Geostropic_Adjustment.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'Geostropic_Adjustment.nc'}") + + flds = cf.read(str(DATA_DIR / "Geostropic_Adjustment.nc")) + f = {f.identity(): f for f in flds}["ncvar%v"] + _configure_example_output("21") + + cfp.con( + f.subspace[9], + title="test data", + xticks=np.arange(5) * 100000 + 100000, + yticks=np.arange(7) * 2000 + 2000, + xlabel="x-axis", + ylabel="z-axis", + ) + + +@pytest.mark.integration +def test_example_21other_rgp_plasma(): + """Test Example 21other: RGP data with plasma colorscale.""" + if not (DATA_DIR / "rgp.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'rgp.nc'}") + + f = cf.read(str(DATA_DIR / "rgp.nc"))[0] + _configure_example_output("21other") + + cfp.cscale("plasma") + cfp.con(f) + _assert_reference_match("21other") + + + +@pytest.mark.integration +def test_example_22_rgp_rotated_projection(): + """Test RGP data with rotated projection.""" + if not (DATA_DIR / "rgp.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'rgp.nc'}") + + f = cf.read(str(DATA_DIR / "rgp.nc"))[0] + _configure_example_output("22") + + cfp.cscale("plasma") + cfp.mapset(proj="rotated") + cfp.con(f) + _assert_reference_match("22") + + +@pytest.mark.integration +def test_example_23_rotated_grid_axes_overlay(): + """Test Example 23: rotated-grid axes overlay.""" + if not (DATA_DIR / "rgp.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'rgp.nc'}") + + f = cf.read(str(DATA_DIR / "rgp.nc"))[0] + _configure_example_output("23") + + data = f.array + xvec = f.construct("ncvar%x").array + yvec = f.construct("ncvar%y").array + xpole = 160 + ypole = 30 + + cfp.gopen() + cfp.cscale("plasma") + xpts = np.arange(np.size(xvec)) + ypts = np.arange(np.size(yvec)) + cfp.gset(xmin=0, xmax=np.size(xvec) - 1, ymin=0, ymax=np.size(yvec) - 1) + cfp.levs(min=980, max=1035, step=2.5) + cfp.con(data, xpts, ypts[::-1]) + cfp.rgaxes(xpole=xpole, ypole=ypole, xvec=xvec, yvec=yvec) + cfp.gclose() + _assert_reference_match("23") + + +@pytest.mark.integration +def test_example_23other_incompass_contour_vectors(): + """Test Example 23other: contour + vectors on INCOMPASS data.""" + incompass_file = DATA_DIR / "20160601-05T0000Z_INCOMPASS_km4p4_uv_RH_500.nc" + if not incompass_file.exists(): + pytest.skip(f"Missing test data: {incompass_file}") + + flds = cf.read(str(incompass_file)) + fdict = {f.identity(): f for f in flds} + _configure_example_output("23other") + + cfp.mapset(50, 100, 5, 35) + cfp.levs(0, 90, 15, extend="neither") + cfp.gopen() + cfp.con(fdict['long_name=Relative humidity'], lines=False) + cfp.vect(u=fdict['eastward_wind'], v=fdict['northward_wind'], stride=40, key_length=10) + cfp.gclose() + _assert_reference_match("23other") + + +@pytest.mark.integration +def test_example_31_ukcp_projection(): + """Test Example 31: UKCP projection.""" + ukcp_file = DATA_DIR / "ukcp_rcm_test.nc" + if not ukcp_file.exists(): + pytest.skip(f"Missing test data: {ukcp_file}") + + f = cf.read(str(ukcp_file))[0] + + fname = str(TEST_GEN_DIR / "gen_fig_31.png") + cfp.setvars( + file=fname, + viewer="matplotlib", + grid_x_spacing=1, + grid_y_spacing=1, + ) + cfp.mapset(proj="UKCP", resolution="50m") + #TODO The original test set the grid_x_ and _y_spacing to 1, + # but this reset all the output, and stopped any output to + # file. We need to fix setvars so it doesn't just reset + # everything. + cfp.levs(-3, 7, 0.5) + cfp.con(f, lines=False) + _assert_reference_match("31") + + +@pytest.mark.integration +def test_example_33_osgb_and_europp(): + """Test Example 33: OSGB and EuroPP projections.""" + ukcp_file = DATA_DIR / "ukcp_rcm_test.nc" + if not ukcp_file.exists(): + pytest.skip(f"Missing test data: {ukcp_file}") + + f = cf.read(str(ukcp_file))[0] + _configure_example_output("33") + + cfp.levs(-3, 7, 0.5) + cfp.gopen(columns=2) + cfp.mapset(proj="OSGB", resolution="50m") + cfp.con(f, lines=False, colorbar_label_skip=2) + cfp.gpos(2) + cfp.mapset(proj="EuroPP", resolution="50m") + cfp.con(f, lines=False, colorbar_label_skip=2) + cfp.gclose() + _assert_reference_match("33") + + +@pytest.mark.integration +def test_example_34_lambert_conformal(): + """Test Example 34: Cropped Lambert conformal projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("34") + + cfp.mapset(proj="lcc", lonmin=-50, lonmax=50, latmin=20, latmax=85) + cfp.con(f.subspace(time=15)) + _assert_reference_match("34") + + +@pytest.mark.integration +def test_example_35_mollweide_projection(): + """Test Example 35: Mollweide projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("35") + + cfp.mapset(proj="moll") + cfp.con(f.subspace(time=15)) + _assert_reference_match("35") + + +@pytest.mark.integration +def test_example_36_mercator_projection(): + """Test Example 36: Mercator projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("36") + + cfp.mapset(proj="merc") + cfp.con(f.subspace(time=15)) + _assert_reference_match("36") + + +@pytest.mark.integration +def test_example_37_orthographic_projection(): + """Test Example 37: Orthographic projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("37") + + cfp.mapset(proj="ortho") + cfp.con(f.subspace(time=15)) + _assert_reference_match("37") + + +@pytest.mark.integration +def test_example_38_robinson_projection(): + """Test Example 38: Robinson projection.""" + if not (DATA_DIR / "tas_A1.nc").exists(): + pytest.skip(f"Missing test data: {DATA_DIR / 'tas_A1.nc'}") + + f = cf.read(str(DATA_DIR / "tas_A1.nc"))[0] + _configure_example_output("38") + + cfp.mapset(proj="robin") + cfp.con(f.subspace(time=15)) + _assert_reference_match("38") + + +@pytest.mark.integration +def test_example_37b_orthographic_full_globe(): + """Test orthographic projection with an explicit full-globe bounding box. + + Exercises the case where lonmin/lonmax/latmin/latmax are all set to the + full range (-180/180/-90/90), which previously produced rendering artefacts + (wedge gaps or a seam column through the visible hemisphere). + """ + data_file = Path(__file__).parent.parent / "data" / "example_ortho_fail.nc" + if not data_file.exists(): + pytest.skip(f"Missing test data: {data_file}") + + pfld = cf.read(str(data_file))[0] + _configure_example_output("37b") + + cfp.mapset( + proj="ortho", + resolution="110m", + lonmin=-180.0, + lonmax=180.0, + latmin=-90.0, + latmax=90.0, + lon_0=0.0, + lat_0=0.0, + ) + cfp.con(pfld, fill=True, lines=False, line_labels=False, + title="time=3080592000.0") + _assert_reference_match("37b") + + + +@pytest.mark.integration +def test_example_numpy_arrays(): + """Test contour with raw numpy arrays.""" + # Create synthetic 2D data + x = np.linspace(0, 10, 50) + y = np.linspace(0, 10, 50) + X, Y = np.meshgrid(x, y) + field = np.sin(X) * np.cos(Y) + + fname = str(TEST_GEN_DIR / "gen_fig_numpy.png") + cfp.setvars(file=fname, viewer="matplotlib") + + # Should accept raw arrays + cfp.con(f=field, x=x, y=y) diff --git a/tests/new_reference-example-images/ref_fig_1.png b/tests/new_reference-example-images/ref_fig_1.png new file mode 100644 index 0000000..8531724 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_1.png differ diff --git a/tests/new_reference-example-images/ref_fig_10.png b/tests/new_reference-example-images/ref_fig_10.png new file mode 100644 index 0000000..bbf751e Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_10.png differ diff --git a/tests/new_reference-example-images/ref_fig_11.png b/tests/new_reference-example-images/ref_fig_11.png new file mode 100644 index 0000000..ca713f5 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_11.png differ diff --git a/tests/new_reference-example-images/ref_fig_12.png b/tests/new_reference-example-images/ref_fig_12.png new file mode 100644 index 0000000..f5fec39 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_12.png differ diff --git a/tests/new_reference-example-images/ref_fig_13.png b/tests/new_reference-example-images/ref_fig_13.png new file mode 100644 index 0000000..2882858 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_13.png differ diff --git a/tests/new_reference-example-images/ref_fig_14.png b/tests/new_reference-example-images/ref_fig_14.png new file mode 100644 index 0000000..7965140 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_14.png differ diff --git a/tests/new_reference-example-images/ref_fig_15.png b/tests/new_reference-example-images/ref_fig_15.png new file mode 100644 index 0000000..f7c8546 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_15.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_16a.png b/tests/new_reference-example-images/ref_fig_16a.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_16a.png rename to tests/new_reference-example-images/ref_fig_16a.png diff --git a/tests/new_reference-example-images/ref_fig_16b.png b/tests/new_reference-example-images/ref_fig_16b.png new file mode 100644 index 0000000..b2f7bb2 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_16b.png differ diff --git a/tests/new_reference-example-images/ref_fig_16c.png b/tests/new_reference-example-images/ref_fig_16c.png new file mode 100644 index 0000000..64af133 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_16c.png differ diff --git a/tests/new_reference-example-images/ref_fig_17.png b/tests/new_reference-example-images/ref_fig_17.png new file mode 100644 index 0000000..813e9fb Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_17.png differ diff --git a/tests/new_reference-example-images/ref_fig_18.png b/tests/new_reference-example-images/ref_fig_18.png new file mode 100644 index 0000000..a3cb717 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_18.png differ diff --git a/tests/new_reference-example-images/ref_fig_19.png b/tests/new_reference-example-images/ref_fig_19.png new file mode 100644 index 0000000..5d394ad Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_19.png differ diff --git a/tests/new_reference-example-images/ref_fig_19a.png b/tests/new_reference-example-images/ref_fig_19a.png new file mode 100644 index 0000000..b1c6d37 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_19a.png differ diff --git a/tests/new_reference-example-images/ref_fig_2.png b/tests/new_reference-example-images/ref_fig_2.png new file mode 100644 index 0000000..a0649fc Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_2.png differ diff --git a/tests/new_reference-example-images/ref_fig_20.png b/tests/new_reference-example-images/ref_fig_20.png new file mode 100644 index 0000000..ae25fc2 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_20.png differ diff --git a/tests/new_reference-example-images/ref_fig_21.png b/tests/new_reference-example-images/ref_fig_21.png new file mode 100644 index 0000000..9ba8db2 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_21.png differ diff --git a/tests/new_reference-example-images/ref_fig_21other.png b/tests/new_reference-example-images/ref_fig_21other.png new file mode 100644 index 0000000..e8c824d Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_21other.png differ diff --git a/tests/new_reference-example-images/ref_fig_22.png b/tests/new_reference-example-images/ref_fig_22.png new file mode 100644 index 0000000..c9c0c1d Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_22.png differ diff --git a/tests/new_reference-example-images/ref_fig_23.png b/tests/new_reference-example-images/ref_fig_23.png new file mode 100644 index 0000000..724ebb6 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_23.png differ diff --git a/tests/new_reference-example-images/ref_fig_23other.png b/tests/new_reference-example-images/ref_fig_23other.png new file mode 100644 index 0000000..3439b9d Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_23other.png differ diff --git a/tests/new_reference-example-images/ref_fig_24a.png b/tests/new_reference-example-images/ref_fig_24a.png new file mode 100644 index 0000000..fc2ffb5 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_24a.png differ diff --git a/tests/new_reference-example-images/ref_fig_24b.png b/tests/new_reference-example-images/ref_fig_24b.png new file mode 100644 index 0000000..dabd7ab Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_24b.png differ diff --git a/tests/new_reference-example-images/ref_fig_24c.png b/tests/new_reference-example-images/ref_fig_24c.png new file mode 100644 index 0000000..7d1f512 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_24c.png differ diff --git a/tests/new_reference-example-images/ref_fig_27.png b/tests/new_reference-example-images/ref_fig_27.png new file mode 100644 index 0000000..c241de9 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_27.png differ diff --git a/tests/new_reference-example-images/ref_fig_28.png b/tests/new_reference-example-images/ref_fig_28.png new file mode 100644 index 0000000..05c66f2 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_28.png differ diff --git a/tests/new_reference-example-images/ref_fig_29.png b/tests/new_reference-example-images/ref_fig_29.png new file mode 100644 index 0000000..c94a3d9 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_29.png differ diff --git a/tests/new_reference-example-images/ref_fig_3.png b/tests/new_reference-example-images/ref_fig_3.png new file mode 100644 index 0000000..e50fb14 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_3.png differ diff --git a/tests/new_reference-example-images/ref_fig_30.png b/tests/new_reference-example-images/ref_fig_30.png new file mode 100644 index 0000000..8047d70 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_30.png differ diff --git a/tests/new_reference-example-images/ref_fig_31.png b/tests/new_reference-example-images/ref_fig_31.png new file mode 100644 index 0000000..b6c0740 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_31.png differ diff --git a/tests/new_reference-example-images/ref_fig_33.png b/tests/new_reference-example-images/ref_fig_33.png new file mode 100644 index 0000000..2fbf197 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_33.png differ diff --git a/tests/new_reference-example-images/ref_fig_34.png b/tests/new_reference-example-images/ref_fig_34.png new file mode 100644 index 0000000..a11dc4c Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_34.png differ diff --git a/tests/new_reference-example-images/ref_fig_35.png b/tests/new_reference-example-images/ref_fig_35.png new file mode 100644 index 0000000..6f0e289 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_35.png differ diff --git a/tests/new_reference-example-images/ref_fig_36.png b/tests/new_reference-example-images/ref_fig_36.png new file mode 100644 index 0000000..1f2144b Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_36.png differ diff --git a/tests/new_reference-example-images/ref_fig_37.png b/tests/new_reference-example-images/ref_fig_37.png new file mode 100644 index 0000000..355eaa3 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_37.png differ diff --git a/tests/new_reference-example-images/ref_fig_37b.png b/tests/new_reference-example-images/ref_fig_37b.png new file mode 100644 index 0000000..d504d3e Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_37b.png differ diff --git a/tests/new_reference-example-images/ref_fig_38.png b/tests/new_reference-example-images/ref_fig_38.png new file mode 100644 index 0000000..2884ac6 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_38.png differ diff --git a/tests/new_reference-example-images/ref_fig_39.png b/tests/new_reference-example-images/ref_fig_39.png new file mode 100644 index 0000000..71acaef Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_39.png differ diff --git a/tests/new_reference-example-images/ref_fig_39b.png b/tests/new_reference-example-images/ref_fig_39b.png new file mode 100644 index 0000000..111b3ed Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_39b.png differ diff --git a/tests/new_reference-example-images/ref_fig_4.png b/tests/new_reference-example-images/ref_fig_4.png new file mode 100644 index 0000000..7b9bf68 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_4.png differ diff --git a/tests/new_reference-example-images/ref_fig_40.png b/tests/new_reference-example-images/ref_fig_40.png new file mode 100644 index 0000000..afcf435 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_40.png differ diff --git a/tests/new_reference-example-images/ref_fig_41.png b/tests/new_reference-example-images/ref_fig_41.png new file mode 100644 index 0000000..2ff51b8 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_41.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_42a.png b/tests/new_reference-example-images/ref_fig_42a.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_42a.png rename to tests/new_reference-example-images/ref_fig_42a.png diff --git a/tests/new_reference-example-images/ref_fig_42b.png b/tests/new_reference-example-images/ref_fig_42b.png new file mode 100644 index 0000000..fa751af Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_42b.png differ diff --git a/tests/new_reference-example-images/ref_fig_5.png b/tests/new_reference-example-images/ref_fig_5.png new file mode 100644 index 0000000..2dacf3b Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_5.png differ diff --git a/tests/new_reference-example-images/ref_fig_6.png b/tests/new_reference-example-images/ref_fig_6.png new file mode 100644 index 0000000..0ff34ee Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_6.png differ diff --git a/tests/new_reference-example-images/ref_fig_7.png b/tests/new_reference-example-images/ref_fig_7.png new file mode 100644 index 0000000..ef1a3f6 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_7.png differ diff --git a/tests/new_reference-example-images/ref_fig_8.png b/tests/new_reference-example-images/ref_fig_8.png new file mode 100644 index 0000000..43d9923 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_8.png differ diff --git a/tests/new_reference-example-images/ref_fig_9.png b/tests/new_reference-example-images/ref_fig_9.png new file mode 100644 index 0000000..e157b63 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_9.png differ diff --git a/tests/new_reference-example-images/ref_fig_canari_1.png b/tests/new_reference-example-images/ref_fig_canari_1.png new file mode 100644 index 0000000..e9e85cc Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_canari_1.png differ diff --git a/tests/new_reference-example-images/ref_fig_numpy.png b/tests/new_reference-example-images/ref_fig_numpy.png new file mode 100644 index 0000000..010840c Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_numpy.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_1.png b/tests/reference-example-images/ref_fig_1.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_1.png rename to tests/reference-example-images/ref_fig_1.png diff --git a/cfplot/test/reference-example-images/ref_fig_10.png b/tests/reference-example-images/ref_fig_10.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_10.png rename to tests/reference-example-images/ref_fig_10.png diff --git a/cfplot/test/reference-example-images/ref_fig_11.png b/tests/reference-example-images/ref_fig_11.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_11.png rename to tests/reference-example-images/ref_fig_11.png diff --git a/cfplot/test/reference-example-images/ref_fig_12.png b/tests/reference-example-images/ref_fig_12.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_12.png rename to tests/reference-example-images/ref_fig_12.png diff --git a/tests/reference-example-images/ref_fig_13.png b/tests/reference-example-images/ref_fig_13.png new file mode 100644 index 0000000..6db730e Binary files /dev/null and b/tests/reference-example-images/ref_fig_13.png differ diff --git a/tests/reference-example-images/ref_fig_14.png b/tests/reference-example-images/ref_fig_14.png new file mode 100644 index 0000000..69e0286 Binary files /dev/null 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 new file mode 100644 index 0000000..ea695c4 Binary files /dev/null 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 new file mode 100644 index 0000000..cfb1f88 Binary files /dev/null and b/tests/reference-example-images/ref_fig_16a.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_16b.png b/tests/reference-example-images/ref_fig_16b.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_16b.png rename to tests/reference-example-images/ref_fig_16b.png diff --git a/cfplot/test/reference-example-images/ref_fig_16c.png b/tests/reference-example-images/ref_fig_16c.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_16c.png rename to tests/reference-example-images/ref_fig_16c.png diff --git a/tests/reference-example-images/ref_fig_17.png b/tests/reference-example-images/ref_fig_17.png new file mode 100644 index 0000000..0dfb0b4 Binary files /dev/null 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 new file mode 100644 index 0000000..932aeb6 Binary files /dev/null and b/tests/reference-example-images/ref_fig_18.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_19a.png b/tests/reference-example-images/ref_fig_19.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_19a.png rename to tests/reference-example-images/ref_fig_19.png diff --git a/cfplot/test/reference-example-images/ref_fig_19b.png b/tests/reference-example-images/ref_fig_19a.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_19b.png rename to tests/reference-example-images/ref_fig_19a.png diff --git a/tests/reference-example-images/ref_fig_19c.png b/tests/reference-example-images/ref_fig_19c.png new file mode 100644 index 0000000..dbe6452 Binary files /dev/null and b/tests/reference-example-images/ref_fig_19c.png differ diff --git a/tests/reference-example-images/ref_fig_2.png b/tests/reference-example-images/ref_fig_2.png new file mode 100644 index 0000000..24b2f83 Binary files /dev/null and b/tests/reference-example-images/ref_fig_2.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_20.png b/tests/reference-example-images/ref_fig_20.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_20.png rename to tests/reference-example-images/ref_fig_20.png diff --git a/cfplot/test/reference-example-images/ref_fig_21a.png b/tests/reference-example-images/ref_fig_21.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_21a.png rename to tests/reference-example-images/ref_fig_21.png diff --git a/tests/reference-example-images/ref_fig_21a.png b/tests/reference-example-images/ref_fig_21a.png new file mode 100644 index 0000000..bc94f7e Binary files /dev/null and b/tests/reference-example-images/ref_fig_21a.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_21b.png b/tests/reference-example-images/ref_fig_21b.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_21b.png rename to tests/reference-example-images/ref_fig_21b.png diff --git a/tests/reference-example-images/ref_fig_21other.png b/tests/reference-example-images/ref_fig_21other.png new file mode 100644 index 0000000..aad2355 Binary files /dev/null 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 new file mode 100644 index 0000000..cf50d57 Binary files /dev/null and b/tests/reference-example-images/ref_fig_22.png differ diff --git a/tests/reference-example-images/ref_fig_22other.png b/tests/reference-example-images/ref_fig_22other.png new file mode 100644 index 0000000..e893aaa Binary files /dev/null and b/tests/reference-example-images/ref_fig_22other.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_23a.png b/tests/reference-example-images/ref_fig_23.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_23a.png rename to tests/reference-example-images/ref_fig_23.png diff --git a/tests/reference-example-images/ref_fig_23a.png b/tests/reference-example-images/ref_fig_23a.png new file mode 100644 index 0000000..16d49f5 Binary files /dev/null and b/tests/reference-example-images/ref_fig_23a.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_23b.png b/tests/reference-example-images/ref_fig_23b.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_23b.png rename to tests/reference-example-images/ref_fig_23b.png diff --git a/tests/reference-example-images/ref_fig_23other.png b/tests/reference-example-images/ref_fig_23other.png new file mode 100644 index 0000000..b6bf701 Binary files /dev/null and b/tests/reference-example-images/ref_fig_23other.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_24a.png b/tests/reference-example-images/ref_fig_24a.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_24a.png rename to tests/reference-example-images/ref_fig_24a.png diff --git a/cfplot/test/reference-example-images/ref_fig_24b.png b/tests/reference-example-images/ref_fig_24b.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_24b.png rename to tests/reference-example-images/ref_fig_24b.png diff --git a/cfplot/test/reference-example-images/ref_fig_24c.png b/tests/reference-example-images/ref_fig_24c.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_24c.png rename to tests/reference-example-images/ref_fig_24c.png diff --git a/cfplot/test/reference-example-images/ref_fig_25.png b/tests/reference-example-images/ref_fig_25.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_25.png rename to tests/reference-example-images/ref_fig_25.png diff --git a/cfplot/test/reference-example-images/ref_fig_26a.png b/tests/reference-example-images/ref_fig_26a.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_26a.png rename to tests/reference-example-images/ref_fig_26a.png diff --git a/cfplot/test/reference-example-images/ref_fig_26b.png b/tests/reference-example-images/ref_fig_26b.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_26b.png rename to tests/reference-example-images/ref_fig_26b.png diff --git a/cfplot/test/reference-example-images/ref_fig_27.png b/tests/reference-example-images/ref_fig_27.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_27.png rename to tests/reference-example-images/ref_fig_27.png diff --git a/cfplot/test/reference-example-images/ref_fig_28.png b/tests/reference-example-images/ref_fig_28.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_28.png rename to tests/reference-example-images/ref_fig_28.png diff --git a/cfplot/test/reference-example-images/ref_fig_29.png b/tests/reference-example-images/ref_fig_29.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_29.png rename to tests/reference-example-images/ref_fig_29.png diff --git a/cfplot/test/reference-example-images/ref_fig_3.png b/tests/reference-example-images/ref_fig_3.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_3.png rename to tests/reference-example-images/ref_fig_3.png diff --git a/cfplot/test/reference-example-images/ref_fig_30.png b/tests/reference-example-images/ref_fig_30.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_30.png rename to tests/reference-example-images/ref_fig_30.png diff --git a/cfplot/test/reference-example-images/ref_fig_31.png b/tests/reference-example-images/ref_fig_31.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_31.png rename to tests/reference-example-images/ref_fig_31.png diff --git a/cfplot/test/reference-example-images/ref_fig_32.png b/tests/reference-example-images/ref_fig_32.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_32.png rename to tests/reference-example-images/ref_fig_32.png diff --git a/cfplot/test/reference-example-images/ref_fig_33.png b/tests/reference-example-images/ref_fig_33.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_33.png rename to tests/reference-example-images/ref_fig_33.png diff --git a/cfplot/test/reference-example-images/ref_fig_34.png b/tests/reference-example-images/ref_fig_34.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_34.png rename to tests/reference-example-images/ref_fig_34.png diff --git a/cfplot/test/reference-example-images/ref_fig_35.png b/tests/reference-example-images/ref_fig_35.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_35.png rename to tests/reference-example-images/ref_fig_35.png diff --git a/cfplot/test/reference-example-images/ref_fig_36.png b/tests/reference-example-images/ref_fig_36.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_36.png rename to tests/reference-example-images/ref_fig_36.png diff --git a/cfplot/test/reference-example-images/ref_fig_37.png b/tests/reference-example-images/ref_fig_37.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_37.png rename to tests/reference-example-images/ref_fig_37.png diff --git a/cfplot/test/reference-example-images/ref_fig_38.png b/tests/reference-example-images/ref_fig_38.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_38.png rename to tests/reference-example-images/ref_fig_38.png diff --git a/cfplot/test/reference-example-images/ref_fig_39.png b/tests/reference-example-images/ref_fig_39.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_39.png rename to tests/reference-example-images/ref_fig_39.png diff --git a/tests/reference-example-images/ref_fig_4.png b/tests/reference-example-images/ref_fig_4.png new file mode 100644 index 0000000..efa42e1 Binary files /dev/null and b/tests/reference-example-images/ref_fig_4.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_40.png b/tests/reference-example-images/ref_fig_40.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_40.png rename to tests/reference-example-images/ref_fig_40.png diff --git a/tests/reference-example-images/ref_fig_41.png b/tests/reference-example-images/ref_fig_41.png new file mode 100644 index 0000000..7ae5b9d Binary files /dev/null and b/tests/reference-example-images/ref_fig_41.png differ diff --git a/tests/reference-example-images/ref_fig_42.png b/tests/reference-example-images/ref_fig_42.png new file mode 100644 index 0000000..24bb630 Binary files /dev/null and b/tests/reference-example-images/ref_fig_42.png differ diff --git a/tests/reference-example-images/ref_fig_42a.png b/tests/reference-example-images/ref_fig_42a.png new file mode 100644 index 0000000..869bad9 Binary files /dev/null and b/tests/reference-example-images/ref_fig_42a.png differ diff --git a/tests/reference-example-images/ref_fig_42b.png b/tests/reference-example-images/ref_fig_42b.png new file mode 100644 index 0000000..869bad9 Binary files /dev/null and b/tests/reference-example-images/ref_fig_42b.png differ diff --git a/tests/reference-example-images/ref_fig_5.png b/tests/reference-example-images/ref_fig_5.png new file mode 100644 index 0000000..7b81c98 Binary files /dev/null and b/tests/reference-example-images/ref_fig_5.png differ diff --git a/cfplot/test/reference-example-images/ref_fig_6.png b/tests/reference-example-images/ref_fig_6.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_6.png rename to tests/reference-example-images/ref_fig_6.png diff --git a/cfplot/test/reference-example-images/ref_fig_7.png b/tests/reference-example-images/ref_fig_7.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_7.png rename to tests/reference-example-images/ref_fig_7.png diff --git a/cfplot/test/reference-example-images/ref_fig_8.png b/tests/reference-example-images/ref_fig_8.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_8.png rename to tests/reference-example-images/ref_fig_8.png diff --git a/cfplot/test/reference-example-images/ref_fig_9.png b/tests/reference-example-images/ref_fig_9.png similarity index 100% rename from cfplot/test/reference-example-images/ref_fig_9.png rename to tests/reference-example-images/ref_fig_9.png diff --git a/tests/test_realdata.py b/tests/test_realdata.py new file mode 100644 index 0000000..7cbbf1f --- /dev/null +++ b/tests/test_realdata.py @@ -0,0 +1,38 @@ +import cf +import cfplot as cfp +from pathlib import Path + +# Path to test data +DATA_FILE1 = Path(__file__).parent / "data" / "da193_example.nc" + + +def test_realdata_contour(): + """Test contour plot with real data.""" + flds = cf.read(str(DATA_FILE1)) + f = flds[0] + cfp.con(f) + + +def test_realdata_pstereo(): + """Test pstereo plot with real data.""" + flds = cf.read(str(DATA_FILE1)) + f = flds[0] + + # produces striped output, is wrong + cfp.mapset(proj="npstere") + cfp.con(f) + + # squeezing the data then plotting also + # produces striped output, and is wrong + # but we don't show that here. + f0 = f.squeeze() + + # this works correctly, though of course there + # is an unplotted segment of data + f1 = f0[:,0:399] + cfp.con(f1) + + # this goes back to the bad stripes, with + # an appropriate empty segment + f1 = f0[:,0:403] + cfp.con(f1) diff --git a/tests/unit/test_contour_animation_titles.py b/tests/unit/test_contour_animation_titles.py new file mode 100644 index 0000000..b954d67 --- /dev/null +++ b/tests/unit/test_contour_animation_titles.py @@ -0,0 +1,96 @@ +import numpy as np + +from cfplot import contour + + +class _FakeConstruct: + def __init__(self, values, dtvalues=None): + self.array = np.asarray(values) + self.dtarray = None if dtvalues is None else np.asarray(dtvalues, dtype=object) + + +class _FakeField: + def __init__(self, constructs): + self._constructs = constructs + + def has_construct(self, key): + return key in self._constructs + + def construct(self, key): + return self._constructs[key] + + +def test_infer_animation_axis_auto_uses_non_ptype_singleton(monkeypatch): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "T"]) + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "T": _FakeConstruct([1]), + } + ) + + axis = contour._infer_animation_axis(f=f, axis_spec="auto", ptype=1) + + assert axis == "T" + + +def test_infer_animation_axis_auto_none_when_non_ptype_not_singleton(monkeypatch): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "Z"]) + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "Z": _FakeConstruct([1000, 850, 500]), + } + ) + + axis = contour._infer_animation_axis(f=f, axis_spec="auto", ptype=1) + + assert axis is None + + +def test_infer_animation_axis_ptype0_fallback_prefers_t(monkeypatch): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "T"]) + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "T": _FakeConstruct([1]), + } + ) + + axis = contour._infer_animation_axis(f=f, axis_spec="auto", ptype=0) + + assert axis == "T" + + +def test_resolve_animation_title_uses_template(monkeypatch): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "T"]) + monkeypatch.setattr(contour.utility, "cf_var_name_titles", lambda f, dim: ("time", None)) + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "T": _FakeConstruct([1], dtvalues=["2001-01-15 00:00:00"]), + } + ) + + title = contour._resolve_animation_title( + f=f, + base_title="Temperature", + animation=True, + animation_axis="auto", + ptype=1, + animation_title_template="{title} [{frame}]", + ) + + assert title == "Temperature [time: 2001-01-15 00:00:00]" diff --git a/tests/unit/test_contour_boundary.py b/tests/unit/test_contour_boundary.py new file mode 100644 index 0000000..221658b --- /dev/null +++ b/tests/unit/test_contour_boundary.py @@ -0,0 +1,80 @@ +import inspect + +import numpy as np +import pytest + +from cfplot import blockfill +from cfplot import contour +from cfplot import layout_runtime + + +def test_con_delegates_to_legacy(monkeypatch): + monkeypatch.setattr("cfplot.contour._can_use_new_xy_path", lambda f, kwargs: False) + + with pytest.raises(NotImplementedError, match="not implemented"): + contour.con(f=np.array([[1.0, 2.0], [3.0, 4.0]]), lines=False) + + +def test_colour_scale_label_skip(): + cs = contour.ColourScale(plotvars=object()) + + labels = cs.colourbar_labels( + levels=np.array([0, 1, 2, 3]), + orientation="horizontal", + n_columns=1, + label_skip=2, + custom_labels=None, + ) + + assert labels == ["0", "", "2", ""] + + +def test_con_uses_new_path_when_available(monkeypatch): + monkeypatch.setattr("cfplot.contour._can_use_new_xy_path", lambda f, kwargs: True) + monkeypatch.setattr( + "cfplot.contour._render_with_new_xy", + lambda f, x, y, kwargs: True, + ) + + out = contour.con(f=np.array([[1.0, 2.0], [3.0, 4.0]])) + + assert out is None + + +def test_con_falls_back_when_new_path_declines(monkeypatch): + monkeypatch.setattr("cfplot.contour._can_use_new_xy_path", lambda f, kwargs: True) + monkeypatch.setattr( + "cfplot.contour._render_with_new_xy", + lambda f, x, y, kwargs: False, + ) + + with pytest.raises(NotImplementedError, match="not implemented"): + contour.con(f=np.array([[1.0, 2.0], [3.0, 4.0]]), lines=False) + + +def test_maybe_autosave_calls_gclose(monkeypatch): + calls: list[bool] = [] + + monkeypatch.setattr(layout_runtime.plotvars, "_contour_session_open", False) + monkeypatch.setattr(layout_runtime, "gclose", lambda view=True: calls.append(view)) + + layout_runtime.maybe_autosave() + + assert calls == [True] + + +def test_maybe_autosave_skips_when_session_open(monkeypatch): + calls: list[bool] = [] + + monkeypatch.setattr(layout_runtime.plotvars, "_contour_session_open", True) + monkeypatch.setattr(layout_runtime, "gclose", lambda view=True: calls.append(view)) + + layout_runtime.maybe_autosave() + + assert calls == [] + + +def test_blockfill_signature_does_not_advertise_lonlat(): + params = inspect.signature(blockfill._bfill).parameters + + assert "lonlat" not in params \ No newline at end of file diff --git a/tests/unit/test_gopen_gclose.py b/tests/unit/test_gopen_gclose.py new file mode 100644 index 0000000..faa2850 --- /dev/null +++ b/tests/unit/test_gopen_gclose.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import cfplot as cfp + + +def setup_function(): + cfp.reset() + + +def teardown_function(): + cfp.reset() + + +def test_gopen_sets_runtime_state(tmp_path: Path): + outfile = tmp_path / "gopen_state.png" + cfp.setvars(file=str(outfile), viewer="matplotlib") + + cfp.gopen(rows=2, columns=3) + + assert cfp.plotvars.master_plot is not None + assert cfp.plotvars.rows == 2 + assert cfp.plotvars.columns == 3 + assert cfp.plotvars.user_plot == 1 + + cfp.gclose(view=False) + + +def test_gclose_clears_runtime_handles(tmp_path: Path): + outfile = tmp_path / "gclose_state.png" + cfp.setvars(file=str(outfile), viewer="matplotlib") + + cfp.gopen() + cfp.gclose(view=False) + + assert cfp.plotvars.master_plot is None + assert cfp.plotvars.plot is None + assert cfp.plotvars.mymap is None + assert cfp.plotvars.gpos_called is False diff --git a/tests/unit/test_level_generation.py b/tests/unit/test_level_generation.py new file mode 100644 index 0000000..5474d81 --- /dev/null +++ b/tests/unit/test_level_generation.py @@ -0,0 +1,146 @@ +"""Unit tests for contour level generation functions. + +Migrated from cfplot/test/test_examples.py::BasicArrayTest and GvalsArrayTest +""" + +import numpy as np +import pytest + +import cfplot as cfp + + +class TestLevsFunction: + """Tests for cfp.levs() contour level generation.""" + + def test_levs_basic_positive(self): + """Test levs with basic positive range.""" + expected = np.array( + [ + -35, + -30, + -25, + -20, + -15, + -10, + -5, + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + ] + ) + + cfp.levs(min=-35, max=65, step=5) + generated = cfp.plotvars.levels + + assert np.size(expected) == np.size(generated) + assert np.allclose(expected, generated, atol=1e-6) + + def test_levs_decimal_step(self): + """Test levs with decimal step size.""" + expected = np.array( + [-6.0, -4.8, -3.6, -2.4, -1.2, 0.0, 1.2, 2.4, 3.6, 4.8, 6.0] + ) + + cfp.levs(min=-6, max=6, step=1.2) + generated = cfp.plotvars.levels + + assert np.size(expected) == np.size(generated) + assert np.allclose(expected, generated, atol=1e-6) + + def test_levs_large_values(self): + """Test levs with large pressure values.""" + expected = np.array( + [50000, 51000, 52000, 53000, 54000, 55000, 56000, 57000, 58000, 59000, 60000] + ) + + cfp.levs(min=50000, max=60000, step=1000) + generated = cfp.plotvars.levels + + assert np.size(expected) == np.size(generated) + assert np.allclose(expected, generated, atol=1e-6) + + def test_levs_negative_range(self): + """Test levs with negative range.""" + expected = np.array( + [-7000, -6500, -6000, -5500, -5000, -4500, -4000, -3500, -3000, -2500, -2000, -1500, -1000, -500] + ) + + cfp.levs(min=-7000, max=-300, step=500) + generated = cfp.plotvars.levels + + assert np.size(expected) == np.size(generated) + assert np.allclose(expected, generated, atol=1e-6) + + +class TestGvalsFunction: + """Tests for cfp._gvals() automatic contour level generation.""" + + def test_gvals_temperature_range(self): + """Test _gvals with temperature-like range.""" + expected = np.array([281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293]) + expected_mult = 0 + + vals, mult = cfp._gvals(dmin=280.50619506835938, dmax=293.48431396484375) + + assert np.size(expected) == np.size(vals) + assert np.allclose(expected, vals, atol=1e-6) + assert mult == expected_mult + + def test_gvals_decimal_range(self): + """Test _gvals with small decimal range.""" + expected = np.array([0.36, 0.38, 0.4, 0.42, 0.44, 0.46, 0.48, 0.5, 0.52, 0.54, 0.56, 0.58, 0.6, 0.62, 0.64, 0.66]) + expected_mult = 0 + + vals, mult = cfp._gvals(dmin=0.356, dmax=0.675) + + assert np.size(expected) == np.size(vals) + assert np.allclose(expected, vals, atol=1e-6) + assert mult == expected_mult + + def test_gvals_symmetric_range(self): + """Test _gvals with symmetric around zero.""" + expected = np.array( + [-45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50] + ) + expected_mult = 0 + + vals, mult = cfp._gvals(dmin=-49.510975, dmax=53.206604) + + assert np.size(expected) == np.size(vals) + assert np.allclose(expected, vals, atol=1e-6) + assert mult == expected_mult + + def test_gvals_pressure_range(self): + """Test _gvals with large pressure range.""" + expected = np.array( + [47000, 48000, 49000, 50000, 51000, 52000, 53000, 54000, 55000, 56000, 57000, 58000, 59000, 60000, 61000, 62000, 63000, 64000] + ) + expected_mult = 0 + + vals, mult = cfp._gvals(dmin=46956, dmax=64538) + + assert np.size(expected) == np.size(vals) + assert np.allclose(expected, vals, atol=1e-6) + assert mult == expected_mult + + def test_gvals_small_decimal(self): + """Test _gvals with small decimal range.""" + expected = np.array([-1.0, -0.9, -0.8, -0.7, -0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1]) + expected_mult = 0 + + vals, mult = cfp._gvals(dmin=-1.0, dmax=0.1) + + assert np.size(expected) == np.size(vals) + assert np.allclose(expected, vals, atol=1e-6) + assert mult == expected_mult diff --git a/tests/unit/test_mapaxis.py b/tests/unit/test_mapaxis.py new file mode 100644 index 0000000..e140b37 --- /dev/null +++ b/tests/unit/test_mapaxis.py @@ -0,0 +1,67 @@ +"""Unit tests for map axis labelling functions. + +Migrated from cfplot/test/test_examples.py::LonLatTest +""" + +import numpy as np +import pytest + +import cfplot as cfp + + +class TestMapaxisLongitude: + """Tests for cfp._mapaxis() longitude labelling (type=1).""" + + def test_mapaxis_lon_full_range(self): + """Test longitude labelling for full -180 to 180.""" + expected_ticks = [-180, -120, -60, 0, 60, 120, 180] + expected_labels = ["180", "120W", "60W", "0", "60E", "120E", "180"] + + ticks, labels = cfp._mapaxis(min=-180, max=180, type=1) + + assert np.allclose(ticks, expected_ticks, atol=1e-6) + assert labels == expected_labels + + def test_mapaxis_lon_eastern_hemisphere(self): + """Test longitude labelling for eastern 135-280.""" + expected_ticks = [150, 180, 210, 240, 270] + expected_labels = ["150E", "180", "150W", "120W", "90W"] + + ticks, labels = cfp._mapaxis(min=135, max=280, type=1) + + assert np.allclose(ticks, expected_ticks, atol=1e-6) + assert labels == expected_labels + + def test_mapaxis_lon_positive(self): + """Test longitude labelling for positive eastern hemisphere.""" + expected_ticks = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90] + expected_labels = ["0", "10E", "20E", "30E", "40E", "50E", "60E", "70E", "80E", "90E"] + + ticks, labels = cfp._mapaxis(min=0, max=90, type=1) + + assert np.allclose(ticks, expected_ticks, atol=1e-6) + assert labels == expected_labels + + +class TestMapaxisLatitude: + """Tests for cfp._mapaxis() latitude labelling (type=2).""" + + def test_mapaxis_lat_full_range(self): + """Test latitude labelling for full -90 to 90.""" + expected_ticks = [-90, -60, -30, 0, 30, 60, 90] + expected_labels = ["90S", "60S", "30S", "0", "30N", "60N", "90N"] + + ticks, labels = cfp._mapaxis(min=-90, max=90, type=2) + + assert np.allclose(ticks, expected_ticks, atol=1e-6) + assert labels == expected_labels + + def test_mapaxis_lat_northern_hemisphere(self): + """Test latitude labelling for northern hemisphere 0-30.""" + expected_ticks = [0, 5, 10, 15, 20, 25, 30] + expected_labels = ["0", "5N", "10N", "15N", "20N", "25N", "30N"] + + ticks, labels = cfp._mapaxis(min=0, max=30, type=2) + + assert np.allclose(ticks, expected_ticks, atol=1e-6) + assert labels == expected_labels diff --git a/tests/unit/test_state_grouped_sync.py b/tests/unit/test_state_grouped_sync.py new file mode 100644 index 0000000..3ca3dc5 --- /dev/null +++ b/tests/unit/test_state_grouped_sync.py @@ -0,0 +1,62 @@ +import cfplot as cfp + + +def setup_function(): + cfp.reset() + + +def teardown_function(): + cfp.reset() + + +def test_grouped_to_legacy_sync(): + cfp.plotvars.map.lonmin = -123 + cfp.plotvars.axes.xmax = 88 + cfp.plotvars.decoration.title_fontsize = 22 + cfp.plotvars.layout.rows = 4 + cfp.plotvars.scale.levels_extend = "min" + cfp.plotvars.runtime.user_mapset = 1 + cfp.plotvars.output.viewer = "matplotlib" + + assert cfp.plotvars.lonmin == -123 + assert cfp.plotvars.xmax == 88 + assert cfp.plotvars.title_fontsize == 22 + assert cfp.plotvars.rows == 4 + assert cfp.plotvars.levels_extend == "min" + assert cfp.plotvars.user_mapset == 1 + assert cfp.plotvars.viewer == "matplotlib" + + +def test_legacy_to_grouped_sync(): + cfp.plotvars.lonmax = 179 + cfp.plotvars.ymin = -50 + cfp.plotvars.axis_label_fontweight = "bold" + cfp.plotvars.columns = 3 + cfp.plotvars.cs_user = "viridis" + cfp.plotvars.user_gset = 1 + cfp.plotvars.tspace_day = 5 + + assert cfp.plotvars.map.lonmax == 179 + assert cfp.plotvars.axes.ymin == -50 + assert cfp.plotvars.decoration.axis_label_fontweight == "bold" + assert cfp.plotvars.layout.columns == 3 + assert cfp.plotvars.scale.cs_user == "viridis" + assert cfp.plotvars.runtime.user_gset == 1 + assert cfp.plotvars.output.tspace_day == 5 + + +def test_levs_updates_grouped_scale_state(): + cfp.levs(min=-2, max=2, step=1, extend="both") + + assert cfp.plotvars.runtime.user_levs == 1 + assert cfp.plotvars.scale.levels is not None + assert cfp.plotvars.levels_extend == "both" + + cfp.levs() + + assert cfp.plotvars.scale.levels is None + assert cfp.plotvars.scale.levels_min is None + assert cfp.plotvars.scale.levels_max is None + assert cfp.plotvars.scale.levels_step is None + assert cfp.plotvars.scale.norm is None + assert cfp.plotvars.runtime.user_levs == 0 diff --git a/tests/unit/test_trajectory_labels.py b/tests/unit/test_trajectory_labels.py new file mode 100644 index 0000000..34bc957 --- /dev/null +++ b/tests/unit/test_trajectory_labels.py @@ -0,0 +1,99 @@ +import numpy as np + +import cfplot as cfp +import cfplot.trajectory as trajectory_module + + +def setup_function(): + cfp.reset() + + +def teardown_function(): + cfp.reset() + + +class _FakeConstruct: + def __init__(self, values): + self.array = np.array(values, dtype=float) + + def nc_get_variable(self, default=False): + return default + + +class _FakeField: + def __init__(self): + self.ndim = 1 + self.DSG = False + self.array = np.array([1.0, 2.0, 3.0], dtype=float) + self._constructs = { + "lon": _FakeConstruct([0.0, 10.0, 20.0]), + "lat": _FakeConstruct([40.0, 42.0, 44.0]), + } + + def auxiliary_coordinates(self): + return ["lon", "lat"] + + def construct(self, dim): + return self._constructs[dim] + + +class _FakeMap: + def plot(self, *args, **kwargs): + return None + + def scatter(self, *args, **kwargs): + return None + + def add_feature(self, *args, **kwargs): + return None + + def text(self, *args, **kwargs): + return None + + def arrow(self, *args, **kwargs): + return None + + +def test_traj_preserves_user_axis_labels(monkeypatch): + captured = {} + + monkeypatch.setattr(trajectory_module.cf, "Field", _FakeField) + monkeypatch.setattr(trajectory_module.cf, "FieldList", tuple) + monkeypatch.setattr( + trajectory_module.utility, + "cf_var_name", + lambda field, dim: "longitude" if dim == "lon" else "latitude", + ) + + def fake_ensure_map_axes(): + cfp.plotvars.mymap = _FakeMap() + + def fake_apply_map_axes_with_toggles(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + trajectory_module, + "_ensure_map_axes", + fake_ensure_map_axes, + ) + monkeypatch.setattr( + trajectory_module, + "_apply_map_axes_with_toggles", + fake_apply_map_axes_with_toggles, + ) + monkeypatch.setattr( + trajectory_module, + "ensure_runtime_session", + lambda *a, **k: True, + ) + monkeypatch.setattr( + trajectory_module, + "finalize_runtime_session", + lambda *a, **k: None, + ) + monkeypatch.setattr(trajectory_module, "gset", lambda *a, **k: None) + + cfp.traj(_FakeField(), xlabel="Custom X", ylabel="Custom Y") + + assert captured["user_xlabel"] == "Custom X" + assert captured["user_ylabel"] == "Custom Y"