Skip to content

Commit e63b203

Browse files
authored
Webapp (#4)
* 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
1 parent b75249d commit e63b203

File tree

9 files changed

+684
-26
lines changed

9 files changed

+684
-26
lines changed

.github/workflows/tests.yaml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@ name: Run test and coverage
22

33
on:
44
push:
5+
branches: [main]
56
pull_request:
7+
branches: [main]
8+
types: [opened, reopened, synchronize]
9+
release:
10+
types: [published]
11+
workflow_dispatch:
612

713
jobs:
814
build:
915
runs-on: ubuntu-latest
1016

1117
steps:
1218
- name: Checkout
13-
uses: actions/checkout@v2
19+
uses: actions/checkout@v3
20+
with:
21+
fetch-depth: 0
1422

1523
- name: Run tests
1624
run: |
@@ -23,3 +31,30 @@ jobs:
2331
uses: codecov/codecov-action@v2
2432
with:
2533
fail_ci_if_error: true
34+
35+
- name: Upload webapp
36+
if: ${{ github.event_name == 'release' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
37+
run: |
38+
cp -rpa webapp webtmp
39+
tar zcf webtmp/pychop.tar.gz --exclude __main__.py --exclude PyChopGui.py --exclude __pycache__ PyChop/
40+
git checkout --force gh-pages
41+
git config --global user.email "[email protected]"
42+
git config --global user.name "Github Actions"
43+
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
44+
git rm -rf $GITHUB_REF || true
45+
rm -rf $GITHUB_REF
46+
mv webtmp $GITHUB_REF
47+
git add $GITHUB_REF
48+
git commit --allow-empty -m "Update web-app files for release $GITHUB_REF"
49+
else
50+
git rm -rf unstable/* || true
51+
mv webtmp/* unstable/*
52+
git add unstable
53+
git commit --allow-empty -m "Update web-app files for update $GITHUB_SHA"
54+
fi
55+
remote_repo="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/mducle/pychop.git"
56+
git push ${remote_repo} HEAD:gh-pages --follow-tags
57+
58+
- name: Setup tmate session
59+
if: ${{ failure() }}
60+
uses: mxschmitt/action-tmate@v3

PyChop/Instruments.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ def soft_hat(x, p):
8585
return y
8686

8787

88+
class PltDummy(object):
89+
# Class to act as a dummy saving all "plot" and "text" commands to a list
90+
def __init__(self):
91+
self.history = []
92+
def __getattr__(self, name):
93+
def passthrough(*args, **kwargs):
94+
if name == 'plot' or name == 'text':
95+
self.history.append([name, args, kwargs])
96+
return passthrough
97+
98+
8899
class FermiChopper(object):
89100
"""
90101
Class which represents a Fermi chopper package
@@ -159,6 +170,7 @@ def __init__(self, inval=None):
159170
self._parse_variants()
160171
self.phase = self.defaultPhase
161172
self.frequency = self.default_frequencies
173+
self.not_warn = True
162174

163175
def __repr__(self):
164176
return self.name if self.name else "Undefined disk chopper system"
@@ -273,7 +285,8 @@ def setFrequency(self, *args, **kwargs):
273285
if argdict["freq"]:
274286
self.frequency = argdict["freq"]
275287
if argdict["phase"]:
276-
self.phase = argdict["phase"]
288+
self.phase = [str(p) if isinstance(self.defaultPhase[i], str) else float(p)
289+
for i, p in enumerate(argdict["phase"])]
277290

278291
def getFrequency(self):
279292
return self.frequency
@@ -290,7 +303,7 @@ def getEi(self):
290303
return self.ei
291304

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

295308
def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=False):
296309
"""
@@ -301,8 +314,12 @@ def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=Fa
301314
try:
302315
from matplotlib import pyplot
303316
except ImportError:
304-
raise RuntimeError("plotMultiRepFrame: Cannot import matplotlib")
305-
plt = pyplot
317+
if self.not_warn:
318+
warnings.warn("plotMultiRepFrame: Cannot import matplotlib, will return list of lines")
319+
self.not_warn = False
320+
plt = PltDummy()
321+
else:
322+
plt = pyplot
306323
else:
307324
plt = h_plt
308325
_check_input(self, Ei_in)
@@ -354,6 +371,8 @@ def plotMultiRepFrame(self, h_plt=None, Ei_in=None, frequency=None, first_rep=Fa
354371
plt.set_xlim(0, xmax)
355372
plt.set_xlabel(r"TOF ($\mu$sec)")
356373
plt.set_ylabel(r"Distance (m)")
374+
if isinstance(plt, PltDummy):
375+
return plt.history
357376

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

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

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

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

859875
def getVanVar(self, Ei_in=None, frequency=None, Etrans=0):
860876
"""Calculates the time squared FWHM in s^2 at the sample (Vanadium widths) for different components"""

PyChop/MulpyRep.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ def calcChopTimes(efocus, freq, instrumentpars, chop2Phase=5):
194194
uSec = 1e6 # seconds to microseconds
195195
lam = np.sqrt(81.8042 / efocus) # convert from energy to wavelenth
196196

197+
# if there's only one disk we prepend a dummy disk with full opening at zero distance
198+
# so that the distance calculations (which needs the difference between disk pos) works
199+
if len(instrumentpars[0]) == 1:
200+
for d1, i in zip([[0], [1], None, [3141], [10], [500], [1]], range(7)):
201+
instrumentpars[i] = (d1 + instrumentpars[i]) if d1 is not None else [d1, instrumentpars[i]]
202+
prepend_disk = True
203+
else:
204+
prepend_disk = False
205+
197206
# extracts the instrument parameters
198207
dist, nslot, slots_ang_pos, slot_width, guide_width, radius, numDisk = tuple(instrumentpars[:7])
199208
samp_det, chop_samp, rep, tmod, frac_ei, ph_ind_v = tuple(instrumentpars[7:])
@@ -219,6 +228,9 @@ def calcChopTimes(efocus, freq, instrumentpars, chop2Phase=5):
219228
source_rep, nframe = tuple(rep[:2]) if (hasattr(rep, "__len__") and len(rep) > 1) else (rep, 1)
220229
p_frames = source_rep / nframe
221230

231+
if prepend_disk:
232+
freq = np.array([source_rep, freq[0]])
233+
222234
# first we optimise on the main Ei
223235
for i in range(len(dist)):
224236
# loop over each chopper

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# PyChop Stand-alone
22

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

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

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

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

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

1416
## Installation
1517
Optionally create and activate a `venv` virtual environment to isolate `PyChop` from your system packages
@@ -28,4 +30,4 @@ this installation method should ensure that all requisite dependencies are avail
2830
Launch the `PyChop` GUI via the installed project script
2931
```shell
3032
PyChop
31-
```
33+
```

tests/PyChopTest.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,21 @@
1313
import warnings
1414
import numpy as np
1515

16-
from PyChop import PyChop2
17-
1816

1917
class PyChop2Tests(unittest.TestCase):
2018

19+
@classmethod
20+
def setUpClass(cls):
21+
from PyChop import PyChop2
22+
cls.PyChop2 = PyChop2
23+
2124
# Tests the Fermi chopper instruments
2225
def test_pychop_fermi(self):
2326
instnames = ['maps', 'mari', 'merlin']
2427
res = []
2528
flux = []
2629
for inc, instname in enumerate(instnames):
27-
chopobj = PyChop2(instname)
30+
chopobj = self.PyChop2(instname)
2831
# Code should give an error if the chopper settings and Ei have
2932
# not been set.
3033
self.assertRaises(ValueError, chopobj.getResolution)
@@ -44,7 +47,7 @@ def test_pychop_fermi(self):
4447
self.assertLess(res[1][0], res[2][0])
4548
# Now tests the standalone function
4649
for inc, instname in enumerate(instnames):
47-
rr, ff = PyChop2.calculate(instname, 's', 200, 18, 0)
50+
rr, ff = self.PyChop2.calculate(instname, 's', 200, 18, 0)
4851
self.assertAlmostEqual(rr[0], res[inc][0], places=7)
4952
self.assertAlmostEqual(ff, flux[inc], places=7)
5053

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

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

108+
#def test_pychop_imports(self):
109+
# # Tests we can run without scipy and matplotlib (not used for webapp)
110+
111+
105112

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

390397

398+
class PyChopImportTests(unittest.TestCase):
399+
# Tests we can run without scipy and matplotlib (not used for webapp)
400+
401+
def test_no_scipy(self):
402+
# Tests that without scipy, the answers are _almost_ the same
403+
ref_vals = [0.08079912729715726, 45438.33797146135] # Calculated with scipy
404+
real_import = builtins.__import__
405+
def my_import_func(name, globals=None, locals=None, fromlist=(), level=0):
406+
if 'scipy' in name:
407+
raise ModuleNotFoundError
408+
else:
409+
return real_import(name, globals, locals, fromlist, level)
410+
# Now remove reference to scipy.interpolate if it's already been imported
411+
import sys
412+
savemods = {}
413+
for mod in ['scipy.interpolate'] + [m for m in sys.modules if m.startswith('PyChop')]:
414+
if mod in sys.modules:
415+
savemods[mod] = sys.modules[mod]
416+
del sys.modules[mod]
417+
with patch('builtins.__import__', my_import_func):
418+
from PyChop import PyChop2
419+
res, flux = PyChop2.calculate('LET', 'High Flux', [240, 120], 3.7, 0)
420+
# Resolution does not require interpolation
421+
np.testing.assert_allclose(res, ref_vals[0], rtol=1e-7, atol=0)
422+
np.testing.assert_allclose(flux, ref_vals[1], rtol=1e-2, atol=0)
423+
for modname, mod in savemods.items():
424+
sys.modules[modname] = mod
425+
426+
def test_no_maptlotlib(self):
427+
# Tests that without matplotlib, plotMultiRepFrame returns a list of lines
428+
real_import = builtins.__import__
429+
def my_import_func(name, globals=None, locals=None, fromlist=(), level=0):
430+
if 'matplotlib' in name:
431+
raise ModuleNotFoundError
432+
else:
433+
return real_import(name, globals, locals, fromlist, level)
434+
with patch('builtins.__import__', my_import_func):
435+
from PyChop import PyChop2
436+
pcobj = PyChop2('ARCS', 'ARCS-100-1.5-AST', 300)
437+
with self.assertWarns(Warning):
438+
rv = pcobj.chopper_system.plotMultiRepFrame(Ei_in=120)
439+
self.assertTrue(rv is not None and len(rv) > 0)
440+
441+
391442
if __name__ == "__main__":
392443
unittest.main()

webapp/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# PyChop Webapp
2+
3+
The PyChop webapp is a single-page application running directly in the browser.
4+
You can edit the javascript and html code directly to change the app behaviour,
5+
but as a convenience it is nicer to use [browser-sync](https://browsersync.io/)
6+
to automatically reload when any of the code changes.
7+
8+
Create a file `package.json` in this folder with this content:
9+
10+
```javascript
11+
{
12+
"scripts": {
13+
"start": "browser-sync start --server . --files . --single"
14+
},
15+
}
16+
```
17+
18+
and run
19+
20+
```shell
21+
npm start
22+
```
23+
24+
(You need to install [node.js](https://nodejs.org/en)).
25+
26+
We use [preact.js](https://preactjs.com/)+[htm](https://github.com/developit/htm) to define the UI,
27+
[pyodide](https://pyodide.org) to run the Python code to do the actual calculations and
28+
[plotly](https://plotly.com/javascript/) for the graphs.
29+
These dependencies are downloaded from content delivery networks, which takes a few seconds depending
30+
on your connection speed and which are then cached.
31+
You can also download the `.js` files imported in `pychop.js` directly for faster processing.

0 commit comments

Comments
 (0)