Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fractfact refactoring #15

Merged
merged 3 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pyDOE3/doe_box_behnken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.],
Expand Down
8 changes: 4 additions & 4 deletions pyDOE3/doe_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. ],
Expand Down
129 changes: 82 additions & 47 deletions pyDOE3/doe_factorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import numpy as np
from scipy.special import binom


__all__ = [
"fullfact",
"ff2n",
Expand Down Expand Up @@ -97,10 +96,7 @@ def fullfact(levels):
return H


################################################################################


def ff2n(n):
def ff2n(n_factors: int) -> np.ndarray:
"""
Create a 2-Level full-factorial design

Expand All @@ -120,22 +116,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.")

# 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):
def fracfact(gen) -> np.ndarray:
"""
Create a 2-level fractional-factorial design with a generator string.

Expand Down Expand Up @@ -186,65 +227,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
Expand Down Expand Up @@ -304,12 +339,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)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_box_behnken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
8 changes: 4 additions & 4 deletions tests/test_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
50 changes: 35 additions & 15 deletions tests/test_factorial.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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):
Expand All @@ -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)
Loading