Please wait, loading Python ...
+ +diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fa9bf87..ed36857 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,7 +2,13 @@ name: Run test and coverage on: push: + branches: [main] pull_request: + branches: [main] + types: [opened, reopened, synchronize] + release: + types: [published] + workflow_dispatch: jobs: build: @@ -10,7 +16,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: Run tests run: | @@ -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 "actions@github.com" + 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 diff --git a/PyChop/Instruments.py b/PyChop/Instruments.py index 126d114..8833cc0 100644 --- a/PyChop/Instruments.py +++ b/PyChop/Instruments.py @@ -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 @@ -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" @@ -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 @@ -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): """ @@ -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) @@ -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) @@ -604,14 +623,8 @@ 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): @@ -619,13 +632,16 @@ def getWidth(self, 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)""" @@ -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""" diff --git a/PyChop/MulpyRep.py b/PyChop/MulpyRep.py index a288ad4..968a60d 100644 --- a/PyChop/MulpyRep.py +++ b/PyChop/MulpyRep.py @@ -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:]) @@ -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 diff --git a/README.md b/README.md index ee8fc24..4178f57 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 -``` \ No newline at end of file +``` diff --git a/tests/PyChopTest.py b/tests/PyChopTest.py index f294303..491843b 100644 --- a/tests/PyChopTest.py +++ b/tests/PyChopTest.py @@ -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) @@ -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) @@ -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 @@ -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.) @@ -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 @@ -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() diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 0000000..0959325 --- /dev/null +++ b/webapp/README.md @@ -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. diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..c59520c --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,57 @@ + + +
+ + + +Please wait, loading Python ...
+ +${frqnames[instid][id]}
+ + ` + } +} + +class PyChopFrequencies extends Preact.Component { + shouldComponentUpdate(nextProps, nextState) { + return (nextProps.inst != this.props.inst) + } + render({ inst }, _) { + const idx = instindx[inst] + //console.log("Rendering PyChopFrequencies for inst index " + idx) + let vdom = [] + for (let i = 0; i < frqnames[idx].length; i++) { + vdom.push(Preact.h(PyChopFreqSingle, { instid: idx, id: i }, null)) + } + return vdom + } +} + +class PyChopPhaseSingle extends Preact.Component { + phasechange = (ev) => { + //console.log("Callback of phase " + this.props.id + " with value " + ev.target.value) + curr_phase.value[this.props.id] = ev.target.value + } + render({ instid, id }, _) { + return html` +${phases[instid].name[id]}
+ + ` + } +} + +class PyChopPhases extends Preact.Component { + shouldComponentUpdate(nextProps, nextState) { + return (nextProps.inst != this.props.inst) + } + render({ inst }, _) { + const idx = instindx[inst] + if (phases[idx].length != 0) { + //console.log("Rendering PyChopPhases") + curr_phase.value = phases[idx].def + let vdom = [] + for (let i = 0; i < phases[idx].id.length; i++) { + vdom.push(Preact.h(PyChopPhaseSingle, { instid: idx, id: i }, null)) + } + return vdom + } else { + curr_phase.value = [] + } + } +} + +class PyChopChoppers extends Preact.Component { + state = { chopper: choppers[0][0] } + chopchange = (ev) => { + this.setState({ chopper: ev.target.value }) + curr_chopper.value = ev.target.value + } + shouldComponentUpdate(nextProps, nextState) { + if (nextProps.inst != this.props.inst) { + this.setState({ chopper: choppers[instindx[nextProps.inst]][0] }) + return true + } + return (nextState.chopper != this.state.chopper) + } + render({ inst }, { chopper }) { + //console.log("Rendering PyChopChoppers") + return html` +Chopper
+ + <${PyChopFrequencies} inst=${inst} /> + <${PyChopPhases} inst=${inst} /> + ` + } +} + +class PyChopInstrument extends Preact.Component { + state = { inst: instnames[0] } + instchange = (ev) => { + this.setState({ inst: ev.target.value }) + curr_inst.value = ev.target.value + curr_chopper.value = choppers[instindx[curr_inst.value]][0] + curr_freq.value = deffreqs[instindx[curr_inst.value]] + curr_phase.value = phases[instindx[curr_inst.value]].def + } + eichange = (ev) => { + curr_ei.value = Number(ev.target.value) + //runCalc() + } + render(_, { inst }) { + //console.log("Rendering PyChopInstrument") + return html` +Instrument
+ + <${PyChopChoppers} inst=${inst} /> +Ei
+ + + +