Skip to content

Commit

Permalink
Webapp (#4)
Browse files Browse the repository at this point in the history
* Add web app using pyodide, preact+htm and plotly

Only Resolution, Flux-Ei and Flux-Freq plots so far
Need Instruments.py modifications to work in javascript
  (explicit conversion to/from np.array and sets as
   pyodide does not handle these types well in proxy)
Currently set up to use local js files rather than CDN

* Add QE and time-distance plots

Change to use plotly and pyodide from CDN
Modify Instruments.py to output lines in plotMultiRepFrame
  if cannot import matplotlib
Modify Instruments.py to respect type of phase (str / number)
  In Javascript, always assume it is a number...

* Update gh-actions and readme

* Modify MulpyRep to sim t-d graphs for single chopper inst

* Update tests
  • Loading branch information
mducle authored Dec 30, 2023
1 parent b75249d commit e63b203
Show file tree
Hide file tree
Showing 9 changed files with 684 additions and 26 deletions.
37 changes: 36 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@ name: Run test and coverage

on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, reopened, synchronize]
release:
types: [published]
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Run tests
run: |
Expand All @@ -23,3 +31,30 @@ jobs:
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true

- name: Upload webapp
if: ${{ github.event_name == 'release' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
run: |
cp -rpa webapp webtmp
tar zcf webtmp/pychop.tar.gz --exclude __main__.py --exclude PyChopGui.py --exclude __pycache__ PyChop/
git checkout --force gh-pages
git config --global user.email "[email protected]"
git config --global user.name "Github Actions"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
git rm -rf $GITHUB_REF || true
rm -rf $GITHUB_REF
mv webtmp $GITHUB_REF
git add $GITHUB_REF
git commit --allow-empty -m "Update web-app files for release $GITHUB_REF"
else
git rm -rf unstable/* || true
mv webtmp/* unstable/*
git add unstable
git commit --allow-empty -m "Update web-app files for update $GITHUB_SHA"
fi
remote_repo="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/mducle/pychop.git"
git push ${remote_repo} HEAD:gh-pages --follow-tags
- name: Setup tmate session
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3
44 changes: 30 additions & 14 deletions PyChop/Instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ def soft_hat(x, p):
return y


class PltDummy(object):
# Class to act as a dummy saving all "plot" and "text" commands to a list
def __init__(self):
self.history = []
def __getattr__(self, name):
def passthrough(*args, **kwargs):
if name == 'plot' or name == 'text':
self.history.append([name, args, kwargs])
return passthrough


class FermiChopper(object):
"""
Class which represents a Fermi chopper package
Expand Down Expand Up @@ -159,6 +170,7 @@ def __init__(self, inval=None):
self._parse_variants()
self.phase = self.defaultPhase
self.frequency = self.default_frequencies
self.not_warn = True

def __repr__(self):
return self.name if self.name else "Undefined disk chopper system"
Expand Down Expand Up @@ -273,7 +285,8 @@ def setFrequency(self, *args, **kwargs):
if argdict["freq"]:
self.frequency = argdict["freq"]
if argdict["phase"]:
self.phase = argdict["phase"]
self.phase = [str(p) if isinstance(self.defaultPhase[i], str) else float(p)
for i, p in enumerate(argdict["phase"])]

def getFrequency(self):
return self.frequency
Expand All @@ -290,7 +303,7 @@ def getEi(self):
return self.ei

def getAllowedEi(self, Ei_in=None):
return set(np.round(self._MulpyRepDriver(Ei_in, calc_res=False)[0], decimals=4))
return list(set(np.round(self._MulpyRepDriver(Ei_in, calc_res=False)[0], decimals=4)))

def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=False):
"""
Expand All @@ -301,8 +314,12 @@ def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=Fa
try:
from matplotlib import pyplot
except ImportError:
raise RuntimeError("plotMultiRepFrame: Cannot import matplotlib")
plt = pyplot
if self.not_warn:
warnings.warn("plotMultiRepFrame: Cannot import matplotlib, will return list of lines")
self.not_warn = False
plt = PltDummy()
else:
plt = pyplot
else:
plt = h_plt
_check_input(self, Ei_in)
Expand Down Expand Up @@ -354,6 +371,8 @@ def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=Fa
plt.set_xlim(0, xmax)
plt.set_xlabel(r"TOF ($\mu$sec)")
plt.set_ylabel(r"Distance (m)")
if isinstance(plt, PltDummy):
return plt.history

def getWidthSquared(self, Ei_in=None):
return self.getWidth(Ei_in, squared=True)
Expand Down Expand Up @@ -604,28 +623,25 @@ def getAnalyticWidthsSquared(self, Ei):

def getWidthSquared(self, Ei):
"""Returns the squared time gaussian FWHM width due to the sample in s^2"""
if hasattr(self, "width_interp"):
wavelength = np.sqrt(E2L / (Ei if not hasattr(Ei, "__len__") else Ei[0]))
if wavelength >= self.wmn:
# Data is obtained from measuring widths of powder Bragg peaks in backscattering
# At low wavelengths / high energies, the peaks are too close together to discern
# so there is no measurements, but the analytical expressions should still be good.
width = self.width_interp(min([wavelength, self.wmx])) ** 2 / 1e12
return (width * SIGMA2FWHMSQ) if self.measured_width["isSigma"] else width
if hasattr(self, "width_interp") or self.imod == 3:
return self.getWidth(Ei) ** 2
return self.getAnalyticWidthsSquared(Ei)

def getWidth(self, Ei):
"""Calculates the moderator time width in seconds for a given neutron energy (Ei)"""
if hasattr(self, "width_interp"):
wavelength = np.sqrt(E2L / (Ei if not hasattr(Ei, "__len__") else Ei[0]))
if wavelength >= self.wmn:
# Data is obtained from measuring widths of powder Bragg peaks in backscattering
# At low wavelengths / high energies, the peaks are too close together to discern
# so there is no measurements, but the analytical expressions should still be good.
width = self.width_interp(min([wavelength, self.wmx])) / 1e6 # Table has widths in microseconds
return width * SIGMA2FWHM if self.measured_width["isSigma"] else width
if self.imod == 3:
# Mode for LET - output of polynomial is FWHM in us
return np.polyval(self.mod_pars, np.sqrt(E2L / Ei)) / 1e6
else:
return np.sqrt(self.getAnalyticWidthSquared(Ei))
return np.sqrt(self.getAnalyticWidthsSquared(Ei))

def getFlux(self, Ei):
"""Returns the white beam flux estimate from either measured data (preferred) or analytical model (backup)"""
Expand Down Expand Up @@ -854,7 +870,7 @@ def getMultiRepResolution(self, Etrans=None, Ei_in=None, frequency=None):
Ei = _check_input(self.chopper_system, Ei_in)
if Etrans is None:
Etrans = np.linspace(0.05, 0.95, 19, endpoint=True)
return [self.getResolution(Etrans * ei, ei, frequency) for ei in self.getAllowedEi(Ei)]
return [self.getResolution(np.array(Etrans) * ei, ei, frequency) for ei in self.getAllowedEi(Ei)]

def getVanVar(self, Ei_in=None, frequency=None, Etrans=0):
"""Calculates the time squared FWHM in s^2 at the sample (Vanadium widths) for different components"""
Expand Down
12 changes: 12 additions & 0 deletions PyChop/MulpyRep.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ def calcChopTimes(efocus, freq, instrumentpars, chop2Phase=5):
uSec = 1e6 # seconds to microseconds
lam = np.sqrt(81.8042 / efocus) # convert from energy to wavelenth

# if there's only one disk we prepend a dummy disk with full opening at zero distance
# so that the distance calculations (which needs the difference between disk pos) works
if len(instrumentpars[0]) == 1:
for d1, i in zip([[0], [1], None, [3141], [10], [500], [1]], range(7)):
instrumentpars[i] = (d1 + instrumentpars[i]) if d1 is not None else [d1, instrumentpars[i]]
prepend_disk = True
else:
prepend_disk = False

# extracts the instrument parameters
dist, nslot, slots_ang_pos, slot_width, guide_width, radius, numDisk = tuple(instrumentpars[:7])
samp_det, chop_samp, rep, tmod, frac_ei, ph_ind_v = tuple(instrumentpars[7:])
Expand All @@ -219,6 +228,9 @@ def calcChopTimes(efocus, freq, instrumentpars, chop2Phase=5):
source_rep, nframe = tuple(rep[:2]) if (hasattr(rep, "__len__") and len(rep) > 1) else (rep, 1)
p_frames = source_rep / nframe

if prepend_disk:
freq = np.array([source_rep, freq[0]])

# first we optimise on the main Ei
for i in range(len(dist)):
# loop over each chopper
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# PyChop Stand-alone

PyChop is a program to calculate the energy resolution of a time-of-flight (ToF) neutron spectrometer
from the burst time of the instruments moderator and the opening times of its choppers.
from the burst time of the instrument's moderator and the opening times of its choppers.

The code is based on `CHOP`, a fortran program written by T. G. Perring,
and `multirep`, a Matlab program by R. I. Bewley.

This is a port of the [Mantid PyChop](https://github.com/mantidproject/mantid/tree/master/scripts/PyChop) code to work without Mantid.
This is a port of the [Mantid PyChop](https://github.com/mantidproject/mantid/tree/master/scripts/PyChop)
code to work without Mantid.

Further documentation is available on the [Mantid webpage](https://docs.mantidproject.org/nightly/interfaces/PyChop.html)

A web version is accessible at [https://mducle.github.io/pychop/unstable/](https://mducle.github.io/pychop/unstable/)

## Installation
Optionally create and activate a `venv` virtual environment to isolate `PyChop` from your system packages
Expand All @@ -28,4 +30,4 @@ this installation method should ensure that all requisite dependencies are avail
Launch the `PyChop` GUI via the installed project script
```shell
PyChop
```
```
67 changes: 59 additions & 8 deletions tests/PyChopTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@
import warnings
import numpy as np

from PyChop import PyChop2


class PyChop2Tests(unittest.TestCase):

@classmethod
def setUpClass(cls):
from PyChop import PyChop2
cls.PyChop2 = PyChop2

# Tests the Fermi chopper instruments
def test_pychop_fermi(self):
instnames = ['maps', 'mari', 'merlin']
res = []
flux = []
for inc, instname in enumerate(instnames):
chopobj = PyChop2(instname)
chopobj = self.PyChop2(instname)
# Code should give an error if the chopper settings and Ei have
# not been set.
self.assertRaises(ValueError, chopobj.getResolution)
Expand All @@ -44,7 +47,7 @@ def test_pychop_fermi(self):
self.assertLess(res[1][0], res[2][0])
# Now tests the standalone function
for inc, instname in enumerate(instnames):
rr, ff = PyChop2.calculate(instname, 's', 200, 18, 0)
rr, ff = self.PyChop2.calculate(instname, 's', 200, 18, 0)
self.assertAlmostEqual(rr[0], res[inc][0], places=7)
self.assertAlmostEqual(ff, flux[inc], places=7)

Expand All @@ -54,7 +57,7 @@ def test_pychop_let(self):
res = []
flux = []
for inc, variant in enumerate(variants):
chopobj = PyChop2('LET', variant)
chopobj = self.PyChop2('LET', variant)
# Checks that it instantiates the correct variant
self.assertTrue(variant in chopobj.getChopper())
# Code should give an error if the chopper settings and Ei have
Expand All @@ -73,12 +76,12 @@ def test_pychop_let(self):
self.assertLessEqual(res[1][0], res[0][0])
# Now tests the standalone function
for inc, variant in enumerate(variants):
rr, ff = PyChop2.calculate('LET', variant, 200, 18, 0)
rr, ff = self.PyChop2.calculate('LET', variant, 200, 18, 0)
self.assertAlmostEqual(rr[0], res[inc][0], places=7)
self.assertAlmostEqual(ff, flux[inc], places=7)

def test_pychop_invalid_ei(self):
chopobj = PyChop2('MARI', 'G', 400.)
chopobj = self.PyChop2('MARI', 'G', 400.)
chopobj.setEi(120)
with warnings.catch_warnings(record=True) as w:
res = chopobj.getResolution(130.)
Expand All @@ -98,10 +101,14 @@ def test_pychop_numerics(self):
ref_flux = [2055.562054927915, 128986.24972543867, 0.014779264739956933, 45438.33797146135,
24196.496233770937, 5747.118187298609, 22287.647098883135, 4063.3113893387676]
for inst, ch, frq, ei, res0, flux0 in zip(instruments, choppers, freqs, eis, ref_res, ref_flux):
res, flux = PyChop2.calculate(inst, ch, frq, ei, 0)
res, flux = self.PyChop2.calculate(inst, ch, frq, ei, 0)
np.testing.assert_allclose(res[0], res0, rtol=1e-7, atol=0)
np.testing.assert_allclose(flux[0], flux0, rtol=1e-7, atol=0)

#def test_pychop_imports(self):
# # Tests we can run without scipy and matplotlib (not used for webapp)



class MockedModule(mock.MagicMock):
# A class which is meant to act like a module
Expand Down Expand Up @@ -388,5 +395,49 @@ def test_merlin_specials(self):
self.window.widgets['Chopper0Phase']['Edit'].show.assert_called()


class PyChopImportTests(unittest.TestCase):
# Tests we can run without scipy and matplotlib (not used for webapp)

def test_no_scipy(self):
# Tests that without scipy, the answers are _almost_ the same
ref_vals = [0.08079912729715726, 45438.33797146135] # Calculated with scipy
real_import = builtins.__import__
def my_import_func(name, globals=None, locals=None, fromlist=(), level=0):
if 'scipy' in name:
raise ModuleNotFoundError
else:
return real_import(name, globals, locals, fromlist, level)
# Now remove reference to scipy.interpolate if it's already been imported
import sys
savemods = {}
for mod in ['scipy.interpolate'] + [m for m in sys.modules if m.startswith('PyChop')]:
if mod in sys.modules:
savemods[mod] = sys.modules[mod]
del sys.modules[mod]
with patch('builtins.__import__', my_import_func):
from PyChop import PyChop2
res, flux = PyChop2.calculate('LET', 'High Flux', [240, 120], 3.7, 0)
# Resolution does not require interpolation
np.testing.assert_allclose(res, ref_vals[0], rtol=1e-7, atol=0)
np.testing.assert_allclose(flux, ref_vals[1], rtol=1e-2, atol=0)
for modname, mod in savemods.items():
sys.modules[modname] = mod

def test_no_maptlotlib(self):
# Tests that without matplotlib, plotMultiRepFrame returns a list of lines
real_import = builtins.__import__
def my_import_func(name, globals=None, locals=None, fromlist=(), level=0):
if 'matplotlib' in name:
raise ModuleNotFoundError
else:
return real_import(name, globals, locals, fromlist, level)
with patch('builtins.__import__', my_import_func):
from PyChop import PyChop2
pcobj = PyChop2('ARCS', 'ARCS-100-1.5-AST', 300)
with self.assertWarns(Warning):
rv = pcobj.chopper_system.plotMultiRepFrame(Ei_in=120)
self.assertTrue(rv is not None and len(rv) > 0)


if __name__ == "__main__":
unittest.main()
31 changes: 31 additions & 0 deletions webapp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# PyChop Webapp

The PyChop webapp is a single-page application running directly in the browser.
You can edit the javascript and html code directly to change the app behaviour,
but as a convenience it is nicer to use [browser-sync](https://browsersync.io/)
to automatically reload when any of the code changes.

Create a file `package.json` in this folder with this content:

```javascript
{
"scripts": {
"start": "browser-sync start --server . --files . --single"
},
}
```

and run

```shell
npm start
```

(You need to install [node.js](https://nodejs.org/en)).

We use [preact.js](https://preactjs.com/)+[htm](https://github.com/developit/htm) to define the UI,
[pyodide](https://pyodide.org) to run the Python code to do the actual calculations and
[plotly](https://plotly.com/javascript/) for the graphs.
These dependencies are downloaded from content delivery networks, which takes a few seconds depending
on your connection speed and which are then cached.
You can also download the `.js` files imported in `pychop.js` directly for faster processing.
Loading

0 comments on commit e63b203

Please sign in to comment.