diff --git a/+sw_tests/+unit_tests/unittest_spinw_optmagk.m b/+sw_tests/+unit_tests/unittest_spinw_optmagk.m index 4d184cf2..2d048629 100644 --- a/+sw_tests/+unit_tests/unittest_spinw_optmagk.m +++ b/+sw_tests/+unit_tests/unittest_spinw_optmagk.m @@ -55,7 +55,7 @@ function test_wrong_shape_kbase_raises_error(testCase) function test_fm_chain_optk(testCase) testCase.swobj.addmatrix('label', 'J1', 'value', -1); testCase.swobj.addcoupling('mat', 'J1', 'bond', 1); - out = testCase.swobj.optmagk; + out = testCase.swobj.optmagk('seed', 1); out.stat = rmfield(out.stat, 'nFunEvals'); expected_mag_str = testCase.default_mag_str; diff --git a/.github/workflows/build_pyspinw.yml b/.github/workflows/build_pyspinw.yml new file mode 100644 index 00000000..771a516b --- /dev/null +++ b/.github/workflows/build_pyspinw.yml @@ -0,0 +1,118 @@ +name: pySpinW + +on: [push, workflow_dispatch] + +jobs: + compile_mex: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + matlab_version: [latest] + include: + - os: macos-latest + INSTALL_DEPS: brew install llvm libomp + fail-fast: true + runs-on: ${{ matrix.os }} + steps: + - name: Check out SpinW + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v1 # v1.1.0 required for Windows/MacOS support + with: + release: ${{ matrix.matlab_version }} + + - name: Remove old mex # This is due to find not working :-/ # find ${{ github.workspace }} -name "*.mex*" -type f -delete + run: | + rm external/chol_omp/chol_omp.mexa64 + rm external/chol_omp/chol_omp.mexmaci64 + rm external/chol_omp/chol_omp.mexw64 + rm external/eig_omp/eig_omp.mexa64 + rm external/eig_omp/eig_omp.mexmaci64 + rm external/eig_omp/eig_omp.mexw64 + rm external/mtimesx/sw_mtimesx.mexa64 + rm external/mtimesx/sw_mtimesx.mexmaci64 + rm external/mtimesx/sw_mtimesx.mexw64 + - name: Run MEXing + uses: matlab-actions/run-command@v1 + with: + command: "addpath(genpath('swfiles')); addpath(genpath('external')); sw_mex('compile', true, 'test', false, 'swtest', false);" + - name: Upload MEX results + uses: actions/upload-artifact@v3 + with: + name: MEX + path: ${{ github.workspace }}/external/**/*.mex* + + build_ctfs: + needs: compile_mex + strategy: + matrix: + matlab_version: [R2021a, R2021b, R2022a, R2022b, R2023a] + runs-on: self-hosted + steps: + - name: Check out SpinW + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Download MEX artifacts + uses: actions/download-artifact@v3 + with: + name: MEX + path: ${{ github.workspace }}/external + - name: Build ctf + run: | + cd python + /Applications/MATLAB_${{ matrix.matlab_version }}.app/bin/matlab -nodisplay -r "build_ctf; exit" + - name: Upload CTF results + uses: actions/upload-artifact@v3 + with: + name: CTF + path: ${{ github.workspace }}/python/ctf/*.ctf + + build_wheel: + runs-on: ubuntu-latest + needs: build_ctfs + permissions: + contents: write + steps: + - name: Checkout SpinW + uses: actions/checkout@v3 + - name: Download CTF artifacts + uses: actions/download-artifact@v3 + with: + name: CTF + path: python/ctf + - name: Set up Python environment + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Move files + run: | + cd python + echo "PYSPINW_VERSION=$( cat pyproject.toml | grep "version = \"" | awk -F'"' '$0=$2' | sed 's/ //g' )" >> $GITHUB_ENV + mkdir pyspinw/ctfs + mv ctf/*.ctf pyspinw/ctfs + - name: Update Versions + if: startsWith(github.ref, 'refs/tags/v') + run: | + pip install poetry + cd ${{ github.workspace }}/python + poetry version $(git describe --tags --abbrev=0) + - name: Build Wheel + run: | + cd ${{ github.workspace }}/python + python -m pip wheel --no-deps --wheel-dir build . + - name: Create wheel artifact + uses: actions/upload-artifact@v3 + with: + name: pySpinW Wheel + path: ${{ github.workspace }}/python/build/*.whl + - uses: ncipollo/release-action@v1 + if: startsWith(github.ref, 'refs/tags/v') + with: + artifacts: ${{ github.workspace }}/python/build/*.whl + prerelease: true + replacesArtifacts: true + name: "pySpinW" + bodyFile: ${{ github.workspace }}/python/release_notes.md diff --git a/.gitignore b/.gitignore index d44dd628..6e8ced94 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,10 @@ dev/standalone/MacOS/Source/ *.xml *.rej -**profile_results \ No newline at end of file +**profile_results +python/ctf +.idea/ +**/*.pyc +python/ctf +.idea/ +**/*.pyc \ No newline at end of file diff --git a/python/build_ctf.m b/python/build_ctf.m new file mode 100644 index 00000000..c74b20ec --- /dev/null +++ b/python/build_ctf.m @@ -0,0 +1,16 @@ +out_dir = 'ctf'; +VERSION = version('-release'); +package_name = ['SpinW_', VERSION]; +full_package = ['SpinW_', VERSION, '.ctf']; + +opts = compiler.build.ProductionServerArchiveOptions( ... + ['matlab', filesep, 'call.m'], ... + 'ArchiveName', package_name, ... + 'OutputDir', out_dir, ... + 'AutoDetectDataFiles', 'on', ... + 'AdditionalFiles', { ... + ['..', filesep, 'swfiles'], ... + ['..', filesep, 'external'], ... + ['..', filesep, 'dat_files']}); + +compiler.build.productionServerArchive(opts); diff --git a/python/matlab/call.m b/python/matlab/call.m new file mode 100644 index 00000000..8646c99b --- /dev/null +++ b/python/matlab/call.m @@ -0,0 +1,164 @@ +function [varargout] = call(name, varargin) + if strcmp(name, '_call_python') + varargout = call_python_m(varargin{:}); + return + end + resultsize = nargout; + try + maxresultsize = nargout(name); + if maxresultsize == -1 + maxresultsize = resultsize; + end + catch + maxresultsize = resultsize; + end + if resultsize > maxresultsize + resultsize = maxresultsize; + end + if nargin == 1 + args = {}; + else + args = varargin; + end + for ir = 1:numel(args) + args{ir} = unwrap(args{ir}); + end + if resultsize > 0 + % call the function with the given number of + % output arguments: + varargout = cell(resultsize, 1); + try + [varargout{:}] = feval(name, args{:}); + catch err + if (strcmp(err.identifier,'MATLAB:unassignedOutputs')) + varargout = eval_ans(name, args); + else + rethrow(err); + end + end + else + varargout = eval_ans(name, args); + end + for ir = 1:numel(varargout) + varargout{ir} = wrap(varargout{ir}); + end +end + +function out = unwrap(in_obj) + out = in_obj; + if isstruct(in_obj) && isfield(in_obj, 'func_ptr') && isfield(in_obj, 'converter') + out = @(varargin) call('_call_python', [in_obj.func_ptr, in_obj.converter], varargin{:}); + elseif isa(in_obj, 'containers.Map') && in_obj.isKey('wrapped_oldstyle_class') + out = in_obj('wrapped_oldstyle_class'); + elseif iscell(in_obj) + for ii = 1:numel(in_obj) + out{ii} = unwrap(in_obj{ii}); + end + end +end + +function out = wrap(obj) + out = obj; + if isobject(obj) && (isempty(metaclass(obj)) && ~isjava(obj)) || has_thin_members(obj) + out = containers.Map({'wrapped_oldstyle_class'}, {obj}); + elseif iscell(obj) + for ii = 1:numel(obj) + out{ii} = wrap(obj{ii}); + end + end +end + +function out = has_thin_members(obj) +% Checks whether any member of a class or struct is an old-style class +% or is already a wrapped instance of such a class + out = false; + if isobject(obj) || isstruct(obj) + try + fn = fieldnames(obj); + catch + return; + end + for ifn = 1:numel(fn) + try + mem = subsref(obj, struct('type', '.', 'subs', fn{ifn})); + catch + continue; + end + if (isempty(metaclass(mem)) && ~isjava(mem)) + out = true; + break; + end + end + end +end + +function results = eval_ans(name, args) + % try to get output from ans: + clear('ans'); + feval(name, args{:}); + try + results = {ans}; + catch err + results = {[]}; + end +end + +function [n, undetermined] = getArgOut(name, parent) + undertermined = false; + if isstring(name) + fun = str2func(name); + try + n = nargout(fun); + catch % nargout fails if fun is a method: + try + n = nargout(name); + catch + n = 1; + undetermined = true; + end + end + else + n = 1; + undetermined = true; + end +end + +function out = call_python_m(varargin) + % Convert row vectors to column vectors for better conversion to numpy + for ii = 1:numel(varargin) + if size(varargin{ii}, 1) == 1 + varargin{ii} = varargin{ii}'; + end + end + fun_name = varargin{1}; + [kw_args, remaining_args] = get_kw_args(varargin(2:end)); + if ~isempty(kw_args) + remaining_args = [remaining_args {struct('pyHorace_pyKwArgs', 1, kw_args{:})}]; + end + out = call_python(fun_name, remaining_args{:}); + if ~iscell(out) + out = {out}; + end +end + +function [kw_args, remaining_args] = get_kw_args(args) + % Finds the keyword arguments (string, val) pairs, assuming that they always at the end (last 2n items) + first_kwarg_id = numel(args) + 1; + for ii = (numel(args)-1):-2:1 + if ischar(args{ii}); args{ii} = string(args{ii}(:)'); end + if isstring(args{ii}) && ... + strcmp(regexp(args{ii}, '^[A-Za-z_][A-Za-z0-9_]*', 'match'), args{ii}) + % Python identifiers must start with a letter or _ and can contain charaters, numbers or _ + first_kwarg_id = ii; + else + break; + end + end + if first_kwarg_id < numel(args) + kw_args = args(first_kwarg_id:end); + remaining_args = args(1:(first_kwarg_id-1)); + else + kw_args = {}; + remaining_args = args; + end +end diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..eb5436e0 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +# A regex preceded with ^/ will apply only to files and directories +# in the root of the project. +^/foo.py # exclude a file named foo.py in the root of the project (in addition to the defaults) +''' + +[tool.coverage.run] +source = ['pyspinw'] + +[tool.github.info] +organization = 'spinw' +repo = 'spinw' + +[tool.poetry] +name = "pyspinw" +version = "0.0.2" +description = "Python library for spin wave calculations" +license = "BSD-3-Clause" +authors = ["Duc Le", "Simon Ward", "Richard Waite"] +readme = "../README.md" +homepage = "https://spinw.org" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering :: Physics", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3 :: Only", +] +packages = [ { include = "pyspinw" } ] + +[tool.poetry.dependencies] +python = ">=3.8,<=3.12" +libpymcr = "^0.1.0" +numpy = "^1.21.4" +# Optional dependencies +pytest = {version = ">=7.0.0", optional = true} +pytest-cov = {version = ">=3,<5", optional = true} +codecov = {version = ">=2.1.11", optional = true} +flake8 = {version = ">=5.0", optional = true} +tox = {version = ">=3.0", optional = true} +tox-gh-actions = {version = ">=2.11,<4.0", optional = true} + +[tool.poetry.extras] +test = ['pytest', 'pytest-cov', 'codecov', 'flake8', 'tox', 'tox-gh-actions'] + +[tool.tox] +legacy_tox_ini = """ +[tox] +isolated_build = True +envlist = py{38,39,310,311,312} +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 +[gh-actions:env] +PLATFORM = + ubuntu-latest: linux + macos-latest: macos + windows-latest: windows +[testenv] +passenv = + CI + GITHUB_ACTIONS + GITHUB_ACTION + GITHUB_REF + GITHUB_REPOSITORY + GITHUB_HEAD_REF + GITHUB_RUN_ID + GITHUB_SHA + COVERAGE_FILE +deps = coverage +whitelist_externals = poetry +commands = + pip install '.[test]' + pytest --cov --cov-report=xml +""" diff --git a/python/pyspinw/__init__.py b/python/pyspinw/__init__.py new file mode 100644 index 00000000..63547a35 --- /dev/null +++ b/python/pyspinw/__init__.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +__author__ = "github.com/wardsimon" +__version__ = "0.1.0" + +import os +import libpymcr + + +# Generate a list of all the MATLAB versions available +_VERSION_DIR = os.path.join(os.path.dirname(__file__), 'ctfs') +_VERSIONS = [] +for file in os.scandir(_VERSION_DIR): + if file.is_file() and file.name.endswith('.ctf'): + _VERSIONS.append({'file': os.path.join(_VERSION_DIR, file.name), + 'version': 'R' + file.name.split('.')[0].split('SpinW_')[1] + }) + + +class Matlab(libpymcr.Matlab): + def __init__(self, matlab_path: Optional[str] = None, matlab_version: Optional[str] = None): + """ + Create a MATLAB instance with the correct compiled library for the MATLAB version specified. If no version is + specified, the first version found will be used. If no MATLAB versions are found, a RuntimeError will be + raised. If a version is specified, but not found, a RuntimeError will be raised. + + :param matlab_path: Path to the root directory of the MATLAB installation or MCR installation. + :param matlab_version: Used to specify the version of MATLAB if the matlab_path is given or if there is more + than 1 MATLAB installation. + """ + + initialized = False + if matlab_version is None: + for version in _VERSIONS: + if initialized: + break + try: + print(f"Trying MATLAB version: {version['version']} ({version['file']}))") + super().__init__(version['file'], mlPath=matlab_path) + initialized = True + except RuntimeError: + continue + else: + ctf = [version['file'] for version in _VERSIONS if version['version'].lower() == matlab_version.lower()] + if len(ctf) == 0: + raise RuntimeError( + f"Compiled library for MATLAB version {matlab_version} not found. Please use: [{', '.join([version['version'] for version in _VERSIONS])}]\n ") + else: + ctf = ctf[0] + try: + super().__init__(ctf, mlPath=matlab_path) + initialized = True + except RuntimeError: + pass + if not initialized: + raise RuntimeError( + f"No MATLAB versions found. Please use: [{', '.join([version['version'] for version in _VERSIONS])}]\n " + f"If installed, please specify the root directory (`matlab_path` and `matlab_version`) of the MATLAB " + f"installation.") diff --git a/python/release_notes.md b/python/release_notes.md new file mode 100644 index 00000000..821f1995 --- /dev/null +++ b/python/release_notes.md @@ -0,0 +1,44 @@ +# pySpinW + +This is an intial release of pySpinW as a `pip` installable wheel for python >= 3.8 and MATLAB >= R2021a + +## Installation + +Please install with + +```bash +pip install pyspinw*.whl +``` + +This package can now be used in python if you have a version of MATLAB or MCR available on the machine. +The package will try to automatically detect your installation, however if it is in a non-standard location, the path and version will have to be specified. + +```python +m = Matlab(matlab_version='R2023a', matlab_path='/usr/local/MATLAB/R2023a/') +``` + +## Example + +An example would be: + +```python +import numpy as np +from pyspinw import Matlab + +m = Matlab() + +# Create a spinw model, in this case a triangular antiferromagnet +s = m.sw_model('triAF', 1) + +# Specify the start and end points of the q grid and the number of points +q_start = [0, 0, 0] +q_end = [1, 1, 0] +pts = 501 + +# Calculate the spin wave spectrum +spec = m.spinwave(s, [q_start, q_end, pts]) +``` + +## Known limitations + +At the moment graphics will not work on macOS systems and is disabled. diff --git a/python/tests/test_spinw.py b/python/tests/test_spinw.py new file mode 100644 index 00000000..a9bb66c4 --- /dev/null +++ b/python/tests/test_spinw.py @@ -0,0 +1,39 @@ +__author__ = 'github.com/wardsimon' +__version__ = '0.0.1' + +import numpy as np +from pyspinw import Matlab + +try: + from matplotlib import pyplot as plt +except ImportError: + plt = None + +m = Matlab() +# An example of specifying the MATLAB version and path +# m = Matlab(matlab_version='R2023a', matlab_path='/usr/local/MATLAB/R2023a/') + +# Create a spinw model, in this case a triangular antiferromagnet +s = m.sw_model('triAF', 1) +print(s) + +# Specify the start and end points of the q grid and the number of points +q_start = [0, 0, 0] +q_end = [1, 1, 0] +pts = 501 + +# Calculate the spin wave spectrum, apply an energy grid and convolute with a Gaussian +spec = m.sw_egrid(m.spinwave(s, [q_start, q_end, pts])) +spec2 = m.sw_instrument(spec, dE=0.3) + +# Plot the result if matplotlib is available +if plt is not None: + ax = plt.imshow(np.flipud(spec2['swConv']), + aspect='auto', + extent=[q_start[-1], q_end[0], spec2["Evect"][0][0], spec2["Evect"][0][-1]]) + ax.set_clim(0, 0.15) + plt.xlabel('Q [q, q, 0] (r.l.u)') + plt.ylabel('Energy (meV)') + plt.title('Spectra of a triangular antiferromagnet') + plt.savefig('pyspinw.png') + plt.show()