Repository: pybamm-team/PyBaMM

Issue:
Allow dictionary search to take multiple substrings
### Description

Currently, the `search` method for dictionaries (in `util.py`) only takes a string. I would be useful to be able to provide a list of strings so it checks for all of them.

### Motivation

It would allow for more specific searches. For example, let's say I want a given overpotential in the negative electrode but can't recall the name. I could search for "[V]" and "negative" to whittle down the list.

### Possible Implementation

For the `search` method it would be fairly straightforward, we just need to iterate over the list and check all strings are in it.

For the `get_best_matches` (which is used if the search is unsuccesful) it is less obvious how to fix it as, from what I understand, the `difflib.get_close_matches` method does not allow for multiple strings to check.

### Additional context

_No response_

Discussion:
medha-14: I have modified the `search` method to accept multiple strings. In cases where an exact match is not found, I pass the concatenated string of the multiple inputs to the `get_close_matches` method.Will this approach suffice for what we are trying to achieve here ?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4650?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) Report
Attention: Patch coverage is `92.85714%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 99.21%. Comparing base [(`72c23ea`)](https://app.codecov.io/gh/pybamm-team/PyBaMM/commit/72c23ea9ecfb81057b7d149e4988000b669b4e6b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) to head [(`99c77e2`)](https://app.codecov.io/gh/pybamm-team/PyBaMM/commit/99c77e2e59ccddf48ae2cbf064b836c35cba1566?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).
> Report is 12 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4650?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) | Patch % | Lines |
|---|---|---|
| [src/pybamm/util.py](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4650?src=pr&el=tree&filepath=src%2Fpybamm%2Futil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team#diff-c3JjL3B5YmFtbS91dGlsLnB5) | 92.85% | [1 Missing :warning: ](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4650?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop    #4650      +/-   ##
===========================================
- Coverage    99.21%   99.21%   -0.01%     
===========================================
  Files          302      302              
  Lines        22858    22881      +23     
===========================================
+ Hits         22679    22701      +22     
- Misses         179      180       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4650?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).

medha-14: I have implemented the suggested changes and added tests for searching multiple strings as well.
agriyakhetarpal: Actually, in the case of a partial match, would it be better to indicate that it is such? For example,

```python
model.variables.search(["NotAVariable", "concentration"])
```
now returns

```
No results for search using '['NotAVariable', 'concentration']'.
Best matches are ['Electrolyte concentration']
```

but we could say something like

```
Partial match for key 'concentration' in search using keys '['NotAVariable', 'concentration']'.
Best matches are ['Electrolyte concentration']
```

because we do have a match here for `"concentration"`, but not for `"NotAVariable"`.
medha-14: In cases where some keys have an exact match while others only have a best match, what should the expected result be?
agriyakhetarpal: > In cases where some keys have an exact match while others only have a best match, what should the expected result be? Should we only prioritize the exact matches in such cases or should we also have the best matches printed separately?

Do you mean the case where we have an exact match for $m$ keys and a partial/best match for $n - m$ keys? Yes, we should print both the exact matches and the best matches according to the keys. Could you share an example?

If I understood your question correctly, then an input as follows:

```python
model.variables.search(["Electrolyte concentration", "Electrolite concentration"])
```

should return something, in my opinion, like:
```
Results matched against 'Electrolyte concentration' in search:
Electrolyte concentration

Partial match for 'Electrolite concentration' in search:
Best matches are ['Electrolyte concentration', 'Electrode potential']
```

We can figure out the best way to display the output later. There is also a case to be made to say that this improvement to the search functionality to accept multiple strings means that the result is returned for only the string that does return a match, but we are not really implementing a search engine, so I feel it is acceptable to have all results for all input strings (as if we are looping over them in the search). @brosaplanella, what do you think?

medha-14: For now i have modified the method to search for exact matches having all the `search_keys` if no such matches are found it gives search results for each term individually.

``` python
model.variables.search(["electrolyte", "concentration"])
```
Since both terms are present together in a single key, the result will be:
```python
 Results for 'Electrolyte concentration': ['Electrolyte concentration']
```

For the cases where there are no such matches it will iterate over each string individually and give results as such:
```python
model.variables.search(["RandomKey", "elecrtolyte concentration","electrolite"])
```
will give results as:
```pyhton
No matches found for 'RandomKey'.
Exact matches for 'electrolyte concentration': ['Electrolyte concentration [Molar]', 'Electrolyte concentration [mol.m-3]']
No exact matches found for 'electrolite'. Best matches are: ['Electrolyte potential [V]']


medha-14: Thank you for the detailed review! I’ve modified the method to handle the empty string case . I’ve also added a tests to cover the same.Regarding the issue with partial matches and using `difflib` to refine the search logic, I’ll be taking that up in a separate PR.
kratman: @medha-14 Thanks for working on this, I will re-review after you finish @Saransh-cpp's suggestions
kratman: I will merge this after the tests pass

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8322884ed..09e1d11fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## Features
 
+- Enhanced the `search` method to accept multiple search terms in the form of a string or a list. ([#4650](https://github.com/pybamm-team/PyBaMM/pull/4650))
 - Made composite electrode model compatible with particle size distribution ([#4687](https://github.com/pybamm-team/PyBaMM/pull/4687))
 - Added `Symbol.post_order()` method to return an iterable that steps through the tree in post-order fashion. ([#4684](https://github.com/pybamm-team/PyBaMM/pull/4684))
 - Added two more submodels (options) for the SEI: Lars von Kolzenberg (2020) model and Tunneling Limit model ([#4394](https://github.com/pybamm-team/PyBaMM/pull/4394))
diff --git a/src/pybamm/util.py b/src/pybamm/util.py
index 39fcdde6f..76db133b8 100644
--- a/src/pybamm/util.py
+++ b/src/pybamm/util.py
@@ -1,3 +1,4 @@
+from __future__ import annotations
 import importlib.util
 import importlib.metadata
 import numbers
@@ -108,38 +109,96 @@ class FuzzyDict(dict):
                 f"'{key}' not found. Best matches are {best_matches}"
             ) from error
 
-    def search(self, key, print_values=False):
+    def _find_matches(self, search_key: str, known_keys: list[str]):
         """
-        Search dictionary for keys containing 'key'. If print_values is True, then
-        both the keys and values will be printed. Otherwise just the values will
-        be printed. If no results are found, the best matches are printed.
-        """
-        key_in = key
-        key = key_in.lower()
+        Helper method to find exact and partial matches for a given search key.
 
-        # Sort the keys so results are stored in alphabetical order
-        keys = list(self.keys())
-        keys.sort()
-        results = {}
+        Parameters
+        ----------
+        search_key : str
+            The term to search for in the keys.
+        known_keys : list of str
+            The list of known dictionary keys to search within.
 
-        # Check if any of the dict keys contain the key we are searching for
-        for k in keys:
-            if key in k.lower():
-                results[k] = self[k]
+        """
+        exact = [key for key in known_keys if search_key in key.lower()]
+        partial = difflib.get_close_matches(search_key, known_keys, n=5, cutoff=0.5)
+        return exact, partial
 
-        if results == {}:
-            # If no results, return best matches
-            best_matches = self.get_best_matches(key)
+    def search(self, keys: str | list[str], print_values: bool = False):
+        """
+        Search dictionary for keys containing all terms in 'keys'.
+        If print_values is True, both the keys and values will be printed.
+        Otherwise, just the keys will be printed. If no results are found,
+        the best matches are printed.
+
+        Parameters
+        ----------
+        keys : str or list of str
+            Search term(s)
+        print_values : bool, optional
+            If True, print both keys and values. Otherwise, print only keys.
+            Default is False.
+        """
+
+        if not isinstance(keys, (str, list)) or not all(
+            isinstance(k, str) for k in keys
+        ):
+            msg = f"'keys' must be a string or a list of strings, got {type(keys)}"
+            raise TypeError(msg)
+
+        if isinstance(keys, str):
+            if not keys.strip():
+                msg = "The search term cannot be an empty or whitespace-only string"
+                raise ValueError(msg)
+            original_keys = [keys]
+            search_keys = [keys.strip().lower()]
+
+        elif isinstance(keys, list):
+            if all(not str(k).strip() for k in keys):
+                msg = "The 'keys' list cannot contain only empty or whitespace strings"
+                raise ValueError(msg)
+
+            original_keys = keys
+            search_keys = [k.strip().lower() for k in keys if k.strip()]
+
+        known_keys = list(self.keys())
+        known_keys.sort()
+
+        # Check for exact matches where all search keys appear together in a key
+        exact_matches = [
+            key
+            for key in known_keys
+            if all(term in key.lower() for term in search_keys)
+        ]
+
+        if exact_matches:
             print(
-                f"No results for search using '{key_in}'. "
-                f"Best matches are {best_matches}"
+                f"Results for '{' '.join(k for k in original_keys if k.strip())}': {exact_matches}"
             )
-        elif print_values:
-            # Else print results, including dict items
-            print("\n".join(f"{k}\t{v}" for k, v in results.items()))
-        else:
-            # Just print keys
-            print("\n".join(f"{k}" for k in results.keys()))
+            if print_values:
+                for match in exact_matches:
+                    print(f"{match} -> {self[match]}")
+            return
+
+        # If no exact matches, iterate over search keys individually
+        for original_key, search_key in zip(original_keys, search_keys):
+            exact_key_matches, partial_matches = self._find_matches(
+                search_key, known_keys
+            )
+
+            if exact_key_matches:
+                print(f"Exact matches for '{original_key}': {exact_key_matches}")
+                if print_values:
+                    for match in exact_key_matches:
+                        print(f"{match} -> {self[match]}")
+            else:
+                if partial_matches:
+                    print(
+                        f"No exact matches found for '{original_key}'. Best matches are: {partial_matches}"
+                    )
+                else:
+                    print(f"No matches found for '{original_key}'")
 
     def copy(self):
         return FuzzyDict(super().copy())


Test patch:
diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py
index 1bf9d6db8..bf1b85201 100644
--- a/tests/unit/test_util.py
+++ b/tests/unit/test_util.py
@@ -188,19 +188,84 @@ class TestSearch:
         # Test variables search (default returns key)
         with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
             model.variables.search("Electrode")
-            assert fake_out.getvalue() == "Electrode potential\n"
-
+            assert (
+                fake_out.getvalue()
+                == "Results for 'Electrode': ['Electrode potential']\n"
+            )
         # Test bad var search (returns best matches)
         with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
             model.variables.search("Electrolyte cot")
             out = (
-                "No results for search using 'Electrolyte cot'. "
-                "Best matches are ['Electrolyte concentration', "
-                "'Electrode potential']\n"
+                "No exact matches found for 'Electrolyte cot'. "
+                "Best matches are: ['Electrolyte concentration', 'Electrode potential']\n"
+            )
+            assert fake_out.getvalue() == out
+
+        # Test for multiple strings as input (default returns key)
+        with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
+            model.variables.search(["Electrolyte", "Concentration"], print_values=True)
+            assert (
+                fake_out.getvalue()
+                == "Results for 'Electrolyte Concentration': ['Electrolyte concentration']\n"
+                "Electrolyte concentration -> 1\n"
+            )
+
+        # Test for multiple strings as input (default returns best matches)
+        with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
+            model.variables.search(["Electrolyte", "Potenteel"], print_values=True)
+            out = (
+                "Exact matches for 'Electrolyte': ['Electrolyte concentration']\n"
+                "Electrolyte concentration -> 1\n"
+                "No exact matches found for 'Potenteel'. Best matches are: ['Electrode potential']\n"
             )
             assert fake_out.getvalue() == out
 
         # Test param search (default returns key, value)
         with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
             param.search("test")
-            assert fake_out.getvalue() == "test\t10\n"
+            out = "Results for 'test': ['test']\n" "test -> 10\n"
+            assert fake_out.getvalue() == out
+
+        # Test no matches and no best matches
+        with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
+            model.variables.search("NonexistentKey")
+            assert fake_out.getvalue() == "No matches found for 'NonexistentKey'\n"
+
+        # Test print_values=True with partial matches
+        with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
+            model.variables.search("Electrolyte", print_values=True)
+            out = (
+                "Results for 'Electrolyte': ['Electrolyte concentration']\n"
+                "Electrolyte concentration -> 1\n"
+            )
+            assert fake_out.getvalue() == out
+
+        # Test for empty string input (raises ValueError)
+        with pytest.raises(
+            ValueError,
+            match="The search term cannot be an empty or whitespace-only string",
+        ):
+            model.variables.search("", print_values=True)
+
+        # Test for list with all empty strings (raises ValueError)
+        with pytest.raises(
+            ValueError,
+            match="The 'keys' list cannot contain only empty or whitespace strings",
+        ):
+            model.variables.search(["", "   ", "\t"], print_values=True)
+
+        # Test for list with a mix of empty and valid strings
+        with mocker.patch("sys.stdout", new=StringIO()) as fake_out:
+            model.variables.search(["", "Electrolyte"], print_values=True)
+            out = (
+                "Results for 'Electrolyte': ['Electrolyte concentration']\n"
+                "Electrolyte concentration -> 1\n"
+            )
+            assert fake_out.getvalue() == out
+
+        # Test invalid input type
+        with pytest.raises(
+            TypeError,
+            match="'keys' must be a string or a list of strings, got <class 'int'>",
+        ):
+            model.variables.search(123)