diff --git a/.gitignore b/.gitignore index 5391d87..c803f26 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +pytest.xml *.cover *.py,cover .hypothesis/ diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..df8afc0 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,73 @@ +pipeline { + agent { + kubernetes { + label 'pathfinder' // all your pods will be named with this prefix, followed by a unique id + idleMinutes 5 // how long the pod will live after no jobs have run on it + yamlFile 'jenkins/ci-agent-pod.yaml' // path to the pod definition relative to the root of our project + defaultContainer 'python' // define a default container if more than a few stages use it, will default to jnlp container + } + } + + options { + parallelsAlwaysFailFast() + } + + environment { + POETRY_HOME="/opt/poetry" + POETRY_VERSION="1.1.4" + } + + stages { + stage('Build') { + steps { + echo 'Building...' + sh 'python --version' + sh 'curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python' + sh '${POETRY_HOME}/bin/poetry --version' + sh '${POETRY_HOME}/bin/poetry install' + } + } + stage('Test') { + parallel{ + stage('pytest') { + steps { + sh '${POETRY_HOME}/bin/poetry run pytest' + } + } + stage('lint') { + steps { + sh '${POETRY_HOME}/bin/poetry run flake8' + } + } + stage('mypy') { + steps { + sh '${POETRY_HOME}/bin/poetry run mypy pathfinder' + } + } + } + } + + } + post { + always { + echo 'This will always run' + junit 'pytest.xml' + cobertura coberturaReportFile: 'coverage.xml' + mail (bcc: '', + body: "Project: ${env.JOB_NAME}
Build Number: ${env.BUILD_NUMBER}
Build URL: ${env.BUILD_URL}", cc: '', charset: 'UTF-8', from: 'jenkins@jenkins.deepak.science', mimeType: 'text/html', replyTo: 'dmallubhotla+jenkins@gmail.com', subject: "${env.JOB_NAME} #${env.BUILD_NUMBER}: Build ${currentBuild.currentResult}", to: "dmallubhotla+ci@gmail.com") + } + success { + echo 'This will run only if successful' + } + failure { + echo 'This will run only if failed' + } + unstable { + echo 'This will run only if the run was marked as unstable' + } + changed { + echo 'This will run only if the state of the Pipeline has changed' + echo 'For example, if the Pipeline was previously failing but is now successful' + } + } +} diff --git a/do.sh b/do.sh index 656f706..fc18205 100644 --- a/do.sh +++ b/do.sh @@ -16,6 +16,10 @@ test() { poetry run pytest } +htmlcov() { + poetry run pytest --cov-report=html +} + all() { build && test } diff --git a/pathfinder/model/__init__.py b/pathfinder/model/__init__.py index 214bd2b..662e43b 100644 --- a/pathfinder/model/__init__.py +++ b/pathfinder/model/__init__.py @@ -1,16 +1,4 @@ +from pathfinder.model.dot import Dot +from pathfinder.model.model import DotDipoleModel - -class DipoleModel(): - ''' - Model object represents a physical dipole finding problem. - - Parameters - ---------- - n : int - The number of dipoles expected. - m: int - The number of dots used to sample the potential. - ''' - def __init__(self, n, m): - self.n = n - serf.m = m \ No newline at end of file +__all__ = ['Dot', 'DotDipoleModel', ] diff --git a/pathfinder/model/dot.py b/pathfinder/model/dot.py new file mode 100644 index 0000000..e0ee759 --- /dev/null +++ b/pathfinder/model/dot.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +import numpy +import numpy.typing + + +@dataclass +class Dot(): + ''' + Representation of a dot measuring static dipoles. + + Parameters + ---------- + v : float + The voltage measured at the dot. + r : numpy.ndarray + The number of dots used to sample the potential. + ''' + v: float + r: numpy.typing.ArrayLike + + def __post_init__(self) -> None: + self.r = numpy.array(self.r) + + def v_for_point(self, pt: numpy.ndarray) -> float: + p = pt[0:3] # hardcoded here because chances + s = pt[3:6] # are we'll only ever work in 3d. + + diff = self.r - s + return p.dot(diff) / (numpy.linalg.norm(diff)**3) + + def cost(self, pts: numpy.ndarray) -> float: + # 6 because dipole in 3d has 6 degrees of freedom. + pt_length = 6 + # creates numpy.ndarrays in groups of pt_length. + # Will throw problems for irregular points, but that's okay for now. + chunked_pts = [pts[i: i + pt_length] for i in range(0, len(pts), pt_length)] + return sum(self.v_for_point(pt) for pt in chunked_pts) - self.v diff --git a/pathfinder/model/model.py b/pathfinder/model/model.py new file mode 100644 index 0000000..6584fe5 --- /dev/null +++ b/pathfinder/model/model.py @@ -0,0 +1,31 @@ +from typing import Callable, Sequence +import numpy + +from pathfinder.model.dot import Dot + + +class DotDipoleModel(): + ''' + Model of n static dipoles with a collection of voltage measurements + at dots at different positions. + + Parameters + ---------- + dots : Sequence[Dot] + A collection of dots representing a series of measured voltages. + n: int + The number of dipoles to assume. + ''' + def __init__(self, dots: Sequence[Dot], n: int) -> None: + self.dots = dots + self.m = len(dots) + self.n = n + + def __repr__(self) -> str: + return f'DotDipoleModel({repr(list(self.dots))}, {self.n})' + + def costs(self) -> Callable[[numpy.ndarray], numpy.ndarray]: + def costs_to_return(pt: numpy.ndarray) -> numpy.ndarray: + return numpy.array([dot.cost(pt) for dot in self.dots]) + + return costs_to_return diff --git a/poetry.lock b/poetry.lock index 43943a5..953ea36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -68,6 +68,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "more-itertools" +version = "8.8.0" +description = "More routines for operating on iterables, beyond itertools" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "mypy" version = "0.790" @@ -229,7 +237,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.8,<3.10" -content-hash = "bde9b5d449e7257dc8c24675658295cf82950d7ec381d873e936ad7cc4bcf6d8" +content-hash = "223211dbc0d0b43607b649f98a88b1d7c2f07c9d7574508bd8f68f36787966b3" [metadata.files] atomicwrites = [ @@ -310,6 +318,10 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +more-itertools = [ + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, +] mypy = [ {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, diff --git a/pyproject.toml b/pyproject.toml index 294219c..c3dde3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ authors = ["Deepak "] python = "^3.8,<3.10" numpy = "^1.21.1" scipy = "~1.5" +more-itertools = "^8.8.0" [tool.poetry.dev-dependencies] pytest = ">=6" @@ -21,3 +22,8 @@ build-backend = "poetry.masonry.api" [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "--junitxml pytest.xml --cov pathfinder --cov-report=xml:coverage.xml --cov-fail-under=90" +junit_family = "xunit1" + +[tool.mypy] +plugins = "numpy.typing.mypy_plugin" diff --git a/tests/model/test_dot.py b/tests/model/test_dot.py new file mode 100644 index 0000000..7be9185 --- /dev/null +++ b/tests/model/test_dot.py @@ -0,0 +1,25 @@ +import numpy +import numpy.testing + +import pathfinder.model as model + + +def test_dot(): + dot = model.Dot(0.235, (1, 2, 3)) + assert dot.v == 0.235 + numpy.testing.assert_array_equal(dot.r, (1, 2, 3), "These arrays should have been equal!") + + +def test_dot_v_from_dipole(): + # for a dot located at (1, 2, 3) + dot = model.Dot(50, (1, 2, 3)) + + # and dipole located at (4, 7, 11) with p=(8, 9, 10) + pt = numpy.array((8, 9, 10, 4, 7, 11)) + + # V should be -0.153584 + target = -0.1535844174880402 + cost = -50.1535844174880402 + + numpy.testing.assert_allclose(dot.v_for_point(pt), target, err_msg="v from dipole at a dot was incorrect!") + numpy.testing.assert_allclose(dot.cost(pt), cost, err_msg="cost from dipole at a dot was incorrect!") diff --git a/tests/model/test_model.py b/tests/model/test_model.py new file mode 100644 index 0000000..6a6e1c7 --- /dev/null +++ b/tests/model/test_model.py @@ -0,0 +1,11 @@ +import pathfinder.model as model + + +def test_dotdipolemodel_repr(): + mod = model.DotDipoleModel((), 1) + assert repr(mod) == "DotDipoleModel([], 1)" + + +def test_dotdipolemodel_m(): + mod = model.DotDipoleModel([model.Dot(1, (0, 0, 0)), model.Dot(2, (0, 0, 0))], 1) + assert mod.m == 2 diff --git a/tests/test_scipy_optimize.py b/tests/test_scipy_optimize.py deleted file mode 100644 index fbe1f78..0000000 --- a/tests/test_scipy_optimize.py +++ /dev/null @@ -1,165 +0,0 @@ -import numpy -import scipy.optimize - -import pytest - - -def circ_cost(radius, center=(0, 0)): - - def cf(pt): - pt2 = numpy.array(pt) - numpy.array(center) - return (radius**2 - pt2.dot(pt2)) - - return cf - - -def test_circ_cost(): - cost = circ_cost(5) - actual = cost([3, 4]) - expected = 0 - assert actual == expected - - cost = circ_cost(13, [12, 5]) - actual = cost([0, 0]) - expected = 0 - assert actual == expected - - -def test_find_sols(): - c1 = circ_cost(5) - c2 = circ_cost(13, [8, -8]) - - def costs(pt): - return numpy.array( - [c1(pt), c2(pt)] - ) - - def jac(pt): - x, y = pt - return numpy.array([[-2 * x, -2 * y], [-2 * (x - 8), -2 * (y + 8)]]) - - print(scipy.optimize.minimize(lambda x: costs(x).dot(costs(x)), numpy.array([1, 2]))) - # - # message, iterations, result = pathfinder.gradient_descent.find_sols(costs, jac, step_size=0.01, max_iterations=5000, initial=(2, 10), desired_cost=1e-6) - # numpy.testing.assert_almost_equal( - # result, (3, 4), - # decimal=7, err_msg='the result was off', verbose=True - # ) - - -def dipole_cost(vn, xn_raw): - xn = numpy.array(xn_raw) - - def dc(pt): - p = pt[0:3] - s = pt[3:6] - - diff = xn - s - return (vn * (numpy.linalg.norm(diff)**3)) - p.dot(diff) - - return dc - -def test_actual_dipole_finding(): - def c0(pt): - p = pt[0:3] - return (p.dot(p) - 35) - - v1 = -0.05547767706400186526225414 - v2 = -0.06018573388098888319642888 - v3 = -0.06364032191901859480476888 - v4 = -0.06488383879243851188402150 - v5 = -0.06297148063759813929659130 - v6 = -0.05735489606460216 - v7 = -0.07237320672886623 - - # the 0 here is a red herring for index purposes later - vns = [0, v1, v2, v3, v4, v5] - # the 0 here is a red herring - xns = [numpy.array([0, 0, n]) for n in range(0, 6)] - - # the 0 here is a red herring for index purposes later - vns2 = [0, v1, v2, v3, v4, v5, v6, v7] - # the 0 here is a red herring - xns2 = [numpy.array([0, 0, n]) for n in range(0, 7)] - xns2.append([1, 1, 7]) - - c1 = dipole_cost(v1, [0, 0, 1]) - c2 = dipole_cost(v2, [0, 0, 2]) - c3 = dipole_cost(v3, [0, 0, 3]) - c4 = dipole_cost(v4, [0, 0, 4]) - c5 = dipole_cost(v5, [0, 0, 5]) - c6 = dipole_cost(v6, [0, 0, 6]) - c6 = dipole_cost(v6, [0, 0, 6]) - c7 = dipole_cost(v7, [1, 1, 7]) - - def costs(pt): - return numpy.array( - [c0(pt), c1(pt), c2(pt), c3(pt), c4(pt), c5(pt)] - ) - def costs2(pt): - return numpy.array( - [c0(pt), c1(pt), c2(pt), c3(pt), c4(pt), c5(pt), c6(pt), c7(pt)] - ) - - def jac_row(n): - def jr(pt): - p = pt[0:3] - s = pt[3:6] - vn = vns2[n] - xn = xns2[n] - diff = xn - s - return [ - -diff[0], -diff[1], -diff[2], - p[0] - vn * 3 * numpy.linalg.norm(diff) * (diff)[0], - p[1] - vn * 3 * numpy.linalg.norm(diff) * (diff)[1], - p[2] - vn * 3 * numpy.linalg.norm(diff) * (diff)[2] - ] - return jr - - def jac(pt): - return numpy.array([ - [2 * pt[0], 2 * pt[1], 2 * pt[2], 0, 0, 0], - jac_row(1)(pt), - jac_row(2)(pt), - jac_row(3)(pt), - jac_row(4)(pt), - jac_row(5)(pt), - ]) - - def jac2(pt): - return numpy.array([ - [2 * pt[0], 2 * pt[1], 2 * pt[2], 0, 0, 0], - jac_row(1)(pt), - jac_row(2)(pt), - jac_row(3)(pt), - jac_row(4)(pt), - jac_row(5)(pt), - jac_row(6)(pt), - jac_row(7)(pt), - ]) - - def print_result(msg, result): - print(msg) - print(f"\tResult: {result.x}") - print(f"\tSuccess: {result.success}. {result.message}") - try: - print(f"\tFunc evals: {result.nfev}") - except AttributeError as e: - pass - try: - print(f"\tJacb evals: {result.njev}") - except AttributeError as e: - pass - print("Minimising the squared costs") - print(scipy.optimize.minimize(lambda x: costs(x).dot(costs(x)), numpy.array([1, 2, 3, 4, 5, 6]))) - # print(scipy.optimize.broyden1(costs, numpy.array([1, 2, 3, 4, 5, 6]))) - # print(scipy.optimize.newton_krylov(costs, numpy.array([1, 2, 3, 4, 5, 6]))) - # print(scipy.optimize.anderson(costs, numpy.array([1, 2, 3, 4, 5, 6]))) - print_result("Using root", scipy.optimize.root(costs, numpy.array([1, 2, 3, 4, 5, 6]))) - print_result("Using root with jacobian", scipy.optimize.root(costs, numpy.array([1, 2, 3, 4, 5, 6]), jac=jac, tol=1e-12)) - print_result("Using least squares", scipy.optimize.least_squares(costs, numpy.array([1, 2, 3, 4, 5, 6]), gtol=1e-12)) - print_result("Using least squares, with jacobian", scipy.optimize.least_squares(costs, numpy.array([1, 2, 3, 4, 5, 6]), jac=jac, ftol=3e-16, gtol=3e-16, xtol=3e-16)) - print_result("Using least squares, with jacobian, lm", scipy.optimize.least_squares(costs, numpy.array([1, 2, 3, 4, 5, 6]), jac=jac, ftol=3e-16, gtol=3e-16, xtol=3e-16, method="lm")) - print_result("Using least squares extra dot", scipy.optimize.least_squares(costs2, numpy.array([1, 2, 3, 4, 5, 6]))) - print_result("Using least squares extra dot, with jacobian", scipy.optimize.least_squares(costs2, numpy.array([1, 2, 3, 4, 5, 6]), jac=jac2, ftol=1e-12)) - print(scipy.optimize.least_squares(costs2, numpy.array([1, 2, 3, 4, 5, 6]), jac=jac2, ftol=1e-12).x[0]) \ No newline at end of file