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

Discussion:
m-mohr: Is there a way to enable this globally? So e.g. also for the .process(...) function?

In JS we are checking the parameters and when they are of type function then handle them as callback. Would something like that also work in Python? It would make custom functions with callbacks easier to use (in general) and generate (in the Web Editor).
soxofaan: It's probably possible to support this generally

but the fix per case is just a simple straightforward one-liner, 
and I'm not sure that some generic way will be able to shave off a lot or make things simpler
m-mohr: I've opened a separate issue for it so that we can consider this later and don't block this PR: #232

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53f9ab94..831591f1 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 213923dc..14683494 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 77cc4529..68da969b 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