Repository: scikit-hep/pyhf

Issue:
Simplify Workspace::_get_measurement()
# Description

At the moment, some of the functionality of `get_measurement()` on the `Workspace` is likely a bit too smart/magic. This should be trimmed/cleaned up to be simpler and less complex. This came out of some changes in #813 which reveal that we're passing in unused keyword arguments into `_ModelConfig` (namely: `measurement_name`).


Discussion:
@kratsg Do you want this to go into patch release `v0.4.2`? Or can it go in `v0.5.0`?
When is the patch going to release, tentatively? If we have some time then could I possibly work on this?
This week. I'll be working on it, since i've assigned it to myself.

Resolving patch:
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -222,11 +222,7 @@ def __init__(self, spec, **config_kwargs):
             spec, self.channel_nbins
         )
 
-        # measurement_name is passed in via Workspace::model and this is a bug. We'll remove it here for now
-        # but needs to be fixed upstream. #836 is filed to keep track.
-        config_kwargs.pop('measurement_name', None)
-
-        poiname = config_kwargs.pop('poiname', 'mu')
+        poi_name = config_kwargs.pop('poi_name', 'mu')
 
         default_modifier_settings = {'normsys': {'interpcode': 'code1'}}
         self.modifier_settings = config_kwargs.pop(
@@ -246,7 +242,7 @@ def __init__(self, spec, **config_kwargs):
         self.auxdata_order = []
 
         self._create_and_register_paramsets(_required_paramsets)
-        self.set_poi(poiname)
+        self.set_poi(poi_name)
         self.npars = len(self.suggested_init())
         self.nmaindata = sum(self.channel_nbins.values())
 
diff --git a/src/pyhf/workspace.py b/src/pyhf/workspace.py
--- a/src/pyhf/workspace.py
+++ b/src/pyhf/workspace.py
@@ -295,8 +295,9 @@ def __repr__(self):
         """Representation of the Workspace."""
         return object.__repr__(self)
 
-    # NB: this is a wrapper function to validate the returned measurement object against the spec
-    def get_measurement(self, **config_kwargs):
+    def get_measurement(
+        self, poi_name=None, measurement_name=None, measurement_index=None
+    ):
         """
         Get (or create) a measurement object.
 
@@ -319,22 +320,14 @@ def get_measurement(self, **config_kwargs):
             :obj:`dict`: A measurement object adhering to the schema defs.json#/definitions/measurement
 
         """
-        m = self._get_measurement(**config_kwargs)
-        utils.validate(m, 'measurement.json', self.version)
-        return m
-
-    def _get_measurement(self, **config_kwargs):
-        """See `Workspace::get_measurement`."""
-        poi_name = config_kwargs.pop('poi_name', None)
-        if poi_name:
-            return {
+        measurement = None
+        if poi_name is not None:
+            measurement = {
                 'name': 'NormalMeasurement',
                 'config': {'poi': poi_name, 'parameters': []},
             }
-
-        if self.measurement_names:
-            measurement_name = config_kwargs.pop('measurement_name', None)
-            if measurement_name:
+        elif self.measurement_names:
+            if measurement_name is not None:
                 if measurement_name not in self.measurement_names:
                     log.debug(
                         'measurements defined:\n\t{0:s}'.format(
@@ -346,36 +339,53 @@ def _get_measurement(self, **config_kwargs):
                             measurement_name
                         )
                     )
-                return self['measurements'][
+                measurement = self['measurements'][
                     self.measurement_names.index(measurement_name)
                 ]
+            else:
+                if measurement_index is None and len(self.measurement_names) > 1:
+                    log.warning(
+                        'multiple measurements defined. Taking the first measurement.'
+                    )
 
-            measurement_index = config_kwargs.pop('measurement_index', None)
-            if measurement_index:
-                return self['measurements'][measurement_index]
-
-            if len(self.measurement_names) > 1:
-                log.warning(
-                    'multiple measurements defined. Taking the first measurement.'
+                measurement_index = (
+                    measurement_index if measurement_index is not None else 0
                 )
-            return self['measurements'][0]
+                try:
+                    measurement = self['measurements'][measurement_index]
+                except IndexError:
+                    raise exceptions.InvalidMeasurement(
+                        f"The measurement index {measurement_index} is out of bounds as only {len(self.measurement_names)} measurement(s) have been defined."
+                    )
+        else:
+            raise exceptions.InvalidMeasurement("No measurements have been defined.")
 
-        raise exceptions.InvalidMeasurement(
-            "A measurement was not given to create the Model."
-        )
+        utils.validate(measurement, 'measurement.json', self.version)
+        return measurement
 
     def model(self, **config_kwargs):
         """
         Create a model object with/without patches applied.
 
+        See :func:`pyhf.workspace.Workspace.get_measurement` and :class:`pyhf.pdf.Model` for possible keyword arguments.
+
         Args:
             patches: A list of JSON patches to apply to the model specification
+            config_kwargs: Possible keyword arguments for the measurement and model configuration
 
         Returns:
             ~pyhf.pdf.Model: A model object adhering to the schema model.json
 
         """
-        measurement = self.get_measurement(**config_kwargs)
+
+        poi_name = config_kwargs.pop('poi_name', None)
+        measurement_name = config_kwargs.pop('measurement_name', None)
+        measurement_index = config_kwargs.pop('measurement_index', None)
+        measurement = self.get_measurement(
+            poi_name=poi_name,
+            measurement_name=measurement_name,
+            measurement_index=measurement_index,
+        )
         log.debug(
             'model being created for measurement {0:s}'.format(measurement['name'])
         )
@@ -389,7 +399,7 @@ def model(self, **config_kwargs):
         for patch in patches:
             modelspec = jsonpatch.JsonPatch(patch).apply(modelspec)
 
-        return Model(modelspec, poiname=measurement['config']['poi'], **config_kwargs)
+        return Model(modelspec, poi_name=measurement['config']['poi'], **config_kwargs)
 
     def data(self, model, with_aux=True):
         """


Test patch:
diff --git a/tests/test_import.py b/tests/test_import.py
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -157,7 +157,7 @@ def test_import_prepHistFactory():
         'channels': parsed_xml['channels'],
         'parameters': parsed_xml['measurements'][0]['config']['parameters'],
     }
-    pdf = pyhf.Model(spec, poiname='SigXsecOverSM')
+    pdf = pyhf.Model(spec, poi_name='SigXsecOverSM')
 
     data = [
         binvalue
@@ -222,7 +222,7 @@ def test_import_histosys():
         'channels': parsed_xml['channels'],
         'parameters': parsed_xml['measurements'][0]['config']['parameters'],
     }
-    pdf = pyhf.Model(spec, poiname='SigXsecOverSM')
+    pdf = pyhf.Model(spec, poi_name='SigXsecOverSM')
 
     channels = {channel['name']: channel for channel in pdf.spec['channels']}
 
@@ -264,7 +264,7 @@ def test_import_shapesys():
         'channels': parsed_xml['channels'],
         'parameters': parsed_xml['measurements'][0]['config']['parameters'],
     }
-    pdf = pyhf.Model(spec, poiname='SigXsecOverSM')
+    pdf = pyhf.Model(spec, poi_name='SigXsecOverSM')
 
     channels = {channel['name']: channel for channel in pdf.spec['channels']}
 
diff --git a/tests/test_pdf.py b/tests/test_pdf.py
--- a/tests/test_pdf.py
+++ b/tests/test_pdf.py
@@ -493,7 +493,7 @@ def test_invalid_modifier_name_resuse():
         ]
     }
     with pytest.raises(pyhf.exceptions.InvalidNameReuse):
-        pyhf.Model(spec, poiname='reused_name')
+        pyhf.Model(spec, poi_name='reused_name')
 
 
 def test_override_paramset_defaults():
@@ -596,7 +596,7 @@ def test_lumi_np_scaling():
             }
         ],
     }
-    pdf = pyhf.pdf.Model(spec, poiname="SigXsecOverSM")
+    pdf = pyhf.pdf.Model(spec, poi_name="SigXsecOverSM")
 
     poi_slice = pdf.config.par_slice('SigXsecOverSM')
     lumi_slice = pdf.config.par_slice('lumi')
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -66,7 +66,7 @@ def test_one_sample_missing_modifiers():
             }
         ]
     }
-    pyhf.Model(spec, poiname='mypoi')
+    pyhf.Model(spec, poi_name='mypoi')
 
 
 def test_add_unknown_modifier():
@@ -207,7 +207,7 @@ def test_parameters_definition():
         ],
         'parameters': [{'name': 'mypoi'}],
     }
-    pyhf.Model(spec, poiname='mypoi')
+    pyhf.Model(spec, poi_name='mypoi')
 
 
 def test_parameters_incorrect_format():
@@ -230,7 +230,7 @@ def test_parameters_incorrect_format():
         'parameters': {'a': 'fake', 'object': 2},
     }
     with pytest.raises(pyhf.exceptions.InvalidSpecification):
-        pyhf.Model(spec, poiname='mypoi')
+        pyhf.Model(spec, poi_name='mypoi')
 
 
 def test_parameters_duplicated():
@@ -253,7 +253,7 @@ def test_parameters_duplicated():
         'parameters': [{'name': 'mypoi'}, {'name': 'mypoi'}],
     }
     with pytest.raises(pyhf.exceptions.InvalidModel):
-        pyhf.Model(spec, poiname='mypoi')
+        pyhf.Model(spec, poi_name='mypoi')
 
 
 def test_parameters_all_props():
@@ -275,7 +275,7 @@ def test_parameters_all_props():
         ],
         'parameters': [{'name': 'mypoi', 'inits': [1], 'bounds': [[0, 1]]}],
     }
-    pyhf.Model(spec, poiname='mypoi')
+    pyhf.Model(spec, poi_name='mypoi')
 
 
 @pytest.mark.parametrize(
@@ -319,7 +319,7 @@ def test_parameters_bad_parameter(bad_parameter):
         'parameters': [bad_parameter],
     }
     with pytest.raises(pyhf.exceptions.InvalidSpecification):
-        pyhf.Model(spec, poiname='mypoi')
+        pyhf.Model(spec, poi_name='mypoi')
 
 
 @pytest.mark.parametrize(
@@ -345,4 +345,4 @@ def test_parameters_normfactor_bad_attribute(bad_parameter):
         'parameters': [bad_parameter],
     }
     with pytest.raises(pyhf.exceptions.InvalidModel):
-        pyhf.Model(spec, poiname='mypoi')
+        pyhf.Model(spec, poi_name='mypoi')
diff --git a/tests/test_validation.py b/tests/test_validation.py
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -779,7 +779,7 @@ def test_import_roundtrip(tmpdir, toplvl, basedir):
         'channels': parsed_xml_before['channels'],
         'parameters': parsed_xml_before['measurements'][0]['config']['parameters'],
     }
-    pdf_before = pyhf.Model(spec, poiname='SigXsecOverSM')
+    pdf_before = pyhf.Model(spec, poi_name='SigXsecOverSM')
 
     tmpconfig = tmpdir.mkdir('config')
     tmpdata = tmpdir.mkdir('data')
@@ -797,7 +797,7 @@ def test_import_roundtrip(tmpdir, toplvl, basedir):
         'channels': parsed_xml_after['channels'],
         'parameters': parsed_xml_after['measurements'][0]['config']['parameters'],
     }
-    pdf_after = pyhf.Model(spec, poiname='SigXsecOverSM')
+    pdf_after = pyhf.Model(spec, poi_name='SigXsecOverSM')
 
     data_before = [
         binvalue
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -77,6 +77,21 @@ def test_get_measurement_nonexist(workspace_factory):
     assert 'nonexistent_measurement' in str(excinfo.value)
 
 
+def test_get_measurement_index_outofbounds(workspace_factory):
+    ws = workspace_factory()
+    with pytest.raises(pyhf.exceptions.InvalidMeasurement) as excinfo:
+        ws.get_measurement(measurement_index=9999)
+    assert 'out of bounds' in str(excinfo.value)
+
+
+def test_get_measurement_no_measurements_defined(workspace_factory):
+    ws = workspace_factory()
+    ws.measurement_names = []
+    with pytest.raises(pyhf.exceptions.InvalidMeasurement) as excinfo:
+        ws.get_measurement()
+    assert 'No measurements have been defined' in str(excinfo.value)
+
+
 def test_get_workspace_measurement_priority(workspace_factory):
     w = workspace_factory()
 
@@ -694,3 +709,11 @@ def test_combine_workspace(workspace_factory, join):
     assert set(combined.channels) == set(ws.channels + new_ws.channels)
     assert set(combined.samples) == set(ws.samples + new_ws.samples)
     assert set(combined.parameters) == set(ws.parameters + new_ws.parameters)
+
+
+def test_workspace_equality(workspace_factory):
+    ws = workspace_factory()
+    ws_other = workspace_factory()
+    assert ws == ws
+    assert ws == ws_other
+    assert ws != 'not a workspace'