Repository: AlexandreDecan/portion

Issue:
Feature Request: `how` callable to receive intervals
**TL;DR:** can you modify `how` signature in `IntervalDict.combine()` method to receive the active interval and to look like this:

`how(v1, v2, i=i)`

The optionals should make it backwards compatible, and no existing code would be broken.

**Rationale:**
I'm writing a code where I have an `IntervalDict`, where each interval has an associated interpolator. When two IntervalDicts are combined, then I'd like to do a piecewise multiplication with discrete samples and then generate a new interpolator.

Concrete example: Imagine the light spectrum from the Sun. When combined with a "filter", the passing light has a single colour. If the interpolator of the solar spectrum is intersected with the filter (1 in the bandpass region, 0 otherwise), then the resulting object would have zero in the entire domain, with the exception of the bandpass region. If the filter has a more complex shape, then two interpolators will have to interact, where I will have to do all the resampling and interpolator generation within the `how` callable. 

Currently,  the callable does not receive the `i` interval, therefore it does not know for which part of the domain it is called.

Discussion:
@AlexandreDecan: Hello, 

I'm not sure I fully got the use-case, but I think I see the point :-) I'm not against the change, but I don't see how changing the signature of `how` to `how(v1, v2, i=i)` makes it backward compatible. 

What about adding an extra parameter on the `combine` method, e.g., `pass_interval=False` that, if set to `True`, makes `combine` call `how` with the current interval as first parameter? E.g., instead of `how(v1, v2)` for all intersecting `i`, if `pass_interval = True`, we call `how(i, v1, v2)`. @egemenimre: Thanks for the reply. 

Of course, adding the third item `i` would break *all* the legacy code. My bad, posting too late at night is rarely a good idea. :)

Then, as you say, the only *easy* option could be to ask the user with a flag and let `combine` handle the two options - with or without the interval. I just suggest that `i` could be the last item in the call rather than the first, simply to make sure the two and three input versions look similar in signature. 

Another *funky* (as opposed to easy, and maybe too funky for its own good) idea could be to inspect the `how` for the number of parameters. This would save the user from specifying the flag each and every time, but introduces a danger that the user may shuffle the signature and we are making an assumption regarding the order:

```
import inspect

def combine(self, other, how, *, missing=...):
       ...

        for i1, v1 in d1.items():
            for i2, v2 in d2.items():

                if i1.overlaps(i2):
                    i = i1 & i2
       
                    # this should be 2 or 3, depending on how the user defines how
                    nr_of_params = len(inspect.signature(how).parameters)

                    if nr_of_params == 2:
                           v = how(v1, v2)
                    else:
                           # this assumes nr_of_params == 3
                            v = how(v1, v2, i)

                    new_items.append((i, v))
```
@egemenimre: Also, `how` can receive a `(*args, **kwargs)`, passed on from `combine`, to enable an even more versatile `how`. This way I can pass my own parameters that I use within the `how` functionality.@AlexandreDecan: Hi, 

Having the interval as third item is indeed better than as first parameter. 

I tend to dislike the *funky* solution since I'm afraid this would make the code more *fragile* and I'm not sure `inspect` works correctly this way for all cases (e.g., functions accepting a variable number of parameters, or functions defining default values for some parameters). 

About the second message, I'm against the idea. The same behaviour can be obtained by using `partial` for the `how` function before passing it to `combine` so I don't see the benefit of adding this "complexity" to `combine` :-) 

@egemenimre: I fully understand the opposition to the funky solution. :)

Can you elaborate a bit on `partial`? Maybe a simple example?

The usecase is where I need to pass on  another variable (say, a `k` that gets multiplied with the two functions). They need to go all the way inside `how,` so that I can do something like:

```
def how_funct(fx, fy):

   # don't know where k comes from now
   combined = fx * fy * k
   return  combined
```
I'm open to ideas as to how I can achieve this.@AlexandreDecan: Sure. If I understand correctly, you want to add `*args` and `**kwargs` to the `combine` method, so that they are passed to the `how` function when it is called. So, to some extent, every call to `how(x, y)` will be replaced by `how(x, y, *args, **kwargs)`, right? 
Let's say you have a `f` function taking 3 parameters (this could be your `how_funct` above), namely `x, y, z` where `z` should be known in advance. This means you want to call `d1.combine(d2, f, z=3)` for example. With the current implementation, this call can be replaced by `d1.combine(d2, functools.partial(f, z=3))`. @egemenimre: Ah, clever. Didn't know about partials before. That would indeed do the trick.

Maybe you can add a recipe somewhere, as it really enhances the `combine` functionality.

And as for the addition of the active interval to `how`, when can I expect it? do you have a minor release lined up? :)@AlexandreDecan: `partial` is quite common, the same trick can also be achieved using a lambda function. I do not think this deserves some attention in the documentation, though ;-) 

Considering the `pass_interval` parameter, I have no ETA yet. I need to work on it (won't take much time), write some tests, some documentation and release a new minor version. I'll see when I have some free time for it ;-) @AlexandreDecan: Hello,

The 2.6.0 release should be soon available on PyPI. I hope it does the job, I haven't written much tests... Even if the implementation is quite straightforward, I should add more :-) 

Solution patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index a6c71d7..e086dd7 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -6,13 +6,16 @@ on:
   schedule:
     - cron: "0 6 * * 1"
 
+concurrency:
+      group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
+
 jobs:
   test:
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python
@@ -33,4 +36,3 @@ jobs:
         uses: coverallsapp/github-action@v2
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11f75a5..0709309 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## 2.6.0 (2024-10-17)
+
+### Added
+ - The `how` function of `combine` can access the current interval if `pass_interval` is set (see [#97](https://github.com/AlexandreDecan/portion/issues/97)).
+
+
 
 ## 2.5.0 (2024-09-18)
 
diff --git a/README.md b/README.md
index 3842b89..666eb7d 100644
--- a/README.md
+++ b/README.md
@@ -675,6 +675,7 @@ The values corresponding to intersecting keys (i.e., when the two instances over
 
 ```
 
+The `how` function can also receive the current interval as third parameter, by enabling the `pass_interval` parameter of `.combine`.
 The `combine` method also accepts a `missing` parameter. When `missing` is set, the `how` function is called even for non-intersecting keys, using the value of `missing` to replace the missing values:
 
 ```python
diff --git a/portion/dict.py b/portion/dict.py
index 88dead8..6135026 100644
--- a/portion/dict.py
+++ b/portion/dict.py
@@ -218,7 +218,7 @@ class IntervalDict(MutableMapping):
         for i, v in data:
             self[i] = v
 
-    def combine(self, other, how, *, missing=...):
+    def combine(self, other, how, *, missing=..., pass_interval=False):
         """
         Return a new IntervalDict that combines the values from current and
         provided IntervalDict.
@@ -232,13 +232,22 @@ class IntervalDict(MutableMapping):
         keys using the value of missing to replace the missing values. This is,
         case (1) corresponds to f(x, missing) and case (2) to f(missing, y).
 
+        If pass_interval is set to True, the current interval will be passed to
+        the "how" function as third parameter.
+
         :param other: another IntervalDict instance.
-        :param how: a function of two parameters that combines values.
+        :param how: a function combining two values.
         :param missing: if set, use this value for missing values when calling "how".
+        :param pass_interval: if set, provide the current interval to the "how" function.
         :return: a new IntervalDict instance.
         """
         new_items = []
 
+        if not pass_interval:
+            _how = lambda x, y, i: how(x, y)
+        else:
+            _how = how
+
         dom1, dom2 = self.domain(), other.domain()
 
         if missing is Ellipsis:
@@ -246,9 +255,9 @@ class IntervalDict(MutableMapping):
             new_items.extend(other[dom2 - dom1].items())
         else:
             for i, v in self[dom1 - dom2].items():
-                new_items.append((i, how(v, missing)))
+                new_items.append((i, _how(v, missing, i)))
             for i, v in other[dom2 - dom1].items():
-                new_items.append((i, how(missing, v)))
+                new_items.append((i, _how(missing, v, i)))
 
         intersection = dom1 & dom2
         d1, d2 = self[intersection], other[intersection]
@@ -257,7 +266,7 @@ class IntervalDict(MutableMapping):
             for i2, v2 in d2.items():
                 if i1.overlaps(i2):
                     i = i1 & i2
-                    v = how(v1, v2)
+                    v = _how(v1, v2, i)
                     new_items.append((i, v))
 
         return self.__class__(new_items)
diff --git a/setup.py b/setup.py
index 228840d..c2396af 100644
--- a/setup.py
+++ b/setup.py
@@ -9,7 +9,7 @@ with open(
 
 setup(
     name="portion",
-    version="2.5.0",
+    version="2.6.0",
     license="LGPLv3",
     author="Alexandre Decan",
     url="https://github.com/AlexandreDecan/portion",
@@ -31,6 +31,7 @@ setup(
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.12",
+        "Programming Language :: Python :: 3.13",
     ],
     keywords="interval operation range math",
     packages=find_packages(include=["portion"]),


Test patch:
diff --git a/tests/test_dict.py b/tests/test_dict.py
index fe0dc8d..95df3d6 100644
--- a/tests/test_dict.py
+++ b/tests/test_dict.py
@@ -205,6 +205,17 @@ class TestIntervalDict:
             (P.openclosed(3, 4), 5)
         ])
 
+    def test_combine_pass_interval(self):
+        def how(x, y, z): return x, y, z
+
+        d1 = P.IntervalDict([(P.closed(1, 3), 1)])
+        d2 = P.IntervalDict([(P.closed(2, 4), 2)])
+
+        assert d1.combine(d2, how, pass_interval=True) == P.IntervalDict([
+            (P.closedopen(1, 2), 1),
+            (P.closed(2, 3), (1, 2, P.closed(2, 3))),
+            (P.openclosed(3, 4), 2)
+        ])
 
     def test_containment(self):
         d = P.IntervalDict([(P.closed(0, 3), 0)])