diff --git a/qexpy/core/__init__.py b/qexpy/core/__init__.py index f325b96..9fc199f 100644 --- a/qexpy/core/__init__.py +++ b/qexpy/core/__init__.py @@ -18,6 +18,7 @@ from .experimental_value import ExperimentalValue from .functions import correlation, covariance from .measurement import Measurement, RepeatedMeasurement +from .monte_carlo import monte_carlo __functions__ = [ "Measurement", diff --git a/qexpy/core/derived_value.py b/qexpy/core/derived_value.py index e4ba33a..5a4e030 100644 --- a/qexpy/core/derived_value.py +++ b/qexpy/core/derived_value.py @@ -2,6 +2,7 @@ from functools import cached_property +import qexpy as q from qexpy.core.experimental_value import ExperimentalValue from qexpy.core.formula import _Formula from qexpy.utils import Unit @@ -29,20 +30,27 @@ class DerivedValue(ExperimentalValue): def __init__(self, formula: _Formula): self._formula = formula + self._mc = MonteCarloConfig(self._formula) + self._error_method = "auto" super().__init__("", None) def __copy__(self): obj = object.__new__(DerivedValue) obj._formula = self._formula + obj._error_method = self._error_method obj._name = self._name return obj @property def value(self) -> float: + if self.error_method == "monte-carlo": + return self._mc.value return self._value @property def error(self) -> float: + if self.error_method == "monte-carlo": + return self._mc.error return self._error @property @@ -60,3 +68,71 @@ def _error(self): @cached_property def _unit(self): # pylint: disable=method-hidden return self._formula.unit + + @property + def error_method(self) -> str: + """The method of error propagation used for this value + + QExPy supports error propagation with partial derivatives (`"derivative"`) and by using + a Monte Carlo simulation (`"monte-carlo"`). By default, the global preference for the + error method will be used, but it is also possible to configure the error method for a + single derived value. To simply use the global option, set this to `"auto"`. + + Examples + -------- + + >>> import qexpy as q + >>> a = q.Measurement(5, 0.1) + >>> b = q.Measurement(6, 0.1) + >>> res = a * b + >>> res.error_method = "monte-carlo" + >>> print(res) + 30.0 +/- 0.8 + + """ + if self._error_method == "auto": + return q.options.error.method + return self._error_method + + @error_method.setter + def error_method(self, method: str): + if method not in ("derivative", "monte-carlo", "auto"): + raise ValueError("The error method can only be 'derivative', 'monte-carlo', or 'auto'") + self._error_method = method + + +class MonteCarloConfig: + """Stores all data and configurations of a Monte Carlo simulation.""" + + def __init__(self, formula: _Formula): + self._formula = formula + self._sample_size = 0 + self._samples = None + + @property + def value(self): + return self.samples.mean() + + @property + def error(self): + return self.samples.std() + + @property + def sample_size(self) -> int: + """The number of samples to use in a Monte Carlo simulation""" + if not self._sample_size: + return q.options.error.mc.sample_size + return self._sample_size + + @sample_size.setter + def sample_size(self, size: int): + if size < 0: + raise ValueError("The sample size must be a positive integer!") + self._sample_size = size + self._samples = None + + @property + def samples(self): + if self._samples is None: + self._samples = q.core.monte_carlo(self._formula, self.sample_size) + return self._samples diff --git a/qexpy/core/monte_carlo.py b/qexpy/core/monte_carlo.py new file mode 100644 index 0000000..3716fc8 --- /dev/null +++ b/qexpy/core/monte_carlo.py @@ -0,0 +1,80 @@ +"""Monte Carlo method of error propagation""" + +from __future__ import annotations + +from typing import Iterable, Dict, List + +import numpy as np + +import qexpy as q +from qexpy.core.formula import _Formula, _Operation, _find_measurements +from qexpy.utils import Unit + + +class _MeasurementSample(_Formula): + """An array of random samples that simulates a measurement.""" + + def __init__(self, samples: np.ndarray, unit: Unit): + self._samples = samples + self._unit = unit + + @property + def value(self) -> float | np.ndarray: + return self._samples + + def _derivative(self, x: _Formula) -> float: + return 0 # pragma: no cover + + @property + def unit(self) -> Unit: + return self._unit # pragma: no cover + + +def monte_carlo(formula: _Formula, sample_size: int) -> np.ndarray: + """Use a Monte Carlo simulation to evaluate a formula.""" + + sources = _find_measurements(formula) + samples = _populate_samples(sources, sample_size) + formula = _reconstruct_formula(formula, samples) + return formula.value + + +def _populate_samples( + sources: Iterable[q.core.Measurement], sample_size: int +) -> Dict[q.core.Measurement, np.ndarray]: + """Populates the samples for all measurements""" + + samples = {} + sources = list(sources) + offset_matrix = np.vstack([np.random.normal(0, 1, sample_size) for _ in sources]) + offset_matrix = _correlate_samples(sources, offset_matrix) + for measurement, offset in zip(sources, offset_matrix): + samples[measurement] = measurement.value + offset * measurement.error + return samples + + +def _correlate_samples(sources: List[q.core.Measurement], offsets: np.ndarray) -> np.ndarray: + """Apply correlation to the offset matrix""" + + corr_matrix = np.array([[q.correlation(row, col) for col in sources] for row in sources]) + + if np.count_nonzero(corr_matrix - np.diag(np.diagonal(corr_matrix))) == 0: + return offsets # if no correlations are present + + cholesky_decomposition = np.linalg.cholesky(corr_matrix) + return np.dot(cholesky_decomposition, offsets) + + +def _reconstruct_formula( + formula: _Formula, samples: Dict[q.core.Measurement, np.ndarray] +) -> _Formula: + """Reconstruct the formula from the samples""" + + if isinstance(formula, q.core.Measurement): + return _MeasurementSample(samples[formula], formula.unit) + + if isinstance(formula, _Operation): + operands = tuple(_reconstruct_formula(operand, samples) for operand in formula.operands) + return formula.__class__(*operands) + + return formula diff --git a/tests/.pylintrc b/tests/.pylintrc index afd753d..da76157 100644 --- a/tests/.pylintrc +++ b/tests/.pylintrc @@ -28,3 +28,5 @@ disable=raw-checker-failed, cyclic-import, protected-access, use-implicit-booleaness-not-comparison, + duplicate-code, + no-member, diff --git a/tests/core/test_derived_value.py b/tests/core/test_derived_value.py index 7c37567..3556765 100644 --- a/tests/core/test_derived_value.py +++ b/tests/core/test_derived_value.py @@ -1,5 +1,7 @@ """Tests for ExperimentalValue arithmetic""" +# pylint: disable=no-value-for-parameter + import numpy as np import pytest @@ -278,7 +280,8 @@ class TestErrorPropagation: lambda x, y: x / y, ], ) - def test_correlated_measurements(self, op_func): + @pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"]) + def test_correlated_measurements(self, op_func, error_method): """Tests that error propagation works correctly for correlated measurements""" arr1 = np.array([399.3, 404.6, 394.6, 396.3, 399.6, 404.9, 387.4, 404.9, 398.2, 407.2]) @@ -291,6 +294,7 @@ def test_correlated_measurements(self, op_func): m1.set_covariance(m2) m = op_func(m1, m2) + m.error_method = error_method assert m.value == pytest.approx(m_expected.value, rel=0.02) assert m.error == pytest.approx(m_expected.error, rel=0.02) @@ -303,12 +307,13 @@ def test_correlated_measurements(self, op_func): lambda x, y, z: x - y / z, ], ) - def test_multiple_correlated_measurements(self, op_func): + @pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"]) + def test_multiple_correlated_measurements(self, op_func, error_method): """Tests that error propagation works for multiple correlated measurements""" arr1 = np.array([399.3, 404.6, 394.6, 396.3, 399.6, 404.9, 387.4, 404.9, 398.2, 407.2]) arr2 = np.array([193.2, 205.1, 192.6, 194.2, 196.6, 201.0, 184.7, 215.2, 203.6, 207.8]) - arr3 = np.array([93.2, 105.1, 92.6, 94.2, 96.6, 101.0, 84.7, 115.2, 103.6, 107.8]) + arr3 = np.array([93.1, 105.1, 92.7, 94.2, 96.6, 101.0, 84.6, 115.3, 103.6, 107.7]) arr_expected = op_func(arr1, arr2, arr3) m1 = q.Measurement(arr1) @@ -320,10 +325,12 @@ def test_multiple_correlated_measurements(self, op_func): m1.set_covariance(m3) m2.set_covariance(m3) m = op_func(m1, m2, m3) + m.error_method = error_method assert m.value == pytest.approx(m_expected.value, rel=0.02) assert m.error == pytest.approx(m_expected.error, rel=0.02) - def test_composite_formula(self): + @pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"]) + def test_composite_formula(self, error_method): """Integration test for error propagation with composite formula""" q.define_unit("F", "C^2/(N*m)") @@ -334,28 +341,26 @@ def test_composite_formula(self): r = q.Measurement(0.12, relative_error=0.01, unit="m") force = 1 / (4 * q.pi * q.eps0) * q1 * q2 / r**2 + force.error_method = error_method + + eps0 = 8.8541878128e-12 + expected_value = 1 / (4 * np.pi * eps0) * 1.23e-6 * 2.34e-5 / 0.12**2 + expected_error = np.sqrt( + (0.01 * 1.23e-6 / (4 * np.pi * eps0) * 2.34e-5 / 0.12**2) ** 2 + + (0.01 * 2.34e-5 / (4 * np.pi * eps0) * 1.23e-6 / 0.12**2) ** 2 + + (0.01 * 0.12 * 2 / (4 * np.pi * eps0) * 1.23e-6 * 2.34e-5 / 0.12**3) ** 2 + + (0.0000000013e-12 / (4 * np.pi * eps0**2) * 1.23e-6 * 2.34e-5 / 0.12**2) ** 2 + ) assert isinstance(force, q.core.DerivedValue) - assert force.value == pytest.approx( - 1 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 * 2.34e-5 / 0.12**2 - ) - assert force.error == pytest.approx( - np.sqrt( - (0.01 * 1.23e-6 / (4 * np.pi * 8.8541878128e-12) * 2.34e-5 / 0.12**2) ** 2 - + (0.01 * 2.34e-5 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 / 0.12**2) ** 2 - + (0.01 * 0.12 * 2 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 * 2.34e-5 / 0.12**3) - ** 2 - + ( - 0.0000000013e-12 - / (4 * np.pi * 8.8541878128e-12**2) - * 1.23e-6 - * 2.34e-5 - / 0.12**2 - ) - ** 2 - ) - ) assert force.unit == {"kg": 1, "m": 1, "s": -2} assert str(force.unit) == "N" + if force.error_method == "derivative": + assert force.value == pytest.approx(expected_value) + assert force.error == pytest.approx(expected_error) + else: + assert force.value == pytest.approx(expected_value, rel=0.001) + assert force.error == pytest.approx(expected_error, rel=0.005) + q.clear_unit_definitions() diff --git a/tests/core/test_experimental_value.py b/tests/core/test_experimental_value.py index 610b806..c9e822b 100644 --- a/tests/core/test_experimental_value.py +++ b/tests/core/test_experimental_value.py @@ -78,7 +78,7 @@ def test_equal(self): assert x != 1.22 assert 1.22 != x - assert not x == "a" + assert x != "a" def test_not_equal(self): """Tests the not equal methods of ExperimentalValue""" diff --git a/tests/core/test_formula.py b/tests/core/test_formula.py index a3c32ac..d7d0b6a 100644 --- a/tests/core/test_formula.py +++ b/tests/core/test_formula.py @@ -288,7 +288,7 @@ def value(self) -> float: @property def unit(self) -> Unit: """The unit of the formula""" - return Unit({}) + return Unit({}) # pragma: no cover def _derivative(self, x: _Formula) -> float: return self.d if x is self.m else 0 diff --git a/tests/core/test_functions.py b/tests/core/test_functions.py index 0c1c3a6..2524ef3 100644 --- a/tests/core/test_functions.py +++ b/tests/core/test_functions.py @@ -1,5 +1,7 @@ """Unit tests for general functions in the core module""" +# pylint: disable=no-value-for-parameter + import pytest import numpy as np diff --git a/tests/core/test_measurements.py b/tests/core/test_measurements.py index 3e92c36..becbe34 100644 --- a/tests/core/test_measurements.py +++ b/tests/core/test_measurements.py @@ -1,6 +1,6 @@ """Unit tests for measurements""" -# pylint: disable=too-few-public-methods +# pylint: disable=no-value-for-parameter import pytest