diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dada9105..4fe72062 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,10 +19,10 @@ jobs: # pure python wheels steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.config.py }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.config.py }} @@ -66,7 +66,7 @@ jobs: # See the following for how to upload to a release # https://eugene-babichenko.github.io/blog/2020/05/09/github-actions-cross-platform-auto-releases/ - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: artifacts path: | @@ -80,7 +80,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Retrieve all artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: artifacts diff --git a/.github/workflows/unstable.yml b/.github/workflows/unstable.yml index 5f8ad970..302d985f 100644 --- a/.github/workflows/unstable.yml +++ b/.github/workflows/unstable.yml @@ -20,10 +20,10 @@ jobs: # all using to stable abi steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.config.py }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.config.py }} @@ -60,7 +60,7 @@ jobs: # See the following for how to upload to a release # https://eugene-babichenko.github.io/blog/2020/05/09/github-actions-cross-platform-auto-releases/ - name: Archive artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: artifacts path: | @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Retrieve all artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: artifacts diff --git a/.gitignore b/.gitignore index 7ff75743..292f3f53 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,13 @@ .idea *.pyd *.pyc +**/__pycache__/ +**/node_modules/ +/refl1d/webview/client/dist/ *.swp *.so *.bak +refl1d-webview-client*.tgz /build/ /dist/ /refl1d.egg-info/ diff --git a/MANIFEST.in b/MANIFEST.in index 2b910976..163692c4 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,6 +14,11 @@ include master_builder.py include refl1d.iss include setup_py2exe.py include refl1d/lib/*.h refl1d/lib/erf.c - +recursive-include refl1d/webview/client/dist *.html *.js *.css *.svg *.png +recursive-include refl1d/webview/client/src *.html *.js *.css *.svg *.png *.vue +include refl1d/webview/client/*.html +include refl1d/webview/client/*.js +include refl1d/webview/client/*.json +include refl1d/webview/client/*.txt # Delete files #prune this that diff --git a/extra/build_conda_packed.sh b/extra/build_conda_packed.sh new file mode 100755 index 00000000..28e2b7ef --- /dev/null +++ b/extra/build_conda_packed.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +ENV_NAME="isolated-base" +PYTHON_VERSION="3.10" +DIRNAME="refl1d" + +eval "$(conda shell.bash hook)" +conda activate base || { echo 'failed: conda not installed'; exit 1; } + +conda install -y conda-pack +conda create -n "$ENV_NAME" -y "python=$PYTHON_VERSION" +conda-pack -n "$ENV_NAME" -f -o "$ENV_NAME.tar.gz" + +# unpack the new environment, that contains only python + pip +tmpdir=$(mktemp -d) +destdir="$tmpdir/$DIRNAME" +mkdir "$destdir" +tar -xzf "$ENV_NAME.tar.gz" -C "$destdir" + +# activate the unpacked environment and install pip packages +conda deactivate +WORKING_DIRECTORY=$(pwd) +cd "$tmpdir" +source "$DIRNAME/bin/activate" +pip install --no-input numba +pip install --no-input git+https://github.com/bumps/bumps@dataclass_overlay +pip install --no-input git+https://github.com/reflectometry/refl1d@webview +pip install -r https://raw.githubusercontent.com/reflectometry/refl1d/webview/webview-requirements +source "$DIRNAME/bin/deactivate" + +# zip it back up +tar -czf "$WORKING_DIRECTORY/refl1d-webview-$(uname -s)-$(uname -m).tar.gz" "$DIRNAME" diff --git a/refl1d/errors.py b/refl1d/errors.py index 431125cc..c0d03d78 100644 --- a/refl1d/errors.py +++ b/refl1d/errors.py @@ -161,7 +161,7 @@ def calc_errors(problem, points): # TODO: return sane datastructure # Make a hashable version of model which just contains the name # attribute, which is all that the rest of this code accesses. - models = [_HashableModel(m) for m in _experiments(problem)] + models = [_HashableModel(m, i) for i, m in enumerate(_experiments(problem))] profiles = {h: [v[k] for v in profiles] for k, h in enumerate(models)} slabs = {h: [v[k] for v in slabs] for k, h in enumerate(models)} @@ -179,8 +179,8 @@ def calc_errors(problem, points): class _HashableModel: name: str - def __init__(self, model): - self.name = model.name + def __init__(self, model, index): + self.name = model.name if model.name is not None else f"M{index}" def __str__(self): return f"model {self.name}" @@ -229,7 +229,7 @@ def align_profiles(profiles, slabs, align): for m in profiles.keys()) def show_errors(errors, contours=CONTOURS, npoints=200, - align='auto', plots=1, save=None): + align='auto', plots=1, save=None, fig=None): """ Plot the aligned profiles and the distribution of the residuals for profiles and residuals returned from calc_errors. @@ -259,15 +259,20 @@ def show_errors(errors, contours=CONTOURS, npoints=200, """ import matplotlib.pyplot as plt + if fig is not None and plots != 1: + raise ValueError("can only pass in a figure object if exactly 1 plot is requested") + if plots == 0: # Don't create plots, just save the data _save_profile_data(errors, contours=contours, npoints=npoints, align=align, save=save) _save_residual_data(errors, contours=contours, save=save) elif plots == 1: # Subplots for profiles/residuals - plt.subplot(211) - show_profiles(errors, contours=contours, npoints=npoints, align=align) - plt.subplot(212) - show_residuals(errors, contours=contours) + if fig is None: + fig = plt.gcf() + ax_profiles = fig.add_subplot(211) + show_profiles(errors, contours=contours, npoints=npoints, align=align, axes=ax_profiles) + ax_residuals = fig.add_subplot(212) + show_residuals(errors, contours=contours, axes=ax_residuals) if save: plt.savefig(save+"-err.png") elif plots == 2: # Separate plots for profiles/residuals @@ -296,24 +301,24 @@ def show_errors(errors, contours=CONTOURS, npoints=200, plt.savefig(save+"-err%d.png"%fignum) fignum += 1 -def show_profiles(errors, align, contours, npoints): +def show_profiles(errors, align, contours, npoints, axes=None): profiles, slabs, _, _ = errors if align is not None: profiles = align_profiles(profiles, slabs, align) if contours: - _profiles_contour(profiles, contours, npoints) + _profiles_contour(profiles, contours, npoints, axes=axes) else: - _profiles_overplot(profiles) + _profiles_overplot(profiles, axes=axes) -def show_residuals(errors, contours): +def show_residuals(errors, contours, axes=None): _, _, Q, residuals = errors if False and contours: _residuals_contour(Q, residuals, contours=contours) else: - _residuals_overplot(Q, residuals) + _residuals_overplot(Q, residuals, axes=axes) def _save_profile_data(errors, align, contours, npoints, save): @@ -376,34 +381,36 @@ def _write_file(path, data, title, columns): def dark(color): return dhsv(color, dv=-0.2) -def _profiles_overplot(profiles): +def _profiles_overplot(profiles, axes=None): for model, group in profiles.items(): name = model.name absorbing = any((L[2] != 1e-4).any() for L in group) magnetic = (len(group[0]) > 3) # Note: Use 3 colours per dataset for consistency - _draw_overplot(group, 1, name + ' rho') + _draw_overplot(group, 1, name + ' rho', axes=axes) if absorbing: - _draw_overplot(group, 2, name + ' irho') + _draw_overplot(group, 2, name + ' irho', axes=axes) else: - next_color() + next_color(axes=axes) if magnetic: - _draw_overplot(group, 3, name + ' rhoM') + _draw_overplot(group, 3, name + ' rhoM', axes=axes) else: - next_color() - _profile_labels() + next_color(axes=axes) + _profile_labels(axes=axes) -def _draw_overplot(group, index, label): +def _draw_overplot(group, index, label, axes=None): import matplotlib.pyplot as plt + if axes is None: + axes = plt.gca() alpha = 0.1 - color = next_color() + color = next_color(axes=axes) for L in group[1:]: - plt.plot(L[0], L[index], '-', color=color, alpha=alpha) + axes.plot(L[0], L[index], '-', color=color, alpha=alpha) # Plot best L = group[0] - plt.plot(L[0], L[index], '-', label=label, color=dark(color)) + axes.plot(L[0], L[index], '-', label=label, color=dark(color)) -def _profiles_contour(profiles, contours=CONTOURS, npoints=200): +def _profiles_contour(profiles, contours=CONTOURS, npoints=200, axes=None): for model, group in profiles.items(): name = model.name if model.name is not None else 'model' absorbing = any((L[2] > 1e-4).any() for L in group) @@ -412,46 +419,52 @@ def _profiles_contour(profiles, contours=CONTOURS, npoints=200): z = np.hstack([line[0] for line in group]) zp = np.linspace(np.min(z), np.max(z), npoints) # Note: Use 3 colours per dataset for consistency - _draw_contours(group, 1, name + ' rho', zp, contours) + _draw_contours(group, 1, name + ' rho', zp, contours, axes=axes) if absorbing: - _draw_contours(group, 2, name + ' irho', zp, contours) + _draw_contours(group, 2, name + ' irho', zp, contours, axes=axes) else: - next_color() + next_color(axes=axes) if magnetic: - _draw_contours(group, 3, name + ' rhoM', zp, contours) + _draw_contours(group, 3, name + ' rhoM', zp, contours, axes=axes) else: - next_color() - _profile_labels() + next_color(axes=axes) + _profile_labels(axes=axes) -def _draw_contours(group, index, label, zp, contours): +def _draw_contours(group, index, label, zp, contours, axes=None): import matplotlib.pyplot as plt - color = next_color() + if axes is None: + axes = plt.gca() + color = next_color(axes=axes) # Interpolate on common z fp = np.vstack([np.interp(zp, L[0], L[index]) for L in group]) # Plot the quantiles - plot_quantiles(zp, fp, contours, color) + plot_quantiles(zp, fp, contours, color, axes=axes) # Plot the best - plt.plot(zp, fp[0], '-', label=label, color=dark(color)) + axes.plot(zp, fp[0], '-', label=label, color=dark(color)) -def _profile_labels(): +def _profile_labels(axes=None): import matplotlib.pyplot as plt - plt.legend() - plt.xlabel(u'z (Å)') - plt.ylabel(u'SLD (10⁻⁶/Ų)') + if axes is None: + axes = plt.gca() + axes.legend() + axes.set_xlabel(u'z (Å)') + axes.set_ylabel(u'SLD (10⁻⁶/Ų)') -def _residuals_overplot(Q, residuals): +def _residuals_overplot(Q, residuals, axes=None): import matplotlib.pyplot as plt + if axes is None: + axes = plt.gca() alpha = 0.4 shift = 0 for m, r in residuals.items(): - color = next_color() - plt.plot(Q[m], shift+r[:, 1:], '.', markersize=1, color=color, alpha=alpha) - plt.plot(Q[m], shift+r[:, 0], '.', label=m.name, markersize=1, color=dark(color)) + color = next_color(axes=axes) + axes.plot(Q[m], shift+r[:, 1:], '.', markersize=1, color=color, alpha=alpha) + axes.plot(Q[m], shift+r[:, 0], '.', label=m.name, markersize=1, color=dark(color)) # Use 3 colours from cycle so reflectivity matches rho for each dataset - next_color() - next_color() + next_color(axes=axes) + next_color(axes=axes) shift += 5 - _residuals_labels() + _residuals_labels(axes=axes) def _residuals_contour(Q, residuals, contours=CONTOURS): import matplotlib.pyplot as plt @@ -466,11 +479,13 @@ def _residuals_contour(Q, residuals, contours=CONTOURS): shift += 5 _residuals_labels() -def _residuals_labels(): +def _residuals_labels(axes=None): import matplotlib.pyplot as plt - plt.legend() - plt.xlabel(u'Q (1/Å)') - plt.ylabel(u'Residuals') + if axes is None: + axes = plt.gca() + axes.legend() + axes.set_xlabel(u'Q (1/Å)') + axes.set_ylabel(u'Residuals') # ==== Helper functions ===== diff --git a/refl1d/experiment.py b/refl1d/experiment.py index a2944f50..c1f0e687 100644 --- a/refl1d/experiment.py +++ b/refl1d/experiment.py @@ -677,7 +677,7 @@ def __init__(self, samples=None, ratio=None, probe=None, self.probe = probe self.ratio = [Parameter.default(r, name="ratio %d"%i) for i, r in enumerate(ratio)] - self.parts = [Experiment(s, probe, **kw) for s in samples] + self.parts = [Experiment(s, probe, name=s.name, **kw) for s in samples] self.coherent = coherent self.interpolation = interpolation self._substrate = self.samples[0][0].material diff --git a/refl1d/material.py b/refl1d/material.py index 92434be6..02188dd4 100644 --- a/refl1d/material.py +++ b/refl1d/material.py @@ -169,177 +169,254 @@ def sld(self, probe): # ELEMENTS = Enum('elements', [(e.name, e.symbol) for e in periodictable.elements._element.values()]) @schema() -class Material(Scatterer): +class BaseMaterial(Scatterer): """ Description of a solid block of material. :Parameters: + *name* : string + *formula* : Formula Composition can be initialized from either a string or a chemical formula. Valid values are defined in periodictable.formula. - *density* : float | |g/cm^3| + *use_incoherent* = False : boolean - If specified, set the bulk density for the material. + True if incoherent scattering should be interpreted as absorption. + + """ + name: str + formula: str # Formula + use_incoherent: bool = False + density: Union[Parameter, Expression] # to be filled in by inheriting classes - *natural_density* : float | |g/cm^3| + # not in the schema: + _formula: BaseFormula - If specified, set the natural bulk density for the material. + def __init__(self, formula: Union[str, BaseFormula], density=None, natural_density=None, name=None, use_incoherent=False): + # coerce formula to string repr: + self.formula = str(formula) + self._formula: BaseFormula = periodictable.formula(formula, density=density, natural_density=natural_density) + self.name = name if name is not None else str(self._formula) + self.use_incoherent = use_incoherent - *use_incoherent* = False : boolean + def sld(self, probe): + rho, irho, incoh = probe.scattering_factors( + self._formula, density=self.density.value) + if self.use_incoherent: + raise NotImplementedError("incoherent scattering not supported") + #irho += incoh + return rho, irho + def __str__(self): + return self.name + def __repr__(self): + return "Material(%s)"%self.name - True if incoherent scattering should be interpreted as absorption. - *fitby* = 'bulk_density' : string +FitByChoices = Literal["bulk_density", "natural_density", "relative_density", "number_density", "cell_volume"] + +def Material( + formula: Union[str, BaseFormula], + density: Optional[float]=None, + natural_density: Optional[float]=None, + name: Optional[str]=None, + use_incoherent: bool=False, + fitby: FitByChoices="bulk_density", + **kw): + if fitby == "bulk_density": + return BulkDensityMaterial(formula, density=density, name=name, use_incoherent=use_incoherent) + elif fitby == "natural_density": + return NaturalDensityMaterial(formula, natural_density=natural_density, name=name, use_incoherent=use_incoherent) + elif fitby == "relative_density": + return RelativeDensityMaterial(formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent, **kw) + elif fitby == "number_density": + return NumberDensityMaterial(formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent, **kw) + elif fitby == "cell_volume": + return CellVolumeMaterial(formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent, **kw) + else: + raise ValueError(f'unknown value "{fitby}" of fitby: should be one of ["bulk_density", "natural_density", "relative_density", "number_density", "cell_volume"]') - Which density parameter is the fitting parameter. The choices - are *bulk_density*, *natural_density*, *relative_density* or - *cell_volume*. See :meth:`fitby` for details. - *value* : Parameter or float | units depends on fitby type +@schema() +class BulkDensityMaterial(BaseMaterial): + """ + A solid block of material, described by its bulk density - Initial value for the fitted density parameter. If None, the - value will be initialized from the material density. + :Parameters: + *density* : float | |g/cm^3| + the bulk density for the material. + """ + bulk_density: Parameter + density: Parameter + + def __init__(self, + formula: Union[str, BaseFormula], + density: Optional[Union[float, Parameter]]=None, + name=None, + use_incoherent=False): + BaseMaterial.__init__(self, formula, density=density, name=name, use_incoherent=use_incoherent) + if density is None: + if self._formula.density is not None: + density = self._formula.density + else: + raise ValueError(f"material {self._formula} does not have known density: please provide it in arguments") + self.density = Parameter.default(density, name=self.name+" density", limits=(0, inf)) + self.bulk_density = self.density - For example, to fit Pd by cell volume use:: + def parameters(self): + return dict(density=self.density) - >>> m = Material('Pd', fitby='cell_volume') - >>> m.cell_volume.range(1, 10) - Parameter(Pd cell volume) - >>> print("%.2f %.2f"%(m.density.value, m.cell_volume.value)) - 12.02 14.70 - You can change density representation by calling *material.fitby(type)*. +@schema() +class NaturalDensityMaterial(BaseMaterial): + """ + A solid block of material, described by its natural density + :Parameters: + *natural_density* : float | |g/cm^3| + the natural bulk density for the material. """ - name: str - formula: str # Formula - formula_density: float - formula_natural_density: Union[float, Literal[None]] - # density: Parameter - value: Parameter - fitby: Literal['bulk_density', 'number_density', 'natural_density', 'relative_density', 'cell_volume'] = 'bulk_density' - use_incoherent: bool = False + natural_density: Parameter + density: Expression + + def __init__(self, + formula: Union[str, BaseFormula], + natural_density: Optional[Union[float, Parameter]]=None, + name=None, + use_incoherent=False): + BaseMaterial.__init__(self, formula, natural_density=natural_density, name=name, use_incoherent=use_incoherent) + if natural_density is None: + if self._formula.density is not None: + natural_density = self._formula.density + else: + raise ValueError(f"material {self._formula} does not have known natural_density: please provide it in arguments") + self.natural_density = Parameter.default(natural_density, name=self.name+" nat. density", limits=(0, inf)) + self.density = self.natural_density / self._formula.natural_mass_ratio() - def __init__(self, formula=None, name=None, use_incoherent=False, - formula_density=None, formula_natural_density=None, - fitby='bulk_density', value=None): - self._formula = periodictable.formula(formula, density=formula_density, - natural_density=formula_natural_density) - self.formula_density = formula_density - self.formula_natural_density = formula_natural_density - self.formula = formula - self.name = name if name is not None else str(self._formula) - self.density = Parameter(name=self.name + " density") + def parameters(self): + return dict(density=self.density, natural_density=self.natural_density) - self.use_incoherent = use_incoherent - self._fitby(type=fitby, value=value) - def _fitby(self, type='bulk_density', value=None): - """ - Specify the fitting parameter to use for material density. - - :Parameters: - *type* : string - Density representation - *value* : Parameter - Initial value, or associated parameter. - - Density type can be one of the following: - - *bulk_density* : |g/cm^3| or kg/L - Density is *bulk_density* - *natural_density* : |g/cm^3| or kg/L - Density is *natural_density* / (natural mass/isotope mass) - *relative_density* : unitless - Density is *relative_density* * formula density - *cell_volume* : |Ang^3| - Density is mass / *cell_volume* - *number_density*: [atoms/cm^3] +@schema() +class NumberDensityMaterial(BaseMaterial): + """ + A solid block of material, described by its number density + + :Parameters: + *number_density*: [atoms/cm^3] Density is *number_density* * molar mass / avogadro constant - The resulting material will have a *density* attribute with the - computed material density in addition to the *fitby* - attribute specified. + *density* : float | |g/cm^3| + if specified, the bulk density for the material. - .. Note:: + *natural_density* : float | |g/cm^3| + if specified, the natural bulk density for the material. + """ + number_density: Parameter + density: Expression + + def __init__(self, + formula: Union[str, BaseFormula], + number_density: Optional[Union[float, Parameter]]=None, + density: Optional[float]=None, + natural_density: Optional[float]=None, + name=None, + use_incoherent=False): + BaseMaterial.__init__(self, formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent) + if number_density is None: + if self._formula.density is not None: + number_density = avogadro_number / self._formula.mass * self._formula.density + else: + raise ValueError(f"material {self._formula} does not have known density: please provide density or natural_density argument") + self.number_density = Parameter.default(number_density, name=self.name+" number density", limits=(0, inf)) + self.density = self.number_density / avogadro_number * self._formula.mass + self._formula.density = float(self.density) - Calling *fitby* replaces the *density* parameter in the - material, so be sure to do so before using *density* in a - parameter expression. Using *bumps.parameter.WrappedParameter* - for *density* is another alternative. - """ + def parameters(self): + return dict(density=self.density, number_density=self.number_density) - if type == 'bulk_density': - if value is None: - value = self._formula.density - self.value = Parameter.default( - value, name=self.name+" density", limits=(0, None)) - self.density.equals(self.value) - elif type == "number_density": - if value is None: - value = avogadro_number / self._formula.mass * self._formula.density - self.value = Parameter.default( - value, name=self.name+" number density", limits=(0, None)) - self.density.equals(self.value / avogadro_number * self._formula.mass) - elif type == 'natural_density': - if value is None: - value = self._formula.natural_density - self.value = Parameter.default( - value, name=self.name+" nat. density", limits=(0, None)) - self.density.equals(self.value / self._formula.natural_mass_ratio()) - elif type == 'relative_density': - if value is None: - value = 1 - self.value = Parameter.default( - value, name=self.name+" rel. density", limits=(0, None)) - self.density.equals(self._formula.density*self.value) - ## packing factor code should be correct, but radii are unreliable - #elif type is 'packing_factor': - # max_density = self.formula.mass/self.formula.volume(packing_factor=1) - # if value is None: - # value = self.formula.density/max_density - # self.packing_factor = Parameter.default( - # value, name=self.name+" packing factor") - # self.density = self.packing_factor * max_density - elif type == 'cell_volume': - # Density is in grams/cm^3. - # Mass is in grams. - # Volume is in A^3 = 1e24*cm^3. - if value is None: - value = (1e24*self._formula.molecular_mass)/self._formula.density - self.value = Parameter.default( - value, name=self.name+" cell volume", limits=(0, None)) - self.density.equals((1e24*self._formula.molecular_mass)/self.value) - else: - raise ValueError("Unknown density calculation type '%s'"%type) - self.fitby = type + +@schema() +class RelativeDensityMaterial(BaseMaterial): + """ + A solid block of material, described by its relative density + + :Parameters: + *relative_density* : unitless + Density is *relative_density* * formula density + + *density* : float | |g/cm^3| + if specified, the bulk density for the material. + + *natural_density* : float | |g/cm^3| + if specified, the natural bulk density for the material. + """ + relative_density: Parameter + + def __init__(self, + formula: Union[str, BaseFormula], + relative_density: Optional[Union[float, Parameter]]=None, + density: Optional[float]=None, + natural_density: Optional[float]=None, + name=None, + use_incoherent=False): + BaseMaterial.__init__(self, formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent) + if self._formula.density is None and density is None and natural_density is None: + raise ValueError(f"material {self._formula} does not have known density: please provide density or natural_density argument") + if relative_density is None: + relative_density = 1 + self.relative_density = Parameter.default(relative_density, name=self.name+" rel. density", limits=(0, inf)) + self.density = self._formula.density*self.relative_density def parameters(self): - return {'density': self.density, "value": self.value} + return dict(density=self.density, relative_density=self.relative_density) - def to_dict(self): - return to_dict({ - 'type': type(self).__name__, - 'name': self.name, - 'formula': self.formula, - 'density': self.density, - 'use_incoherent': self.use_incoherent, - # TODO: what about fitby, natural_density and cell_volume? - }) - def sld(self, probe): - rho, irho, incoh = probe.scattering_factors( - self._formula, density=self.density.value) - if self.use_incoherent: - raise NotImplementedError("incoherent scattering not supported") - #irho += incoh - return rho, irho - def __str__(self): - return self.name - def __repr__(self): - return "Material(%s)"%self.name +@schema() +class CellVolumeMaterial(BaseMaterial): + """ + A solid block of material, described by the volume of one unit cell + + :Parameters: + *cell_volume* : |Ang^3| + Density is mass / *cell_volume* + + *density* : float | |g/cm^3| + if specified, the bulk density for the material. + + *natural_density* : float | |g/cm^3| + if specified, the natural bulk density for the material. + + + For example, to fit Pd by cell volume use:: + >>> m = Material('Pd', fitby='cell_volume') + >>> m.cell_volume.range(1, 10) + Parameter(Pd cell volume) + >>> print("%.2f %.2f"%(m.density.value, m.cell_volume.value)) + 12.02 14.70 + + """ + cell_volume: Parameter + + def __init__(self, + formula: Union[str, BaseFormula], + cell_volume: Optional[Union[float, Parameter]]=None, + density: Optional[float]=None, + natural_density: Optional[float]=None, + name=None, + use_incoherent=False): + BaseMaterial.__init__(self, formula, density=density, natural_density=natural_density, name=name, use_incoherent=use_incoherent) + if self._formula.density is None and density is None and natural_density is None: + raise ValueError(f"material {self._formula} does not have known density: please provide density or natural_density argument") + if cell_volume is None: + cell_volume = (1e24*self._formula.molecular_mass)/self._formula.density + self.cell_volume = Parameter.default(cell_volume, name=self.name+" cell volume", limits=(0, inf)) + self.density = (1e24*self._formula.molecular_mass)/self.cell_volume + + def parameters(self): + return dict(density=self.density, cell_volume=self.cell_volume) + class Compound(Scatterer): """ diff --git a/refl1d/materialdb.py b/refl1d/materialdb.py index 465c15ed..60bdffec 100644 --- a/refl1d/materialdb.py +++ b/refl1d/materialdb.py @@ -32,10 +32,10 @@ rho_DHO = periodictable.formula('DHO').mass/periodictable.formula('H2O').mass air = Vacuum() -water = H2O = Material('H2O', formula_density=1, name='water') -heavywater = D2O = Material('D2O', formula_density=rho_D2O) -lightheavywater = DHO = Material('DHO', formula_density=rho_DHO) +water = H2O = Material('H2O', density=1, name='water') +heavywater = D2O = Material('D2O', density=rho_D2O) +lightheavywater = DHO = Material('DHO', density=rho_DHO) silicon = Si = Material('Si') -sapphire = Al2O3 = Material('Al2O3', formula_density=3.965, name='sapphire') +sapphire = Al2O3 = Material('Al2O3', density=3.965, name='sapphire') gold = Au = Material('Au', name='gold') -permalloy = Ni8Fe2 = Material('Ni8Fe2', formula_density=8.692, name='permalloy') +permalloy = Ni8Fe2 = Material('Ni8Fe2', density=8.692, name='permalloy') diff --git a/refl1d/probe.py b/refl1d/probe.py index 9c10f09e..a293f0f2 100644 --- a/refl1d/probe.py +++ b/refl1d/probe.py @@ -257,6 +257,17 @@ def _set_TLR(self, T, dT, L, dL, R, dR, dQ): # Make sure that we are dealing with vectors T, dT, L, dL = [np.ones_like(Q)*v for v in (T, dT, L, dL)] + # remove nan + nan_indices = set() + for column in [T, dT, L, dL, R, dR, Q, dQ]: + if column is not None: + indices = np.argwhere(np.isnan(column)).flatten() + nan_indices.update(indices) + + nan_indices = list(nan_indices) + T, dT, L, dL, R, dR, Q, dQ = ( + np.delete(c, nan_indices) if c is not None else None for c in [T, dT, L, dL, R, dR, Q, dQ]) + # Probe stores sorted values for convenience of resolution calculator idx = np.argsort(Q) self.T, self.dT = T[idx], dT[idx] @@ -1562,7 +1573,7 @@ def fetch_key(key, override): T=data_T, dT=data_dT, L=data_L, dL=data_dL, data=(data_R, data_dR), - dQ=data_dQ, + dQo=data_dQ, **probe_args) else: # QProbe doesn't accept theta_offset or sample_broadening diff --git a/refl1d/webview/__init__.py b/refl1d/webview/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refl1d/webview/client/.npmignore b/refl1d/webview/client/.npmignore new file mode 100644 index 00000000..e2d91bfc --- /dev/null +++ b/refl1d/webview/client/.npmignore @@ -0,0 +1 @@ +refl1d-webview-client*.tgz diff --git a/refl1d/webview/client/README.md b/refl1d/webview/client/README.md new file mode 100644 index 00000000..df525e22 --- /dev/null +++ b/refl1d/webview/client/README.md @@ -0,0 +1,43 @@ +# refl1d-web-gui + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). + +## Customize configuration + +See [Vite Configuration Reference](https://vitejs.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` + +# Publishing new client versions: +(...after checking to make sure there aren't extraneous files in this folder) +```sh +npm version patch +npm publish +``` + +and then +```sh +git commit package.json -m "webview client version bump" +git pull +git push +``` \ No newline at end of file diff --git a/refl1d/webview/client/index.html b/refl1d/webview/client/index.html new file mode 100644 index 00000000..c55b2ea8 --- /dev/null +++ b/refl1d/webview/client/index.html @@ -0,0 +1,16 @@ + + + + + + + + Refl1D + + +
+ + + diff --git a/refl1d/webview/client/index_template.txt b/refl1d/webview/client/index_template.txt new file mode 100644 index 00000000..2e1710e1 --- /dev/null +++ b/refl1d/webview/client/index_template.txt @@ -0,0 +1,17 @@ + + + + + + + + Refl1D + + + + +
+ + diff --git a/refl1d/webview/client/package.json b/refl1d/webview/client/package.json new file mode 100644 index 00000000..4d1e269c --- /dev/null +++ b/refl1d/webview/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "refl1d-webview-client", + "version": "0.0.4", + "scripts": { + "dev": "vite", + "build": "vite build --emptyOutDir -m development", + "build_prod": "vite build --emptyOutDir -m production", + "preview": "vite preview --port 4173" + }, + "dependencies": { + "bootstrap": "^5.2.1", + "bumps-webview-client": "^0.0.4", + "json-difference": "^1.8.2", + "mpld3": "^0.5.8", + "plotly.js": "^2.20.0", + "socket.io-client": "^4.5.2", + "uuid": "^9.0.0", + "vue": "^3.2.38", + "vue-json-viewer": "^3.0.4" + }, + "devDependencies": { + "@types/plotly.js": "^2.12.5", + "@types/uuid": "^8.3.4", + "@vitejs/plugin-vue": "^3.0.3", + "vite": "^3.2.5", + "vite-plugin-generate-file": "^0.0.4" + } +} diff --git a/refl1d/webview/client/src/assets/refl1d-icon_256x256x32.png b/refl1d/webview/client/src/assets/refl1d-icon_256x256x32.png new file mode 100644 index 00000000..96a6895b Binary files /dev/null and b/refl1d/webview/client/src/assets/refl1d-icon_256x256x32.png differ diff --git a/refl1d/webview/client/src/assets/refl1d-icon_48x48x32.png b/refl1d/webview/client/src/assets/refl1d-icon_48x48x32.png new file mode 100644 index 00000000..dfaa7415 Binary files /dev/null and b/refl1d/webview/client/src/assets/refl1d-icon_48x48x32.png differ diff --git a/refl1d/webview/client/src/asyncSocket.ts b/refl1d/webview/client/src/asyncSocket.ts new file mode 100644 index 00000000..a343d904 --- /dev/null +++ b/refl1d/webview/client/src/asyncSocket.ts @@ -0,0 +1,13 @@ +import { Socket } from 'socket.io-client'; + +declare module 'socket.io-client' { + class Socket { + public asyncEmit(ev: string, ...args: any[]): Promise; + } +} + +Socket.prototype.asyncEmit = async function asyncEmit(ev: string, ...args: any[]) { + return new Promise((resolve, reject) => { + this.emit(ev, ...args, resolve); + }) +}; \ No newline at end of file diff --git a/refl1d/webview/client/src/components/DataView.vue b/refl1d/webview/client/src/components/DataView.vue new file mode 100644 index 00000000..eabb3db6 --- /dev/null +++ b/refl1d/webview/client/src/components/DataView.vue @@ -0,0 +1,138 @@ + + + diff --git a/refl1d/webview/client/src/components/ModelView.vue b/refl1d/webview/client/src/components/ModelView.vue new file mode 100644 index 00000000..de1740f8 --- /dev/null +++ b/refl1d/webview/client/src/components/ModelView.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/refl1d/webview/client/src/components/ModelViewPlotly.vue b/refl1d/webview/client/src/components/ModelViewPlotly.vue new file mode 100644 index 00000000..2512d42c --- /dev/null +++ b/refl1d/webview/client/src/components/ModelViewPlotly.vue @@ -0,0 +1,116 @@ + + + + + \ No newline at end of file diff --git a/refl1d/webview/client/src/fitter_defaults.ts b/refl1d/webview/client/src/fitter_defaults.ts new file mode 100644 index 00000000..2f2d638a --- /dev/null +++ b/refl1d/webview/client/src/fitter_defaults.ts @@ -0,0 +1,62 @@ +export const FITTERS = { + dream: { + name: "DREAM", + settings: { + "samples": 10000, + "burn": 100, + "pop": 10, + "init": "eps", + "thin": 1, + "alpha": 0.01, + "outliers": "none", + "trim": false, + "steps": 0 + } + }, + lm: { + name: "Levenberg-Marquardt", + settings: { + "steps": 200, + "ftol": 1.5e-08, + "xtol": 1.5e-08 + } + }, + amoeba: { + name: "Nelder-Mead Simplex", + settings: { + "steps": 1000, + "starts": 1, + "radius": 0.15, + "xtol": 1e-06, + "ftol": 1e-08 + } + }, + de: { + name: "Differential Evolution", + settings: { + "steps": 1000, + "pop": 10, + "CR": 0.9, + "F": 2.0, + "ftol": 1e-08, + "xtol": 1e-06 + } + }, + mp: { + name: "MPFit", + settings: { + "steps": 200, + "ftol": 1e-10, + "xtol": 1e-10 + } + }, + newton: { + name: "Quasi-Newton BFGS", + settings: { + "steps": 3000, + "starts": 1, + "ftol": 1e-06, + "xtol": 1e-12 + } + }, + } \ No newline at end of file diff --git a/refl1d/webview/client/src/main.js b/refl1d/webview/client/src/main.js new file mode 100644 index 00000000..8416b183 --- /dev/null +++ b/refl1d/webview/client/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import 'bootstrap/dist/css/bootstrap.min.css'; +import './style.css' +import App from 'bumps-webview-client/src/App.vue'; +import { panels } from './panels.mjs'; + +createApp(App, {panels}).mount('#app'); \ No newline at end of file diff --git a/refl1d/webview/client/src/panels.mjs b/refl1d/webview/client/src/panels.mjs new file mode 100644 index 00000000..9dc9a51c --- /dev/null +++ b/refl1d/webview/client/src/panels.mjs @@ -0,0 +1,10 @@ +import { panels as bumps_panels } from 'bumps-webview-client/src/panels'; +import DataView from './components/DataView.vue'; +import ModelView from './components/ModelView.vue'; + +export const panels = [...bumps_panels]; + +// replace the bumps data view with the refl1d one. +panels.splice(0, 1, {title: 'Reflectivity', component: DataView}); +// insert the profile panel at position 3 +panels.splice(2, 0, {title: 'Profile', component: ModelView}); diff --git a/refl1d/webview/client/src/plotcache.ts b/refl1d/webview/client/src/plotcache.ts new file mode 100644 index 00000000..44b47920 --- /dev/null +++ b/refl1d/webview/client/src/plotcache.ts @@ -0,0 +1,2 @@ +// TODO: move cache to server? Send timestamp code to server to retrieve plottables... +export const cache: {[plot_type: string]: {timestamp: string, plotdata: object}} = {}; \ No newline at end of file diff --git a/refl1d/webview/client/src/setupDrawLoop.ts b/refl1d/webview/client/src/setupDrawLoop.ts new file mode 100644 index 00000000..7ba91079 --- /dev/null +++ b/refl1d/webview/client/src/setupDrawLoop.ts @@ -0,0 +1,51 @@ +import { ref, onMounted, onBeforeUnmount } from 'vue'; +import type { Socket } from 'socket.io-client'; +import './asyncSocket'; + +export function setupDrawLoop(topic: string, socket: Socket, draw: Function, name: string = '') { + const mounted = ref(false); + const drawing_busy = ref(false); + const draw_requested = ref(false); + const latest_timestamp = ref(); + + const topic_callback = function() { + draw_requested.value = true; + } + + const draw_if_needed = async function() { + if (!mounted.value) { + return; + } + if (drawing_busy.value) { + console.log(`drawing ${name} busy!`); + } + else if (draw_requested.value) { + drawing_busy.value = true; + draw_requested.value = false; + await draw(latest_timestamp.value); + drawing_busy.value = false; + } + window.requestAnimationFrame(draw_if_needed); + } + + onMounted(async () => { + mounted.value = true; + socket.on(topic, topic_callback); + + const messages = await socket.asyncEmit('get_topic_messages', topic); + // console.log(topic, messages); + const last_message = messages.pop(); + if (last_message) { + draw_requested.value = true; + latest_timestamp.value = last_message.timestamp; + } + window.requestAnimationFrame(draw_if_needed); + }); + + onBeforeUnmount(() => { + mounted.value = false; + socket.off(topic, topic_callback); + }); + + return { mounted, drawing_busy, draw_requested }; +} diff --git a/refl1d/webview/client/src/style.css b/refl1d/webview/client/src/style.css new file mode 100644 index 00000000..a2b25da4 --- /dev/null +++ b/refl1d/webview/client/src/style.css @@ -0,0 +1,7 @@ +html, body, #app { + height: 100%; +} + +.flex-column { + min-height: 0; +} \ No newline at end of file diff --git a/refl1d/webview/client/vite.config.js b/refl1d/webview/client/vite.config.js new file mode 100644 index 00000000..b8c0dcef --- /dev/null +++ b/refl1d/webview/client/vite.config.js @@ -0,0 +1,42 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import generateFile from 'vite-plugin-generate-file' + +// https://vitejs.dev/config/ +export default ({mode}) => { + return defineConfig({ + plugins: [ + vue(), + generateFile([{ + type: 'yaml', + output: 'VERSION', + data: process.env.npm_package_version.toString(), + }]) + ], + base: '', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + define: { + // By default, Vite doesn't include shims for NodeJS/ + // necessary for segment analytics lib to work + global: {}, + }, + build: { + rollupOptions: { + output: { + // Default + // dir: 'dist', + entryFileNames: (mode == 'production') ? 'assets/[name].js' : 'assets/[name].[hash].js', + assetFileNames: (mode == 'production') ? 'assets/[name][extname]' : undefined, + // chunkFileNames: "chunk-[name].js", + // manualChunks: undefined, + } + } + } + }) +} diff --git a/refl1d/webview/server/__init__.py b/refl1d/webview/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/refl1d/webview/server/__main__.py b/refl1d/webview/server/__main__.py new file mode 100644 index 00000000..d764dfdc --- /dev/null +++ b/refl1d/webview/server/__main__.py @@ -0,0 +1,3 @@ +from .webserver import main + +main() \ No newline at end of file diff --git a/refl1d/webview/server/profile_plot.py b/refl1d/webview/server/profile_plot.py new file mode 100644 index 00000000..8bb8acfe --- /dev/null +++ b/refl1d/webview/server/profile_plot.py @@ -0,0 +1,275 @@ +# Below taken from the GUI profile plot methods in Refl1d - modified to be stand alone +# This is to allow for the layer labels to be easily added to the SLD plots + +# Originally written for use in a jupyter notebook +# Adapted to act as a prototype for the profile plotting in the new GUI for refl1d + +from refl1d.experiment import Experiment +from numpy import inf +import numpy as np +import plotly.graph_objs as go + + +# === Sample information === +class FindLayers: + def __init__(self, experiment, axes=None, x_offset=0): + + self.experiment = experiment + self.axes = axes + self.x_offset = x_offset + self._show_labels = True + self._show_boundaries = True + + self._find_layer_boundaries() + + def find(self, z): + return self.experiment.sample.find(z - self.x_offset) + + def _find_layer_boundaries(self): + offset = self.x_offset + boundary = [-inf, offset] + if hasattr(self.experiment, 'sample'): + for L in self.experiment.sample[1:-1]: + dx = L.thickness.value + offset += dx + boundary.append(offset) + boundary.append(inf) + self.boundary = np.asarray(boundary) + + def sample_labels(self): + return [L.name for L in self.experiment.sample] + + def sample_layer(self, n): + return self.experiment.sample[n] + + def label_offsets(self): + z = self.boundary[1:-1] + left = -20 + middle = [(a + b) / 2. for a, b in zip(z[:-1], z[1:])] + right = z[-1] + 150 + return [left] + middle + [right] + + def reset_markers_plotly(self): + """ + Reset all markers, for plotly plots + """ + # self.clear_markers() + if not isinstance(self.axes, go.Figure): + raise ValueError("reset_markers_plotly can only be used with type: axes=plotly.graph_objs.Figure") + + fig = self.axes + + # Add bars + fittable = [self.experiment.sample[idx].thickness.fittable + for idx, _ in enumerate(self.boundary[1:-1])] + fittable[0] = False # First interface is not fittable + + for f, z_pos in zip(fittable, self.boundary[1:-1]): + if not f: + line_dict = dict(dash="dash") + else: + line_dict = dict(dash="solid") + + fig.add_vline(x=z_pos, opacity=0.75, + line=line_dict, + ) + + # for f,m in zip(fittable,self.markers): + # if not f: m.set(linestyle='--', linewidth=1.25) + # self.connect_markers(m for f,m in zip(fittable,self.markers) if f) + + # Add labels + offsets = self.label_offsets() + labels = self.sample_labels() + for label_pos, label in zip(offsets, labels): + fig.add_annotation(text=label, + textangle=-30, + x=label_pos, + yref="paper", + y=1.0, + yanchor="bottom", + showarrow=False + ) + + +def generate_best_profile(model: Experiment): + if model.ismagnetic: + best = model.magnetic_smooth_profile() + else: + best = model.smooth_profile() + return best + +# ============================================================================= # +# Plotting script below +# ============================================================================= # + +def plot_sld_profile_plotly(model): + fig = go.Figure() + if model.ismagnetic: + z_best, rho_best, irho_best, rhoM_best, thetaM_best = generate_best_profile(model) + + fig.add_scatter(x=z_best, y=thetaM_best, + name="θM", yaxis="y2", + hovertemplate='(%{x}, %{y})
' + 'Theta M' + '', + line={"color": "gold"}) + # TODO: need to make axis scaling for thetaM dependent on if thetaM exceeds 0-360 + fig.update_layout(yaxis2={ + 'title': {'text': 'Magnetic Angle θM / °'}, + 'type': 'linear', + 'autorange': False, + 'range': [0, 360], + 'anchor': 'x', + 'overlaying': 'y', + 'side': 'right', + 'ticks': "inside", + # 'ticklen': 20, + }) + + fig.add_scatter(x=z_best, y=rhoM_best, name="ρM", + hovertemplate='(%{x}, %{y})
' + 'M SLD' + '', + line={"color": "blue"}) + yaxis_title = 'SLD: ρ, ρi, ρM / 10-6 Å-2' + + else: + z_best, rho_best, irho_best = generate_best_profile(model) + yaxis_title = 'SLD: ρ, ρi / 10-6 Å-2' + + fig.add_scatter(x=z_best, y=irho_best, name="ρi", + hovertemplate='(%{x}, %{y})
' + 'Im SLD' + '', + line={"color": "green"}) + fig.add_scatter(x=z_best, y=rho_best, name="ρ", + hovertemplate='(%{x}, %{y})
' + 'SLD' + '', + line={"color": "black"}) + + fig.update_layout(uirevision=1, plot_bgcolor="white") + + fig.update_layout(xaxis={ + 'title': {'text': 'depth (Å)'}, + 'type': 'linear', + 'autorange': True, + # 'gridcolor': "Grey", + 'ticks': "inside", + # 'ticklen': 20, + 'showline': True, + 'linewidth': 1, + 'linecolor': 'black', + 'mirror': "ticks", + 'side': 'bottom' + }) + + fig.update_layout(yaxis={ + 'title': {'text': yaxis_title}, + 'exponentformat': 'e', + 'showexponent': 'all', + 'type': 'linear', + 'autorange': True, + # 'gridcolor': "Grey", + 'ticks': "inside", + # 'ticklen': 20, + 'showline': True, + 'linewidth': 1, + 'linecolor': 'black', + 'mirror': True + }) + + fig.update_layout(margin={ + "l": 75, + "r": 50, + "t": 50, + "b": 75, + "pad": 4 + }) + + fig.update_layout(legend={ + "x": -0.1, + "bgcolor": "rgba(255,215,0,0.15)", + "traceorder": "reversed" + }) + + marker_positions = FindLayers(model, axes=fig) + marker_positions.reset_markers_plotly() + + # fig.show(renderer='firefox') + return fig + +# def plot_contours(model, title, savepath=None, nsamples=1000, show_contours=True, show_mag=True, savetitle=None, +# store=None, ultranest=False, dream=False, align=0): +# if show_contours: +# # data, columns = generate_contour_data(model) +# if ultranest: +# points_array = model.post_samples +# sub_array = points_array[np.sort(np.random.randint(points_array.shape[0], size=nsamples)), :] +# errors = calc_errors(model.problem, sub_array) +# data, columns = generate_profile_data(errors) +# # if this comment is removed the above code will not overwrite the previous reference to the errors object +# # and it will act as a bad memory leak - using more ram everytime the function is called +# if dream: +# state, points = load_dream_state_notebook(problem=model.problem, store=store) +# errors = calc_errors(model.problem, points[-nsamples:-1]) +# data, columns = generate_profile_data(errors, align) +# +# # get best profile manually as we are not using DREAM state +# if show_mag: +# z_best, rho_best, irho_best, rhoM_best, thetaM_best = generate_best_profile(model) +# else: +# z_best, rho_best, irho_best = generate_best_profile(model) +# +# fig, ax = plt.subplots(1, 1, figsize=(8, 6)) +# +# # rho +# if show_contours: +# z, best, sig2lo, sig2hi, siglo, sighi = data[0] +# ax.plot(z, best, label=r"$\rho$ SLD", color="orange") +# ax.fill_between(z, siglo, sighi, alpha=0.5, color="orange", label=r"$\rho$ SLD $1\sigma$") +# ax.fill_between(z, sig2lo, sig2hi, alpha=0.25, color="orange", label=r"$\rho$ SLD $2\sigma$") +# else: +# ax.plot(z_best, rho_best, label=r"$\rho$ SLD", color="orange") +# +# # irho +# # ax.plot(z_best, irho_best, label=r"$\rho_{i}$ Im SLD") +# # if show_contours: +# # z, best, sig2lo, sig2hi, siglo, sighi = data[1] +# # ax.fill_between(z, siglo, sighi, alpha=0.5) +# # ax.fill_between(z, sig2lo, sig2hi, alpha=0.25) +# if show_mag: +# if show_contours: +# z, best, sig2lo, sig2hi, siglo, sighi = data[2] +# ax.plot(z, best, label=r"$\rho_{M}$ mSLD", color="b") +# ax.fill_between(z, siglo, sighi, alpha=0.5, color="b", label=r"$\rho_{M}$ mSLD $1\sigma$") +# ax.fill_between(z, sig2lo, sig2hi, alpha=0.25, color="b", label=r"$\rho_{M}$ mSLD $2\sigma$") +# else: +# ax.plot(z_best, rhoM_best, label=r"$\rho_{M}$ mSLD", color="b") +# +# # ax.legend(fontsize=14, loc='upper right', framealpha=0.5, ncol=2) +# ax.legend(fontsize=16, framealpha=0.5, ncol=3) +# +# ax.grid(True) +# ax.set_xlabel(r"z $\left(\AA\right)$", fontsize=18) +# ax.set_ylabel(r"$\rho, \rho_{i} \left(10^{-6}\AA^{-2}\right)$", fontsize=18) +# if show_mag: +# ax.set_ylabel(r"$\rho, \rho_{M} \left(10^{-6}\AA^{-2}\right)$", fontsize=18) +# ax.tick_params(axis='y', labelsize=16) +# ax.tick_params(axis='x', labelsize=16) +# +# experiment = model.determine_problem_fitness() +# +# layer_markers = FindLayers(experiment, axes=ax) +# layer_markers.reset_markers() +# fig.suptitle(title) +# # if np.max(rho_best) > 75: +# # top = np.max(rho_best)*1.15 +# # else: +# # top =75 +# # ax.set_ylim(top=top) +# if savepath: +# if savetitle: +# title = savetitle +# plt.savefig(f"{savepath}/{title}.png") diff --git a/refl1d/webview/server/webserver.py b/refl1d/webview/server/webserver.py new file mode 100644 index 00000000..5acacbce --- /dev/null +++ b/refl1d/webview/server/webserver.py @@ -0,0 +1,135 @@ +from aiohttp import web +import json +from pathlib import Path +from typing import Union, Dict, List + +import numpy as np +from bumps.webview.server.webserver import app, main as bumps_main, sio, rest_get, state, get_chisq, to_json_compatible_dict +from refl1d.experiment import Experiment, ExperimentBase, MixedExperiment +import refl1d.probe + +# Register the refl1d model loader +import refl1d.fitplugin +import bumps.cli +bumps.cli.install_plugin(refl1d.fitplugin) + +from .profile_plot import plot_sld_profile_plotly + +client_path = Path(__file__).parent.parent / 'client' +index_path = client_path / 'dist' +static_assets_path = index_path / 'assets' + +async def index(request): + """Serve the client-side application.""" + # check if the locally-build site has the correct version: + with open(client_path / 'package.json', 'r') as package_json: + client_version = json.load(package_json)['version'].strip() + + try: + local_version = open(index_path / 'VERSION', 'rt').read().strip() + except FileNotFoundError: + local_version = None + + print(index_path, local_version, client_version, local_version == client_version) + if client_version == local_version: + return web.FileResponse(index_path / 'index.html') + else: + CDN = f"https://cdn.jsdelivr.net/npm/refl1d-webview-client@{client_version}/dist" + with open(client_path / 'index_template.txt', 'r') as index_template: + index_html = index_template.read().format(cdn=CDN) + return web.Response(body=index_html, content_type="text/html") + + +@sio.event +@rest_get +async def get_plot_data(sid: str="", view: str = 'linear'): + # TODO: implement view-dependent return instead of doing this in JS + # (calculate x,y,dy.dx for given view, excluding log) + if state.problem is None or state.problem.fitProblem is None: + return None + fitProblem = state.problem.fitProblem + chisq = get_chisq(fitProblem) + plotdata = [] + result = {"chisq": chisq, "plotdata": plotdata} + for model in fitProblem.models: + assert(isinstance(model, ExperimentBase)) + theory = model.reflectivity() + probe = model.probe + plotdata.append(get_probe_data(theory, probe, model._substrate, model._surface)) + # fresnel_calculator = probe.fresnel(model._substrate, model._surface) + # Q, FQ = probe.apply_beam(probe.calc_Q, fresnel_calculator(probe.calc_Q)) + # Q, R = theory + # assert isinstance(FQ, np.ndarray) + # if len(Q) != len(probe.Q): + # # Saving interpolated data + # output = dict(Q = Q, R = R, fresnel=np.interp(Q, probe.Q, FQ)) + # elif getattr(probe, 'R', None) is not None: + # output = dict(Q = probe.Q, dQ = probe.dQ, R = probe.R, dR = probe.dR, fresnel = FQ) + # else: + # output = dict(Q = probe.Q, dQ = probe.dQ, R = R, fresnel = FQ) + # result.append(output) + return to_json_compatible_dict(result) + +@sio.event +@rest_get +async def get_profile_plot(sid: str="", model_index: int=0, sample_index: int=0): + if state.problem is None or state.problem.fitProblem is None: + return None + fitProblem = state.problem.fitProblem + models = list(fitProblem.models) + if model_index > len(models): + return None + model = models[model_index] + assert (isinstance(model, Union[Experiment, MixedExperiment])) + if isinstance(model, MixedExperiment): + model = model.parts[sample_index] + fig = plot_sld_profile_plotly(model) + return to_json_compatible_dict(fig.to_dict()) + + +def get_single_probe_data(theory, probe, substrate=None, surface=None, label=''): + fresnel_calculator = probe.fresnel(substrate, surface) + Q, FQ = probe.apply_beam(probe.calc_Q, fresnel_calculator(probe.calc_Q)) + Q, R = theory + output: Dict[str, Union[str, np.ndarray]] + assert isinstance(FQ, np.ndarray) + if len(Q) != len(probe.Q): + # Saving interpolated data + output = dict(Q = Q, theory = R, fresnel=np.interp(Q, probe.Q, FQ)) + elif getattr(probe, 'R', None) is not None: + output = dict(Q = probe.Q, dQ = probe.dQ, R = probe.R, dR = probe.dR, theory = R, fresnel = FQ, background=probe.background.value, intensity=probe.intensity.value) + else: + output = dict(Q = probe.Q, dQ = probe.dQ, theory = R, fresnel = FQ) + output['label'] = f"{probe.label()} {label}" + return output + +def get_probe_data(theory, probe, substrate=None, surface=None): + if isinstance(probe, refl1d.probe.PolarizedNeutronProbe): + output = [] + for xsi, xsi_th, suffix in zip(probe.xs, theory, ('--', '-+', '+-', '++')): + if xsi is not None: + output.append(get_single_probe_data(xsi_th, xsi, substrate, surface, suffix)) + return output + else: + return [get_single_probe_data(theory, probe, substrate, surface)] + +@sio.event +async def get_model_names(sid: str=""): + problem = state.problem.fitProblem + if problem is None: + return None + output: List[Dict] = [] + for model_index, model in enumerate(problem.models): + if isinstance(model, Experiment): + output.append(dict(name=model.name, part_name=None, model_index=model_index, part_index=0)) + elif isinstance(model, MixedExperiment): + for part_index, part in enumerate(model.parts): + output.append(dict(name=model.name, part_name=part.name, model_index=model_index, part_index=part_index)) + return output + + +def main(): + bumps_main(index=index, static_assets_path=static_assets_path, arg_defaults={"serializer": "dataclass"}) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/setup.py b/setup.py index 9853a98b..1c0ac626 100755 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ from setuptools import setup, Extension from setuptools.command.build_ext import build_ext -packages = ['refl1d', 'refl1d.view', 'refl1d.lib_numba'] +packages = ['refl1d', 'refl1d.view', 'refl1d.lib_numba', 'refl1d.webview.server'] version = None for line in open(os.path.join("refl1d", "__init__.py")): @@ -49,6 +49,7 @@ ], packages=packages, #package_data=gui_resources.package_data(), + include_package_data=True, scripts=['bin/refl1d_cli.py', 'bin/refl1d_gui.py'], entry_points={ 'console_scripts': ['refl1d=refl1d.main:cli'], diff --git a/webview-requirements b/webview-requirements new file mode 100644 index 00000000..8ddca1e5 --- /dev/null +++ b/webview-requirements @@ -0,0 +1,8 @@ +aiohttp +blinker +python-socketio +plotly +mpld3 +matplotlib +nodejs +h5py