Repository: PyVRP/PyVRP (python)

Issue:
Assigment of customer groups is broken
In version 0.10.0 I get an error when I change the order of the group assignment

**Code sample**

```python
from pyvrp import Model
from pyvrp.show_versions import show_versions

show_versions()

m = Model()

A_group = m.add_client_group(False)
B_group = m.add_client_group(False)

depot = m.add_depot(x=0, y=0)

# If I change the order of the two lines it works again
B = m.add_client(x=0, y=0, required=False, group=B_group) 
A = m.add_client(x=0, y=0, required=False, group=A_group)

print("Clients in Groups")
for x in m.groups:
    print(x.clients)

```
**Output (Both Clients are in one group --> ValueError: Empty client group not understood.):**
```
INSTALLED VERSIONS
------------------
     pyvrp: 0.10.0
     numpy: 2.1.2
matplotlib: 3.9.2
    vrplib: 1.5.1
      tqdm: 4.66.5
     tomli: 2.0.2
    Python: 3.11.10
Clients in Groups
[1, 2]
[]
```
**Expected Output (Clients are in different groups):**
```
INSTALLED VERSIONS
------------------
     pyvrp: 0.10.0
     numpy: 2.1.2
matplotlib: 3.9.2
    vrplib: 1.5.1
      tqdm: 4.66.5
     tomli: 2.0.2
    Python: 3.11.10
Clients in Groups
[1]
[2]
```
**Note**: 
In version 0.9.1 the order did not matter and the group assignment was correct



Pull request:
Fix client group bug
This PR:

- [x] Closes #681.
- [ ] Adds and passes relevant tests.
- [ ] Has well-formatted code and documentation.

<details>

**Notes**:

Please read our [contributing guidelines](https://pyvrp.org/dev/contributing.html) first.
In particular:

- You must add tests when making code changes.
  This keeps the code coverage level up, and helps ensure the changes work as intended.
- When fixing a bug, you must add a test that would produce the bug in the master branch, and then show that it is fixed with the new code. 
- New code additions must be well formatted. Changes should pass the pre-commit workflow, which you can set up locally using [pre-commit](https://pre-commit.com/#intro). 
- Docstring additions must render correctly, including escapes and LaTeX.
- Finally, it is essential that all contributions in this PR are license-compatible with PyVRP's MIT license.
  Please check that this PR can be included into PyVRP under the MIT license.

</details>


Patch:
diff --git a/pyvrp/Model.py b/pyvrp/Model.py
index 769c777..92269b7 100644
--- a/pyvrp/Model.py
+++ b/pyvrp/Model.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Sequence
 from warnings import warn
 
 import numpy as np
@@ -209,8 +209,8 @@ class Model:
         """
         if group is None:
             group_idx = None
-        elif group in self._groups:
-            group_idx = self._groups.index(group)
+        elif (idx := _idx_by_id(group, self._groups)) is not None:
+            group_idx = idx
         else:
             raise ValueError("The given group is not in this model instance.")
 
@@ -343,22 +343,22 @@ class Model:
         """
         if start_depot is None:
             start_idx = 0
-        elif start_depot in self._depots:
-            start_idx = self._depots.index(start_depot)
+        elif (idx := _idx_by_id(start_depot, self._depots)) is not None:
+            start_idx = idx
         else:
             raise ValueError("The given start depot is not in this model.")
 
         if end_depot is None:
             end_idx = 0
-        elif end_depot in self._depots:
-            end_idx = self._depots.index(end_depot)
+        elif (idx := _idx_by_id(end_depot, self._depots)) is not None:
+            end_idx = idx
         else:
             raise ValueError("The given end depot is not in this model.")
 
         if profile is None:
             profile_idx = 0
-        elif profile in self._profiles:
-            profile_idx = self._profiles.index(profile)
+        elif (idx := _idx_by_id(profile, self._profiles)) is not None:
+            profile_idx = idx
         else:
             raise ValueError("The given profile is not in this model.")
 
@@ -469,3 +469,17 @@ class Model:
             found solution.
         """
         return solve(self.data(), stop, seed, collect_stats, display, params)
+
+
+def _idx_by_id(item: object, container: Sequence[object]) -> int | None:
+    """
+    Obtains the index of item in the container by identity rather than equality
+    (as would happen with index()). This is important for various objects in
+    the Model, because objects that compare equal may not be the same as the
+    one intended. See #681 for a bug caused by this.
+    """
+    for idx, other in enumerate(container):
+        if id(item) == id(other):
+            return idx
+
+    return None


Test patch:
diff --git a/tests/test_Model.py b/tests/test_Model.py
index 426cc90..2ed4062 100644
--- a/tests/test_Model.py
+++ b/tests/test_Model.py
@@ -931,3 +931,26 @@ def test_model_solves_instance_with_zero_load_dimensions():
     # results in a distance of 1 + 1 + 1 + 1 = 4.
     route = res.best.routes()[0]
     assert_equal(route.distance(), 4)
+
+
+def test_bug_client_group_indices():
+    """
+    Tests the bug of #681. Because empty client groups compare equal, the
+    group to index implementation returned the first object that compared
+    equal, in this case resulting in both clients being inserted into the
+    first client group rather than the each into one.
+    """
+    m = Model()
+    m.add_depot(x=0, y=0)
+
+    group1 = m.add_client_group()
+    group2 = m.add_client_group()
+
+    client1 = m.add_client(x=0, y=0, required=False, group=group2)
+    assert_equal(client1.group, 1)
+
+    client2 = m.add_client(x=0, y=0, required=False, group=group1)
+    assert_equal(client2.group, 0)
+
+    assert_equal(len(group1), 1)
+    assert_equal(len(group2), 1)
diff --git a/tests/test_ProblemData.py b/tests/test_ProblemData.py
index 06ed90e..02bcb90 100644
--- a/tests/test_ProblemData.py
+++ b/tests/test_ProblemData.py
@@ -875,6 +875,9 @@ def test_depot_eq():
     assert_(depot1 != "text")
     assert_(depot1 != 3)
 
+    depot4 = Depot(x=0, y=0, name="test")
+    assert_(depot1 != depot4)
+
 
 def test_vehicle_type_eq():
     """