diff --git a/README.md b/README.md index a39e488..2b084f6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Features - Requirements weighting with a Binary Weighting Matrix - Programmatic or Spreadsheet based model creation (via Excel workbooks or Google Sheets). + - Native JSON serialisation for both CODA models and Binary + Weighting Matrices (human-readable, git-diff-friendly). - Command-line interface for quick model inspection and interactive weighting @@ -59,6 +61,37 @@ Questions are shuffled by default. Pass `--no-shuffle` to work through them in a fixed order. +JSON model i/o +-------------- + +Both CODA models and Binary Weighting Matrices serialise to and from a +flat, human-readable JSON format. This is handy for version-controlling +models or moving them between the two tools. + +```python +from vdd.coda.models import CODA +from vdd.requirements.models import BinWM + +# Weight requirements with a binary weighting matrix, then persist it. +bwm = BinWM('Stiffness', 'Friction', 'Weight') +bwm.prompt() +bwm.to_json('weights.json') + +# Seed a CODA model directly from the weighting matrix scores. +coda = CODA() +coda.add_requirements_from(bwm) + +# Round-trip a CODA model through JSON. +coda.to_json('model.json') +reloaded = CODA.read_json('model.json') +``` + +`to_json()` returns a string when no path is given. `to_dict()` / +`from_dict()` expose the same data as plain-python dicts. The BinWM +format (`{"requirements": [...], "binary_matrix": [[...]]}`) is the +same shape used by the bundled example fixtures. + + Documentation ------- diff --git a/vdd/coda/models.py b/vdd/coda/models.py index 5e9e311..21d0a25 100644 --- a/vdd/coda/models.py +++ b/vdd/coda/models.py @@ -11,6 +11,7 @@ """ from operator import attrgetter import abc +import json from collections.abc import Sequence import numpy as np @@ -72,6 +73,79 @@ def read_excel(cls, path, parser_class=io.CompactExcelParser): model = cls() return cls._transfer_elements(model, parser) + @classmethod + def read_json(cls, path): + """Construct a CODA model from a native JSON file. + + Parameters + ---------- + + path : str + Filesystem path to a JSON file previously written by + :meth:`to_json` (or any file matching that schema). + + Returns + ------- + + CODA + Populated CODA model + + See Also + -------- + + from_dict, to_json + """ + with open(path) as f: + data = json.load(f) + return cls.from_dict(data) + + @classmethod + def from_dict(cls, data): + """Construct a CODA model from a plain-python dict. + + This is the inverse of :meth:`to_dict`. The model is rebuilt + through the public ``add_requirement``/``add_characteristic``/ + ``add_relationship`` API so that requirement weight + normalisation is replayed on this instance (rather than copying + element objects, whose weights are context-dependent). + + Parameters + ---------- + + data : dict + Mapping with optional ``requirements``, ``characteristics`` + and ``relationships`` keys, as produced by :meth:`to_dict`. + + Returns + ------- + + CODA + Populated CODA model + """ + model = cls() + for req in data.get('requirements', []): + model.add_requirement( + req['name'], + req['weight'], + normalise=req.get('normalise', True), + ) + for char in data.get('characteristics', []): + limits = char.get('limits') + if limits is not None: + limits = tuple(limits) + model.add_characteristic(char['name'], limits, + char.get('value')) + for rel in data.get('relationships', []): + model.add_relationship( + rel['requirement'], + rel['characteristic'], + rel['type'], + rel['correlation'], + rel['target'], + rel.get('tolerance'), + ) + return model + @staticmethod def _transfer_elements(inst, source): # Helper method for the constructors. @@ -260,6 +334,33 @@ def add_requirement(self, name, weight, normalise=True): cls(context=self, name=name, weight=weight), ) + def add_requirements_from(self, binwm): + """Add requirements from a Binary Weighting Matrix. + + One requirement is added per BinWM requirement, using its + relative score as the weight. + + Parameters + ---------- + + binwm : vdd.requirements.models.BinWM + Source of requirement names and scores. + + Notes + ----- + + ``BinWM.score`` sums to unity by construction, so the scores are + already-normalised weights. They are nonetheless added with + ``normalise=True`` (self-normalising requirements) so that + + 1. floating-point drift around unity cannot trip the + combined-weight guard in :meth:`add_requirement`, and + 2. further requirements can be mixed in afterwards, with the + whole set re-normalising automatically. + """ + for name, score in zip(binwm.requirements, binwm.score): + self.add_requirement(name, float(score), normalise=True) + def add_characteristic(self, name, limits=None, value=None): """Add a characteristic to the model. @@ -337,6 +438,122 @@ def add_relationship(self, rlkup, clkup, reltype, correlation, cls, args = relationships[reltype] self.matrix[r,c] = cls(*args) + def to_dict(self): + """Serialise the model to a plain-python dict. + + The schema is flat and human/diff-friendly:: + + { + "requirements": [ + {"name": ..., "weight": ..., "normalise": bool} + ], + "characteristics": [ + {"name": ..., "limits": [lo, hi], "value": ...} + ], + "relationships": [ + {"requirement": ..., "characteristic": ..., + "type": "min"|"max"|"opt", "correlation": ..., + "target": ..., "tolerance": ...} + ] + } + + Only non-null relationships are emitted. For requirements the + *raw* weight is stored (``base_weight`` for self-normalising + requirements, otherwise the pre-normalised weight) alongside the + ``normalise`` flag, so :meth:`from_dict` can replay + normalisation faithfully. ``limits`` elements and ``value`` may + be ``None``; ``tolerance`` is ``None`` for non-optimising + relationships. + + Returns + ------- + + dict + """ + requirements = [] + for req in self.requirements: + if isinstance(req, CODARequirementNorm): + requirements.append({ + 'name': req.name, + 'weight': req.base_weight, + 'normalise': True, + }) + else: + requirements.append({ + 'name': req.name, + 'weight': req.weight, + 'normalise': False, + }) + + characteristics = [] + for char in self.characteristics: + try: + value = char.value + except AttributeError: + value = None + characteristics.append({ + 'name': char.name, + 'limits': list(char.limits), + 'value': value, + }) + + type_by_cls = { + CODAMaximise: 'max', + CODAMinimise: 'min', + CODAOptimise: 'opt', + } + matrix = self.matrix + relationships = [] + for i, req in enumerate(self.requirements): + for j, char in enumerate(self.characteristics): + rel = matrix[i, j] + reltype = type_by_cls.get(type(rel)) + if reltype is None: + # CODANull (or any non-modelled relationship). + continue + relationships.append({ + 'requirement': req.name, + 'characteristic': char.name, + 'type': reltype, + 'correlation': rel.correlation, + 'target': rel.target, + 'tolerance': (rel.tolerance if reltype == 'opt' + else None), + }) + + return { + 'requirements': requirements, + 'characteristics': characteristics, + 'relationships': relationships, + } + + def to_json(self, path=None): + """Serialise the model to JSON. + + Parameters + ---------- + + path : str, optional + If given, the JSON is written to this file. Otherwise the + JSON is returned as a string. + + Returns + ------- + + str or None + The JSON string when ``path`` is ``None``, else ``None``. + + See Also + -------- + + to_dict, read_json + """ + text = json.dumps(self.to_dict(), indent=2) + if path is None: + return text + with open(path, 'w') as f: + f.write(text) + def compare(self, other): """Return True if the model matrix is the same as another's. """ diff --git a/vdd/coda/tests/test_models.py b/vdd/coda/tests/test_models.py index 3cc3151..c86d4ee 100644 --- a/vdd/coda/tests/test_models.py +++ b/vdd/coda/tests/test_models.py @@ -1,3 +1,4 @@ +import json import os import numpy as np @@ -6,6 +7,7 @@ from .. import models from .. import io from . import DATA_DIR +from vdd.requirements import models as rmodels class TestCODA: @@ -515,6 +517,193 @@ def test_read_excel(self): assert self.wheel.merit == model.merit +class TestCODAJSONRoundTrip: + """Native JSON serialisation round-trips for CODA models.""" + + def _build_wheel(self): + # Programmatic copy of the bicycle-wheel case study (ref 1). + wheel = models.CODA() + for name in ('Stiffness', 'Friction', 'Weight', + 'Manufacturability', 'Repairability'): + wheel.add_requirement(name, 0.2) + + wheel.add_characteristic('Tyre Diameter', (24, 29), 24) + wheel.add_characteristic('Tyre Width', (11, 18), 13) + wheel.add_characteristic('Spoke Thickness', (2.8, 5), 4.3) + wheel.add_characteristic('Use of Composites', (0.05, 0.8), 0.2) + + reqt = 'Stiffness' + wheel.add_relationship(reqt, 0, 'min', 'strong', 29) + wheel.add_relationship(reqt, 1, 'max', 'moderate', 12) + wheel.add_relationship(reqt, 2, 'max', 'strong', 3) + wheel.add_relationship(reqt, 3, 'opt', 'moderate', 0.5, 0.2) + + reqt = 'Friction' + wheel.add_relationship(reqt, 'Tyre Diameter', 'max', 'moderate', 25) + wheel.add_relationship(reqt, 'Tyre Width', 'max', 'strong', 11) + + reqt = 'Weight' + wheel.add_relationship(reqt, 'Tyre Diameter', 'min', 'strong', 26) + wheel.add_relationship(reqt, 'Tyre Width', 'min', 'strong', 15) + wheel.add_relationship(reqt, 'Spoke Thickness', 'min', 'moderate', 3.5) + wheel.add_relationship(reqt, 'Use of Composites', 'max', 'strong', 0.3) + + reqt = 'Manufacturability' + wheel.add_relationship(reqt, 'Tyre Width', 'max', 'weak', 12) + wheel.add_relationship(reqt, 'Spoke Thickness', 'max', 'moderate', 2.9) + wheel.add_relationship(reqt, 'Use of Composites', 'min', 'strong', 0.5) + + reqt = 'Repairability' + wheel.add_relationship(reqt, 'Tyre Width', 'max', 'weak', 14) + wheel.add_relationship(reqt, 'Spoke Thickness', 'max', 'moderate', 3.8) + wheel.add_relationship(reqt, 'Use of Composites', 'min', 'strong', 0.25) + + return wheel + + def _assert_equivalent(self, a, b): + assert ([r.name for r in a.requirements] == + [r.name for r in b.requirements]) + assert ([c.name for c in a.characteristics] == + [c.name for c in b.characteristics]) + for ca, cb in zip(a.characteristics, b.characteristics): + assert ca.limits == cb.limits + assert ca.value == cb.value + np.testing.assert_array_almost_equal(a.correlation, b.correlation) + np.testing.assert_array_almost_equal(a.weight, b.weight) + np.testing.assert_array_almost_equal(a.satisfaction, b.satisfaction) + assert a.merit == pytest.approx(b.merit) + + def test_to_dict_schema(self): + """Dict has the expected flat schema and only non-null rels.""" + wheel = self._build_wheel() + d = wheel.to_dict() + + assert set(d) == {'requirements', 'characteristics', + 'relationships'} + assert d['requirements'][0] == { + 'name': 'Stiffness', 'weight': 0.2, 'normalise': True + } + assert d['characteristics'][0] == { + 'name': 'Tyre Diameter', 'limits': [24, 29], 'value': 24 + } + # 16 non-null relationships (nulls omitted). + assert len(d['relationships']) == 16 + + opt = [r for r in d['relationships'] if r['type'] == 'opt'] + assert len(opt) == 1 + assert opt[0]['target'] == 0.5 + assert opt[0]['tolerance'] == 0.2 + assert all(r['tolerance'] is None + for r in d['relationships'] if r['type'] != 'opt') + + def test_roundtrip_dict(self): + wheel = self._build_wheel() + assert wheel.merit == pytest.approx(0.5788, abs=1e-4) + clone = models.CODA.from_dict(wheel.to_dict()) + self._assert_equivalent(wheel, clone) + + def test_roundtrip_json_string(self): + wheel = self._build_wheel() + text = wheel.to_json() + assert isinstance(text, str) + clone = models.CODA.from_dict(json.loads(text)) + self._assert_equivalent(wheel, clone) + + def test_roundtrip_json_file(self, tmp_path): + wheel = self._build_wheel() + path = tmp_path / 'wheel.json' + assert wheel.to_json(str(path)) is None + clone = models.CODA.read_json(str(path)) + self._assert_equivalent(wheel, clone) + + def test_roundtrip_optimise_and_unset_values(self): + """Optimise tolerance and unset characteristic values survive.""" + model = models.CODA() + model.add_requirement('Comfort', 2.0) + model.add_requirement('Speed', 1.0) + model.add_characteristic('Damping') # default limits + model.add_characteristic('Power', (0.0, 100.0)) # value unset + model.add_relationship('Comfort', 'Damping', 'opt', 'strong', + 0.5, 0.1) + model.add_relationship('Speed', 'Power', 'max', 'moderate', 50) + + d = model.to_dict() + assert d['characteristics'][0]['value'] is None + assert d['characteristics'][1]['value'] is None + # Raw base_weight preserved (not the normalised value). + assert d['requirements'][0]['weight'] == 2.0 + assert d['requirements'][0]['normalise'] is True + + clone = models.CODA.from_dict(d) + assert [r.name for r in clone.requirements] == ['Comfort', 'Speed'] + np.testing.assert_array_almost_equal(clone.weight, model.weight) + assert (clone.characteristics[0].limits == + model.characteristics[0].limits) + with pytest.raises(AttributeError): + _ = clone.characteristics[0].value + + opt = clone.matrix[0, 0] + assert isinstance(opt, models.CODAOptimise) + assert opt.target == 0.5 + assert opt.tolerance == 0.1 + + def test_roundtrip_prenormalised_weights(self): + """normalise=False requirements round-trip as CODARequirement.""" + model = models.CODA() + model.add_requirement('A', 0.3, normalise=False) + model.add_requirement('B', 0.7, normalise=False) + + d = model.to_dict() + assert d['requirements'] == [ + {'name': 'A', 'weight': 0.3, 'normalise': False}, + {'name': 'B', 'weight': 0.7, 'normalise': False}, + ] + + clone = models.CODA.from_dict(d) + assert isinstance(clone.requirements[0], models.CODARequirement) + assert not isinstance(clone.requirements[0], + models.CODARequirementNorm) + np.testing.assert_array_almost_equal(clone.weight, model.weight) + + +class TestCODAAddRequirementsFrom: + """Glue adding CODA requirements from a Binary Weighting Matrix.""" + + def _binwm(self): + bwm = rmodels.BinWM('Stiffness', 'Friction', 'Weight') + bwm._matrix = np.array([[0, 0, 1], + [0, 0, 1], + [0, 0, 0]]) + return bwm + + def test_binwm_score_sums_to_unity(self): + """Documents the assumption underpinning the glue's choices.""" + bwm = self._binwm() + assert bwm.score.sum() == pytest.approx(1.0) + + def test_names_and_weights_match_scores(self): + bwm = self._binwm() + coda = models.CODA() + coda.add_requirements_from(bwm) + + assert ([r.name for r in coda.requirements] == + list(bwm.requirements)) + np.testing.assert_allclose( + [r.weight for r in coda.requirements], bwm.score + ) + + def test_further_requirements_mix_in(self): + """Self-normalising requirements let further ones be mixed.""" + bwm = self._binwm() + coda = models.CODA() + coda.add_requirements_from(bwm) + + coda.add_requirement('Cost', 1.0) # normalise=True by default + + assert len(coda.requirements) == 4 + np.testing.assert_allclose(coda.weight.sum(), 1.0) + + class TestCODACharacteristic: @pytest.fixture(autouse=True) diff --git a/vdd/requirements/models.py b/vdd/requirements/models.py index 7e09995..b4868e8 100644 --- a/vdd/requirements/models.py +++ b/vdd/requirements/models.py @@ -1,6 +1,7 @@ from __future__ import division import itertools +import json import random import warnings @@ -40,6 +41,50 @@ def __init__(self, *args, **kwargs): matrix = kwargs.get('matrix', default_matrix) self._matrix = matrix + @classmethod + def from_dict(cls, data): + """Construct a BinWM from a plain-python dict. + + This is the inverse of :meth:`to_dict` and consumes the same + shape as the JSON test fixtures:: + + {"requirements": [...], "binary_matrix": [[...]]} + + Parameters + ---------- + + data : dict + Mapping with ``requirements`` (list of names) and + ``binary_matrix`` (2D list) keys. + + Returns + ------- + + BinWM + """ + return cls(*data['requirements'], + matrix=np.array(data['binary_matrix'])) + + @classmethod + def read_json(cls, path): + """Construct a BinWM from a native JSON file. + + Parameters + ---------- + + path : str + Filesystem path to a JSON file matching the + :meth:`to_dict` schema. + + Returns + ------- + + BinWM + """ + with open(path) as f: + data = json.load(f) + return cls.from_dict(data) + @classmethod def from_google_sheet(cls, workbook_name): """Construct the binary matrix from a Google Sheet. @@ -162,6 +207,46 @@ def save(self): sheet.update(self.to_dataframe()) + def to_dict(self): + """Serialise to a plain-python dict. + + The shape matches the JSON test fixtures, so serialised output + is a valid interchange file:: + + {"requirements": [...], "binary_matrix": [[...]]} + + Returns + ------- + + dict + """ + return { + 'requirements': list(self.requirements), + 'binary_matrix': self.matrix.astype(int).tolist(), + } + + def to_json(self, path=None): + """Serialise to JSON. + + Parameters + ---------- + + path : str, optional + If given, the JSON is written to this file. Otherwise the + JSON is returned as a string. + + Returns + ------- + + str or None + The JSON string when ``path`` is ``None``, else ``None``. + """ + text = json.dumps(self.to_dict(), indent=2) + if path is None: + return text + with open(path, 'w') as f: + f.write(text) + def to_dataframe(self): """Convert to a pandas dataframe. diff --git a/vdd/requirements/tests/test_models.py b/vdd/requirements/tests/test_models.py index 299428d..f3fafaa 100644 --- a/vdd/requirements/tests/test_models.py +++ b/vdd/requirements/tests/test_models.py @@ -129,6 +129,61 @@ def test_save(self): bwm.save() +class TestBinWMJSON: + """Native JSON serialisation for BinWM. + + The interchange shape matches the ``case__*.json`` test fixtures, + so those fixtures are themselves valid interchange files. + """ + + def test_from_dict(self): + data = get_fixture_data('case__simple_aircraft.json') + bwm = models.BinWM.from_dict(data) + + assert list(bwm.requirements) == data['requirements'] + np.testing.assert_array_equal( + bwm.matrix, np.array(data['binary_matrix']) + ) + + def test_to_dict_matches_fixture(self): + data = get_fixture_data('case__minimal_example.json') + bwm = models.BinWM.from_dict(data) + + assert bwm.to_dict() == { + 'requirements': data['requirements'], + 'binary_matrix': data['binary_matrix'], + } + + def test_roundtrip_dict(self): + data = get_fixture_data('case__motorcycle_helmet.json') + bwm = models.BinWM.from_dict(data) + + clone = models.BinWM.from_dict(bwm.to_dict()) + + assert clone.requirements == bwm.requirements + np.testing.assert_array_equal(clone.matrix, bwm.matrix) + np.testing.assert_allclose(clone.score, bwm.score) + + def test_roundtrip_json_file(self, tmp_path): + data = get_fixture_data('case__minimal_example.json') + bwm = models.BinWM.from_dict(data) + + path = tmp_path / 'bwm.json' + assert bwm.to_json(str(path)) is None + clone = models.BinWM.read_json(str(path)) + + assert clone.requirements == bwm.requirements + np.testing.assert_array_equal(clone.matrix, bwm.matrix) + + def test_read_json_fixture_directly(self): + """Existing case fixtures load straight through read_json.""" + path = os.path.join(FIXTURES_DIR, 'case__simple_aircraft.json') + bwm = models.BinWM.read_json(path) + + assert len(bwm.requirements) == 9 + assert bwm.matrix.shape == (9, 9) + + class TestBinWM_GoogleSheetsIntegration: @pytest.fixture(autouse=True)