Repository: Open-EO/openeo-python-client

Issue:
Proper callable support in fit_curve and related
from https://github.com/openEOPlatform/documentation/issues/10

```python
curve_fitting = l2a_bands.fit_curve(
    parameters=[1,1,1], # Initial guess of the parameters
    dimension="t",      # Fit the function along the temporal dimension
    function=l2a_bands._get_callback(fit_function, parent_parameters=["data","parameters"])
)
```

it's unfortunate that you have to call a private method `l2a_bands._get_callback` like that, we should make sure that you can just do `function=fit_function` directly

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53f9ab9..831591f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   ([#225](https://github.com/Open-EO/openeo-python-client/issues/225), [openeo-api#410](https://github.com/Open-EO/openeo-api/issues/410))
 - Add `DataCube.dimension_labels()` (EP-4008)
 - Add `Connection.load_result()` (EP-4008)
+- Add proper support for child callbacks in `fit_curve` and `predict_curve` ([#229](https://github.com/Open-EO/openeo-python-client/issues/229))
 
 
 ### Changed
diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py
index 213923d..1468349 100644
--- a/openeo/rest/datacube.py
+++ b/openeo/rest/datacube.py
@@ -785,7 +785,7 @@ class DataCube(ImageCollection, _FromNodeMixin):
         as `apply`, `apply_dimension`, `reduce`, ....)
 
         :param process: process id string, PGNode or callable that uses the ProcessBuilder mechanism to build a process
-        :parameter parameter_mapping: mapping of child (callback) parameters names to parent process parameter names
+        :param parent_parameters: list of parameter names defined for child process
         :return:
         """
 
@@ -895,7 +895,7 @@ class DataCube(ImageCollection, _FromNodeMixin):
         Add a reduce process with given reducer callback along given dimension
 
         :param dimension: the label of the dimension to reduce
-        :param reducer: a callback function that creates a process graph, see :ref:`callbackfunctions`
+        :param reducer: "child callback" function, see :ref:`callbackfunctions`
         """
         # TODO: check if dimension is valid according to metadata? #116
         # TODO: #125 use/test case for `reduce_dimension_binary`?
@@ -1680,8 +1680,7 @@ class DataCube(ImageCollection, _FromNodeMixin):
             arguments["options"] = options
         return self.process(process_id="sar_backscatter", arguments=arguments)
 
-
-    def fit_curve(self, parameters, function, dimension ):
+    def fit_curve(self, parameters: list, function: Union[str, PGNode, typing.Callable], dimension: str):
         """
         EXPERIMENTAL: https://github.com/Open-EO/openeo-processes/pull/240
         Use non-linear least squares to fit a model function `y = f(x, parameters)` to data.
@@ -1689,32 +1688,34 @@ class DataCube(ImageCollection, _FromNodeMixin):
         The process throws an `InvalidValues` exception if invalid values are encountered.
         Invalid values are finite numbers (see also ``is_valid()``).
 
-        @param parameters:
-        @param function:
-        @param dimension:
-        @return:
+        :param parameters:
+        :param function: "child callback" function, see :ref:`callbackfunctions`
+        :dimension:
         """
         return self.process(process_id="fit_curve", arguments={
             "data": THIS,
             "parameters": parameters,
-            "function": function,
+            "function": self._get_callback(function, parent_parameters=["x", "parameters"]),
             "dimension": dimension
         })
 
-    def predict_curve(self, parameters, function, dimension, labels = None ):
+    def predict_curve(
+            self, parameters: list, function: Union[str, PGNode, typing.Callable], dimension: str,
+            labels=None
+    ):
         """
         EXPERIMENTAL: https://github.com/Open-EO/openeo-processes/pull/240
         Predict values using a model function and pre-computed parameters.
 
-        @param parameters:
-        @param function:
-        @param dimension:
+        :param parameters:
+        :param function: "child callback" function, see :ref:`callbackfunctions`
+        :dimension:
         @return:
         """
         return self.process(process_id="predict_curve", arguments={
             "data": THIS,
             "parameters": parameters,
-            "function": function,
+            "function": self._get_callback(function, parent_parameters=["x", "parameters"]),
             "dimension": dimension,
             "labels": labels
         })


Test patch:
diff --git a/tests/rest/datacube/test_datacube100.py b/tests/rest/datacube/test_datacube100.py
index 77cc452..68da969 100644
--- a/tests/rest/datacube/test_datacube100.py
+++ b/tests/rest/datacube/test_datacube100.py
@@ -1174,3 +1174,100 @@ def test_dimension_labels_invalid(con100):
     # Don't validate when no metadata
     cube = con100.load_collection("S2", fetch_metadata=False).dimension_labels("unv6lidd")
     assert cube.flat_graph()["dimensionlabels1"]["arguments"]["dimension"] == "unv6lidd"
+
+
+def test_fit_curve_callback(con100: Connection):
+    from openeo.processes import array_element
+    def model(x, parameters):
+        return array_element(parameters, 0) + array_element(parameters, 1) * x
+
+    img = con100.load_collection("S2")
+    res = img.fit_curve(parameters=[0, 0], function=model, dimension="t")
+    expected = {
+        'loadcollection1': {
+            'process_id': 'load_collection',
+            'arguments': {'id': 'S2', 'spatial_extent': None, 'temporal_extent': None},
+        },
+        'fitcurve1': {
+            'process_id': 'fit_curve',
+            'arguments': {
+                'data': {'from_node': 'loadcollection1'},
+                'parameters': [0, 0],
+                'function': {
+                    'process_graph': {
+                        'arrayelement1': {
+                            'process_id': 'array_element',
+                            'arguments': {'data': {'from_parameter': 'parameters'}, 'index': 0},
+                        },
+                        'arrayelement2': {
+                            'process_id': 'array_element',
+                            'arguments': {'data': {'from_parameter': 'parameters'}, 'index': 1},
+                        },
+                        'multiply1': {
+                            'process_id': 'multiply',
+                            'arguments': {'x': {'from_node': 'arrayelement2'}, 'y': {'from_parameter': 'x'}},
+                        },
+                        'add1': {
+                            'process_id': 'add',
+                            'arguments': {'x': {'from_node': 'arrayelement1'}, 'y': {'from_node': 'multiply1'}},
+                            'result': True
+                        },
+                    }
+                },
+                'dimension': 't',
+            },
+            'result': True
+        },
+    }
+    assert res.graph == expected
+
+
+def test_predict_curve_callback(con100: Connection):
+    from openeo.processes import array_element, cos
+    def model(x, parameters):
+        return array_element(parameters, 0) * cos(array_element(parameters, 1) * x)
+
+    img = con100.load_collection("S2")
+    res = img.predict_curve(parameters=[0, 0], function=model, dimension="t")
+    expected = {
+        'loadcollection1': {
+            'process_id': 'load_collection',
+            'arguments': {'id': 'S2', 'spatial_extent': None, 'temporal_extent': None},
+        },
+        'predictcurve1': {
+            'process_id': 'predict_curve',
+            'arguments': {
+                'data': {'from_node': 'loadcollection1'},
+                'parameters': [0, 0],
+                'function': {
+                    'process_graph': {
+                        'arrayelement1': {
+                            'process_id': 'array_element',
+                            'arguments': {'data': {'from_parameter': 'parameters'}, 'index': 0},
+                        },
+                        'arrayelement2': {
+                            'process_id': 'array_element',
+                            'arguments': {'data': {'from_parameter': 'parameters'}, 'index': 1},
+                        },
+                        'multiply1': {
+                            'process_id': 'multiply',
+                            'arguments': {'x': {'from_node': 'arrayelement2'}, 'y': {'from_parameter': 'x'}},
+                        },
+                        'cos1': {
+                            'process_id': 'cos',
+                            'arguments': {'x': {'from_node': "multiply1"}},
+                        },
+                        'multiply2': {
+                            'process_id': 'multiply',
+                            'arguments': {'x': {'from_node': 'arrayelement1'}, 'y': {'from_node': 'cos1'}},
+                            'result': True
+                        },
+                    }
+                },
+                'dimension': 't',
+                'labels': None,
+            },
+            'result': True
+        },
+    }
+    assert res.graph == expected