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 @@ + + + + + + + PyChop in browser + + + + +
+ +
+

Please wait, loading Python ...

+ +
+ +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+
+ +
+ + diff --git a/webapp/pychop.css b/webapp/pychop.css new file mode 100644 index 0000000..ff1a2e3 --- /dev/null +++ b/webapp/pychop.css @@ -0,0 +1,83 @@ +body { + font-family: "Open Sans"; + background: #cccccc; + color: #000000; + line-height: 1.618em; +} + +.main-wrapper { + display: grid; + grid-template-columns: 1fr 4fr; +} + +.tab-wrapper { + width: 100%; + min-width: 525px; + margin: 0 auto; +} +.tabs { + position: relative; + margin: 1vh 0; + background: #dddddd; + height: 90vh; +} +.tabs::before, +.tabs::after { + content: ""; + display: table; +} +.tabs::after { + clear: both; +} +.tab { + float: left; +} +.tab-switch { + display: none; +} +.tab-label { + position: relative; + display: block; + line-height: 2.25em; + height: 2.5em; + padding: 0 1.618em; + background: #dddddd; + font-size: 14px; + border-right: 0.125rem solid #aaaaaa; + color: #000000; + cursor: pointer; + top: 0; + transition: all 0.25s; +} +.tab-label:hover { + top: -0.25rem; + transition: top 0.25s; +} +.tab-content { + height: 80vh; + width: 100%; + position: absolute; + z-index: 1; + top: 2.0em; + left: 0; + padding: 1.618rem; + background: #ffffff; + color: #000000; + border-bottom: 0.25rem solid #aaaaaa; + opacity: 0; + transition: all 0.35s; +} +.tab-switch:checked + .tab-label { + background: #fff; + color: #000000; + border-bottom: 0; + border-right: 0.125rem solid #fff; + transition: all 0.35s; + z-index: 1; + top: -0.0625rem; +} +.tab-switch:checked + label + .tab-content { + z-index: 2; + opacity: 1; + transition: all 0.35s; +} diff --git a/webapp/pychop.js b/webapp/pychop.js new file mode 100644 index 0000000..b0bc8ed --- /dev/null +++ b/webapp/pychop.js @@ -0,0 +1,371 @@ +console.log("Initialising") + +import * as Preact from 'https://esm.sh/preact' +import { signal } from 'https://esm.sh/@preact/signals' +import htm from 'https://esm.sh/htm' +const html = htm.bind(Preact.h) + +import "https://cdn.plot.ly/plotly-2.27.0.min.js"; +import "https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"; + +console.log("Loaded preact, pyodide and plotly") + +// Initialise python and import PyChop +let pyodide = await loadPyodide(); +let pychopresponse = await fetch("./pychop.tar.gz"); +let pychoptar = await pychopresponse.arrayBuffer(); +pyodide.unpackArchive(pychoptar, "gztar"); +for (const pkg of ["numpy", "pyyaml"]) { + await pyodide.loadPackage(pkg); +} + +const pychop = pyodide.pyimport("PyChop"); + +// Loads instruments from files, we must do this in Python because +// the Emscripten virtual file system is not accessible from JS +const instruments = pyodide.runPython(` + import PyChop + from PyChop.Instruments import Instrument + from os import path, listdir + folder = path.dirname(PyChop.__file__) + [Instrument(path.join(folder, f)) for f in listdir(folder) if f.endswith('.yaml')] +`); + +console.log("Loaded PyChop and instruments") + +// Parses instruments into JS arrays to construct the preact UI +let instnames = [], instindx = {}, reps = []; +let choppers = [], maxfreqs = [], deffreqs = [], frqnames = [], phases = []; +let idx = 0; +for (const inst of instruments) { + //console.log(inst.name) + instnames.push(inst.name) + instindx[inst.name] = idx; + idx = idx + 1; + choppers.push(inst.getChopperNames().toJs()) + reps.push(inst.chopper_system.source_rep) + maxfreqs.push(inst.chopper_system.max_frequencies.toJs()) + deffreqs.push(inst.chopper_system.default_frequencies.toJs()) + if (maxfreqs.slice(-1)[0].length > 1) { + frqnames.push(inst.chopper_system.frequency_names.toJs()) + } else { + frqnames.push(["Frequency"]) + } + if (inst.chopper_system.isPhaseIndependent.length > 0) { + phases.push({id:inst.chopper_system.isPhaseIndependent.toJs(), + name:inst.chopper_system.phaseNames.toJs(), + def:inst.chopper_system.defaultPhase.toJs()}) + } else { + phases.push([]) + } +} + +// Defines the signals which hold current state values for calculation +const curr_inst = signal(instnames[0]); +const curr_chopper = signal(choppers[0][0]); +const curr_freq = signal(deffreqs[0]); +const curr_ei = signal(0); +const curr_phase = signal([]) + +// Defines Preact GUI components +class PyChopFreqSingle extends Preact.Component { + state = { freq: deffreqs[this.props.instid][this.props.id] } + freqs = Array(maxfreqs[0][0] / reps[0] + 1).fill().map((_, idx) => idx * reps[0]) + freqchange = (ev) => { + this.setState({ freq: ev.target.value }) + curr_freq.value[this.props.id] = Number(ev.target.value) + } + shouldComponentUpdate(nextProps, nextState) { + if (nextProps.instid != this.props.instid) { + const nfrq = maxfreqs[nextProps.instid][nextProps.id] / reps[nextProps.instid] + 1 + this.freqs = Array(nfrq).fill().map((_, idx) => idx * reps[nextProps.instid]) + this.setState({ freq: deffreqs[nextProps.instid][nextProps.id] }) + return true + } + return (nextState.freq != this.state.freq) + } + render({ instid, id }, { freq }) { + //console.log("Rendering PyChopFreqSingle") + return html` +

${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

+ +

+ +
+ + +
+
+ + +
+ ` + } +} + +// Render the control panel using preact+htm +const panel = document.getElementById("ControlPanel") +Preact.render(html`<${PyChopInstrument} />`, panel) + +// Defines the layout of the different graphs +const p0 = {x:[0], y:[0]}, lgl = {x:1, y:1, xanchor:'right'} +const flxstr = 'Flux (n/cm²/s)', elstr = 'Elastic Resolution FWHM (meV)'; +const eistr = 'Incident Energy (meV)', chstr = 'Chopper Frequency (Hz)'; +const restab = document.getElementById("ResolutionPlot") +const reslayout = {xaxis: {title: 'Energy Transfer (meV)'}, yaxis: {title: 'ΔE (meV FWHM)'}, legend:lgl} +Plotly.newPlot(restab, [p0], reslayout, {responsive: true}) +const fluxei = document.getElementById("FluxEiPlot") +const fleila = {xaxis: {title: eistr}, yaxis: {title: flxstr}, legend:lgl} +Plotly.newPlot(fluxei, [p0], fleila, {responsive: true}) +const resei = document.getElementById("ResEiPlot") +const reeila = {xaxis: {title: eistr}, yaxis: {title: elstr}, legend:lgl} +Plotly.newPlot(resei, [p0], reeila, {responsive: true}) +const fluxfreq = document.getElementById("FluxFreqPlot") +const flfqla = {xaxis: {title: chstr}, yaxis: {title: flxstr}, legend:lgl} +Plotly.newPlot(fluxfreq, [p0], flfqla, {responsive: true}) +const resfreq = document.getElementById("ResFreqPlot") +const refqla = {xaxis: {title: chstr}, yaxis: {title: elstr}, legend:lgl} +Plotly.newPlot(resfreq, [p0], refqla, {responsive: true}) +const tdplot = document.getElementById("TimeDistancePlot") +const tdlay = {xaxis: {title: "ToF (µs)"}, yaxis: {title: "Distance (m)"}, showlegend:false} +Plotly.newPlot(tdplot, [p0], tdlay, {responsive: true}) +const qeplot = document.getElementById("QEPlot") +const qelay = {xaxis: {title: "|Q| (Å⁻¹)"}, yaxis: {title: "Energy Transfer (meV)"}, legend:lgl} +Plotly.newPlot(qeplot, [p0], qelay, {responsive: true}) + +// Helper functions +function linspace(start, stop, num, endpoint = true) { + const div = endpoint ? (num - 1) : num; + const step = (stop - start) / div; + return Array.from({length: num}, (_, i) => start + step * i); +} +const E2Q = 0.48259640220781652; +const bc = '#00f', kc = '#000', wc = '#fff', rc = '#f00', mc = '#f0f'; +let d_ei = {inst:'None', chop:'None', freq:0}, d_fq = {inst:'None', chop:'None', ei:0}; + +// Runs the PyChop calculations and plots the data +function runCalc() { + console.log(curr_inst.value + " " + curr_chopper.value + " " + curr_freq.value + + " " + curr_ei.value + " " + curr_phase.value) + const hold_cb = document.getElementById("hold_checkbox") + const multirep_cb = document.getElementById("multirep_checkbox") + const is_hold = hold_cb.checked, is_multirep = multirep_cb.checked + const instid = instindx[curr_inst.value], inst = instruments[instid] + inst.setChopper(curr_chopper.value) + inst.setEi(curr_ei.value) + if (curr_phase.value.length > 0) { + //console.log('Setting phase') + inst.setFrequency(curr_freq.value, curr_phase.value) + } else { + inst.setFrequency(curr_freq.value) + } + if (!is_hold) { + if (restab.data.length > 0) { + Plotly.deleteTraces(restab, Array(restab.data.length).fill(1).map((_,i) => i)) + // For some reason deleteTraces doesn't work properly for Flux-Ei and Flux-Freqs graphs... + Plotly.deleteTraces(qeplot, Array(qeplot.data.length).fill(1).map((_,i) => i)) + } + } + const labinst = curr_inst.value + '_' + curr_chopper.value + '_' + const labei = labinst + curr_ei.value + 'meV_' + const labfreq = labinst + curr_freq.value + 'Hz_' + if (is_multirep) { + const eis = inst.getAllowedEi().toJs() + const en = linspace(0, 0.95, 200) + const res = inst.getMultiRepResolution(en).toJs() + const flux = inst.getMultiRepFlux().toJs().map((v) => Number(v).toFixed(0)) + for (let i = 0; i < eis.length; i++) { + Plotly.addTraces(restab, [{x:linspace(0, 0.95*eis[i], 200), y:res[i], type:'scatter', + name:labfreq + eis[i] + 'meV_' + flux[i] + 'n/cm2/s'}]) + } + } else { + const en = linspace(0, 0.95*curr_ei.value, 200) + const res = inst.getResolution(en).toJs() + const flux = Number(inst.getFlux().toJs()).toFixed(0) + Plotly.addTraces(restab, [{x:en, y:res, type:'scatter', + name:labfreq + curr_ei.value + 'meV_' + flux + 'n/cm2/s'}]) + } + // Plots the time-frame (must do it here as Flux-Freq changes inst internal state) + if (choppers[instid].length > 1) { + const tdframe = inst.plotMultiRepFrame().toJs() + //console.log(tdframe) + let bx = [], by = [], kx = [], ky = [], wx = [], wy = [], mx = [], my = [], rx = [], ry = []; + let tx = {x:[], y:[], mode:'text', text:[]}; + for (const l of tdframe) { + if (l[0] === 'plot') { + const x = [].slice.call(l[1][0]), y = [].slice.call(l[1][1]); + switch (l[2].get('c')) { + case 'b': bx = bx.concat(x.concat([null])); by = by.concat(y.concat([null])); break; + case 'k': kx = kx.concat(x.concat([null])); ky = ky.concat(y.concat([null])); break; + case 'white': wx = wx.concat(x.concat([null])); wy = wy.concat(y.concat([null])); break; + case 'm': mx = mx.concat(x.concat([null])); my = my.concat(y.concat([null])); break; + case 'r': rx = rx.concat(x.concat([null])); ry = ry.concat(y.concat([null])); break; + } + } else if (l[0] === 'text') { + tx.x.push(l[1][0]); tx.y.push(l[1][1]); tx.text.push(l[1][2]); + } + } + Plotly.react(tdplot, [{x:kx, y:ky, line:{color:kc, width:3}}, {x:wx, y:wy, line:{color:wc, width:3}}, + {x:bx, y:by, line:{color:bc, width:3}}, {x:mx, y:my, line:{color:mc, width:3}}, + {x:rx, y:ry, line:{color:rc, width:3}}, tx], tdlay) + Plotly.relayout(tdplot, {'xaxis.range':[0, 1000000/reps[instid]]}) + } else { + Plotly.react(tdplot, [{x:[0], y:[0]}], tdlay) + } + if (curr_inst.value != d_ei.inst || curr_chopper.value != d_ei.chop || curr_freq.value[0] != d_ei.freq) { + //console.log('Calculating Ei-dep') + let flux = [], elres = []; + const eis = linspace(Math.max(inst.emin, 0.1), inst.emax, 100) + for (const ei of eis) { + flux.push(Number(inst.getFlux(ei).toJs())) + elres.push(Number(inst.getResolution(0.0, ei).toJs())) + } + if (is_hold) { + Plotly.addTraces(fluxei, [{x:eis, y:flux, type:'scatter', name:labfreq}]) + Plotly.addTraces(resei, [{x:eis, y:elres, type:'scatter', name:labfreq}]) + } else { + Plotly.react(fluxei, [{x:eis, y:flux, type:'scatter', name:labfreq}], fleila) + Plotly.react(resei, [{x:eis, y:elres, type:'scatter', name:labfreq}], reeila) + } + d_ei = {inst:curr_inst.value, chop:curr_chopper.value, freq:curr_freq.value[0]} + } + if (curr_inst.value != d_fq.inst || curr_chopper.value != d_fq.chop || curr_ei.value != d_fq.ei) { + //console.log('Calculating Freq-dep') + const ei = curr_ei.value, en = linspace(-ei/5, ei, 100), enr = en.toReversed(); + let flux = [], elres = [], e2 = [], q2 = []; + const fqs = Array(maxfreqs[instid][0] / reps[instid]).fill().map((_, idx) => (idx+1) * reps[instid]) + for (const freq of fqs) { + inst.setFrequency([freq].concat(curr_freq.value.slice(1))) + flux.push(Number(inst.getFlux(ei).toJs())) + elres.push(Number(inst.getResolution(0.0, ei).toJs())) + } + if (is_hold) { + Plotly.addTraces(fluxfreq, [{x:fqs, y:flux, type:'scatter', name:labei}]) + Plotly.addTraces(resfreq, [{x:fqs, y:elres, type:'scatter', name:labei}]) + } else { + Plotly.react(fluxfreq, [{x:fqs, y:flux, type:'scatter', name:labei}], flfqla) + Plotly.react(resfreq, [{x:fqs, y:elres, type:'scatter', name:labei}], refqla) + } + // Also plots Q-E here which depends only on Ei (so only plot if Ei changes, not chopper) + if (curr_inst.value != d_fq.inst || curr_ei.value != d_fq.ei) { + for (const tth of inst.detector.tthlims.toJs().map((v) => Math.PI * v / 180)) { + const q = en.map((v) => Math.sqrt(E2Q * (2*ei - v - 2*Math.sqrt(ei*(ei - v)) * Math.cos(tth))) ); + q2 = q2.concat(q.toReversed().concat(q).concat([null])) + e2 = e2.concat(enr.concat(en).concat([null])) + } + Plotly.addTraces(qeplot, {x:q2, y:e2, type:'scatter', name:labei}) + } + d_fq = {inst:curr_inst.value, chop:curr_chopper.value, ei:curr_ei.value} + } +} +