diff --git a/CHANGELOG.md b/CHANGELOG.md index 1050af8..1cd0c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ of being developed elsewhere. a ``Fieldlist`` of length greater than one to use ``select_by_identity `` instead of indexing, for clarity (https://github.com/NCAS-CMS/cf-plot/issues/119) +* Add optional animation hooks for external library integration on + `gopen` (`animation_session_id`, `animation_meta_callback`, + `animation_frame_callback`) and emit additive animation metadata/frame + callback payloads from the contour animation path. ### Version `3.4.X`, first released (`3.4.0`) `2025-04-28` diff --git a/cfplot/contour.py b/cfplot/contour.py index f6bf405..10c90d9 100644 --- a/cfplot/contour.py +++ b/cfplot/contour.py @@ -24,6 +24,8 @@ from __future__ import annotations from dataclasses import dataclass, replace +import logging +import time from typing import Any import cf @@ -58,6 +60,9 @@ ) +logger = logging.getLogger(__name__) + + def _detect_lon_cyclic(f: "cf.Field", x: "np.ndarray | None") -> bool: """Return True when the longitude axis closes on itself at 360°. @@ -1032,15 +1037,28 @@ def _can_use_new_xy_path(f: Any, kwargs: dict[str, Any]) -> bool: 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: + if artists: + for artist in artists: + try: + artist.remove() + except Exception: + continue + plotvars.runtime._contour_animation_artists = [] + _clear_animation_map_feature_artists(plotvars) + _clear_animation_title_artist(plotvars) + + +def _clear_animation_map_feature_artists(plotvars: Any) -> None: + """Remove per-frame map feature artists from previous animation frame.""" + feature_artists = getattr(plotvars.runtime, "_contour_animation_map_feature_artists", None) + if not feature_artists: return - for artist in artists: + for artist in feature_artists: try: artist.remove() except Exception: continue - plotvars.runtime._contour_animation_artists = [] - _clear_animation_title_artist(plotvars) + plotvars.runtime._contour_animation_map_feature_artists = [] def _clear_animation_title_artist(plotvars: Any) -> None: @@ -1074,6 +1092,120 @@ def _clear_animation_colorbar(plotvars: Any) -> None: plotvars.runtime._contour_animation_colorbar = None +def _safe_animation_callback(callback: Any, payload: dict[str, Any]) -> None: + """Invoke an animation callback and log failures without interrupting render.""" + if not callable(callback): + return + try: + callback(payload) + except Exception: + logger.exception("animation callback failed") + + +def _infer_animation_total_frames( + reference: Any, + *, + animation_axis: Any, + ptype: int | None, +) -> int | None: + """Best-effort frame count from animation reference input.""" + if reference is None: + return None + + if isinstance(reference, cf.Field): + axis = _infer_animation_axis(reference, animation_axis, ptype) + if axis is None: + return None + try: + values = np.asanyarray(reference.construct(axis).array) + except Exception: + return None + size = int(values.size) + return size if size > 0 else None + + try: + arr = np.asanyarray(reference) + except Exception: + return None + if arr.ndim == 0: + return None + size = int(arr.shape[0]) + return size if size > 0 else None + + +def _animation_axis_value_object(f: Any, axis: str | None) -> Any: + """Return a frame-axis scalar value for callback payloads.""" + if axis is None or not isinstance(f, cf.Field): + return None + + axis_key = axis + if axis == "Z": + try: + axis_key = utility.find_z(f) + except Exception: + axis_key = axis + + try: + construct = f.construct(axis_key) + except Exception: + return None + + try: + if axis == "T" and getattr(construct, "dtarray", None) is not None: + values = np.asanyarray(construct.dtarray) + else: + values = np.asanyarray(construct.array) + except Exception: + return None + + if values.size != 1: + return None + return values.reshape(-1)[0] + + +def _emit_animation_meta_callback( + *, + kwargs: dict[str, Any], + ptype: int | None, + levels_locked: bool, +) -> None: + """Emit one metadata callback for the active animation session.""" + runtime = plotvars.runtime + if runtime._animation_meta_emitted: + return + + payload = { + "session_id": runtime._animation_session_id, + "total_frames": _infer_animation_total_frames( + kwargs.get("animation_reference"), + animation_axis=kwargs.get("animation_axis", "auto"), + ptype=ptype, + ), + "fps_hint": None, + "title_template": kwargs.get("animation_title_template", None), + "plot_kind": "contour", + "levels_locked": bool(levels_locked), + } + + _safe_animation_callback(runtime._animation_meta_callback, payload) + runtime._animation_meta_emitted = True + + +def _emit_animation_frame_callback(*, f: Any, ptype: int | None, kwargs: dict[str, Any]) -> None: + """Emit a per-frame callback after drawing is complete.""" + runtime = plotvars.runtime + axis = _infer_animation_axis(f, kwargs.get("animation_axis", "auto"), ptype) + payload = { + "session_id": runtime._animation_session_id, + "frame_index": int(runtime._animation_frame_index), + "frame_value": _animation_axis_value_object(f, axis), + "canvas_ready": True, + "timestamp": time.time(), + } + _safe_animation_callback(runtime._animation_frame_callback, payload) + runtime._animation_frame_index = int(runtime._animation_frame_index) + 1 + + def _ptype_axes(ptype: int | None) -> set[str]: """Return logical axes used by each contour plot type.""" mapping: dict[int, set[str]] = { @@ -1228,6 +1360,127 @@ def _resolve_animation_title( return frame_text +def _count_data_axes_gt_one(f: Any) -> int: + """Return the number of domain axes with size greater than one.""" + if not isinstance(f, cf.Field): + return 0 + try: + return len(f.domain_axes(filter_by_size=(cf.gt(1),))) + except Exception: + return 0 + + +def _animation_axis_size_gt_one(f: Any, axis: str | None) -> int: + """Return axis length when available and greater than one, else zero.""" + if not isinstance(f, cf.Field) or axis is None: + return 0 + + axis_key = axis + if axis == "Z": + try: + axis_key = utility.find_z(f) + except Exception: + axis_key = axis + + try: + values = np.asanyarray(f.construct(axis_key).array) + except Exception: + return 0 + + size = int(values.size) + return size if size > 1 else 0 + + +def _choose_animation_sequence_axis(f: Any, axis_spec: Any, ptype: int | None) -> str | None: + """Choose an axis for internal animation slicing when full field is >2D.""" + if not isinstance(f, cf.Field): + return None + + axis = _infer_animation_axis(f, axis_spec, ptype) + if _animation_axis_size_gt_one(f, axis) > 1: + return axis + + # Auto fallback for non-singleton animation axes. + ptype_axes = _ptype_axes(ptype) + for candidate in ("T", "Z", "Y", "X"): + if candidate in ptype_axes: + continue + if _animation_axis_size_gt_one(f, candidate) > 1: + return candidate + + for candidate in ("T", "Z", "Y", "X"): + if _animation_axis_size_gt_one(f, candidate) > 1: + return candidate + + return None + + +def _slice_animation_frame(f: cf.Field, axis: str, frame_value: Any) -> cf.Field: + """Return one frame slice for the chosen animation axis.""" + axis_key = axis + if axis == "Z": + try: + axis_key = utility.find_z(f) + except Exception: + axis_key = axis + + try: + construct = f.construct(axis_key) + coord_name = str(construct.identity(default=axis.lower())) + except Exception: + coord_name = axis + + try: + return f.subspace(**{coord_name: frame_value}) + except Exception: + return f.subspace(**{axis: frame_value}) + + +def _render_animation_sequence( + *, + f: cf.Field, + x: Any, + y: Any, + kwargs: dict[str, Any], + animation_axis: str, +) -> bool: + """Render an animation by slicing a >2D field into 2D frames.""" + axis_key = animation_axis + if animation_axis == "Z": + try: + axis_key = utility.find_z(f) + except Exception: + axis_key = animation_axis + + try: + construct = f.construct(axis_key) + if animation_axis == "T" and getattr(construct, "dtarray", None) is not None: + frame_values = np.asanyarray(construct.dtarray).reshape(-1) + else: + frame_values = np.asanyarray(construct.array).reshape(-1) + except Exception: + return False + + if frame_values.size <= 1: + return _render_with_new_xy(f=f, x=x, y=y, kwargs=kwargs) + + base_kwargs = dict(kwargs) + base_kwargs["animation_axis"] = animation_axis + base_kwargs.setdefault("animation_reference", f) + + for i, frame_value in enumerate(frame_values): + frame_field = _slice_animation_frame(f, animation_axis, frame_value) + frame_field = frame_field.squeeze() + frame_kwargs = dict(base_kwargs) + if i == 0 and bool(base_kwargs.get("reuse_map_background", False)): + frame_kwargs["reuse_map_background"] = False + + if not _render_with_new_xy(f=frame_field, x=x, y=y, kwargs=frame_kwargs): + return False + + return True + + def _field_has_ugrid_faces(f: cf.Field) -> bool: """Return True when a CF field exposes face connectivity for UGRID plots.""" try: @@ -1360,6 +1613,7 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: pv_scale = plotvars.scale pv_runtime = plotvars.runtime pv_output = plotvars.output + animation = bool(kwargs.get("animation", False)) if isinstance(f, cf.Field) and (x is not None or y is not None): field_arr = np.asanyarray(f.array) @@ -1496,6 +1750,13 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: # 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: + if animation: + _emit_animation_meta_callback( + kwargs=kwargs, + ptype=data.ptype, + levels_locked=pv_scale.levels is not None, + ) + data = replace( data, levels=np.asarray(clevs), @@ -1505,7 +1766,7 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: lines=lines, blockfill=blockfill, ) - return _render_ptype6_rotated_pole( + rendered = _render_ptype6_rotated_pole( f=f, data=data, kwargs=kwargs, @@ -1526,6 +1787,11 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: finalize_callback=maybe_autosave, ) + if animation: + _emit_animation_frame_callback(f=f, ptype=data.ptype, kwargs=kwargs) + + return rendered + if pv_runtime.user_plot == 0: ensure_xy_viewport() @@ -1609,7 +1875,6 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: 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) @@ -1761,16 +2026,15 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: 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. + if ( + data.ptype == 1 + and transform_first is None + and plotvars.proj in ("ortho", "npstere", "spstere") + ): + # Azimuthal projections are prone to clipping/striping artefacts when + # Cartopy pre-transforms dense regular lon/lat grids in data space. + # Use map-space rendering by default; explicit user overrides are + # still respected via transform_first kwarg. transform_first = False if fill: @@ -1796,14 +2060,16 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: 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 not reuse_map_background: + _clear_animation_colorbar(plotvars) + + draw_features_each_frame = bool(animation and reuse_map_background) if draw_static_map: apply_axes( @@ -1816,22 +2082,54 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: yticklabels=kwargs.get("yticklabels", None), ) - _apply_map_features( + feature_kwargs = dict(kwargs) + if draw_features_each_frame: + raw_zorder = kwargs.get("zorder", 1) + try: + contour_zorder = float(raw_zorder) + except (TypeError, ValueError): + contour_zorder = 1.0 + feature_kwargs["zorder"] = contour_zorder + 1.0 + + map_feature_artists = _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, + kwargs=feature_kwargs, ) + if draw_features_each_frame: + pv_runtime._contour_animation_map_feature_artists = map_feature_artists if kwargs.get("grid", pv_dec.grid): map_runtime.draw_grid() map_runtime.draw_polar_axes() + elif draw_features_each_frame: + feature_kwargs = dict(kwargs) + raw_zorder = kwargs.get("zorder", 1) + try: + contour_zorder = float(raw_zorder) + except (TypeError, ValueError): + contour_zorder = 1.0 + feature_kwargs["zorder"] = contour_zorder + 1.0 + pv_runtime._contour_animation_map_feature_artists = _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=feature_kwargs, + ) + # Persist only dynamic contour artists for animation updates. pv_runtime._contour_animation_artists = list(renderer.frame_artists) - if colorbar: + render_colorbar_this_frame = True + if bool(kwargs.get("animation", False)) and bool(kwargs.get("reuse_map_background", False)): + frame_index = int(getattr(pv_runtime, "_animation_frame_index", 0)) + render_colorbar_this_frame = frame_index == 0 + + if colorbar and render_colorbar_this_frame: colorbar_artist = renderer.render_colorbar( orientation=colorbar_orientation, shrink=kwargs.get("colorbar_shrink", None), @@ -1878,8 +2176,18 @@ def _render_with_new_xy(f: Any, x: Any, y: Any, kwargs: dict[str, Any]) -> bool: fontweight=pv_dec.title_fontweight, ) + if animation: + _emit_animation_meta_callback( + kwargs=kwargs, + ptype=data.ptype, + levels_locked=pv_scale.levels is not None, + ) + maybe_autosave() + if animation: + _emit_animation_frame_callback(f=f, ptype=data.ptype, kwargs=kwargs) + return True @@ -1923,6 +2231,25 @@ def con(f=None, x=None, y=None, **kwargs): "Contour case not implemented in refactored renderer yet" ) + animation = bool(kwargs.get("animation", False)) + if animation and isinstance(f, cf.Field): + ndim_gt_1 = _count_data_axes_gt_one(f) + if ndim_gt_1 > 2: + axis = _choose_animation_sequence_axis( + f, + kwargs.get("animation_axis", "auto"), + kwargs.get("ptype", None), + ) + if axis is not None: + if _render_animation_sequence( + f=f, + x=x, + y=y, + kwargs=kwargs, + animation_axis=axis, + ): + return None + if _render_with_new_xy(f=f, x=x, y=y, kwargs=kwargs): return None diff --git a/cfplot/layout_runtime.py b/cfplot/layout_runtime.py index 12071cb..8a7b4e5 100644 --- a/cfplot/layout_runtime.py +++ b/cfplot/layout_runtime.py @@ -33,6 +33,9 @@ def gopen( hspace: float | None = None, dpi: float | None = None, user_position: bool = False, + animation_session_id: str | None = None, + animation_meta_callback: Any = None, + animation_frame_callback: Any = None, ) -> None: """Open a contour-runtime graphics session compatible with cfplot.gopen.""" plotvars.rows = rows @@ -55,6 +58,14 @@ def gopen( user_position=user_position, ) + plotvars.runtime._animation_session_id = ( + "" if animation_session_id is None else str(animation_session_id) + ) + plotvars.runtime._animation_meta_callback = animation_meta_callback + plotvars.runtime._animation_frame_callback = animation_frame_callback + plotvars.runtime._animation_meta_emitted = False + plotvars.runtime._animation_frame_index = 0 + plotvars._contour_session_open = True diff --git a/cfplot/map_runtime.py b/cfplot/map_runtime.py index bd42d9a..f19e542 100644 --- a/cfplot/map_runtime.py +++ b/cfplot/map_runtime.py @@ -670,16 +670,17 @@ def _apply_map_features( continent_thickness: float | None = None, continent_linestyle: str | None = None, kwargs: dict[str, Any] | None = None, -) -> None: +) -> list[Any]: """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 + return [] if kwargs is None: kwargs = {} + artists: list[Any] = [] import cartopy.feature as cfeature @@ -689,35 +690,37 @@ def _apply_map_features( scale=plotvars.resolution, facecolor="none", ) - mymap.add_feature( + artists.append(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( + artists.append(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( + artists.append(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( + artists.append(mymap.add_feature( cfeature.LAKES, edgecolor="face", facecolor=plotvars.lake_color, zorder=plotvars.feature_zorder, - ) + )) + + return artists def _apply_map_axes( diff --git a/cfplot/state.py b/cfplot/state.py index 572d137..cc8e88d 100644 --- a/cfplot/state.py +++ b/cfplot/state.py @@ -143,8 +143,14 @@ class RuntimeState: user_plot: int = 0 _contour_session_open: bool = False _contour_animation_artists: list[Any] = field(default_factory=list) + _contour_animation_map_feature_artists: list[Any] = field(default_factory=list) _contour_animation_title_artist: Any = None _contour_animation_colorbar: Any = None + _animation_session_id: str = "" + _animation_meta_callback: Any = None + _animation_frame_callback: Any = None + _animation_meta_emitted: bool = False + _animation_frame_index: int = 0 @dataclass class OutputState: @@ -246,8 +252,14 @@ def reset_runtime_state() -> None: plotvars.user_plot = 0 plotvars._contour_session_open = False plotvars._contour_animation_artists = [] + plotvars._contour_animation_map_feature_artists = [] plotvars._contour_animation_title_artist = None plotvars._contour_animation_colorbar = None + plotvars._animation_session_id = "" + plotvars._animation_meta_callback = None + plotvars._animation_frame_callback = None + plotvars._animation_meta_emitted = False + plotvars._animation_frame_index = 0 plotvars.twinx = None plotvars.twiny = None plotvars.plot_xmin = None diff --git a/docs/dev/cfplot-animation-guidance.md b/docs/dev/cfplot-animation-guidance.md new file mode 100644 index 0000000..f2e06ed --- /dev/null +++ b/docs/dev/cfplot-animation-guidance.md @@ -0,0 +1,214 @@ +# cf-plot Animation Hook Guidance + +This document is a cf-plot-only implementation guide for animation callbacks. + +## Scope + +In scope: + +- Add callback hooks to the contour animation path. +- Preserve existing contour animation behavior and outputs. +- Keep current frame-writing workflows unchanged. + +Out of scope: + +- Worker transport protocol details. +- GUI session and playback behavior. +- Any semantic changes to contour logic, level handling, or map behavior. + +## Compatibility contract + +1. Existing behavior remains the default. +- If no new callback kwargs are provided, output and side effects must match current behavior. + +2. Existing file-output workflows remain intact. +- Current options that write frame sequences must continue to do so. +- Callback hooks are additive only and must not disable, reorder, or replace writes. + +3. Existing con animation kwargs keep current meaning. +- ptype +- animation +- animation_reference +- reuse_map_background +- clear_previous_frame +- animation_axis +- animation_title_template +- lines + +## API additions + +Add these optional kwargs to cfp.gopen only: + +- animation_session_id +- animation_meta_callback +- animation_frame_callback + +No new kwargs are required on cfp.con for v1. + +Recommended usage shape: + +```python +cfp.gopen( + file="cfplot.png", + user_plot=1, + animation_session_id=session_id, + animation_frame_callback=on_frame, + animation_meta_callback=on_meta, +) + +cfp.con( + frame, + ptype=1, + animation=True, + animation_reference=f, + reuse_map_background=True, + clear_previous_frame=True, + animation_axis="auto", + animation_title_template="{title} [{frame}]", + lines=False, +) +``` + +## Callback contracts + +### Meta callback + +Called once before the first frame callback (or as soon as metadata is known). + +Required payload keys: + +- session_id: str +- total_frames: int | None +- fps_hint: float | None +- title_template: str | None +- plot_kind: str # contour +- levels_locked: bool + +### Frame callback + +Called after each frame has been fully drawn. + +Required payload keys: + +- session_id: str +- frame_index: int # 0-based +- frame_value: object | None +- canvas_ready: bool # true +- timestamp: float + +## Callback placement and ordering + +Callbacks should be stored in gopen state and used only in the con animation branch. + +Required control flow: + +1. Enter con. +2. If animation is false, run existing static contour path unchanged. +3. If animation is true: +- Resolve animation frames using existing logic. +- Build metadata once known. +- Invoke animation_meta_callback(meta) once before first frame draw. +4. For each frame: +- Apply existing per-frame state updates. +- Perform existing draw/render. +- Perform existing frame-on-disk write behavior when configured. +- Invoke animation_frame_callback(frame_event). +5. Exit with existing return behavior unchanged. + +Hard constraints: + +- Meta callback must happen before first frame callback. +- Frame callback must run after draw completes. +- Callback exceptions should be caught and logged so rendering continues. +- Callbacks must not mutate contour internal state. + +## Reference pseudocode + +```python +import logging +import time + +logger = logging.getLogger(__name__) +_ANIMATION_HOOKS = None + + +def _safe_callback(cb, payload): + if not callable(cb): + return + try: + cb(payload) + except Exception: + logger.exception("animation callback failed") + + +def gopen(file, **kwargs): + global _ANIMATION_HOOKS + _ANIMATION_HOOKS = { + "session_id": kwargs.get("animation_session_id"), + "on_meta": kwargs.get("animation_meta_callback"), + "on_frame": kwargs.get("animation_frame_callback"), + } + # existing gopen behavior unchanged + + +def con(field, **kwargs): + animation = bool(kwargs.get("animation", False)) + if not animation: + return _con_static(field, **kwargs) + + hooks = _ANIMATION_HOOKS or {} + session_id = hooks.get("session_id") + on_meta = hooks.get("on_meta") + on_frame = hooks.get("on_frame") + + frames = _resolve_animation_frames(field, kwargs) + _safe_callback( + on_meta, + { + "session_id": session_id, + "total_frames": _safe_len_or_none(frames), + "fps_hint": None, + "title_template": kwargs.get("animation_title_template"), + "plot_kind": "contour", + "levels_locked": _levels_locked_state(), + }, + ) + + for i, frame in enumerate(frames): + _apply_existing_animation_state(frame, kwargs) + _draw_frame(frame, kwargs) + _write_frame_if_configured(frame, kwargs) + _safe_callback( + on_frame, + { + "session_id": session_id, + "frame_index": i, + "frame_value": _frame_value(frame), + "canvas_ready": True, + "timestamp": time.time(), + }, + ) + + return _finish_animation() +``` + +## Implementation checklist + +1. Add hook state storage for animation callbacks set by gopen. +2. Add new optional gopen kwargs and store values in hook state. +3. Retrieve hook state inside con animation path. +4. Add a safe callback wrapper with exception logging. +5. Invoke meta callback once before first frame draw. +6. Invoke frame callback after draw and after existing frame output writes. +7. Keep non-animation con path untouched. +8. Update cf-plot docs/changelog to mark these as additive hooks. + +## cf-plot test checklist + +1. Backward compatibility without callbacks. +2. Meta callback called exactly once and before first frame callback. +3. Frame callback called once per frame with monotonic frame_index. +4. Existing frame-write behavior still occurs when enabled. +5. Callback exceptions are logged and animation continues. +6. Existing kwargs combinations still behave identically with hooks enabled. +7. gopen-registered hooks are visible to subsequent con animation calls. diff --git a/tests/data/example_seaice.nc b/tests/data/example_seaice.nc new file mode 100644 index 0000000..d6f5db2 Binary files /dev/null and b/tests/data/example_seaice.nc differ diff --git a/tests/integration/test_contour_plot_examples.py b/tests/integration/test_contour_plot_examples.py index e7df1c7..c03071b 100644 --- a/tests/integration/test_contour_plot_examples.py +++ b/tests/integration/test_contour_plot_examples.py @@ -22,6 +22,7 @@ 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" +LOCAL_DATA_DIR = Path(__file__).parent.parent / "data" TEST_GEN_DIR.mkdir(parents=True, exist_ok=True) @@ -87,6 +88,23 @@ def _assert_reference_match(example_id: str) -> None: pytest.fail(f"Image mismatch for example {example_id}: {result}") +def _assert_reference_match_strict(generated: Path, reference: Path) -> None: + """Compare a generated image against a reference with zero tolerance.""" + if not reference.exists(): + pytest.skip(f"Missing reference image: {reference}") + if not generated.exists(): + pytest.fail(f"Generated image missing: {generated}") + + result = mpl_compare.compare_images( + str(reference), + str(generated), + tol=0.0, + in_decorator=True, + ) + if result is not None: + pytest.fail(f"Image mismatch: {result}") + + @pytest.fixture(autouse=True) def setup_cfplot(): """Set up cfplot for each test.""" @@ -190,6 +208,56 @@ def test_example_5_south_pole_with_boundary(ggap_file): _assert_reference_match("5") +@pytest.mark.integration +def test_regression_seaice_ortho_bbox_image(): + """Regression image test for sea-ice orthographic rendering with bbox.""" + seaice_path = LOCAL_DATA_DIR / "example_seaice.nc" + if not seaice_path.exists(): + pytest.skip(f"Missing test data: {seaice_path}") + + f = cf.read(str(seaice_path))[0] + generated = TEST_GEN_DIR / "gen_fig_seaice_ortho_bbox.png" + reference = REF_IMAGE_DIR / "ref_fig_seaice_ortho_bbox.png" + + cfp.setvars(file=str(generated), viewer="matplotlib") + 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(f, fill=True, lines=False, line_labels=False, title="seaice_ortho_bbox") + + _assert_reference_match_strict(generated=generated, reference=reference) + + +@pytest.mark.integration +def test_regression_npstere_da193_image(): + """Regression image test for npstere seam/striping behavior.""" + da193_path = LOCAL_DATA_DIR / "da193_example.nc" + if not da193_path.exists(): + pytest.skip(f"Missing test data: {da193_path}") + + f = cf.read(str(da193_path))[0] + generated = TEST_GEN_DIR / "gen_fig_npstere_da193.png" + reference = REF_IMAGE_DIR / "ref_fig_npstere_da193.png" + + cfp.setvars(file=str(generated), viewer="matplotlib") + cfp.mapset( + proj="npstere", + resolution="110m", + boundinglat=0.0, + lon_0=0.0, + ) + cfp.con(f, fill=True, lines=False, line_labels=False, title="npstere_da193") + + _assert_reference_match_strict(generated=generated, reference=reference) + + @pytest.mark.integration def test_example_6_latitude_pressure_plot(ggap_file): """Test Example 6: latitude-pressure plot.""" diff --git a/tests/new_reference-example-images/ref_fig_npstere_da193.png b/tests/new_reference-example-images/ref_fig_npstere_da193.png new file mode 100644 index 0000000..335fc39 Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_npstere_da193.png differ diff --git a/tests/new_reference-example-images/ref_fig_seaice_ortho_bbox.png b/tests/new_reference-example-images/ref_fig_seaice_ortho_bbox.png new file mode 100644 index 0000000..bf71c9c Binary files /dev/null and b/tests/new_reference-example-images/ref_fig_seaice_ortho_bbox.png differ diff --git a/tests/unit/test_contour_animation_hooks.py b/tests/unit/test_contour_animation_hooks.py new file mode 100644 index 0000000..43056df --- /dev/null +++ b/tests/unit/test_contour_animation_hooks.py @@ -0,0 +1,145 @@ +import numpy as np + +import cfplot as cfp +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 setup_function(): + cfp.reset() + + +def teardown_function(): + cfp.reset() + + +def test_gopen_registers_animation_hooks(tmp_path): + outfile = tmp_path / "animation_hooks.png" + cfp.setvars(file=str(outfile), viewer="matplotlib") + + events = [] + + cfp.gopen( + animation_session_id="session-1", + animation_meta_callback=lambda payload: events.append(("meta", payload)), + animation_frame_callback=lambda payload: events.append(("frame", payload)), + ) + + runtime = cfp.plotvars.runtime + assert runtime._animation_session_id == "session-1" + assert callable(runtime._animation_meta_callback) + assert callable(runtime._animation_frame_callback) + assert runtime._animation_meta_emitted is False + assert runtime._animation_frame_index == 0 + + cfp.gclose(view=False) + + +def test_meta_and_frame_callbacks_emit_in_order(monkeypatch): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "T"]) + + runtime = cfp.plotvars.runtime + runtime._animation_session_id = "session-2" + + events = [] + runtime._animation_meta_callback = lambda payload: events.append(("meta", payload)) + runtime._animation_frame_callback = lambda payload: events.append(("frame", payload)) + runtime._animation_meta_emitted = False + runtime._animation_frame_index = 0 + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "T": _FakeConstruct([1], dtvalues=["2001-01-01 00:00:00"]), + } + ) + + contour._emit_animation_meta_callback( + kwargs={ + "animation_reference": np.arange(5), + "animation_axis": "auto", + "animation_title_template": "{title} [{frame}]", + }, + ptype=1, + levels_locked=False, + ) + contour._emit_animation_meta_callback( + kwargs={ + "animation_reference": np.arange(5), + "animation_axis": "auto", + }, + ptype=1, + levels_locked=False, + ) + contour._emit_animation_frame_callback( + f=f, + ptype=1, + kwargs={"animation_axis": "auto"}, + ) + + assert len(events) == 2 + assert events[0][0] == "meta" + assert events[1][0] == "frame" + + meta_payload = events[0][1] + assert meta_payload["session_id"] == "session-2" + assert meta_payload["total_frames"] == 5 + assert meta_payload["plot_kind"] == "contour" + assert meta_payload["levels_locked"] is False + + frame_payload = events[1][1] + assert frame_payload["session_id"] == "session-2" + assert frame_payload["frame_index"] == 0 + assert frame_payload["frame_value"] == "2001-01-01 00:00:00" + assert frame_payload["canvas_ready"] is True + assert frame_payload["timestamp"] is not None + + +def test_callback_exceptions_are_logged_and_frame_index_advances(monkeypatch, caplog): + monkeypatch.setattr(contour.cf, "Field", _FakeField) + monkeypatch.setattr(contour.utility, "find_dim_names", lambda f: ["X", "Y", "T"]) + + runtime = cfp.plotvars.runtime + runtime._animation_session_id = "session-3" + runtime._animation_meta_callback = None + runtime._animation_frame_callback = lambda payload: (_ for _ in ()).throw( + RuntimeError("boom") + ) + runtime._animation_meta_emitted = True + runtime._animation_frame_index = 0 + + f = _FakeField( + { + "X": _FakeConstruct(np.linspace(0, 350, 36)), + "Y": _FakeConstruct(np.linspace(-90, 90, 19)), + "T": _FakeConstruct([1]), + } + ) + + with caplog.at_level("ERROR"): + contour._emit_animation_frame_callback( + f=f, + ptype=1, + kwargs={"animation_axis": "auto"}, + ) + + assert "animation callback failed" in caplog.text + assert runtime._animation_frame_index == 1