Repository: fusion-energy/openmc-plasma-source (language: python)

Issue:
Update to package configuration
This update aims to bring openmc-plasma-source in line with PEP 517 recommendations for packaging etc. The build/install details in setup.py are moved to setup.cfg and pyproject.toml as simple input files. Rather than calling:

- `python setup.py install`/`python setup.py develop`
- `python setup.py sdist bdist_wheel`

One should instead call:

- `pip install .`/`pip install -e .` (for 'develop' mode)
- `python -m build`

Rather than using a requirements file, the project requirements may be listed in setup.cfg, and optional dependencies may be installed using:

- `pip install -e .[tests]` (-e is optional)

This update also introduces automatic version inference, meaning the git tag is the 'single source of truth' for versioning. The version is updated with developer tags when between patch versions, i.e. one commit after 0.5.3 will be 0.5.4.dev0+[git commit].d[date].


Discussion / hints:

Fix patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b2691e1..9ffc64f 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -9,15 +9,14 @@ jobs:
       - run:
           name: install
           command: |
-            python setup.py install
+            pip install --upgrade pip
+            pip install .[tests]
             apt-get update -y && apt-get install -y curl
+
       - run:
           name: run tests
           command: |
-            pytest tests/test_point_source.py -v --cov=openmc_plasma_source --cov-append --cov-report term --cov-report xml
-            pytest tests/test_ring_source.py -v --cov=openmc_plasma_source --cov-append --cov-report term --cov-report xml
-            pytest tests/test_tokamak_source.py -v --cov=openmc_plasma_source --cov-append --cov-report term --cov-report xml
-            pytest tests/test_plotting.py -v --cov=openmc_plasma_source --cov-append --cov-report term --cov-report xml
+            pytest tests -v --cov=openmc_plasma_source --cov-append --cov-report term --cov-report xml
 
       - store_test_results:
           path: test-reports
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 776d907..b0c9f0a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,10 +20,16 @@ jobs:
         uses: actions/checkout@v2
       
       - name: Install
-        run: python setup.py install
+        run: |
+          python3 -m pip install --upgrade pip
+          python3 -m pip install .[tests]
+
+      - name: Run tests
+        run: |
+          python3 -m pytest -v tests
 
-      - name: run example
+      - name: Run examples
         run: |
-          python examples/point_source_example.py
-          python examples/ring_source_example.py
-          python examples/tokamak_source_example.py
+          python3 examples/point_source_example.py
+          python3 examples/ring_source_example.py
+          python3 examples/tokamak_source_example.py
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 60809ea..b202720 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -19,29 +19,25 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
+    
     - name: Set up Python
       uses: actions/setup-python@v2
       with:
         python-version: '3.x'
-    - name: Autobump version
-      run: |
-        # from refs/tags/v1.2.3 get 1.2.3
-        VERSION=$(echo $GITHUB_REF | sed 's#.*/v##')
-        PLACEHOLDER='version="develop"'
-        VERSION_FILE='setup.py'
-        # Grep checks that the placeholder is in the file. If grep doesn't find
-        # the placeholder then it exits with exit code 1 and github actions fails.
-        grep "$PLACEHOLDER" "$VERSION_FILE"
-        sed -i "s/$PLACEHOLDER/version=\"${VERSION}\"/g" "$VERSION_FILE"
-      shell: bash
+    
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install build
+        pip install setuptools build wheel twine
+    
     - name: Build package
       run: python -m build
+
+    - name: Check build
+      run: twine check dist/*
+
     - name: Publish package
-      uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
+      uses: pypa/gh-action-pypi-publish@release/v1
       with:
         user: __token__
         password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index a449343..2cc66f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ share/python-wheels/
 .installed.cfg
 *.egg
 MANIFEST
+_version.py
 
 # PyInstaller
 #  Usually these files are written by a python script from a template
diff --git a/LICENSE b/LICENSE.txt
similarity index 100%
rename from LICENSE
rename to LICENSE.txt
diff --git a/openmc_plasma_source/__init__.py b/openmc_plasma_source/__init__.py
index 05fbcdb..e7553a0 100644
--- a/openmc_plasma_source/__init__.py
+++ b/openmc_plasma_source/__init__.py
@@ -1,3 +1,16 @@
+try:
+    from importlib.metadata import version, PackageNotFoundError
+except (ModuleNotFoundError, ImportError):
+    from importlib_metadata import version, PackageNotFoundError
+try:
+    __version__ = version("openmc_plasma_source")
+except PackageNotFoundError:
+    from setuptools_scm import get_version
+
+    __version__ = get_version(root="..", relative_to=__file__)
+
+__all__ = ["__version__"]
+
 from .tokamak_source import TokamakSource
 from .ring_source import FusionRingSource
 from .point_source import FusionPointSource
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..bc250d5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,11 @@
+[build-system]
+requires = [
+    "setuptools >= 45",
+    "wheel",
+    "setuptools_scm[toml] >= 6.2",
+    "setuptools_scm_git_archive",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+write_to = "openmc_plasma_source/_version.py"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 327be37..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-numpy >= 1.9
-matplotlib >= 3.4.3
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..a49a7c6
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,36 @@
+[metadata]
+name = openmc_plasma_source
+author = The openmc-plasma-source Development Team
+author_email = rdelaportemathurin@gmail.com
+description = Creates tokamak plasma sources for OpenMC
+long_description = file: README.md
+long_description_content_type = text/markdown
+url = https://github.com/fusion-energy/openmc-plasma-source
+license = MIT
+license_file = LICENSE.txt
+classifiers =
+    Natural Language :: English
+    Topic :: Scientific/Engineering
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.6
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    License :: OSI Approved :: MIT License
+    Operating System :: OS Independent
+project_urls =
+    Source = https://github.com/fusion-energy/openmc-plasma-source
+    Tracker = https://github.com/fusion-energy/openmc-plasma-source/issues
+
+[options]
+packages = find:
+python_requires= >=3.6
+install_requires=
+    numpy >= 1.9
+    matplotlib >= 3.4.3
+    importlib-metadata; python_version < "3.8"
+
+[options.extras_require]
+tests = 
+    pytest >= 5.4.3
+    hypothesis
diff --git a/setup.py b/setup.py
index fdc7383..1abbd06 100644
--- a/setup.py
+++ b/setup.py
@@ -1,32 +1,4 @@
 import setuptools
 
-with open("README.md", "r") as fh:
-    long_description = fh.read()
-
-setuptools.setup(
-    name="openmc_plasma_source",
-    version="develop",
-    author="The openmc-plasma-source Development Team",
-    author_email="rdelaportemathurin@gmail.com",
-    description="Creates tokamak plasma sources for OpenMC",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    url="https://github.com/fusion-energy/openmc-plasma-source",
-    packages=setuptools.find_packages(),
-    classifiers=[
-        "Natural Language :: English",
-        "Topic :: Scientific/Engineering",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-    ],
-    tests_require=[
-        "pytest",
-    ],
-    python_requires=">=3.6",
-    install_requires=["numpy>=1.9", "matplotlib >= 3.2.2"],
-)
+if __name__ == "__main__":
+    setuptools.setup()


Test patch:
diff --git a/tests/test_tokamak_source.py b/tests/test_tokamak_source.py
index 957cc16..ca28b77 100644
--- a/tests/test_tokamak_source.py
+++ b/tests/test_tokamak_source.py
@@ -1,74 +1,118 @@
 from openmc_plasma_source import TokamakSource
-
 from openmc import Source
+import numpy as np
 
 import pytest
+from hypothesis import given, settings, assume, strategies as st
+
+
+@st.composite
+def tokamak_source_strategy(draw, return_dict=False):
+    """Defines a hypothesis strategy that automatically generates a TokamakSource.
+    When passed `return_dict=True`, only a dict of the args is passed.
+    Geometry attributes are varied, while plasma attributes are fixed.
+    """
+    # Used to avoid generation of inappropriate float values
+    finites = {"allow_nan": False, "allow_infinity": False, "allow_subnormal": False}
+
+    # Specify the base strategies for each geometry input
+    minor_radius = draw(st.floats(min_value=0.0, max_value=100.0, **finites))
+    major_radius = draw(st.floats(min_value=0.0, max_value=100.0, **finites))
+    pedestal_radius = draw(st.floats(min_value=0.0, max_value=100.0, **finites))
+    elongation = draw(st.floats(min_value=1.0, max_value=10.0, **finites))
+    triangularity = draw(st.floats(min_value=-1.0, max_value=1.0, **finites))
+    shafranov_factor = draw(st.floats(min_value=0.0, max_value=1.0, **finites))
+
+    # Specify requirements that must be satisfied for a valid tokamak
+    assume(major_radius > minor_radius)
+    assume(minor_radius > pedestal_radius)
+    assume(minor_radius > shafranov_factor)
+
+    args_dict = {
+        "elongation": elongation,
+        "triangularity": triangularity,
+        "major_radius": major_radius,
+        "minor_radius": minor_radius,
+        "pedestal_radius": pedestal_radius,
+        "shafranov_factor": shafranov_factor,
+        "ion_density_centre": 1.09e20,
+        "ion_density_peaking_factor": 1,
+        "ion_density_pedestal": 1.09e20,
+        "ion_density_separatrix": 3e19,
+        "ion_temperature_centre": 45.9,
+        "ion_temperature_peaking_factor": 8.06,
+        "ion_temperature_pedestal": 6.09,
+        "ion_temperature_separatrix": 0.1,
+        "mode": "H",
+        "ion_temperature_beta": 6,
+    }
+
+    return args_dict if return_dict else TokamakSource(**args_dict)
 
 
-def test_creation():
-    my_source = TokamakSource(
-        elongation=1.557,
-        ion_density_centre=1.09e20,
-        ion_density_peaking_factor=1,
-        ion_density_pedestal=1.09e20,
-        ion_density_separatrix=3e19,
-        ion_temperature_centre=45.9,
-        ion_temperature_peaking_factor=8.06,
-        ion_temperature_pedestal=6.09,
-        ion_temperature_separatrix=0.1,
-        major_radius=9.06,
-        minor_radius=2.92258,
-        pedestal_radius=0.8 * 2.92258,
-        mode="H",
-        shafranov_factor=0.44789,
-        triangularity=0.270,
-        ion_temperature_beta=6,
-    )
-    for source in my_source.make_openmc_sources():
+@given(tokamak_source=tokamak_source_strategy())
+@settings(max_examples=1, deadline=None)
+def test_creation(tokamak_source):
+    """Tests that the sources generated by TokamakSource are of type openmc.Source"""
+    for source in tokamak_source.sources:
         assert isinstance(source, Source)
 
 
-def test_strengths_are_normalised():
+@given(tokamak_source=tokamak_source_strategy())
+@settings(max_examples=100, deadline=None)
+def test_strengths_are_normalised(tokamak_source):
     """Tests that the sum of the strengths attribute is equal to"""
-    my_source = TokamakSource(
-        elongation=1.557,
-        ion_density_centre=1.09e20,
-        ion_density_peaking_factor=1,
-        ion_density_pedestal=1.09e20,
-        ion_density_separatrix=3e19,
-        ion_temperature_centre=45.9,
-        ion_temperature_peaking_factor=8.06,
-        ion_temperature_pedestal=6.09,
-        ion_temperature_separatrix=0.1,
-        major_radius=9.06,
-        minor_radius=2.92258,
-        pedestal_radius=0.8 * 2.92258,
-        mode="H",
-        shafranov_factor=0.44789,
-        triangularity=0.270,
-        ion_temperature_beta=6,
-    )
-    assert pytest.approx(sum(my_source.strengths), 1)
-
-
-def test_angles():
+    assert pytest.approx(sum(tokamak_source.strengths), 1)
+
+
+@given(tokamak_source=tokamak_source_strategy())
+@settings(max_examples=100, deadline=None)
+def test_source_locations_are_within_correct_range(tokamak_source):
+    """Tests that each source has RZ locations within the expected range.
+    As the function converting (a,alpha) coordinates to (R,Z) is not bijective, we
+    cannot convert back to validate each individual point. However, we can determine
+    whether the generated points are contained within the shell of the last closed
+    magnetic surface.
+    See "Tokamak D-T neutron source models for different plasma physics confinement
+    modes", C. Fausser et al., Fusion Engineering and Design, 2012 for more info.
+    """
+    R_0 = tokamak_source.major_radius
+    A = tokamak_source.minor_radius
+    El = tokamak_source.elongation
+    delta = tokamak_source.triangularity
+
+    def get_R_on_LCMS(alpha):
+        """Gets R on the last closed magnetic surface for a given alpha"""
+        return R_0 + A * np.cos(alpha + delta * np.sin(alpha))
+
+    approx_lt = lambda x, y: x < y or np.isclose(x, y)
+    approx_gt = lambda x, y: x > y or np.isclose(x, y)
+
+    for source in tokamak_source.sources:
+        R, Z = source.space.r.x[0], source.space.z.x[0]
+        # First test that the point is contained with a simple box with
+        # lower left (r_min,-z_max) and upper right (r_max,z_max)
+        assert approx_gt(R, R_0 - A)
+        assert approx_lt(R, R_0 + A)
+        assert approx_lt(abs(Z), A * El)
+        # For a given Z, we can determine the two values of alpha where
+        # where a = minor_radius, and from there determine the upper and
+        # lower bounds for R.
+        alpha_1 = np.arcsin(abs(Z) / (El * A))
+        alpha_2 = np.pi - alpha_1
+        R_max, R_min = get_R_on_LCMS(alpha_1), get_R_on_LCMS(alpha_2)
+        assert approx_lt(R_max, R_0 + A)
+        assert approx_gt(R_min, R_0 - A)
+        assert approx_lt(R, R_max)
+        assert approx_gt(R, R_min)
+
+
+@given(tokamak_args_dict=tokamak_source_strategy(return_dict=True))
+@settings(max_examples=1, deadline=None)
+def test_angles(tokamak_args_dict):
     """Checks that custom angles can be set"""
-    my_source = TokamakSource(
-        elongation=1.557,
-        ion_density_centre=1.09e20,
-        ion_density_peaking_factor=1,
-        ion_density_pedestal=1.09e20,
-        ion_density_separatrix=3e19,
-        ion_temperature_centre=45.9,
-        ion_temperature_peaking_factor=8.06,
-        ion_temperature_pedestal=6.09,
-        ion_temperature_separatrix=0.1,
-        major_radius=9.06,
-        minor_radius=2.92258,
-        pedestal_radius=0.8 * 2.92258,
-        mode="H",
-        shafranov_factor=0.44789,
-        triangularity=0.270,
-        ion_temperature_beta=6,
-        angles=(0, 1),
-    )
+    tokamak_args_dict["angles"] = (0, 1)
+    tokamak_source = TokamakSource(**tokamak_args_dict)
+    assert tokamak_source.angles == (0, 1)
+    for source in tokamak_source.sources:
+        assert (source.space.phi.a, source.space.phi.b) == (0, 1)