Repository: scikit-hep/pyhf

Issue:
data specification for workspace is not specific enough
# Description

Related to #106.  https://github.com/diana-hep/pyhf/blob/master/pyhf/schemas/1.0.0/workspace.json#L8 currently describes data as an object, but that could be anything. We probably want to specify it as an object with keys for example... or perhaps as @lukasheinrich stated before, an array of items.


Discussion:
To ping this given some recent discussion, it seems that there is consensus to use an array of items. :+1: The idea is that it is easier to inject things into a fixed order list than to inject by key name (which won't be known until look up). @kratsg gave the example that

> ... `workspace.json/data/0/rates` is easier than `workspace.json/data/SRA_lowmeff`

This needs to get fixed before the `v1.0.0` schema gets tagged and so should also get done before pyhf `v0.1.1` is released.

Resolving patch:
diff --git a/pyhf/pdf.py b/pyhf/pdf.py
--- a/pyhf/pdf.py
+++ b/pyhf/pdf.py
@@ -493,6 +493,10 @@ def __init__(self, spec, **config_kwargs):
         for measurement in self.spec.get('measurements', []):
             self.measurement_names.append(measurement['name'])
 
+        self.observations = {}
+        for obs in self.spec['observations']:
+            self.observations[obs['name']] = obs['data']
+
     # NB: this is a wrapper function to validate the returned measurement object against the spec
     def get_measurement(self, **config_kwargs):
         """
@@ -601,7 +605,15 @@ def data(self, model, with_aux=True):
             data: A list of numbers
         """
 
-        observed_data = sum((self.spec['data'][c] for c in model.config.channels), [])
+        try:
+            observed_data = sum(
+                (self.observations[c] for c in model.config.channels), []
+            )
+        except KeyError:
+            log.error(
+                "Invalid channel: the workspace does not have observation data for one of the channels in the model."
+            )
+            raise
         if with_aux:
             observed_data += model.config.auxdata
         return observed_data
diff --git a/pyhf/readxml.py b/pyhf/readxml.py
--- a/pyhf/readxml.py
+++ b/pyhf/readxml.py
@@ -269,7 +269,7 @@ def parse(configfile, rootdir, track_progress=False):
     result = {
         'measurements': process_measurements(toplvl),
         'channels': [{'name': k, 'samples': v['samples']} for k, v in channels.items()],
-        'data': {k: v['data'] for k, v in channels.items()},
+        'observations': [{'name': k, 'data': v['data']} for k, v in channels.items()],
         'version': utils.SCHEMA_VERSION,
     }
 
diff --git a/pyhf/writexml.py b/pyhf/writexml.py
--- a/pyhf/writexml.py
+++ b/pyhf/writexml.py
@@ -183,19 +183,21 @@ def build_sample(samplespec, channelname):
     return sample
 
 
-def build_data(dataspec, channelname):
+def build_data(obsspec, channelname):
     histname = _make_hist_name(channelname, 'data')
     data = ET.Element('Data', HistoName=histname, InputFile=_ROOT_DATA_FILE._path)
-    _export_root_histogram(histname, dataspec[channelname])
+
+    observation = next((obs for obs in obsspec if obs['name'] == channelname), None)
+    _export_root_histogram(histname, observation['data'])
     return data
 
 
-def build_channel(channelspec, dataspec):
+def build_channel(channelspec, obsspec):
     channel = ET.Element(
         'Channel', Name=channelspec['name'], InputFile=_ROOT_DATA_FILE._path
     )
-    if dataspec:
-        data = build_data(dataspec, channelspec['name'])
+    if obsspec:
+        data = build_data(obsspec, channelspec['name'])
         channel.append(data)
     for samplespec in channelspec['samples']:
         channel.append(build_sample(samplespec, channelspec['name']))
@@ -219,7 +221,7 @@ def writexml(spec, specdir, data_rootdir, resultprefix):
                 specdir, '{0:s}_{1:s}.xml'.format(resultprefix, channelspec['name'])
             )
             with open(channelfilename, 'w') as channelfile:
-                channel = build_channel(channelspec, spec.get('data'))
+                channel = build_channel(channelspec, spec.get('observations'))
                 indent(channel)
                 channelfile.write(
                     "<!DOCTYPE Channel SYSTEM '../HistFactorySchema.dtd'>\n\n"


Test patch:
diff --git a/tests/test_export.py b/tests/test_export.py
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -269,7 +269,7 @@ def test_export_channel(mocker, spec):
 
 def test_export_data(mocker):
     channelname = 'channel'
-    dataspec = {channelname: [0, 1, 2, 3]}
+    dataspec = [{'name': channelname, 'data': [0, 1, 2, 3]}]
 
     mocker.patch('pyhf.writexml._ROOT_DATA_FILE')
     data = pyhf.writexml.build_data(dataspec, channelname)
diff --git a/tests/test_import.py b/tests/test_import.py
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -56,7 +56,9 @@ def test_import_prepHistFactory():
     data = [
         binvalue
         for k in pdf.spec['channels']
-        for binvalue in parsed_xml['data'][k['name']]
+        for binvalue in next(
+            obs for obs in parsed_xml['observations'] if obs['name'] == k['name']
+        )['data']
     ] + pdf.config.auxdata
 
     channels = {channel['name'] for channel in pdf.spec['channels']}
@@ -120,7 +122,9 @@ def test_import_histosys():
     data = [
         binvalue
         for k in pdf.spec['channels']
-        for binvalue in parsed_xml['data'][k['name']]
+        for binvalue in next(
+            obs for obs in parsed_xml['observations'] if obs['name'] == k['name']
+        )['data']
     ] + pdf.config.auxdata
 
     channels = {channel['name']: channel for channel in pdf.spec['channels']}
@@ -172,7 +176,9 @@ def test_import_shapesys():
     data = [
         binvalue
         for k in pdf.spec['channels']
-        for binvalue in parsed_xml['data'][k['name']]
+        for binvalue in next(
+            obs for obs in parsed_xml['observations'] if obs['name'] == k['name']
+        )['data']
     ] + pdf.config.auxdata
 
     channels = {channel['name']: channel for channel in pdf.spec['channels']}
diff --git a/tests/test_validation.py b/tests/test_validation.py
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -801,13 +801,17 @@ def test_import_roundtrip(tmpdir, toplvl, basedir):
     data_before = [
         binvalue
         for k in pdf_before.config.channels
-        for binvalue in parsed_xml_before['data'][k]
+        for binvalue in next(
+            obs for obs in parsed_xml_before['observations'] if obs['name'] == k
+        )['data']
     ] + pdf_before.config.auxdata
 
     data_after = [
         binvalue
         for k in pdf_after.config.channels
-        for binvalue in parsed_xml_after['data'][k]
+        for binvalue in next(
+            obs for obs in parsed_xml_after['observations'] if obs['name'] == k
+        )['data']
     ] + pdf_after.config.auxdata
 
     assert data_before == data_after
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -3,6 +3,7 @@
 import pytest
 import pyhf.exceptions
 import json
+import logging
 
 
 @pytest.fixture(
@@ -104,3 +105,25 @@ def test_get_workspace_model_default(workspace_factory):
     w = workspace_factory()
     m = w.model()
     assert m
+
+
+def test_workspace_observations(workspace_factory):
+    w = workspace_factory()
+    assert w.observations
+
+
+def test_get_workspace_data(workspace_factory):
+    w = workspace_factory()
+    m = w.model()
+    assert w.data(m)
+
+
+def test_get_workspace_data_bad_model(workspace_factory, caplog):
+    w = workspace_factory()
+    m = w.model()
+    # the iconic fragrance of an expected failure
+    m.config.channels = [c.replace('channel', 'chanel') for c in m.config.channels]
+    with caplog.at_level(logging.INFO, 'pyhf.pdf'):
+        with pytest.raises(KeyError):
+            assert w.data(m)
+            assert 'Invalid channel' in caplog.text