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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions improver/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"BaseNeighbourhoodProcessing": "improver.nbhood.nbhood",
"CalculateForecastBias": "improver.calibration.simple_bias_correction",
"CalibratedForecastDistributionParameters": "improver.calibration.emos_calibration",
"call_object_method": "improver.utilities.call_object_method",
"ChooseDefaultWeightsLinear": "improver.blending.weights",
"ChooseDefaultWeightsNonLinear": "improver.blending.weights",
"ChooseDefaultWeightsTriangular": "improver.blending.weights",
Expand Down
31 changes: 31 additions & 0 deletions improver/utilities/call_object_method.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# (C) Crown Copyright, Met Office. All rights reserved.
#
# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
# See LICENSE in the root of the repository for full licensing details.

"""module to give access to callable methods on objects."""
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""module to give access to callable methods on objects."""
"""module to give access to callable methods on objects."""
from iris.cube import Cube, CubeList
from improver.utilities.common_input_handle import as_cubelist



def call_object_method(obj: object, method_name: str, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
def call_object_method(obj: object, method_name: str, **kwargs):
def call_cubelike_method(*cubes: Union[Cube, CubeList], method_name: str = None, **kwargs): -> Union[Cube, CubeList]

Copy link
Member Author

Choose a reason for hiding this comment

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

My thinking was that we did not have to be restricted to cubelike objects. ANY object could be used in this way, e.g. a NumPy array or a Pandas dataframe.

I am wondering whether this would be better as a Plugin class though, with the init method taking the name of the attribute to be called, so that args and kwargs can be disambiguated.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure we need that degree of generality, especially given the benefit we might receive from assuming pipelines using cubes and cubelists.
(improver applications right now expect cubes/cubelists)

Copy link
Member Author

Choose a reason for hiding this comment

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

I've also realised that we don't need this at all. For any class object, you can do MyClass.method(class_object), so iris.cube.Cube.collapsed(temperature_cube, "height", iris.analysis.MAX) would give the same answer as temperature_cube.collapsed("height", iris.analysis.MAX), so I don't think this PR adds anything we can't already do.

Copy link
Contributor

@cpelley cpelley Oct 6, 2025

Choose a reason for hiding this comment

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

I see what you mean, but remaining benefit as I see it is in this handling of 1 or more argument (Cube or CubeList) in a way that simplifies handling of such method calls in the workflow (so you don't need an additional step to handle inputs).

Illustration only:

graph LR;
proc_C["call_cubelike_method(<br>collapsed, ...)"]
proc_A --> proc_C;
proc_B --> proc_C;
Loading

VS

graph LR;
proc_A --> merge_cube;
proc_B --> merge_cube;
merge_cube --> collapsed[iris.cube.Cube.collapsed]
Loading

OR

Wrapping every possible Cubes and CubeList method with its own plugin to handle the more than 1 input cube/cubelist.


Just a thought -- not saying definitively what the right thing to do is here -- certainly a class plugin would be the way to distinguish between input object handling (plugin __init__) and the calling of the resulting underlying method call (__call__).

"""
Calls a method on an object with the supplied arguments.

This method allows us to construct a callable method for DAGRunner to execute
Copy link
Contributor

Choose a reason for hiding this comment

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

I wouldn't reference what library you choose to execute these with.

Suggested change
This method allows us to construct a callable method for DAGRunner to execute
This method allows us to construct a callable method for the caller to execute

where the method to be called is on an object that comes from another plugin.

e.g. cube.collapsed("height", iris.analysis.SUM) becomes
call_object_method(cube, "collapsed", coords="height", aggregator=iris.analysis.SUM)

Args:
obj:
The object containing the method to be called.
method_name:
The name of the method to be called.
**kwargs:
The keyword arguments to be passed to the method.

Returns:
The return value from the called method.
"""
method = getattr(obj, method_name)
return method(**kwargs)
Comment on lines +30 to +31
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
method = getattr(obj, method_name)
return method(**kwargs)
if len(cubes) > 1:
cubes = as_cubelist(*cubes)
method = getattr(cubes, method_name)
return method(**kwargs)

57 changes: 57 additions & 0 deletions improver_tests/utilities/test_call_object_method.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# (C) Crown Copyright, Met Office. All rights reserved.
#
# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
# See LICENSE in the root of the repository for full licensing details.
"""Tests for the call_object_method utility function."""

import iris.analysis
import numpy as np
import pytest
from iris.cube import Cube

from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube
from improver.utilities.call_object_method import call_object_method


@pytest.fixture(name="cube")
def cube_fixture() -> Cube:
"""Set up a cube of data"""
data = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=np.float32)
cube = set_up_variable_cube(
data,
name="test_variable",
units="m/s",
vertical_levels=[1000, 2000],
height=True,
)
return cube


def test_call_object_method(cube):
"""Test that call_object_method correctly calls a method on an object."""
result = call_object_method(
cube, "collapsed", coords="height", aggregator=iris.analysis.SUM
)
expected_data = np.array([[6.0, 8.0], [10.0, 12.0]], dtype=np.float32)
assert np.array_equal(result.data, expected_data)
assert result.name() == "test_variable"
assert result.units == "m/s"


def test_call_object_method_invalid_method(cube):
"""Test that call_object_method raises an AttributeError for an invalid method."""
with pytest.raises(AttributeError):
call_object_method(cube, "non_existent_method")


def test_call_object_method_invalid_args(cube):
"""Test that call_object_method raises a TypeError for invalid arguments."""
with pytest.raises(TypeError):
call_object_method(cube, "collapsed", non_existent_arg=True)


def test_call_object_method_no_args(cube):
"""Test that call_object_method works with no additional arguments."""
result = call_object_method(cube, "copy")
assert result == cube
assert result is not cube # Ensure it's a copy, not the same object