From 7489ea6c3816ad7a8675ba38f3852d95a87df98e Mon Sep 17 00:00:00 2001 From: Daniel de Koning Date: Tue, 25 Feb 2020 12:05:01 +0100 Subject: [PATCH] Add tests for new uncertainty code (#375) * Force closing of pytest_project databases before deleting project * Add missing pedigree field for uncertainty interface * add no-cover annotations for abstract class methods * Simplify UncertaintyDelegate value conversion * Remember to pass pedigree into the matrix correctly * Test most of the features of the uncertainty wizard * Test the uncertainty delegate and remaining interfaces * Fix for AppVeyor being unruly for some reason. --- activity_browser/app/bwutils/uncertainty.py | 12 +- .../app/ui/tables/delegates/uncertainty.py | 11 +- .../app/ui/wizards/uncertainty.py | 2 +- appveyor.yml | 2 +- tests/conftest.py | 7 + tests/test_uncertainty.py | 100 ++++++++ tests/test_uncertainty_wizard.py | 214 ++++++++++++++++++ 7 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 tests/test_uncertainty.py create mode 100644 tests/test_uncertainty_wizard.py diff --git a/activity_browser/app/bwutils/uncertainty.py b/activity_browser/app/bwutils/uncertainty.py index dcf87822d..70456f4f2 100644 --- a/activity_browser/app/bwutils/uncertainty.py +++ b/activity_browser/app/bwutils/uncertainty.py @@ -12,7 +12,7 @@ class BaseUncertaintyInterface(abc.ABC): __slots__ = ["_data"] KEYS = { "uncertainty type", "loc", "scale", "shape", "minimum", "maximum", - "negative" + "negative", "pedigree" } data_type = "" @@ -21,22 +21,22 @@ def __init__(self, unc_obj): @property def data(self): - return self._data + return self._data # pragma: no cover @property @abc.abstractmethod def amount(self) -> float: - pass + pass # pragma: no cover @property @abc.abstractmethod def uncertainty_type(self) -> UncertaintyBase: - pass + pass # pragma: no cover @property @abc.abstractmethod def uncertainty(self) -> dict: - pass + pass # pragma: no cover class ExchangeUncertaintyInterface(BaseUncertaintyInterface): @@ -70,8 +70,6 @@ def amount(self) -> float: @property def uncertainty_type(self) -> UncertaintyBase: - if "uncertainty type" not in self._data.data: - return UndefinedUncertainty return uc[self._data.data.get("uncertainty type", 0)] @property diff --git a/activity_browser/app/ui/tables/delegates/uncertainty.py b/activity_browser/app/ui/tables/delegates/uncertainty.py index d7b3263bc..fde8f1bb8 100644 --- a/activity_browser/app/ui/tables/delegates/uncertainty.py +++ b/activity_browser/app/ui/tables/delegates/uncertainty.py @@ -46,12 +46,11 @@ def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): take the value and set the index in that way. """ value = index.data(QtCore.Qt.DisplayRole) - if isinstance(value, (str, float)): - try: - value = int(value) - except ValueError as e: - print("{}, using 0 instead".format(str(e))) - value = 0 + try: + value = int(value) if value is not None else 0 + except ValueError as e: + print("{}, using 0 instead".format(str(e))) + value = 0 editor.setCurrentIndex(uc.choices.index(uc[value])) def setModelData(self, editor: QtWidgets.QComboBox, model: QtCore.QAbstractItemModel, diff --git a/activity_browser/app/ui/wizards/uncertainty.py b/activity_browser/app/ui/wizards/uncertainty.py index 4d0be4725..5cb11af05 100644 --- a/activity_browser/app/ui/wizards/uncertainty.py +++ b/activity_browser/app/ui/wizards/uncertainty.py @@ -573,7 +573,7 @@ def initializePage(self): self.balance_mean_with_loc() obj = getattr(self.wizard(), "obj") try: - matrix = PedigreeMatrix.from_dict(obj.uncertainty) + matrix = PedigreeMatrix.from_dict(obj.uncertainty.get("pedigree", {})) self.pedigree = matrix.factors except AssertionError as e: print("Could not extract pedigree data: {}".format(str(e))) diff --git a/appveyor.yml b/appveyor.yml index d1c4995b0..2aff3b236 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,7 +16,7 @@ environment: install: - call %CONDA_INSTALL_LOCN%\Scripts\activate.bat - conda config --set always_yes yes --set changeps1 no - - conda update -q conda + # - conda update -q conda # Yeet. - conda info -a # Install package requirements & test suite - conda install -q -c conda-forge -c cmutel -c haasad -c pascallesage arrow brightway2 bw2io bw2data eidl fuzzywuzzy matplotlib-base networkx pandas pyside2=5.13 seaborn presamples "pytest>=5.2" pytest-qt pytest-mock diff --git a/tests/conftest.py b/tests/conftest.py index fa70ee127..87450e89f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,8 +14,15 @@ def ab_application(): """ app = Application() yield app + # Explicitly close the connection to all the databases for the pytest_project + if bw.projects.current == "pytest_project": + for _, db in bw.config.sqlite3_databases: + if not db._database.is_closed(): + db._database.close() if 'pytest_project' in bw.projects: bw.projects.delete_project('pytest_project', delete_dir=True) + # finally, perform a cleanup of any remnants, mostly for local testing + bw.projects.purge_deleted_directories() @pytest.fixture() diff --git a/tests/test_uncertainty.py b/tests/test_uncertainty.py new file mode 100644 index 000000000..dd998f945 --- /dev/null +++ b/tests/test_uncertainty.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +Use the existing parameters to look at the uncertainty and edit it in +multiple ways +""" +import brightway2 as bw +from PySide2 import QtCore, QtWidgets +import pytest +from stats_arrays.distributions import UndefinedUncertainty, UniformUncertainty + +from activity_browser.app.bwutils.uncertainty import ( + ExchangeUncertaintyInterface, CFUncertaintyInterface, get_uncertainty_interface +) +from activity_browser.app.ui.tables.delegates import UncertaintyDelegate +from activity_browser.app.ui.tables.parameters import ProjectParameterTable + + +def test_table_uncertainty_delegate(qtbot, bw2test, monkeypatch): + """ Open the uncertainty delegate to test all related methods within the table. + """ + table = ProjectParameterTable() + qtbot.addWidget(table) + table.add_parameter() + table.sync(table.build_df()) + + assert isinstance(table.itemDelegateForColumn(3), UncertaintyDelegate) + + delegate = UncertaintyDelegate(table) + option = QtWidgets.QStyleOptionViewItem() + option.rect = QtCore.QRect(0, 0, 100, 100) + index = table.proxy_model.index(0, 3) + rect = table.visualRect(index) + qtbot.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) + editor = delegate.createEditor(table, option, index) + qtbot.addWidget(editor) + + # Test displayText + assert delegate.displayText("1", None) == "No uncertainty" + assert delegate.displayText("nan", None) == "Undefined or unknown uncertainty" + + delegate.setEditorData(editor, index) + delegate.setModelData(editor, table.proxy_model, index) + + monkeypatch.setattr(QtCore.QModelIndex, "data", lambda *args, **kwargs: "nan") + delegate.setEditorData(editor, index) + + +def test_exchange_interface(qtbot, ab_app): + flow = bw.Database(bw.config.biosphere).random() + db = bw.Database("testdb") + act_key = ("testdb", "act_unc") + db.write({ + act_key: { + "name": "act_unc", + "unit": "kilogram", + "exchanges": [ + {"input": act_key, "amount": 1, "type": "production"}, + {"input": flow.key, "amount": 2, "type": "biosphere"}, + ] + } + }) + + act = bw.get_activity(act_key) + exc = next(e for e in act.biosphere()) + interface = get_uncertainty_interface(exc) + assert isinstance(interface, ExchangeUncertaintyInterface) + assert interface.amount == 2 + assert interface.uncertainty_type == UndefinedUncertainty + assert interface.uncertainty == {} + + +@pytest.mark.xfail(reason="Selected CF was already uncertain") +def test_cf_interface(qtbot, ab_app): + key = bw.methods.random() + method = bw.Method(key).load() + cf = next(f for f in method) + + assert isinstance(cf, tuple) + if isinstance(cf[-1], dict): + cf = method[1] + assert isinstance(cf[-1], float) + amount = cf[-1] # last value in the CF should be the amount. + + interface = get_uncertainty_interface(cf) + assert isinstance(interface, CFUncertaintyInterface) + assert not interface.is_uncertain # CF should not be uncertain. + assert interface.amount == amount + assert interface.uncertainty_type == UndefinedUncertainty + assert interface.uncertainty == {} + + # Now add uncertainty. + uncertainty = {"minimum": 1, "maximum": 18, "uncertainty type": UniformUncertainty.id} + uncertainty["amount"] = amount + cf = (cf[0], uncertainty) + interface = get_uncertainty_interface(cf) + assert isinstance(interface, CFUncertaintyInterface) + assert interface.is_uncertain # It is uncertain now! + assert interface.amount == amount + assert interface.uncertainty_type == UniformUncertainty + assert interface.uncertainty == {"uncertainty type": UniformUncertainty.id, "minimum": 1, "maximum": 18} diff --git a/tests/test_uncertainty_wizard.py b/tests/test_uncertainty_wizard.py new file mode 100644 index 000000000..f59660bfe --- /dev/null +++ b/tests/test_uncertainty_wizard.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +from bw2data.parameters import ProjectParameter +import numpy as np +from PySide2.QtWidgets import QMessageBox, QWizard +import pytest +from stats_arrays.distributions import ( + LognormalUncertainty, UniformUncertainty, UndefinedUncertainty, + TriangularUncertainty +) + +from activity_browser.app.ui.wizards import UncertaintyWizard +from activity_browser.app.signals import signals + +""" +Mess around with the uncertainty wizard. +""" + + +def test_wizard_fail(qtbot): + """Can't create a wizard if no uncertainty interface exists.""" + mystery_box = ["Hello", "My", "Name", "Is", "Error"] # Type is list. + with pytest.raises(TypeError): + UncertaintyWizard(mystery_box) + + +def test_uncertainty_wizard_simple(qtbot, bw2test, capsys): + """Use extremely simple text to open the wizard and go to all the pages.""" + param = ProjectParameter.create(name="test1", amount=3) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + assert "uncertainty type" in wizard.uncertainty_info + wizard.extract_uncertainty() + wizard.extract_lognormal_loc() + + # Go to the pedigree page + with qtbot.waitSignal(wizard.currentIdChanged, timeout=100): + wizard.type.pedigree.click() + + # Pedigree is empty, so complaint is issued. + captured = capsys.readouterr() + assert "Could not extract pedigree data" in captured.out + + # Now go back for giggles. + with qtbot.waitSignal(wizard.currentIdChanged, timeout=100): + wizard.button(QWizard.BackButton).click() + assert not wizard.using_pedigree + + +def test_graph_rebuild(qtbot, bw2test): + """Test that the graph is correctly built and rebuilt, ensure + that the 'finish' button is enabled and disabled at the correct + times. + """ + param = ProjectParameter.create(name="test1", amount=3) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + # Check that the graph exists and distribution is 'unknown' + assert wizard.type.plot.isVisible() + assert wizard.type.distribution.currentIndex() == UndefinedUncertainty.id + assert wizard.button(QWizard.FinishButton).isEnabled() + # Select an uncertainty distribution, fill out numbers. + with qtbot.waitSignal(wizard.type.distribution.currentIndexChanged, timeout=100): + wizard.type.distribution.setCurrentIndex(UniformUncertainty.id) + assert not wizard.type.complete # Missing values for valid uncertainty. + assert not wizard.button(QWizard.FinishButton).isEnabled() + + # When programmatically changing values, no textEdited signal is emitted. + with qtbot.assertNotEmitted(wizard.type.minimum.textEdited): + wizard.type.minimum.setText("1") + wizard.type.generate_plot() + assert not wizard.type.complete # Still missing 'maximum' + assert not wizard.button(QWizard.FinishButton).isEnabled() + + with qtbot.assertNotEmitted(wizard.type.minimum.textEdited): + wizard.type.maximum.setText("5") + wizard.type.generate_plot() + assert wizard.type.complete + assert wizard.button(QWizard.FinishButton).isEnabled() + + +def test_update_uncertainty(qtbot, ab_app): + """Using the signal/controller setup, update the uncertainty of a parameter""" + param = ProjectParameter.create(name="uc1", amount=3) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + wizard.type.distribution.setCurrentIndex(TriangularUncertainty.id) + wizard.type.minimum.setText("1") + wizard.type.maximum.setText("5") + wizard.type.generate_plot() + assert wizard.type.complete + + # Now trigger a 'finish' action + with qtbot.waitSignal(signals.parameters_changed, timeout=100): + wizard.button(QWizard.FinishButton).click() + + # Reload param + param = ProjectParameter.get(name="uc1") + assert "loc" in param.data and param.data["loc"] == 3 + + +def test_update_alter_mean(qtbot, monkeypatch, ab_app): + param = ProjectParameter.create(name="uc2", amount=1) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + # Select the lognormal distribution and set 'loc' and 'scale' fields. + wizard.type.distribution.setCurrentIndex(LognormalUncertainty.id) + wizard.type.loc.setText("1") + wizard.type.scale.setText("0.3") + wizard.type.generate_plot() + assert wizard.type.complete + + # Now, monkeypatch Qt to ensure a 'yes' is selected for updating. + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *args: QMessageBox.Yes)) + # Now trigger a 'finish' action + with qtbot.waitSignal(signals.parameters_changed, timeout=100): + wizard.button(QWizard.FinishButton).click() + + # Reload param and check that the amount is changed. + param = ProjectParameter.get(name="uc2") + assert "loc" in param.data and param.amount != 1 + loc = param.data["loc"] + assert loc == 1 + assert np.isclose(np.log(param.amount), loc) + + +def test_lognormal_mean_balance(qtbot, bw2test): + uncertain = { + "loc": 2, + "scale": 0.2, + "uncertainty type": 2, + } + param = ProjectParameter.create(name="uc1", amount=3, data=uncertain) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + # Compare loc with mean, + loc, mean = float(wizard.type.loc.text()), float(wizard.type.mean.text()) + assert np.isclose(np.exp(loc), mean) + wizard.type.check_negative() + assert not wizard.field("negative") + + # Alter mean and loc fields in turn to show balancing methods + with qtbot.assertNotEmitted(wizard.type.mean.textEdited): + wizard.type.mean.setText("") + wizard.type.balance_loc_with_mean() + wizard.type.check_negative() + assert wizard.type.loc.text() == "nan" + # Setting the mean to a negative number will still return the same loc + # value, but it will alter the 'negative' field. + with qtbot.assertNotEmitted(wizard.type.mean.textEdited): + wizard.type.mean.setText("-5") + wizard.type.balance_loc_with_mean() + wizard.type.check_negative() + assert np.isclose(np.exp(float(wizard.type.loc.text())), 5) + assert wizard.field("negative") + + +def test_pedigree(qtbot, bw2test): + """Configure uncertainty using the pedigree page of the wizard.""" + uncertain = { + "loc": 2, + "scale": 0.2, + "uncertainty type": 2, + "pedigree": { + "reliability": 1, + "completeness": 2, + "temporal correlation": 2, + "geographical correlation": 2, + "further technological correlation": 3 + }, + } + param = ProjectParameter.create(name="uc1", amount=3, data=uncertain) + wizard = UncertaintyWizard(param, None) + qtbot.addWidget(wizard) + wizard.show() + + # Uncertainty data has pedigree in it. + assert "pedigree" in wizard.obj.uncertainty + + # Go to the pedigree page + with qtbot.waitSignal(wizard.currentIdChanged, timeout=100): + wizard.type.pedigree.click() + assert wizard.using_pedigree # Uncertainty/Pedigree data is valid + + loc, mean = float(wizard.pedigree.loc.text()), float(wizard.pedigree.mean.text()) + assert np.isclose(np.exp(loc), mean) + # The uncertainty should be positive + assert not wizard.field("negative") + wizard.pedigree.check_negative() + assert not wizard.field("negative") + + # Alter mean and loc fields in turn to show balancing methods + with qtbot.assertNotEmitted(wizard.pedigree.mean.textEdited): + wizard.pedigree.mean.setText("") + wizard.pedigree.balance_loc_with_mean() + wizard.pedigree.check_negative() + assert wizard.pedigree.loc.text() == "nan" + # Setting the mean to a negative number will still return the same loc + # value, but it will alter the 'negative' field. + with qtbot.assertNotEmitted(wizard.pedigree.mean.textEdited): + wizard.pedigree.mean.setText("-5") + wizard.pedigree.balance_loc_with_mean() + wizard.pedigree.check_negative() + assert np.isclose(np.exp(float(wizard.pedigree.loc.text())), 5) + assert wizard.field("negative")