From 94f8a2e2e5a27ccd996ed8731783810e7b541217 Mon Sep 17 00:00:00 2001 From: relf Date: Fri, 16 Aug 2024 09:59:11 +0200 Subject: [PATCH 1/3] Refactor of fracfact from bofire --- pyDOE3/doe_factorial.py | 129 ++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 46 deletions(-) diff --git a/pyDOE3/doe_factorial.py b/pyDOE3/doe_factorial.py index 807415b..dec03ff 100644 --- a/pyDOE3/doe_factorial.py +++ b/pyDOE3/doe_factorial.py @@ -21,6 +21,7 @@ import numpy as np from scipy.special import binom +from typing import List __all__ = [ "fullfact", @@ -97,10 +98,7 @@ def fullfact(levels): return H -################################################################################ - - -def ff2n(n): +def ff2n(n_factors: int) -> np.ndarray: """ Create a 2-Level full-factorial design @@ -120,22 +118,67 @@ def ff2n(n): >>> ff2n(3) array([[-1., -1., -1.], - [ 1., -1., -1.], - [-1., 1., -1.], - [ 1., 1., -1.], [-1., -1., 1.], - [ 1., -1., 1.], + [-1., 1., -1.], [-1., 1., 1.], + [ 1., -1., -1.], + [ 1., -1., 1.], + [ 1., 1., -1.], [ 1., 1., 1.]]) - """ - return 2 * fullfact([2] * n) - 1 + return np.array(list(itertools.product([-1.0, 1.0], repeat=n_factors))) -################################################################################ +def validate_generator(n_factors: int, generator: str) -> str: + """Validates the generator and thows an error if it is not valid.""" + + if len(generator.split(" ")) != n_factors: + raise ValueError("Generator does not match the number of factors.") + # clean it and transform it into a list + generators = [item for item in re.split(r"\-|\s|\+", generator) if item] + lengthes = [len(i) for i in generators] + + # Indices of single letters (main factors) + idx_main = [i for i, item in enumerate(lengthes) if item == 1] + + if len(idx_main) == 0: + raise ValueError("At least one unconfounded main factor is needed.") + + # Check that single letters (main factors) are unique + if len(idx_main) != len({generators[i] for i in idx_main}): + raise ValueError("Main factors are confounded with each other.") + + # Check that single letters (main factors) follow the alphabet + if ( + "".join(sorted([generators[i] for i in idx_main])) + != string.ascii_lowercase[: len(idx_main)] + ): + raise ValueError( + f'Use the letters `{" ".join(string.ascii_lowercase[: len(idx_main)])}` for the main factors.' + ) + + # Indices of letter combinations. + idx_combi = [i for i, item in enumerate(generators) if item != 1] + # check that main factors come before combinations + if min(idx_combi) > max(idx_main): + raise ValueError("Main factors have to come before combinations.") -def fracfact(gen): + # Check that letter combinations are unique + if len(idx_combi) != len({generators[i] for i in idx_combi}): + raise ValueError("Generators are not unique.") + + # Check that only letters are used in the combinations that are also single letters (main factors) + if not all( + set(item).issubset({generators[i] for i in idx_main}) + for item in [generators[i] for i in idx_combi] + ): + raise ValueError("Generators are not valid.") + + return generator + + +def fracfact(gen) -> np.ndarray: """ Create a 2-level fractional-factorial design with a generator string. @@ -186,65 +229,59 @@ def fracfact(gen): >>> fracfact("a b ab") array([[-1., -1., 1.], - [ 1., -1., -1.], [-1., 1., -1.], + [ 1., -1., -1.], [ 1., 1., 1.]]) >>> fracfact("A B AB") array([[-1., -1., 1.], - [ 1., -1., -1.], [-1., 1., -1.], + [ 1., -1., -1.], [ 1., 1., 1.]]) >>> fracfact("a b -ab c +abc") array([[-1., -1., -1., -1., -1.], - [ 1., -1., 1., -1., 1.], - [-1., 1., 1., -1., 1.], - [ 1., 1., -1., -1., -1.], [-1., -1., -1., 1., 1.], - [ 1., -1., 1., 1., -1.], + [-1., 1., 1., -1., 1.], [-1., 1., 1., 1., -1.], + [ 1., -1., 1., -1., 1.], + [ 1., -1., 1., 1., -1.], + [ 1., 1., -1., -1., -1.], [ 1., 1., -1., 1., 1.]]) """ - # Recognize letters and combinations - A = [item for item in re.split(r"\-|\s|\+", gen) if item] # remove empty strings - C = [len(item) for item in A] + gen = validate_generator(n_factors=gen.count(" ") + 1, generator=gen.lower()) - # Indices of single letters (main factors) - I = [i for i, item in enumerate(C) if item == 1] # noqa + generators = [item for item in re.split(r"\-|\s|\+", gen) if item] + lengthes = [len(i) for i in generators] - # Indices of letter combinations (we need them to fill out H2 properly). - J = [i for i, item in enumerate(C) if item != 1] + # Indices of single letters (main factors) + idx_main = [i for i, item in enumerate(lengthes) if item == 1] - # Check if there are "-" or "+" operators in gen - U = [item for item in gen.split(" ") if item] # remove empty strings + # Indices of letter combinations. + idx_combi = [i for i, item in enumerate(generators) if item != 1] - # If R1 is either None or not, the result is not changed, since it is a - # multiplication of 1. - # R1 = _grep(U, "+") - R2 = _grep(U, "-") + # Check if there are "-" operators in gen + idx_negative = [ + i for i, item in enumerate(gen.split(" ")) if item[0] == "-" + ] # remove empty strings # Fill in design with two level factorial design - H1 = ff2n(len(I)) - H = np.zeros((H1.shape[0], len(C))) - H[:, I] = H1 + H1 = ff2n(len(idx_main)) + H = np.zeros((H1.shape[0], len(lengthes))) + H[:, idx_main] = H1 # Recognize combinations and fill in the rest of matrix H2 with the proper # products - for k in J: + for k in idx_combi: # For lowercase letters - xx = np.array([ord(c) for c in A[k]]) - 97 - - # For uppercase letters - if np.any(xx < 0): - xx = np.array([ord(c) for c in A[k]]) - 65 + xx = np.array([ord(c) for c in generators[k]]) - 97 H[:, k] = np.prod(H1[:, xx], axis=1) # Update design if gen includes "-" operator - if R2: - H[:, R2] *= -1 + if len(idx_negative) > 0: + H[:, idx_negative] *= -1 # Return the fractional factorial design return H @@ -304,12 +341,12 @@ def fracfact_by_res(n, res): :: >>> fracfact_by_res(6, 3) array([[-1., -1., -1., 1., 1., 1.], - [ 1., -1., -1., -1., -1., 1.], - [-1., 1., -1., -1., 1., -1.], - [ 1., 1., -1., 1., -1., -1.], [-1., -1., 1., 1., -1., -1.], - [ 1., -1., 1., -1., 1., -1.], + [-1., 1., -1., -1., 1., -1.], [-1., 1., 1., -1., -1., 1.], + [ 1., -1., -1., -1., -1., 1.], + [ 1., -1., 1., -1., 1., -1.], + [ 1., 1., -1., 1., -1., -1.], [ 1., 1., 1., 1., 1., 1.]]) >>> fracfact_by_res(5, 5) From 6af9cadc3fe595b374e0ca5745bd41096ca1cbd2 Mon Sep 17 00:00:00 2001 From: relf Date: Fri, 16 Aug 2024 10:00:29 +0200 Subject: [PATCH 2/3] Make test pass (result reordering) --- pyDOE3/doe_box_behnken.py | 6 ++--- pyDOE3/doe_composite.py | 8 +++---- tests/test_box_behnken.py | 6 ++--- tests/test_composite.py | 8 +++---- tests/test_factorial.py | 50 +++++++++++++++++++++++++++------------ 5 files changed, 49 insertions(+), 29 deletions(-) diff --git a/pyDOE3/doe_box_behnken.py b/pyDOE3/doe_box_behnken.py index 0de2154..dc40011 100644 --- a/pyDOE3/doe_box_behnken.py +++ b/pyDOE3/doe_box_behnken.py @@ -45,16 +45,16 @@ def bbdesign(n, center=None): >>> bbdesign(3) array([[-1., -1., 0.], - [ 1., -1., 0.], [-1., 1., 0.], + [ 1., -1., 0.], [ 1., 1., 0.], [-1., 0., -1.], - [ 1., 0., -1.], [-1., 0., 1.], + [ 1., 0., -1.], [ 1., 0., 1.], [ 0., -1., -1.], - [ 0., 1., -1.], [ 0., -1., 1.], + [ 0., 1., -1.], [ 0., 1., 1.], [ 0., 0., 0.], [ 0., 0., 0.], diff --git a/pyDOE3/doe_composite.py b/pyDOE3/doe_composite.py index 9f40c12..eb0a2b3 100644 --- a/pyDOE3/doe_composite.py +++ b/pyDOE3/doe_composite.py @@ -88,12 +88,12 @@ def ccdesign(n, center=(4, 4), alpha="orthogonal", face="circumscribed"): >>> ccdesign(3) array([[-1. , -1. , -1. ], - [ 1. , -1. , -1. ], - [-1. , 1. , -1. ], - [ 1. , 1. , -1. ], [-1. , -1. , 1. ], - [ 1. , -1. , 1. ], + [-1. , 1. , -1. ], [-1. , 1. , 1. ], + [ 1. , -1. , -1. ], + [ 1. , -1. , 1. ], + [ 1. , 1. , -1. ], [ 1. , 1. , 1. ], [ 0. , 0. , 0. ], [ 0. , 0. , 0. ], diff --git a/tests/test_box_behnken.py b/tests/test_box_behnken.py index 98291f1..e7e34d5 100644 --- a/tests/test_box_behnken.py +++ b/tests/test_box_behnken.py @@ -7,16 +7,16 @@ class TestBoxBehnken(unittest.TestCase): def test_box_behnken1(self): expected = [ [-1.0, -1.0, 0.0], - [1.0, -1.0, 0.0], [-1.0, 1.0, 0.0], + [1.0, -1.0, 0.0], [1.0, 1.0, 0.0], [-1.0, 0.0, -1.0], - [1.0, 0.0, -1.0], [-1.0, 0.0, 1.0], + [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [0.0, -1.0, -1.0], - [0.0, 1.0, -1.0], [0.0, -1.0, 1.0], + [0.0, 1.0, -1.0], [0.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], diff --git a/tests/test_composite.py b/tests/test_composite.py index bcdddcf..79eeb62 100644 --- a/tests/test_composite.py +++ b/tests/test_composite.py @@ -7,12 +7,12 @@ class TestComposite(unittest.TestCase): def test_composite1(self): expected = [ [-1.0, -1.0, -1.0], - [1.0, -1.0, -1.0], - [-1.0, 1.0, -1.0], - [1.0, 1.0, -1.0], [-1.0, -1.0, 1.0], - [1.0, -1.0, 1.0], + [-1.0, 1.0, -1.0], [-1.0, 1.0, 1.0], + [1.0, -1.0, -1.0], + [1.0, -1.0, 1.0], + [1.0, 1.0, -1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], diff --git a/tests/test_factorial.py b/tests/test_factorial.py index a17a19f..12c4424 100644 --- a/tests/test_factorial.py +++ b/tests/test_factorial.py @@ -1,9 +1,12 @@ import unittest +import numpy as np +import pytest + from pyDOE3.doe_factorial import fracfact_opt, fullfact from pyDOE3.doe_factorial import ff2n from pyDOE3.doe_factorial import fracfact from pyDOE3.doe_factorial import fracfact_by_res -import numpy as np +from pyDOE3.doe_factorial import validate_generator class TestFactorial(unittest.TestCase): @@ -40,12 +43,12 @@ def test_factorial1(self): def test_factorial2(self): expected = [ [-1.0, -1.0, -1.0], - [1.0, -1.0, -1.0], - [-1.0, 1.0, -1.0], - [1.0, 1.0, -1.0], [-1.0, -1.0, 1.0], - [1.0, -1.0, 1.0], + [-1.0, 1.0, -1.0], [-1.0, 1.0, 1.0], + [1.0, -1.0, -1.0], + [1.0, -1.0, 1.0], + [1.0, 1.0, -1.0], [1.0, 1.0, 1.0], ] actual = ff2n(3) @@ -54,8 +57,8 @@ def test_factorial2(self): def test_factorial3(self): expected = [ [-1.0, -1.0, 1.0], - [1.0, -1.0, -1.0], [-1.0, 1.0, -1.0], + [1.0, -1.0, -1.0], [1.0, 1.0, 1.0], ] actual = fracfact("a b ab") @@ -64,8 +67,8 @@ def test_factorial3(self): def test_factorial4(self): expected = [ [-1.0, -1.0, 1.0], - [1.0, -1.0, -1.0], [-1.0, 1.0, -1.0], + [1.0, -1.0, -1.0], [1.0, 1.0, 1.0], ] actual = fracfact("A B AB") @@ -74,12 +77,12 @@ def test_factorial4(self): def test_factorial5(self): expected = [ [-1.0, -1.0, -1.0, -1.0, -1.0], - [1.0, -1.0, 1.0, -1.0, 1.0], - [-1.0, 1.0, 1.0, -1.0, 1.0], - [1.0, 1.0, -1.0, -1.0, -1.0], [-1.0, -1.0, -1.0, 1.0, 1.0], - [1.0, -1.0, 1.0, 1.0, -1.0], + [-1.0, 1.0, 1.0, -1.0, 1.0], [-1.0, 1.0, 1.0, 1.0, -1.0], + [1.0, -1.0, 1.0, -1.0, 1.0], + [1.0, -1.0, 1.0, 1.0, -1.0], + [1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, -1.0, 1.0, 1.0], ] actual = fracfact("a b -ab c +abc") @@ -88,15 +91,16 @@ def test_factorial5(self): def test_factorial6(self): expected = [ [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0], - [1.0, -1.0, -1.0, -1.0, -1.0, 1.0], - [-1.0, 1.0, -1.0, -1.0, 1.0, -1.0], - [1.0, 1.0, -1.0, 1.0, -1.0, -1.0], [-1.0, -1.0, 1.0, 1.0, -1.0, -1.0], - [1.0, -1.0, 1.0, -1.0, 1.0, -1.0], + [-1.0, 1.0, -1.0, -1.0, 1.0, -1.0], [-1.0, 1.0, 1.0, -1.0, -1.0, 1.0], + [1.0, -1.0, -1.0, -1.0, -1.0, 1.0], + [1.0, -1.0, 1.0, -1.0, 1.0, -1.0], + [1.0, 1.0, -1.0, 1.0, -1.0, -1.0], [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], ] actual = fracfact_by_res(6, 3) + print(actual) np.testing.assert_allclose(actual, expected) def test_issue_9(self): @@ -118,3 +122,19 @@ def test_issue_9(self): np.testing.assert_array_equal( ffo_doe[2], np.array([0.0, 0.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) ) + + +@pytest.mark.parametrize( + "n_factors, generator, message", + [ + (2, "a b c", "Generator does not match the number of factors."), + (2, "a a", "Main factors are confounded with each other."), + (2, "a c", "Use the letters `a b` for the main factors."), + (5, "a b c ab ab", "Generators are not unique."), + (5, "a b c ab ad", "Generators are not valid."), + (2, "ab ac", "At least one unconfounded main factor is needed."), + ], +) +def test_validate_generator_invalid(n_factors: int, generator: str, message: str): + with pytest.raises(ValueError, match=message): + validate_generator(n_factors, generator) From 2872002c8ae9a13c1d11361e59e47720cd29d380 Mon Sep 17 00:00:00 2001 From: relf Date: Fri, 16 Aug 2024 10:24:56 +0200 Subject: [PATCH 3/3] Remove unused import --- pyDOE3/doe_factorial.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyDOE3/doe_factorial.py b/pyDOE3/doe_factorial.py index dec03ff..63a35eb 100644 --- a/pyDOE3/doe_factorial.py +++ b/pyDOE3/doe_factorial.py @@ -21,8 +21,6 @@ import numpy as np from scipy.special import binom -from typing import List - __all__ = [ "fullfact", "ff2n",