diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 606369c..0e6cc20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,11 @@ name: Build on: push: - branches: [main] + branches: [ main ] pull_request: - branches: [main, feature/*] + branches: [ main, feature/* ] + schedule: # monthly, first day + - cron: 0 0 1 * * jobs: build: @@ -12,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9"] #, "3.7", "3.8", "3.9", "3.10"] + python-version: [ "3.9" ] #, "3.7", "3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v4 @@ -26,7 +28,10 @@ jobs: run: pip install -r requirements-dev.txt - name: Check - run: nox -e check + run: | + nox --version + poetry --version + nox -e check - name: Install run: nox -e install @@ -45,6 +50,8 @@ jobs: - name: Coverage uses: coverallsapp/github-action@v2 + with: + fail-on-error: false - name: Benchmark uses: CodSpeedHQ/action@v3 diff --git a/fuzzylite/variable.py b/fuzzylite/variable.py index 00d6a8d..70bd4e7 100644 --- a/fuzzylite/variable.py +++ b/fuzzylite/variable.py @@ -515,7 +515,7 @@ def defuzzify(self) -> None: f"expected a defuzzifier in output variable '{self.name}', but found None" ) # value at t+1 - value = self.defuzzifier.defuzzify(self.fuzzy, self.minimum, self.maximum) + value = np.atleast_1d(self.defuzzifier.defuzzify(self.fuzzy, self.minimum, self.maximum)) # previous value is the last element of the value at t self.previous_value = np.take(self.value, -1).astype(float) @@ -532,7 +532,6 @@ def defuzzify(self) -> None: # Applying default values if not np.isnan(self.default_value): - value = np.atleast_1d(value) value[np.isnan(value)] = self.default_value # Committing the value diff --git a/pyproject.toml b/pyproject.toml index b9fd87b..f01c831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ mkdocs-material = "^9.1.21" # Documentation with material theme for mkdocs mkdocstrings = {extras = ["python"], version = "^0.26.1"} mkdocstrings-python = "^1.11.1" mypy = "^1.10.0" # Static code analysis -poetry-bumpversion = "^0.3.0" # Version management +# poetry-bumpversion = "^0.3.0" # Version management; manual installation to avoid modifying poetry pymarkdownlnt = "^0.9.12" # Markdown linter pyright = "^1.1.362" # Static code analysis pytest = "^7.3.1" # Test driven development diff --git a/tests/test_issues.py b/tests/test_issues.py index ad33d69..2a39430 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -1,12 +1,12 @@ -import fuzzylite as fl import unittest + import numpy as np +import fuzzylite as fl + class TestIssue91(unittest.TestCase): - """ - Test case for https://github.com/fuzzylite/pyfuzzylite/issues/91 - """ + """Test case for https://github.com/fuzzylite/pyfuzzylite/issues/91""" def engine(self) -> fl.Engine: fll = """ @@ -84,3 +84,101 @@ def test_issue_91(self) -> None: engine.process() np.testing.assert_almost_equal(engine.variable("mTip").value, 25.00104979) np.testing.assert_almost_equal(engine.variable("tsTip").value, 25) + + def test_issue_92(self) -> None: + class Approximation: + def __init__(self) -> None: + self.engine = fl.Engine( + name="approximation", + input_variables=[ + fl.InputVariable( + name="inputX", + minimum=0.0, + maximum=10.0, + lock_range=False, + terms=[ + fl.Triangle("NEAR_1", 0.0, 1.0, 2.0), + fl.Triangle("NEAR_2", 1.0, 2.0, 3.0), + fl.Triangle("NEAR_3", 2.0, 3.0, 4.0), + fl.Triangle("NEAR_4", 3.0, 4.0, 5.0), + fl.Triangle("NEAR_5", 4.0, 5.0, 6.0), + fl.Triangle("NEAR_6", 5.0, 6.0, 7.0), + fl.Triangle("NEAR_7", 6.0, 7.0, 8.0), + fl.Triangle("NEAR_8", 7.0, 8.0, 9.0), + fl.Triangle("NEAR_9", 8.0, 9.0, 10.0), + ], + ) + ], + output_variables=[ + fl.OutputVariable( + name="outputFx", + minimum=-1.0, + maximum=1.0, + lock_range=False, + lock_previous=True, + default_value=fl.nan, + aggregation=None, + defuzzifier=fl.WeightedAverage(type="TakagiSugeno"), + terms=[ + fl.Constant("f1", 0.84), + fl.Constant("f2", 0.45), + fl.Constant("f3", 0.04), + fl.Constant("f4", -0.18), + fl.Constant("f5", -0.19), + fl.Constant("f6", -0.04), + fl.Constant("f7", 0.09), + fl.Constant("f8", 0.12), + fl.Constant("f9", 0.04), + ], + ), + fl.OutputVariable( + name="trueFx", + minimum=-1.0, + maximum=1.0, + lock_range=False, + lock_previous=True, + default_value=fl.nan, + aggregation=None, + defuzzifier=fl.WeightedAverage(), + terms=[fl.Function("fx", "sin(inputX)/inputX")], + ), + fl.OutputVariable( + name="diffFx", + minimum=-1.0, + maximum=1.0, + lock_range=False, + lock_previous=False, + default_value=fl.nan, + aggregation=None, + defuzzifier=fl.WeightedAverage(), + terms=[fl.Function("diff", "fabs(outputFx-trueFx)")], + ), + ], + rule_blocks=[ + fl.RuleBlock( + name="", + conjunction=None, + disjunction=None, + implication=None, + activation=fl.General(), + rules=[ + fl.Rule.create("if inputX is NEAR_1 then outputFx is f1"), + fl.Rule.create("if inputX is NEAR_2 then outputFx is f2"), + fl.Rule.create("if inputX is NEAR_3 then outputFx is f3"), + fl.Rule.create("if inputX is NEAR_4 then outputFx is f4"), + fl.Rule.create("if inputX is NEAR_5 then outputFx is f5"), + fl.Rule.create("if inputX is NEAR_6 then outputFx is f6"), + fl.Rule.create("if inputX is NEAR_7 then outputFx is f7"), + fl.Rule.create("if inputX is NEAR_8 then outputFx is f8"), + fl.Rule.create("if inputX is NEAR_9 then outputFx is f9"), + fl.Rule.create( + "if inputX is any then trueFx is fx and diffFx is diff" + ), + ], + ) + ], + ) + + test = Approximation() + test.engine.input_variable("inputX").value = 5.0 + test.engine.process() diff --git a/tests/test_vectorization.py b/tests/test_vectorization.py index 2c60740..5b6da69 100644 --- a/tests/test_vectorization.py +++ b/tests/test_vectorization.py @@ -23,46 +23,37 @@ class AssertIntegration: """Asserts integration of a Mamdani or Takagi-Sugeno system with a defuzzifier.""" - def __init__(self, engine: fl.Engine, vectorize: bool = True) -> None: + def __init__(self, engine: fl.Engine) -> None: """Constructor. @param engine is the engine to test on. @param vectorize is whether to test vectorization. """ self.engine = copy.deepcopy(engine) - self.vectorize = vectorize - def assert_that(self, defuzzifier: fl.Defuzzifier, input_expected: dict[float, float]) -> None: + def assert_that( + self, + defuzzifier: fl.Defuzzifier, + input_expected: dict[float, float] | list[tuple[float, float]], + ) -> None: """Asserts integration of a Mamdani or Takagi-Sugeno system with a defuzzifier.""" for output in self.engine.output_variables: output.defuzzifier = defuzzifier + if isinstance(input_expected, dict): + input_expected = list(input_expected.items()) + inputs = fl.array([x[0] for x in input_expected]) + expected_outputs = fl.array([x[1] for x in input_expected]) - self.engine.restart() - for input, expected_output in input_expected.items(): - self.engine.input_variables[0].value = input - self.engine.process() - obtained = self.engine.output_variables[0].value - np.testing.assert_allclose( - obtained, - expected_output, - err_msg=f"{fl.Op.class_name(defuzzifier)}({input}) = {obtained}, but expected {expected_output}", - atol=fl.settings.atol, - rtol=fl.settings.rtol, - ) - if self.vectorize: - self.engine.restart() - inputs = fl.array([x for x in input_expected]) - expected_outputs = fl.array([x for x in input_expected.values()]) - self.engine.input_variables[0].value = inputs - self.engine.process() - obtained = self.engine.output_variables[0].value - np.testing.assert_allclose( - obtained, - expected_outputs, - err_msg=f"{fl.Op.class_name(defuzzifier)}([{inputs}]) = {obtained}, but expected {expected_outputs}", - atol=fl.settings.atol, - rtol=fl.settings.rtol, - ) + self.engine.input_variables[0].value = inputs + self.engine.process() + obtained = self.engine.output_variables[0].value + np.testing.assert_allclose( + obtained, + expected_outputs, + err_msg=f"{fl.Op.class_name(defuzzifier)}([{inputs}]) = {obtained}, but expected {expected_outputs}", + atol=fl.settings.atol, + rtol=fl.settings.rtol, + ) class TestMamdani(unittest.TestCase): @@ -84,6 +75,46 @@ def test_simple_mamdani_bisector_integration(self) -> None: }, ) + def test_simple_mamdani_bisector_integration_with_locking(self) -> None: + """Test a simple integration with Bisector.""" + engine = mamdani.simple_dimmer.SimpleDimmer().engine + for output_variable in engine.output_variables: + output_variable.lock_previous = True + output_variable.default_value = 0.2468 + output_variable.value = np.array([1.234, 9.876]) + + AssertIntegration(engine).assert_that( + fl.Bisector(), + [ + # replace first nans with last value + (0.0, 9.876), # (0.0, fl.nan), + (1.0, 9.876), # (1.0, fl.nan), + (0.25, 0.75), + (0.375, 0.625), + (0.5, 0.5), + # replace nans with previous values + (0.0, 0.5), # (0.0, fl.nan), + (1.0, 0.5), # (1.0, fl.nan), + ], + ) + + engine.restart() + + AssertIntegration(engine).assert_that( + fl.Bisector(), + [ + # replace first nans default value + (0.0, 0.2468), # 0.0: fl.nan, + (1.0, 0.2468), # 1.0: fl.nan, + (0.25, 0.75), + (0.375, 0.625), + (0.5, 0.5), + # replace nans with previous values + (0.0, 0.5), # (0.0, fl.nan), + (1.0, 0.5), # (1.0, fl.nan), + ], + ) + def test_simple_mamdani_centroid_integration(self) -> None: """Test a simple integration with Centroid.""" AssertIntegration(mamdani.simple_dimmer.SimpleDimmer().engine).assert_that( @@ -168,6 +199,46 @@ def test_simple_takagisugeno_avg_integration(self) -> None: }, ) + def test_simple_takagisugeno_avg_integration_locking_previous(self) -> None: + """Test a simple integration with WeightedAverage.""" + engine = takagi_sugeno.simple_dimmer.SimpleDimmer().engine + for output_variable in engine.output_variables: + output_variable.lock_previous = True + output_variable.default_value = 0.2468 + output_variable.value = np.array([1.234, 9.876]) + + AssertIntegration(engine).assert_that( + fl.WeightedAverage(), + [ + # replace first nans with last value + (0.0, 9.876), # 0.0: fl.nan, + (1.0, 9.876), # 1.0: fl.nan, + (0.25, 0.75), + (0.375, 0.625), + (0.5, 0.5), + # replace nans with previous values + (0.0, 0.5), + (1.0, 0.5), + ], + ) + + engine.restart() + + AssertIntegration(engine).assert_that( + fl.WeightedAverage(), + [ + # replace first nans with default value + (0.0, 0.2468), # 0.0: fl.nan, + (1.0, 0.2468), # 1.0: fl.nan, + (0.25, 0.75), + (0.375, 0.625), + (0.5, 0.5), + # replace nans with previous values + (0.0, 0.5), + (1.0, 0.5), + ], + ) + def test_simple_takagisugeno_sum_integration(self) -> None: """Test a simple integration with WeightedSum.""" AssertIntegration(takagi_sugeno.simple_dimmer.SimpleDimmer().engine).assert_that(