From 839bf34cae93699ffd16b3fc127bd115720e28aa Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 20:40:39 -0700 Subject: [PATCH 01/14] chore(tests): static typing, linting, and add unit tests. --- Annealing/cooling.py | 119 --------------- setup.py | 80 ---------- {Annealing => src/Annealing}/__init__.py | 0 {Annealing => src/Annealing}/anneal.py | 0 src/Annealing/cooling.py | 185 +++++++++++++++++++++++ {Annealing => src/Annealing}/fitness.py | 34 +++-- tests/__init__.py | 21 +++ tests/unit/__init__.py | 21 +++ tests/unit/test_cooling.py | 66 ++++++++ tests/unit/test_fitness.py | 63 ++++++++ 10 files changed, 375 insertions(+), 214 deletions(-) delete mode 100644 Annealing/cooling.py delete mode 100644 setup.py rename {Annealing => src/Annealing}/__init__.py (100%) rename {Annealing => src/Annealing}/anneal.py (100%) create mode 100644 src/Annealing/cooling.py rename {Annealing => src/Annealing}/fitness.py (76%) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_cooling.py create mode 100644 tests/unit/test_fitness.py diff --git a/Annealing/cooling.py b/Annealing/cooling.py deleted file mode 100644 index 9e9e292..0000000 --- a/Annealing/cooling.py +++ /dev/null @@ -1,119 +0,0 @@ -# MIT License - -# Copyright (c) 2023 Spill-Tea - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -""" - Annealing/cooling.py - -""" -from abc import ABC, abstractmethod -from math import sqrt -from typing import Optional - - -class Cooling(ABC): - """Interface Class to define Temperature Cooling Functions.""" - def __init__(self, - steps: int = 1_000, - alpha: Optional[float] = None, - tm_min: float = 0., - tm_max: float = 100. - ) -> None: - self.steps = steps - self.alpha = alpha - self.tm_min = tm_min - self.tm_max = tm_max - - @abstractmethod - def cool(self, step: int) -> float: - ... - - def __call__(self, step: int) -> float: - return self.cool(step) - - -# Temperature Cooling Functions -def inverse_cooling(step_n, tm_max, alpha): - """T(k) = Tmax / (1. + alpha * k)""" - return tm_max / (1. + alpha * step_n) - - -class InverseCooling(Cooling): - def cool(self, step: int): - return inverse_cooling(step, self.tm_max, self.alpha) - - -def linear_cooling(step_n, tm_max, tm_min, max_steps): - """Linearly Scaled Cooling.""" - dt = tm_max - tm_min - ds = (max_steps - step_n) / max_steps - return tm_min + dt * ds - - -class LinearCooling(Cooling): - def cool(self, step: int): - return linear_cooling(step, self.tm_max, self.tm_min, self.steps) - - -def quadratic_cooling(step_n, tm_max, tm_min, max_steps): - """Squared Linear Cooling.""" - dt = tm_max - tm_min - ds = (max_steps - step_n) / max_steps - return tm_min + dt * (ds * ds) - - -class QuadraticCooling(Cooling): - def cool(self, step: int): - return quadratic_cooling(step, self.tm_max, self.tm_min, self.steps) - - -def exponential_cooling(step_n, tm_max, alpha): - """T(k) = tm_max * (alpha ** k)) """ - return tm_max * (alpha ** step_n) - - -class ExponentialCooling(Cooling): - def cool(self, step: int): - return exponential_cooling(step, self.tm_max, self.alpha) - - -class SqExponentialCooling(Cooling): - def cool(self, step: int): - a = exponential_cooling(step, self.tm_max, self.alpha) - a /= exponential_cooling(0, self.tm_max, self.alpha) - return self.tm_max * a * a - - -class SqrtExponentialCooling(Cooling): - def cool(self, step: int): - a = exponential_cooling(step, self.tm_max, self.alpha) - a /= exponential_cooling(0, self.tm_max, self.alpha) - return self.tm_max * sqrt(a) - - -class ExponentialQuadCooling(Cooling): - def _cool(self, step: int): - exps = exponential_cooling(step, self.tm_max, self.alpha) - quad = quadratic_cooling(step, self.tm_max, self.tm_min, self.steps) - return exps + quad - - def cool(self, step: int): - tops = self.tm_max - self.tm_min - return tops * self._cool(step) / self._cool(0) diff --git a/setup.py b/setup.py deleted file mode 100644 index a6d3715..0000000 --- a/setup.py +++ /dev/null @@ -1,80 +0,0 @@ -""" Setuptools module for building and packaging darwin. """ - - -# Python Dependencies -from os import path -from setuptools import setup -from setuptools import find_packages - - -here = path.abspath(path.dirname(__file__)) -PACKAGE_NAME = "Annealing" - - -def get_version(rel_path): - """Function to read the __version__ from the init. - This lets us define the version in a single location. - - Args: - rel_path (str): Path from here defined above. - - Returns: - Version string. - - """ - def read(x): - with open(path.join(here, x), "r") as fp: - return fp.read() - - for line in read(rel_path).splitlines(): - if line.startswith("__version__"): - delim = '"' if '"' in line else "'" - return line.split(delim)[1] - else: - raise RuntimeError("Unable to find version string.") - - -# Get the long description from the README file -with open(path.join(here, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -# Get Requirements from text file -with open(path.join(here, "requirements.txt"), encoding="utf-8") as f: - dependencies = f.read().splitlines() - - -setup( - name=PACKAGE_NAME, # Required - version=get_version(f"{PACKAGE_NAME}/__init__.py"), - description="Clean Extensible Interface for Simulated Annealing", - long_description=long_description, - long_description_content_type="text/markdown", # Optional (see note above) - url="https://github.com/Spill-Tea/Simulated-Annealing", - author="Jason C Del Rio", - author_email="spillthetea917@gmail.com", - classifiers=[ # Optional - "Development Status :: 3 - Alpha", - "Intended Audience :: Science/Research", - "Topic :: Scientific/Engineering :: Information Analysis", - "License :: OSI Approved :: MIT License", - ], - - # Note that this is a string of words separated by whitespace, not a list. - keywords="Annealing Optimization TSP", - packages=find_packages( - exclude=["tests", "dist", "build", "docs", "scripts", "templates", "notebooks", "__pycache__"] - ), # Required - - # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires - python_requires=">=3.6, <4", - install_requires=dependencies, - extras_require={ # Optional - "dev": ["pytest", "flake8", "pytest-cov", "wheel", "pdoc"], - "test": ["pytest", "flake8", "pytest-cov", "wheel"], - }, - - project_urls={ # Optional - "Bug Reports": "https://github.com/Spill-Tea/Simulated-Annealing/issues", - "Source": "https://github.com/Spill-Tea/Simulated-Annealing", - }, -) diff --git a/Annealing/__init__.py b/src/Annealing/__init__.py similarity index 100% rename from Annealing/__init__.py rename to src/Annealing/__init__.py diff --git a/Annealing/anneal.py b/src/Annealing/anneal.py similarity index 100% rename from Annealing/anneal.py rename to src/Annealing/anneal.py diff --git a/src/Annealing/cooling.py b/src/Annealing/cooling.py new file mode 100644 index 0000000..ccc78a1 --- /dev/null +++ b/src/Annealing/cooling.py @@ -0,0 +1,185 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Annealing/cooling.py.""" + +from abc import ABC, abstractmethod +from math import sqrt + + +class Cooling(ABC): + """Interface Class to define temperature cooling functions.""" + + steps: int + alpha: float + tm_min: float + tm_max: float + + def __init__( + self, + steps: int = 1_000, + alpha: float = 0.922, + tm_min: float = 0.0, + tm_max: float = 100.0, + ) -> None: + self.steps = steps + self.alpha = alpha + self.tm_min = tm_min + self.tm_max = tm_max + + @abstractmethod + def cool(self, step: int) -> float: ... + + def __call__(self, step: int) -> float: + return self.cool(step) + + +# Temperature Cooling Functions +def inverse_cooling(step_n: int, tm_max: float, alpha: float) -> float: + """T(k) = Tmax / (1. + alpha * k).""" + return tm_max / (1.0 + alpha * step_n) + + +class InverseCooling(Cooling): + """Inverse Cooling method which scales by the reciprocal of k. + + Equation: + T(k) = Tmax / (1 + alpha * k) + + """ + + def cool(self, step: int) -> float: + return inverse_cooling(step, self.tm_max, self.alpha) + + +def linear_cooling(step_n: int, tm_max: float, tm_min: float, max_steps: int) -> float: + """Linearly Scaled Cooling.""" + dt: float = tm_max - tm_min + ds: float = (max_steps - step_n) / max_steps + + return tm_min + dt * ds + + +class LinearCooling(Cooling): + """Linear cooling method. + + Equation: + T(k) = Tmin + (Tmax - Tmin) * (Ntotal - k) / Ntotal + + """ + + def cool(self, step: int) -> float: + return linear_cooling(step, self.tm_max, self.tm_min, self.steps) + + +def quadratic_cooling( + step_n: int, + tm_max: float, + tm_min: float, + max_steps: int, +) -> float: + """Squared Linear Cooling.""" + dt: float = tm_max - tm_min + ds: float = (max_steps - step_n) / max_steps + + return tm_min + dt * (ds * ds) + + +class QuadraticCooling(Cooling): + """Quadratic cooling method. + + Equation: + T(k) = Tmin + (Tmax - Tmin) * ((Ntotal - k) / Ntotal) ** 2 + + """ + + def cool(self, step: int) -> float: + return quadratic_cooling(step, self.tm_max, self.tm_min, self.steps) + + +def exponential_cooling(step_n: int, tm_max: float, alpha: float) -> float: + """T(k) = tm_max * (alpha ** k)).""" + return tm_max * (alpha**step_n) + + +class ExponentialCooling(Cooling): + """Exponential cooling method. + + Equation: + T(k) = Tmax * (alpha ** k) + + """ + + def cool(self, step: int) -> float: + return exponential_cooling(step, self.tm_max, self.alpha) + + +class SqExponentialCooling(Cooling): + """Square exponential cooling method. + + Equation: + T(k) = Tmax * (alpha ** k) ** 2 + + """ + + def cool(self, step: int) -> float: + a: float = exponential_cooling(step, self.tm_max, self.alpha) + a /= exponential_cooling(0, self.tm_max, self.alpha) + + return self.tm_max * a * a + + +class SqrtExponentialCooling(Cooling): + """Square root exponential cooling method. + + Equation: + T(k) = Tmax * (alpha ** k) ** (1/2) + + """ + + def cool(self, step: int) -> float: + a: float = exponential_cooling(step, self.tm_max, self.alpha) + a /= exponential_cooling(0, self.tm_max, self.alpha) + + return self.tm_max * sqrt(a) + + +class ExponentialQuadCooling(Cooling): + """Exponential quadratic cooling method. + + Equation: + a(k) = Tmin + (Tmax - Tmin) * ((Ntotal - k) / Ntotal) ** 2 + b(k) = Tmax * (alpha ** k) + T(k) = a(k) + b(k) + + """ + + def _cool(self, step: int) -> float: + exps: float = exponential_cooling(step, self.tm_max, self.alpha) + quad: float = quadratic_cooling(step, self.tm_max, self.tm_min, self.steps) + + return exps + quad + + def cool(self, step: int) -> float: + tops: float = self.tm_max - self.tm_min + + return tops * self._cool(step) / self._cool(0) diff --git a/Annealing/fitness.py b/src/Annealing/fitness.py similarity index 76% rename from Annealing/fitness.py rename to src/Annealing/fitness.py index e9e67fe..87b2ae6 100644 --- a/Annealing/fitness.py +++ b/src/Annealing/fitness.py @@ -1,17 +1,17 @@ # MIT License - +# # Copyright (c) 2023 Spill-Tea - +# # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: - +# # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. - +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -19,30 +19,32 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -""" - fitness.py -""" -from abc import ABC -from abc import abstractmethod +"""Simple interface class to define fitness, or improved performance of a metric.""" + +from abc import ABC, abstractmethod import numpy as np class Fitness(ABC): + """Define fitness measurement.""" + @abstractmethod def performance(self, data: np.ndarray) -> float: - """Calculates Performance Metric from given Data.""" - ... + """Calculates performance metric from given data.""" + raise NotImplementedError("Must implement a performance method.") def __call__(self, data: np.ndarray) -> float: return self.performance(data) class LinearEuclidean(Fitness): - """Linear (One Way) Euclidean Distance""" + """Linear (One Way) Euclidean Distance.""" + def _performance(self, dx: np.ndarray) -> float: - ss = np.sum(dx * dx, axis=1) + ss: np.ndarray = np.sum(dx * dx, axis=1) + return np.sqrt(ss).sum() def performance(self, data: np.ndarray) -> float: @@ -50,7 +52,9 @@ def performance(self, data: np.ndarray) -> float: class CircularEuclidean(LinearEuclidean): - """Circular (Round Trip) Euclidean Distance""" + """Circular (Round Trip) Euclidean Distance.""" + def performance(self, data: np.ndarray) -> float: - dx = data - np.roll(data, 1, 0) + dx: np.ndarray = data - np.roll(data, 1, 0) + return self._performance(dx) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..634d8f8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..634d8f8 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,21 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. diff --git a/tests/unit/test_cooling.py b/tests/unit/test_cooling.py new file mode 100644 index 0000000..94ceda7 --- /dev/null +++ b/tests/unit/test_cooling.py @@ -0,0 +1,66 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""unit test cooling paradigms.""" + +import numpy as np +import pytest + +from Annealing import cooling + + +@pytest.fixture +def options() -> dict: + return dict(steps=1000, alpha=0.8, tm_min=0, tm_max=100) + + +@pytest.mark.parametrize( + ["cooler", "step", "expected"], + [ + (cooling.LinearCooling, 0, 100.0), + (cooling.LinearCooling, 100, 90.0), + (cooling.LinearCooling, 1000, 0.0), + (cooling.InverseCooling, 0, 100.0), + (cooling.InverseCooling, 150, 0.826446), + (cooling.InverseCooling, 1000, 0.124843), + (cooling.QuadraticCooling, 0, 100.0), + (cooling.QuadraticCooling, 225, 60.0625), + (cooling.QuadraticCooling, 1000, 0.0), + (cooling.ExponentialCooling, 0, 100.0), + (cooling.ExponentialCooling, 995, 3.75e-95), + (cooling.ExponentialCooling, 1000, 0.0), + (cooling.SqExponentialCooling, 0, 100.0), + (cooling.SqExponentialCooling, 515, 1.52e-98), + (cooling.SqExponentialCooling, 1000, 0.0), + (cooling.ExponentialQuadCooling, 0, 100.0), + (cooling.ExponentialQuadCooling, 725, 3.78125), + (cooling.ExponentialQuadCooling, 1000, 0.0), + ], +) +def test_cooling_paradigms( + options, + cooler: type[cooling.Cooling], + step: int, + expected: float, +) -> None: + cool: cooling.Cooling = cooler(**options) + result = cool(step) + assert np.isclose(result, expected), f"Unexpected {cooler.__name__}." diff --git a/tests/unit/test_fitness.py b/tests/unit/test_fitness.py new file mode 100644 index 0000000..5cbcb9b --- /dev/null +++ b/tests/unit/test_fitness.py @@ -0,0 +1,63 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""unit test fitness calculations.""" + +import numpy as np +import pytest + +from Annealing import fitness + + +@pytest.fixture +def coordinates_2d() -> np.ndarray: + """Example 3d coordinates.""" + return np.asarray([[1, 2], [4, 6]]) + + +@pytest.fixture +def coordinates_3d() -> np.ndarray: + """Example 3d coordinates.""" + return np.asarray([[1, 2, 2], [1, 2, 6]]) + + +@pytest.mark.parametrize( + ["cls", "name", "expected"], + [ + (fitness.LinearEuclidean, "coordinates_2d", 5.0), + (fitness.CircularEuclidean, "coordinates_2d", 10.0), + (fitness.LinearEuclidean, "coordinates_3d", 4.0), + (fitness.CircularEuclidean, "coordinates_3d", 8.0), + ], +) +def test_linear( + name: str, + cls: type[fitness.Fitness], + expected: float, + request: pytest.FixtureRequest, +) -> None: + """Test linear Euclidean""" + fit: fitness.Fitness = cls() + coordinates: np.ndarray = request.getfixturevalue(name) + result: float = fit.performance(coordinates) + + assert result == expected, "Unexpected Linear Euclidean" From 6cf40663aa06307793b2328f14ac6285e965b95b Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 20:43:07 -0700 Subject: [PATCH 02/14] chore(anneal): Improve and support static typing. --- src/Annealing/__init__.py | 26 +++++++- src/Annealing/anneal.py | 122 +++++++++++++++++++++----------------- src/Annealing/py.typed | 0 3 files changed, 91 insertions(+), 57 deletions(-) create mode 100644 src/Annealing/py.typed diff --git a/src/Annealing/__init__.py b/src/Annealing/__init__.py index 93b0ca1..361735f 100644 --- a/src/Annealing/__init__.py +++ b/src/Annealing/__init__.py @@ -1,5 +1,25 @@ -""" - Annealing +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Simulated annealing.""" -""" __version__ = "0.0.1" diff --git a/src/Annealing/anneal.py b/src/Annealing/anneal.py index 9ee4c19..3cfcb6c 100644 --- a/src/Annealing/anneal.py +++ b/src/Annealing/anneal.py @@ -1,17 +1,17 @@ # MIT License - +# # Copyright (c) 2023 Spill-Tea - +# # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: - +# # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. - +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -19,27 +19,27 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -""" - Annealing/anneal.py -""" -from abc import ABC -from abc import abstractmethod +"""Annealing/anneal.py.""" + +from abc import ABC, abstractmethod from dataclasses import dataclass from logging import Logger from math import exp -from typing import List, Optional import numpy as np from .cooling import Cooling from .fitness import Fitness -_log = Logger(__name__, 10) + +_log: Logger = Logger(__name__, 10) @dataclass class Sample: + """Simulated sample.""" + iteration: int tm: float perf: float @@ -47,35 +47,36 @@ class Sample: order: np.ndarray -def stochastic(options: int, size: int): +def stochastic(options: int, size: int) -> np.ndarray: + """Stochastic choice (subsample) of options without duplicates.""" return np.random.choice(options, size=size, replace=False) -def swap(array): - """Stochastically Swaps two indices of an array, inplace. +def swap(array: np.ndarray) -> None: + """Stochastically swaps two indices of an array, inplace. Note: For Potential Asymetric Swapping, call this function more than once, on the same array. """ - idx1, idx2 = np.random.choice(len(array), 2, replace=False) + idx1, idx2 = stochastic(len(array), 2) array[idx1], array[idx2] = array[idx2], array[idx1] -def n_opt(array: np.ndarray, n: int = 2): - """Stochastically Swaps inplace any N indices within an array""" - index = np.random.choice(len(array), n, replace=False) +def n_opt(array: np.ndarray, n: int = 2) -> None: + """Stochastically Swaps inplace any N indices within an array.""" + index: np.ndarray = stochastic(len(array), n) array[index] = array[np.roll(index, 1)] def probability(difference: float, temperature: float) -> float: - """Calculates the Probability scaled by the difference and current temperature""" + """Calculates the Probability scaled by the difference and current temperature.""" return exp(-difference / temperature) class AnnealingBase(ABC): - """Abstract Simulated Annealing Base Class + """Abstract Simulated Annealing Base Class. Args: data (np.ndarray): Data used to simulate @@ -83,8 +84,9 @@ class AnnealingBase(ABC): fitness (Fitness): Define Optimization Metrics log (Logger): Logger for debugging purposes - tm (float): Current Temperature - history (List[Sample]): History of saved best performing added to during simulation. + Attributes: + tm (float): Current temperature. + history (list[Sample]): History of best performing added to during simulation. Notes: 1. In the Traveling Salesman Problem (TSP), we are aiming to @@ -98,89 +100,101 @@ class AnnealingBase(ABC): case will resemble a 2d distance matrix. """ - def __init__(self, - data: np.ndarray, - chill: Cooling, - fitness: Fitness, - log: Logger = _log, - ) -> None: + + data: np.ndarray + chill: Cooling + fitness: Fitness + log: Logger + tm: float + history: list[Sample] + + def __init__( + self, + data: np.ndarray, + chill: Cooling, + fitness: Fitness, + log: Logger = _log, + ) -> None: self.data = data self.chill = chill self.fitness = fitness self.log = log self.tm = self.chill.tm_max - self.history: List[Sample] = [] + self.history: list[Sample] = [] @property - def steps(self): - """Number of Steps, or Iterations""" + def steps(self) -> int: + """Number of steps (iterations).""" return self.chill.steps @property def best(self) -> Sample: - """Best Performing Sample Found""" + """Best performing sample found.""" return min(self.history, key=lambda x: x.perf) @abstractmethod - def mixing(self, index, n: int) -> None: + def mixing(self, index: np.ndarray, n: int) -> None: """Defines how we shuffle or select next indices.""" n_opt(index, np.random.randint(2, n + 1)) @abstractmethod def subsample(self, indices: np.ndarray) -> np.ndarray: - """Slices a Subsample of the Larger Dataset. + """Slices a subsample of the larger dataset. Args: - indices (np.ndarray[int]): Array of Row Indices + indices (np.ndarray[int]): Array of row indices. Returns: - (np.ndarray) + (np.ndarray) subsample of data (in provided order). """ return self.data[indices] - def nucleate(self, k: Optional[int] = None): - """Initialize Iterative Selection Process.""" - total = len(self.data) + def nucleate(self, k: int | None = None) -> np.ndarray: + """Initialize iterative selection process.""" + total: int = len(self.data) k = k or total - index = stochastic(total, k) + index: np.ndarray = stochastic(total, k) + return self.subsample(index) - def simulate(self, k: Optional[int] = None, nswaps: int = 3): - """Simulate Annealing. + def simulate(self, k: int | None = None, nswaps: int = 3) -> np.ndarray: + """Simulate annealing. Args: - k (int): Choose k, Optional - nswaps (int): Maximum Number of indices to swap + k (int): Choose k or default to length of data. + nswaps (int): Maximum Number of indices to swap. Returns: - (np.ndarray) Best Performing Data + (np.ndarray) Best performing data found. """ # Reset Tm self.tm = self.chill.tm_max - data = self.data if k is None else self.nucleate(k) + data: np.ndarray = self.data if k is None else self.nucleate(k) if not (2 <= nswaps <= len(data)): nswaps = max(2, min(nswaps, len(data))) self.log.info(f"Setting nswaps argument to: {nswaps}") - index = np.arange(len(data)) - best_index = np.copy(index) - best = self.fitness(data) + index: np.ndarray = np.arange(len(data)) + best_index: np.ndarray = np.copy(index) + best: float = self.fitness(data) for j in range(self.steps): self.mixing(index, nswaps) - array = self.subsample(index) - current = self.fitness(array) + array: np.ndarray = self.subsample(index) + current: float = self.fitness(array) self.tm = self.chill(j) - delta = current - best - test = delta <= 0 + delta: float = current - best + test: bool = delta <= 0 if test or probability(delta, self.tm) > np.random.random(1): self.log.debug("Iteration %d: Performance (%.4f)", j, current) best = current best_index = np.copy(index) data = np.copy(array) - self.history.append(Sample(iteration=j, tm=self.tm, perf=best, better=test, order=data)) + self.history.append( + Sample(iteration=j, tm=self.tm, perf=best, better=test, order=data) + ) else: index = np.copy(best_index) diff --git a/src/Annealing/py.typed b/src/Annealing/py.typed new file mode 100644 index 0000000..e69de29 From a7548aad0bfc85759f66d4dd99862c65b5c4506c Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:21:16 -0700 Subject: [PATCH 03/14] chore(linting): code linting. --- src/Annealing/anneal.py | 3 ++- src/Annealing/cooling.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Annealing/anneal.py b/src/Annealing/anneal.py index 3cfcb6c..82a54ef 100644 --- a/src/Annealing/anneal.py +++ b/src/Annealing/anneal.py @@ -175,7 +175,8 @@ def simulate(self, k: int | None = None, nswaps: int = 3) -> np.ndarray: data: np.ndarray = self.data if k is None else self.nucleate(k) if not (2 <= nswaps <= len(data)): nswaps = max(2, min(nswaps, len(data))) - self.log.info(f"Setting nswaps argument to: {nswaps}") + self.log.info("Setting nswaps argument to: %d", nswaps) + index: np.ndarray = np.arange(len(data)) best_index: np.ndarray = np.copy(index) best: float = self.fitness(data) diff --git a/src/Annealing/cooling.py b/src/Annealing/cooling.py index ccc78a1..7935c37 100644 --- a/src/Annealing/cooling.py +++ b/src/Annealing/cooling.py @@ -47,7 +47,9 @@ def __init__( self.tm_max = tm_max @abstractmethod - def cool(self, step: int) -> float: ... + def cool(self, step: int) -> float: + """Strategy dependent decrease in temperature by current step.""" + raise NotImplementedError def __call__(self, step: int) -> float: return self.cool(step) From ed29658947e1046e5c822136dad2aa037a9c97a9 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:27:57 -0700 Subject: [PATCH 04/14] package(pyproject): Replace setup script for use of a pyproject toml. --- pyproject.toml | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bd978ea --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,156 @@ +[build-system] +requires = ["setuptools>=67.6.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "Annealing" +authors = [{ name = "Jason C Del Rio", email = "spillthetea917@gmail.com" }] +maintainers = [{ name = "Jason C Del Rio", email = "spillthetea917@gmail.com" }] +description = "Clean Extensible Interface for Simulated Annealing." +license = { file = "LICENSE" } +requires-python = ">=3.10" +keywords = [ + "simulated annealing", + "optimization", + "TSP", + "combinatorics", + "traveling salesman problem", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Information Analysis", +] +dynamic = ["version", "readme", "dependencies"] + +[project.urls] +homepage = "https://github.com/Spill-Tea/Simulated-Annealing" +issues = "https://github.com/Spill-Tea/Simulated-Annealing/issues" + +[tool.setuptools.dynamic] +version = { attr = "Annealing.__version__" } +readme = { file = ["README.md"], content-type = "text/markdown" } +dependencies = { file = ["requirements.txt"] } + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["benchmarks", "docs", "tests"] + +[tool.setuptools.package-data] +"*" = ["py.typed", "*.pyi"] + +[project.optional-dependencies] +dev = ["Annealing[doc,test,lint,type]", "tox", "pre-commit"] +doc = ["sphinx", "furo", "sphinx_multiversion"] +test = ["pytest", "coverage", "pytest-xdist"] +lint = ["pylint", "ruff"] +type = ["mypy"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-n auto -rA" + +[tool.coverage.run] +parallel = true +branch = true +source = ["Annealing"] +disable_warnings = ["no-data-collected", "module-not-imported"] + +[tool.coverage.paths] +source = ["src", "*/.tox/py*/**/site-packages"] + +[tool.coverage.report] +fail_under = 95.0 +precision = 1 +show_missing = true +skip_empty = true +exclude_also = ["def __repr__", 'if __name__ == "__main__"'] + +[tool.mypy] +mypy_path = "Annealing" +warn_unused_ignores = true +allow_redefinition = false +force_uppercase_builtins = true + +[tool.pylint.main] +ignore = ["tests", "dist", "build"] +fail-under = 9.0 +jobs = 0 +limit-inference-results = 100 +persistent = true +suggestion-mode = true + +[tool.pylint.basic] +argument-naming-style = "snake_case" +attr-naming-style = "snake_case" +class-const-naming-style = "UPPER_CASE" +class-naming-style = "PascalCase" +variable-naming-style = "snake_case" +module-naming-style = "any" + +[tool.pylint.format] +max-line-length = 88 + +[tool.pylint."messages control"] +disable = [ + "R0903", # too-few-public-methods +] + +[tool.ruff] +line-length = 88 +indent-width = 4 +respect-gitignore = true + +[tool.ruff.lint] +select = [ + "B", # bugbear + "D", # pydocstyle + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "PYI", # flake8-pyi + "RUF", # ruff + "W", # pycodestyle + "PIE", # flake8-pie + "PGH004", # pygrep-hooks - Use specific rule codes when using noqa + "PLE", # pylint error + "PLW", # pylint warning + "PLR1714", # Consider merging multiple comparisons + "UP", # Pyupgrade +] +ignore = [ + "D102", # undocumented-public-method (D102) + "D105", # undocumented-magic-method (D105) + "D107", # undocumented-public-init (D107) + "D203", # one-blank-line-before-class (D203) + "D213", # multi-line-summary-second-line (D213) + "PLR0913", # too-many-arguments (PLR0913) + "C408", # unnecessary-collection-call (C408) +] + +[tool.ruff.lint.pydocstyle] +convention = "google" # Accepts: "google" | "numpy" | "pep257" + +[tool.ruff.lint.isort] +lines-after-imports = 2 + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = [ + "E402", # Import Statement not at Top of File + "F401", # Unused Imports +] +"tests/*.py" = [ + "D", # PyDocstyle + "PLR2004", # magic-value-comparison (PLR2004) + "F841", # unused-variable (F841) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" From 54d0a0e49cf46942f43dad33e737247ceafd0ed1 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:46:37 -0700 Subject: [PATCH 05/14] package(gitignore): Use gitignore as a git include instead. --- .gitignore | 187 +++++++---------------------------------------------- 1 file changed, 25 insertions(+), 162 deletions(-) diff --git a/.gitignore b/.gitignore index 45c043a..b48cd49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,163 +1,26 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# File Systems -.DS_store - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +# ignore all root items +/* + +# unignore folders +!src/ +!tests/ +!docs/ +!.github/ + +# unignore files +!.gitignore +!.gitattributes +!setup.py +!README.md +!LICENSE +!pyproject.toml +!.pre-commit-config.yaml +!tox.ini +!requirements.txt +!.python-version-default +!rename.py + +# recursively re-ignore +__pycache__ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +docs/build From ddb4899abe7a6969d651ae5045396abd04489584 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:52:28 -0700 Subject: [PATCH 06/14] ci(tox): Include a tox configuration for ci. --- tests/unit/test_fitness.py | 12 +++++------ tox.ini | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tox.ini diff --git a/tests/unit/test_fitness.py b/tests/unit/test_fitness.py index 5cbcb9b..46b37c3 100644 --- a/tests/unit/test_fitness.py +++ b/tests/unit/test_fitness.py @@ -41,7 +41,7 @@ def coordinates_3d() -> np.ndarray: @pytest.mark.parametrize( - ["cls", "name", "expected"], + ["cls", "fixture_name", "expected"], [ (fitness.LinearEuclidean, "coordinates_2d", 5.0), (fitness.CircularEuclidean, "coordinates_2d", 10.0), @@ -49,15 +49,15 @@ def coordinates_3d() -> np.ndarray: (fitness.CircularEuclidean, "coordinates_3d", 8.0), ], ) -def test_linear( - name: str, +def test_fitness( + fixture_name: str, cls: type[fitness.Fitness], expected: float, request: pytest.FixtureRequest, ) -> None: - """Test linear Euclidean""" + """Test fitness computation.""" fit: fitness.Fitness = cls() - coordinates: np.ndarray = request.getfixturevalue(name) + coordinates: np.ndarray = request.getfixturevalue(fixture_name) result: float = fit.performance(coordinates) - assert result == expected, "Unexpected Linear Euclidean" + assert result == expected, f"Unexpected result: {cls.__name__}" diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..711ea29 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +[tox] +requires = tox>=4 +envlist = type, lint, coverage, docs, py{310,311,312,313}-tests + +[testenv] +description = Base Environment +extras = test +set_env = + COVERAGE_PROCESS_START={toxinidir}/pyproject.toml +commands_pre = + {envpython} --version +commands = + coverage run --rcfile pyproject.toml -m pytest {posargs} + +[testenv:py{310,311,312,313}-tests] +description = Run Unit Tests +commands_pre = + {envpython} --version + {envpython} -c 'import pathlib; pathlib.Path("{env_site_packages_dir}/cov.pth").write_text("import coverage; coverage.process_startup()")' + +[testenv:coverage] +description = Report Code Coverage +skip_install = true +deps = coverage +parallel_show_output = true +depends = py{310,311,312,313}-tests +commands = + coverage combine --quiet --rcfile pyproject.toml + coverage report --rcfile pyproject.toml {posargs} + +[testenv:type] +description = Run Static Type Check +extras = type +commands = + mypy --config-file pyproject.toml {posargs: src} + +[testenv:lint] +description = Run Code Linting +extras = lint +commands = + ruff check --config pyproject.toml {posargs: src} + ruff format --check --config pyproject.toml {posargs: src} + pylint --rcfile pyproject.toml {posargs: src} From a8fe0fff7d34ea43a42c438a0c0e5f4d61eed0b6 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:55:49 -0700 Subject: [PATCH 07/14] ci(python-version): add default version for setup of remote ci. --- .python-version-default | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version-default diff --git a/.python-version-default b/.python-version-default new file mode 100644 index 0000000..3a4f41e --- /dev/null +++ b/.python-version-default @@ -0,0 +1 @@ +3.13 \ No newline at end of file From 5985b6df30b5922a8d988e8c6ecd2332c8ec8f02 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Mon, 28 Jul 2025 23:57:44 -0700 Subject: [PATCH 08/14] fix(tox): remove docs from envlist. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 711ea29..e10493d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] requires = tox>=4 -envlist = type, lint, coverage, docs, py{310,311,312,313}-tests +envlist = type, lint, coverage, py{310,311,312,313}-tests [testenv] description = Base Environment From 4572881c40e651eed27d5102151a9e81f456c545 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 00:05:31 -0700 Subject: [PATCH 09/14] tests(unit): improve unit testing coverage of fitness and cooling modules. --- pyproject.toml | 6 +++++- tests/unit/test_cooling.py | 3 +++ tests/unit/test_fitness.py | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd978ea..a1f45c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,11 @@ fail_under = 95.0 precision = 1 show_missing = true skip_empty = true -exclude_also = ["def __repr__", 'if __name__ == "__main__"'] +exclude_also = [ + "def __repr__", + 'if __name__ == "__main__"', + "raise NotImplementedError" +] [tool.mypy] mypy_path = "Annealing" diff --git a/tests/unit/test_cooling.py b/tests/unit/test_cooling.py index 94ceda7..3ff26d3 100644 --- a/tests/unit/test_cooling.py +++ b/tests/unit/test_cooling.py @@ -50,6 +50,9 @@ def options() -> dict: (cooling.SqExponentialCooling, 0, 100.0), (cooling.SqExponentialCooling, 515, 1.52e-98), (cooling.SqExponentialCooling, 1000, 0.0), + (cooling.SqrtExponentialCooling, 0, 100.0), + (cooling.SqrtExponentialCooling, 500, 5.92e-23), + (cooling.SqrtExponentialCooling, 1000, 0.0), (cooling.ExponentialQuadCooling, 0, 100.0), (cooling.ExponentialQuadCooling, 725, 3.78125), (cooling.ExponentialQuadCooling, 1000, 0.0), diff --git a/tests/unit/test_fitness.py b/tests/unit/test_fitness.py index 46b37c3..b52d600 100644 --- a/tests/unit/test_fitness.py +++ b/tests/unit/test_fitness.py @@ -59,5 +59,7 @@ def test_fitness( fit: fitness.Fitness = cls() coordinates: np.ndarray = request.getfixturevalue(fixture_name) result: float = fit.performance(coordinates) - assert result == expected, f"Unexpected result: {cls.__name__}" + + resultb: float = fit(coordinates) + assert resultb == expected, f"Unexpected result: {cls.__name__}" From 563fd58fdc006682081065975249871973cb0181 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 00:13:55 -0700 Subject: [PATCH 10/14] chore(linting): minor linting. --- README.md | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0c085a2..d3d9ed7 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ result = travel.simulate(nswaps=2) print(f"Proposed Minimum Distance: {travel.best}") # Best Observed Performance: 1088.2251082017222 -best_idx = np.asarray([ +best_idx = np.asarray([ 0, 6, 13, 28, 37, 3, 19, 44, 34, 45, - 2, 49, 30, 41, 25, 31, 18, 38, 8, 29, + 2, 49, 30, 41, 25, 31, 18, 38, 8, 29, 24, 14, 27, 20, 33, 10, 42, 48, 40, 7, 5, 26, 46, 12, 9, 36, 1, 32, 47, 22, 39, 16, 4, 43, 23, 17, 35, 15, 11, 21, diff --git a/pyproject.toml b/pyproject.toml index a1f45c2..876a5c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ precision = 1 show_missing = true skip_empty = true exclude_also = [ - "def __repr__", + "def __repr__", 'if __name__ == "__main__"', "raise NotImplementedError" ] diff --git a/requirements.txt b/requirements.txt index 4adf6d7..f24f9ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -numpy>=1.22.4 \ No newline at end of file +numpy>=1.22.4 From 95461a8645203c966a3cdad9560b4fd058b19a22 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 14:23:48 -0700 Subject: [PATCH 11/14] test(pre-commit): add pre commit config. --- .pre-commit-config.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..da84716 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +ci: + autoupdate_schedule: quarterly + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + exclude: ^tests/(integration|unit)/data/ + - id: end-of-file-fixer + exclude: ^(tests/(integration|unit)/data/|LICENSE|.python-version-default) + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: [ --maxkb=1024 ] + - id: requirements-txt-fixer + + - repo: https://github.com/Spill-Tea/addlicense-pre-commit + rev: v1.1.2 + hooks: + - id: addlicense + language: golang + args: [ + -f, LICENSE, + ] + types_or: [ python, cython ] From cd161ea16e238af8f5a46a4b0a43076b3f658857 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 14:45:30 -0700 Subject: [PATCH 12/14] ci(tox): Include pre commit as an additional environment for unit testing ci. --- README.md | 2 +- pyproject.toml | 1 - src/Annealing/anneal.py | 2 +- tox.ini | 10 +++++++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d3d9ed7..17f2041 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ tsp = TSP( intial = tsp.fitness(coordinates) # 3571.1151820333043 print(f"Initial Distance: {initial}") -# You might want to simualte several times +# You might want to simulate several times result = travel.simulate(nswaps=2) print(f"Proposed Minimum Distance: {travel.best}") diff --git a/pyproject.toml b/pyproject.toml index 876a5c0..677b289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,6 @@ exclude_also = [ mypy_path = "Annealing" warn_unused_ignores = true allow_redefinition = false -force_uppercase_builtins = true [tool.pylint.main] ignore = ["tests", "dist", "build"] diff --git a/src/Annealing/anneal.py b/src/Annealing/anneal.py index 82a54ef..16e7fd3 100644 --- a/src/Annealing/anneal.py +++ b/src/Annealing/anneal.py @@ -56,7 +56,7 @@ def swap(array: np.ndarray) -> None: """Stochastically swaps two indices of an array, inplace. Note: - For Potential Asymetric Swapping, call this function more + For Potential Asymmetric Swapping, call this function more than once, on the same array. """ diff --git a/tox.ini b/tox.ini index e10493d..2acf798 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] requires = tox>=4 -envlist = type, lint, coverage, py{310,311,312,313}-tests +envlist = type, lint, coverage, commit, py{310,311,312,313}-tests [testenv] description = Base Environment @@ -41,3 +41,11 @@ commands = ruff check --config pyproject.toml {posargs: src} ruff format --check --config pyproject.toml {posargs: src} pylint --rcfile pyproject.toml {posargs: src} + +[testenv:commit] +description = Report Code Coverage +skip_install = true +deps = pre-commit +parallel_show_output = true +commands = + pre-commit run --all-files From 85ad83cf12ba9e5b7a7996860b490d53345645fd Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 15:57:42 -0700 Subject: [PATCH 13/14] test(anneal): Add unit tests for anneal module. Fix swap method with invalid use of tuple swapping. --- src/Annealing/anneal.py | 18 +---- tests/unit/test_anneal.py | 153 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_anneal.py diff --git a/src/Annealing/anneal.py b/src/Annealing/anneal.py index 16e7fd3..bfe79ed 100644 --- a/src/Annealing/anneal.py +++ b/src/Annealing/anneal.py @@ -52,20 +52,8 @@ def stochastic(options: int, size: int) -> np.ndarray: return np.random.choice(options, size=size, replace=False) -def swap(array: np.ndarray) -> None: - """Stochastically swaps two indices of an array, inplace. - - Note: - For Potential Asymmetric Swapping, call this function more - than once, on the same array. - - """ - idx1, idx2 = stochastic(len(array), 2) - array[idx1], array[idx2] = array[idx2], array[idx1] - - -def n_opt(array: np.ndarray, n: int = 2) -> None: - """Stochastically Swaps inplace any N indices within an array.""" +def swap(array: np.ndarray, n: int = 2) -> None: + """Stochastically swaps inplace any N indices within an array.""" index: np.ndarray = stochastic(len(array), n) array[index] = array[np.roll(index, 1)] @@ -136,7 +124,7 @@ def best(self) -> Sample: @abstractmethod def mixing(self, index: np.ndarray, n: int) -> None: """Defines how we shuffle or select next indices.""" - n_opt(index, np.random.randint(2, n + 1)) + swap(index, np.random.randint(2, n + 1)) @abstractmethod def subsample(self, indices: np.ndarray) -> np.ndarray: diff --git a/tests/unit/test_anneal.py b/tests/unit/test_anneal.py new file mode 100644 index 0000000..2c430a9 --- /dev/null +++ b/tests/unit/test_anneal.py @@ -0,0 +1,153 @@ +# MIT License +# +# Copyright (c) 2023 Spill-Tea +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""unit test annealing interface and peripheral functions.""" + +import numpy as np +import pytest + +from Annealing import anneal +from Annealing.cooling import InverseCooling +from Annealing.fitness import CircularEuclidean + + +@pytest.fixture +def tsp() -> type[anneal.AnnealingBase]: + """tsp annealing base.""" + + class TSP(anneal.AnnealingBase): + def mixing(self, index, nshuffle): + super().mixing(index, nshuffle) + + def subsample(self, indices: np.ndarray) -> np.ndarray: + return super().subsample(indices) + + return TSP + + +@pytest.fixture +def coord() -> np.ndarray: + """random 3d coordinates.""" + seed = np.random.default_rng(23) + coordinates = seed.uniform(0.0, 100.0, (50, 3)) + + return coordinates + + +@pytest.fixture +def tsp_base( + coord: np.ndarray, + tsp: type[anneal.AnnealingBase], +) -> anneal.AnnealingBase: + """Instance of TSP annealing base.""" + base: anneal.AnnealingBase = tsp( + coord, + InverseCooling(1000, 0.9), + CircularEuclidean(), + ) + + return base + + +def test_stochastic() -> None: + """unit test stochastic sample.""" + expect = 5 + maximum = 100 + result = anneal.stochastic(maximum, expect) + + assert isinstance(result, np.ndarray), "Expected an array." + assert len(result) == expect, "Expected 5 elements within array." + assert np.count_nonzero(result < maximum) == expect, ( + "Expected all elements to be below threshold." + ) + assert len(set(result.tolist())) == expect, "Expected all 5 elements to be unique." + + +@pytest.mark.parametrize( + ["n"], + [(i,) for i in range(2, 11)], +) +def test_swap(coord: np.ndarray, n: int) -> None: + """Test coordinate swapping acts in place correctly.""" + result: np.ndarray = coord.copy() + anneal.swap(result, n) + assert isinstance(result, np.ndarray), "Expected an array." + assert result.shape == coord.shape, "Expected same shape as input array." + assert not np.all(result == coord), "Expected a different output array." + + indices: np.ndarray = np.argwhere(result != coord) + assert len(np.unique(indices[:, 0])) == n, f"Expected {n} elements to be swapped." + + +@pytest.mark.parametrize( + ["diff", "temp", "expected"], + [ + (-5, 45, 1.117519), + (5, 45, 0.8948393), + (-100, 99, 2.745878), + (-100, 1, 2.688117e43), + (100, 1, 3.72e-44), + ], +) +def test_probability(diff: float, temp: float, expected: float) -> None: + """Test stochastic probability of accepting a worse result scaling.""" + result = anneal.probability(diff, temp) + assert np.isclose(result, expected) + + +def test_annealing_nucleate( + tsp_base: anneal.AnnealingBase, +) -> None: + """Test a simulation of TSP annealing.""" + base: anneal.AnnealingBase = tsp_base + assert isinstance(base, anneal.AnnealingBase), "Expected subclass instance." + assert len(base.history) == 0, "Expected no elements" + + result = base.simulate(len(base.data)) + assert isinstance(result, np.ndarray), "Expected an array." + assert len(result) == len(base.data), "Expected same size array." + + +def test_annealing( + tsp_base: anneal.AnnealingBase, +) -> None: + """Test annealing without nucleation to more reliably measure fitness improves.""" + base: anneal.AnnealingBase = tsp_base + result = base.simulate() + + assert len(base.history) > 0, "Expected to have results saved in history." + assert base.fitness(result) < base.fitness(base.data), ( + "Expected improvement of coordinate order." + ) + assert base.fitness(base.best.order) < base.fitness(base.data), ( + "Expected improvement of coordinate order." + ) + + +def test_annealing_low_nswaps( + tsp_base: anneal.AnnealingBase, +) -> None: + """Test annealing simulation setting low nswaps.""" + base: anneal.AnnealingBase = tsp_base + result = base.simulate(nswaps=1) + + assert isinstance(result, np.ndarray), "Expected an array." From a0076a77db48153bc55b87ac287b82f76d4fd1c9 Mon Sep 17 00:00:00 2001 From: Spill-Tea Date: Tue, 29 Jul 2025 16:09:47 -0700 Subject: [PATCH 14/14] ci(workflows): Add python workflow on remote. --- .github/workflows/python-app.yml | 122 +++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..3a677dd --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,122 @@ +name: Annealing CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + style: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + steps: + - name: Checkout Annealing Project + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: .python-version-default + + - name: Install dependencies + run: | + python -m pip install -U pip + pip install wheel setuptools + pip install tox + + - name: Code Linting & Formatting + if: always() + run: tox -e lint + + - name: Static Type Safety Check + if: always() + run: tox -e type + + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Checkout Annealing Project + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install -U pip + pip install wheel setuptools + pip install tox + echo "TOX_VERSION=py$(echo ${{ matrix.python-version }} | tr -d .)-tests" >> $GITHUB_ENV + + - name: Unit Testing + run: | + tox -e ${{ env.TOX_VERSION }} + + - name: Save Coverage Data Temporarily + uses: actions/upload-artifact@v4 + with: + name: coverage-data-${{ matrix.python-version }} + path: .coverage.* + retention-days: 1 + if-no-files-found: ignore + include-hidden-files: true + + coverage: + runs-on: "ubuntu-latest" + needs: [ tests ] + strategy: + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: coverage-data-* + merge-multiple: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: .python-version-default + + - name: Install Dependencies + run: | + pip install coverage + + - name: Report Testing Coverage + run: | + coverage combine + coverage report --format markdown | tee $GITHUB_STEP_SUMMARY + coverage report + + license: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Golang + uses: actions/setup-go@v5 + with: + go-version: '1.22.4' + + - name: Install addlicense + run: | + go install github.com/google/addlicense@v1.1.1 + + - name: Check for License Headers + run: | + addlicense -check -f LICENSE src + addlicense -check -f LICENSE tests