Repository: dwavesystems/dwave-cloud-client
Pull request #189 (2018-06-05 01:50:04)

Add solver feature properties and feature-based solver selection


Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index a30e69f..b8333be 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -232,7 +232,7 @@ class Client(object):
 
             legacy_config_fallback (bool, default=True):
                 If True (the default) and loading from a standard D-Wave Cloud Client configuration
-                file (``dwave.conf``) fails, tries loading a legacy configuration file (``~\.dwrc``).
+                file (``dwave.conf``) fails, tries loading a legacy configuration file (``~/.dwrc``).
 
         Other Parameters:
             Unrecognized keys (str):
@@ -533,10 +533,8 @@ class Client(object):
 
                 @staticmethod
                 def is_solver_handled(solver):
-                    if not solver:
-                        return False
-                    return solver.id.startswith('My_Solver_')
-        
+                    return solver and solver.id.startswith('My_Solver_')
+
         """
         return True
 
@@ -572,8 +570,8 @@ class Client(object):
              u'2000Q_ONLINE_SOLVER2': <dwave.cloud.solver.Solver at 0x7e84828>}
             >>> # code that uses client
             >>> client.close() # doctest: +SKIP
-
         """
+
         with self._solvers_lock:
             if self._all_solvers_ready and not refresh:
                 return self._solvers
@@ -604,6 +602,65 @@ class Client(object):
             self._all_solvers_ready = True
             return self._solvers
 
+    def solvers(self, qpu=None, software=None, vfyc=None,
+                flux_biases=None, num_qubits=None, refresh=False):
+        """Returns a filtered list of solvers handled by this client.
+
+        Args:
+            qpu (bool, default=None):
+                Should solver be QPU based?
+
+            software (bool, default=None):
+                Should solver be software based?
+
+            vfyc (bool, default=None):
+                Should solver work on "virtual full-yield chip"?
+
+            flux_biases (bool, default=None):
+                Should solver accept flux biases?
+
+            num_qubits (int/[int,int], default=None):
+                What's the range (or exact number) of qubits solver should handle?
+
+        Returns:
+            list[Solver]: List of all solvers that satisfy the above conditions.
+
+        Examples:
+            Get a solver not based on VFYC, with 2048 qubits:
+
+                solver = client.solvers(vfyc=True, num_qubits=2048)[0]
+
+            Get all solvers that have between 1024 and 2048 qubits:
+
+                solvers = client.solvers(num_qubits=[1024, 2048])
+
+            Get all solvers that have at least 2000 qubits:
+
+                solvers = client.solvers(num_qubits=[2000, None])
+        """
+
+        def predicate(solver):
+            if qpu is not None and solver.is_qpu != qpu:
+                return False
+            if software is not None and solver.is_software != software:
+                return False
+            if vfyc is not None and solver.is_vfyc != vfyc:
+                return False
+            if flux_biases is not None and solver.has_flux_biases != flux_biases:
+                return False
+            if num_qubits is not None:
+                if isinstance(num_qubits, list) and len(num_qubits) == 2:
+                    min_qubits, max_qubits = num_qubits
+                else:
+                    min_qubits, max_qubits = num_qubits, num_qubits
+                if min_qubits is not None and solver.num_qubits < min_qubits:
+                    return False
+                if max_qubits is not None and max_qubits < solver.num_qubits:
+                    return False
+            return True
+
+        return list(filter(predicate, self.get_solvers(refresh=refresh).values()))
+
     def get_solver(self, name=None, refresh=False):
         """Load the configuration for a single solver.
 
diff --git a/dwave/cloud/qpu.py b/dwave/cloud/qpu.py
index 792e6da..a162edf 100644
--- a/dwave/cloud/qpu.py
+++ b/dwave/cloud/qpu.py
@@ -72,11 +72,7 @@ class Client(BaseClient):
 
                 @staticmethod
                 def is_solver_handled(solver):
-                    if not solver:
-                        return False
-                    return solver.id.startswith('2000Q')
+                    return solver and solver.id.startswith('2000Q')
 
         """
-        if not solver:
-            return False
-        return not solver.id.startswith('c4-sw_')
+        return solver and solver.is_qpu
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 094ac8a..683186f 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -134,6 +134,33 @@ class Solver(object):
     def __str__(self):
         return "Solver(id={!r})".format(self.id)
 
+    @property
+    def is_qpu(self):
+        "Is this a QPU-based solver?"
+        # TODO: add a field for this in SAPI response; for now base decision on id/name
+        return not self.id.startswith('c4-sw_')
+
+    @property
+    def is_software(self):
+        "Is this a software-based solver?"
+        # TODO: add a field for this in SAPI response; for now base decision on id/name
+        return self.id.startswith('c4-sw_')
+
+    @property
+    def is_vfyc(self):
+        "Is this a virtual full-yield chip?"
+        return self.properties.get('vfyc', False) == True
+
+    @property
+    def num_qubits(self):
+        "The number of active (encoding) qubits."
+        return len(self.nodes)
+
+    @property
+    def has_flux_biases(self):
+        "Solver supports/accepts ``flux_biases``."
+        return 'flux_biases' in self.parameters
+
     def sample_ising(self, linear, quadratic, **params):
         """Sample from the specified Ising model.
 
diff --git a/dwave/cloud/sw.py b/dwave/cloud/sw.py
index 7366b9d..c585f37 100644
--- a/dwave/cloud/sw.py
+++ b/dwave/cloud/sw.py
@@ -64,11 +64,7 @@ class Client(BaseClient):
 
                 @staticmethod
                 def is_solver_handled(solver):
-                    if not solver:
-                        return False
-                    return solver.id.startswith('My_SW_Solver')
+                    return solver and solver.id.startswith('My_SW_Solver')
 
         """
-        if not solver:
-            return False
-        return solver.id.startswith('c4-sw_')
+        return solver and solver.is_software


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 2e39f77..48ea57a 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -9,6 +9,7 @@ import unittest
 
 from dwave.cloud.config import load_config
 from dwave.cloud.qpu import Client
+from dwave.cloud.solver import Solver
 from dwave.cloud.exceptions import SolverAuthenticationError
 from dwave.cloud.testing import mock
 import dwave.cloud
@@ -113,5 +114,85 @@ class ClientFactory(unittest.TestCase):
                     ValueError, dwave.cloud.Client.from_config, legacy_config_fallback=False)
 
 
+class FeatureBasedSolverSelection(unittest.TestCase):
+    """Test Client.solvers()."""
+
+    def setUp(self):
+        # mock solvers
+        self.solver1 = Solver(client=None, data={
+            "properties": {
+                "supported_problem_types": ["qubo", "ising"],
+                "qubits": [0, 1, 2],
+                "couplers": [[0, 1], [0, 2], [1, 2]],
+                "num_qubits": 3,
+                "parameters": {"num_reads": "Number of samples to return."}
+            },
+            "id": "solver1",
+            "description": "A test solver 1"
+        })
+        self.solver2 = Solver(client=None, data={
+            "properties": {
+                "supported_problem_types": ["qubo", "ising"],
+                "qubits": [0, 1, 2, 3, 4],
+                "couplers": [[0, 1], [0, 2], [1, 2], [2, 3], [3, 4]],
+                "num_qubits": 5,
+                "parameters": {
+                    "num_reads": "Number of samples to return.",
+                    "flux_biases": "Supported ..."
+                },
+                "vfyc": True
+            },
+            "id": "solver2",
+            "description": "A test solver 2"
+        })
+        self.solvers = [self.solver1, self.solver2]
+
+        # mock client
+        self.client = Client('endpoint', 'token')
+        self.client._solvers = {
+            self.solver1.id: self.solver1,
+            self.solver2.id: self.solver2
+        }
+        self.client._all_solvers_ready = True
+
+    def shutDown(self):
+        self.client.close()
+
+    def assertSolvers(self, container, members):
+        return set(container) == set(members)
+
+    def test_default(self):
+        self.assertSolvers(self.client.solvers(), self.solvers)
+
+    def test_one_boolean(self):
+        self.assertSolvers(self.client.solvers(vfyc=False), [self.solver1])
+        self.assertSolvers(self.client.solvers(vfyc=True), [self.solver2])
+        self.assertSolvers(self.client.solvers(flux_biases=False), [self.solver1])
+        self.assertSolvers(self.client.solvers(flux_biases=True), [self.solver2])
+
+    def test_boolean_combo(self):
+        self.assertSolvers(self.client.solvers(vfyc=False, flux_biases=True), [])
+        self.assertSolvers(self.client.solvers(vfyc=True, flux_biases=True), [self.solver2])
+
+    def test_int_range(self):
+        self.assertSolvers(self.client.solvers(num_qubits=3), [self.solver1])
+        self.assertSolvers(self.client.solvers(num_qubits=4), [])
+        self.assertSolvers(self.client.solvers(num_qubits=5), [self.solver2])
+
+        self.assertSolvers(self.client.solvers(num_qubits=[3, 5]), self.solvers)
+        self.assertSolvers(self.client.solvers(num_qubits=[2, None]), self.solvers)
+        self.assertSolvers(self.client.solvers(num_qubits=[None, 6]), self.solvers)
+
+        self.assertSolvers(self.client.solvers(num_qubits=[2, 4]), [self.solver1])
+        self.assertSolvers(self.client.solvers(num_qubits=[4, None]), [self.solver2])
+
+    def test_range_boolean_combo(self):
+        self.assertSolvers(self.client.solvers(num_qubits=3, vfyc=True), [])
+        self.assertSolvers(self.client.solvers(num_qubits=[3, None], vfyc=True), [self.solver2])
+        self.assertSolvers(self.client.solvers(num_qubits=[None, 4], vfyc=True), [])
+        self.assertSolvers(self.client.solvers(num_qubits=[None, 4], flux_biases=False), [self.solver1])
+        self.assertSolvers(self.client.solvers(num_qubits=5, flux_biases=True), [self.solver2])
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_mock_solver_loading.py b/tests/test_mock_solver_loading.py
index 21adf08..04af5be 100644
--- a/tests/test_mock_solver_loading.py
+++ b/tests/test_mock_solver_loading.py
@@ -22,7 +22,7 @@ bad_url = 'https://not-a-subdomain.dwavesys.com'
 bad_token = '------------------------'
 
 
-def solver_object(id_, incomplete=False):
+def solver_data(id_, incomplete=False):
     """Return string describing a single solver."""
     obj = {
         "properties": {
@@ -41,6 +41,9 @@ def solver_object(id_, incomplete=False):
 
     return json.dumps(obj)
 
+def solver_object(id_, incomplete=False):
+    return Solver(client=None, data=json.loads(solver_data(id_, incomplete)))
+
 
 # Define the endpoinds
 all_solver_url = '{}/solvers/remote/'.format(url)
@@ -51,8 +54,8 @@ solver2_url = '{}/solvers/remote/{}/'.format(url, second_solver_name)
 def setup_server(m):
     """Add endpoints to the server."""
     # Content strings
-    first_solver_response = solver_object(solver_name)
-    second_solver_response = solver_object(second_solver_name)
+    first_solver_response = solver_data(solver_name)
+    second_solver_response = solver_data(second_solver_name)
     two_solver_response = '[' + first_solver_response + ',' + second_solver_response + ']'
 
     # Setup the server
@@ -159,7 +162,7 @@ class MockSolverLoading(unittest.TestCase):
     def test_load_solver_missing_data(self):
         """Try to load a solver that has incomplete data."""
         with requests_mock.mock() as m:
-            m.get(solver1_url, text=solver_object(solver_name, True))
+            m.get(solver1_url, text=solver_data(solver_name, True))
             with Client(url, token) as client:
                 with self.assertRaises(InvalidAPIResponseError):
                     client.get_solver(solver_name)
@@ -167,16 +170,35 @@ class MockSolverLoading(unittest.TestCase):
     def test_load_solver_broken_response(self):
         """Try to load a solver for which the server has returned a truncated response."""
         with requests_mock.mock() as m:
-            body = solver_object(solver_name)
+            body = solver_data(solver_name)
             m.get(solver1_url, text=body[0:len(body)//2])
             with Client(url, token) as client:
                 with self.assertRaises(ValueError):
                     client.get_solver(solver_name)
 
     def test_solver_filtering_in_client(self):
-        self.assertTrue(Client.is_solver_handled(Solver(None, json.loads(solver_object('test')))))
-        self.assertFalse(Client.is_solver_handled(Solver(None, json.loads(solver_object('c4-sw_')))))
-        self.assertFalse(None)
+        self.assertTrue(Client.is_solver_handled(solver_object('test')))
+        self.assertFalse(Client.is_solver_handled(solver_object('c4-sw_')))
+        self.assertFalse(Client.is_solver_handled(None))
+
+    def test_solver_feature_properties(self):
+        self.assertTrue(solver_object('dw2000').is_qpu)
+        self.assertFalse(solver_object('dw2000').is_software)
+        self.assertFalse(solver_object('c4-sw_x').is_qpu)
+        self.assertTrue(solver_object('c4-sw_x').is_software)
+
+        self.assertFalse(solver_object('dw2000').is_vfyc)
+        self.assertEqual(solver_object('dw2000').num_qubits, 3)
+        self.assertFalse(solver_object('dw2000').has_flux_biases)
+
+        data = json.loads(solver_data('test'))
+        data['properties']['vfyc'] = 'error'
+        self.assertFalse(Solver(None, data).is_vfyc)
+        data['properties']['vfyc'] = True
+        self.assertTrue(Solver(None, data).is_vfyc)
+
+        data['properties']['parameters']['flux_biases'] = '...'
+        self.assertTrue(Solver(None, data).has_flux_biases)
 
 
 class GetEvent(Exception):