diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
new file mode 100644
index 0000000..4e7565d
--- /dev/null
+++ b/.github/workflows/build-test.yml
@@ -0,0 +1,124 @@
+name: Build Test
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ build-desktop:
+ name: Build on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ include:
+ - os: macos-latest
+ spec: DeerAnalysis_MacOS.spec
+ python-version: '3.12'
+ - os: windows-latest
+ spec: DeerAnalysis_Win.spec
+ python-version: '3.12'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install pyinstaller
+ pip install -e .
+
+ - name: Build with PyInstaller
+ run: pyinstaller "${{ matrix.spec }}" --distpath dist --workpath build
+ working-directory: packaging/pyinstaller
+
+ - name: Get version
+ id: get_version
+ run: |
+ VERSION=$(python -c "import tomllib; f = open('pyproject.toml', 'rb'); data = tomllib.load(f); print(data['project']['version']); f.close()")
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ shell: bash
+
+ - name: Prepare artifact name
+ id: artifact_name
+ run: |
+ if [ "${{ runner.os }}" = "macOS" ]; then
+ ARTIFACT_NAME="DeerAnalysis-${{ steps.get_version.outputs.version }}-macos"
+ elif [ "${{ runner.os }}" = "Windows" ]; then
+ ARTIFACT_NAME="DeerAnalysis-${{ steps.get_version.outputs.version }}-windows"
+ fi
+ echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
+ shell: bash
+
+ - name: Package macOS build
+ if: runner.os == 'macOS'
+ run: |
+ hdiutil create \
+ -volname "DeerAnalysis" \
+ -srcfolder packaging/pyinstaller/dist/DeerAnalysis.app \
+ -ov -format UDZO \
+ "packaging/pyinstaller/dist/${{ steps.artifact_name.outputs.artifact_name }}.dmg"
+
+ - name: Package Windows build
+ if: runner.os == 'Windows'
+ # The Win spec names the executable "DeerAnalysis 2026.exe".
+ run: |
+ cd packaging/pyinstaller/dist
+ Compress-Archive -Path "DeerAnalysis 2026.exe" -DestinationPath "${{ steps.artifact_name.outputs.artifact_name }}.zip"
+ shell: pwsh
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ steps.artifact_name.outputs.artifact_name }}
+ path: |
+ packaging/pyinstaller/dist/*.zip
+ packaging/pyinstaller/dist/*.dmg
+ retention-days: 1
+
+ build-flatpak:
+ name: Build Linux Flatpak
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y flatpak flatpak-builder xvfb
+ flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
+
+ - name: Install Flatpak SDK
+ run: flatpak install --user -y --noninteractive flathub org.gnome.Platform//50 org.gnome.Sdk//50
+
+ - name: Get version
+ id: get_version
+ run: |
+ VERSION=$(python3 -c "import tomllib; f = open('pyproject.toml', 'rb'); data = tomllib.load(f); print(data['project']['version']); f.close()")
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Build Flatpak bundle
+ run: |
+ xvfb-run -a flatpak-builder \
+ --user \
+ --repo=repo \
+ --disable-rofiles-fuse \
+ --force-clean \
+ flatpak_app \
+ packaging/flatpak/io.github.JeschkeLab.DeerAnalysis.yml
+ flatpak build-bundle \
+ --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
+ repo DeerAnalysis.flatpak io.github.JeschkeLab.DeerAnalysis
+ - name: Upload Flatpak artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: DeerAnalysis-${{ steps.get_version.outputs.version }}-linux
+ path: DeerAnalysis.flatpak
+ retention-days: 1
diff --git a/pyproject.toml b/pyproject.toml
index 1833e9c..e129d80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "deeranalysis"
-version = "2026.0.1"
+version = "2026.0.2"
description = "A graphical fitting and data managment tool for DEER spectroscopy, using DeerLab and DeerNet as fitting engines."
authors = [
{name = "Hugo Karas",email = "hkaras@ethz.ch"}
diff --git a/src/deeranalysis/assets/dmc.css b/src/deeranalysis/assets/dmc.css
index 38d1248..0293cc9 100644
--- a/src/deeranalysis/assets/dmc.css
+++ b/src/deeranalysis/assets/dmc.css
@@ -78,4 +78,21 @@ html[data-mantine-color-scheme="dark"] .ag-theme-alpine {
.mantine-Notification-icon {
width: 28px;
height: 28px;
+}
+
+/* ── Fit detail: title-sized TextInput ──────────────────────────────────────
+ The fit-name TextInput on the fit detail page should render as a page title.
+ Mantine's `.mantine-Input-input { font-size: var(--input-fz) }` rule (class
+ specificity) beats the styles-prop class at equal weight, so we scope by id
+ (#fd-fit-name) to win on specificity without !important.
+
+ font-size is tied to --input-height so the text scales with the size prop
+ (size="lg" → ~50px box → ~42px text) and fills the input box rather than
+ floating in a sea of whitespace. */
+#fd-fit-name {
+ font-size: calc(var(--input-height) * 0.85);
+ line-height: 1;
+ font-weight: var(--mantine-heading-font-weight, 700);
+ letter-spacing: -0.02em;
+ padding: 0;
}
\ No newline at end of file
diff --git a/src/deeranalysis/assets/figure_download.js b/src/deeranalysis/assets/figure_download.js
new file mode 100644
index 0000000..5ff655e
--- /dev/null
+++ b/src/deeranalysis/assets/figure_download.js
@@ -0,0 +1,132 @@
+// Routes Plotly's modebar "Download plot as image" button through
+// pywebview's native save dialog. Intercepts the camera button click in
+// the document-level capture phase. Shows a format picker (SVG / PNG)
+// before opening the native save dialog so the choice is always explicit.
+(function () {
+ function figureName(gd) {
+ var title = gd && gd.layout && gd.layout.title;
+ if (title && typeof title === 'string') return title;
+ if (title && title.text) return title.text;
+ return 'figure';
+ }
+
+ function isDownloadButton(btn) {
+ if (btn.hasAttribute('data-pywv-download')) return true;
+ var title = (btn.getAttribute('data-title') || '').toLowerCase();
+ return title.indexOf('download') !== -1 ||
+ title.indexOf('save plot') !== -1 ||
+ title.indexOf('image') !== -1;
+ }
+
+ function showFormatPicker(svgUrl, pngUrl, name) {
+ var existing = document.getElementById('pywv-fmt-picker');
+ if (existing) existing.remove();
+
+ var overlay = document.createElement('div');
+ overlay.id = 'pywv-fmt-overlay';
+ overlay.style.cssText =
+ 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)';
+
+ var picker = document.createElement('div');
+ picker.id = 'pywv-fmt-picker';
+ picker.style.cssText =
+ 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
+ 'background:#fff;border-radius:8px;padding:20px 24px;' +
+ 'box-shadow:0 8px 32px rgba(0,0,0,0.2);z-index:9999;' +
+ 'font-family:system-ui,sans-serif;text-align:center;min-width:220px';
+ picker.innerHTML =
+ '
' +
+ 'Save figure as…
' +
+ '' +
+ '' +
+ '' +
+ '
';
+
+ function dismiss() {
+ overlay.remove();
+ picker.remove();
+ document.removeEventListener('keydown', onKey, true);
+ }
+
+ function onKey(e) {
+ if (e.key === 'Escape') dismiss();
+ }
+
+ overlay.addEventListener('click', dismiss);
+ document.addEventListener('keydown', onKey, true);
+
+ picker.querySelector('#pywv-save-svg').addEventListener('click', function (e) {
+ e.stopPropagation();
+ dismiss();
+ fetch('/save-figure', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({data_url: svgUrl, suggested_name: name, fmt: 'svg'})
+ });
+ });
+ picker.querySelector('#pywv-save-png').addEventListener('click', function (e) {
+ e.stopPropagation();
+ dismiss();
+ fetch('/save-figure', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({data_url: pngUrl, suggested_name: name, fmt: 'png'})
+ });
+ });
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(picker);
+ }
+
+ function handle(e) {
+ var t = e.target;
+ if (!t || !t.closest) return;
+ var btn = t.closest('.modebar-btn');
+ if (!btn || !isDownloadButton(btn)) return;
+ var gd = btn.closest('.js-plotly-plot') || btn.closest('.plot-container');
+ if (!gd || !window.Plotly) return;
+ if (!window.DEERANALYSIS_PYWEBVIEW) {
+ window.alert('Figure download unavailable: not running in pywebview.');
+ return;
+ }
+
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ var layout = gd._fullLayout || {};
+ var width = layout.width || 800;
+ var height = layout.height || 600;
+ var name = figureName(gd);
+
+ Promise.all([
+ window.Plotly.toImage(gd, {format: 'svg', width: width, height: height}),
+ window.Plotly.toImage(gd, {format: 'png', width: width, height: height, scale: 2})
+ ]).then(function (urls) {
+ showFormatPicker(urls[0], urls[1], name);
+ }).catch(function (err) {
+ console.error('[figure_download] toImage failed:', err);
+ window.alert('Could not render figure: ' + (err && err.message ? err.message : err));
+ });
+ }
+
+ function patchDownloadTooltips(root) {
+ (root || document).querySelectorAll('.modebar-btn').forEach(function (btn) {
+ if (isDownloadButton(btn)) {
+ btn.setAttribute('data-title', 'Save as SVG or PNG');
+ btn.setAttribute('data-pywv-download', '1');
+ }
+ });
+ }
+
+ new MutationObserver(function () {
+ patchDownloadTooltips();
+ }).observe(document.body, {childList: true, subtree: true});
+
+ document.addEventListener('click', handle, true);
+})();
diff --git a/src/deeranalysis/assets/scinotation.js b/src/deeranalysis/assets/scinotation.js
new file mode 100644
index 0000000..e45cc2f
--- /dev/null
+++ b/src/deeranalysis/assets/scinotation.js
@@ -0,0 +1,147 @@
+/**
+ * scinotation.js
+ * Handles ScientificNumberInput fields (tagged data-scinotation="true"):
+ * • Re-formats value to scientific notation on blur.
+ * • Step ▲/▼ buttons increment/decrement the value.
+ *
+ * Format rules (mirror Python format_sci()):
+ * |v| < 1e-3 or |v| >= 1e6 → "1.23 E-6"
+ * otherwise → plain decimal, trailing zeros stripped
+ */
+
+(function () {
+ var SCI_LOWER = 1e-3;
+ var SCI_UPPER = 1e6;
+
+ // ------------------------------------------------------------------ //
+ // Formatting / parsing
+ // ------------------------------------------------------------------ //
+
+ function formatSci(v) {
+ if (!isFinite(v)) return String(v);
+ if (v === 0) return '0';
+ var absV = Math.abs(v);
+ if (absV < SCI_LOWER || absV >= SCI_UPPER) {
+ var exp = Math.floor(Math.log10(absV));
+ var mantissa = Math.round((v / Math.pow(10, exp)) * 100) / 100;
+ var sign = exp >= 0 ? '+' : '';
+ return mantissa + ' E' + sign + exp;
+ }
+ return parseFloat(v.toPrecision(6)).toString();
+ }
+
+ function parseSci(text) {
+ if (!text || text.trim() === '') return NaN;
+ var s = text.replace(/\s/g, '').replace(/E/gi, 'e');
+ return parseFloat(s);
+ }
+
+ // ------------------------------------------------------------------ //
+ // React-aware value setter
+ // ------------------------------------------------------------------ //
+
+ var nativeSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype, 'value'
+ ).set;
+
+ function setInputValue(input, newValue) {
+ if (input.value === newValue) return;
+ nativeSetter.call(input, newValue);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+
+ // ------------------------------------------------------------------ //
+ // Blur: re-format
+ // ------------------------------------------------------------------ //
+
+ function reformatInput(input) {
+ var num = parseSci(input.value);
+ if (isNaN(num)) return;
+ setInputValue(input, formatSci(num));
+ }
+
+ function attachBlurListener(input) {
+ if (input._sciBlurBound) return;
+ input._sciBlurBound = true;
+ input.addEventListener('blur', function () { reformatInput(input); });
+ }
+
+ // ------------------------------------------------------------------ //
+ // Step buttons: ▲ / ▼
+ // ------------------------------------------------------------------ //
+
+ function computeStep(input, currentValue) {
+ var stepAttr = input.dataset.step;
+ if (stepAttr && stepAttr !== 'auto' && stepAttr !== 'None') {
+ var s = parseFloat(stepAttr);
+ if (!isNaN(s) && s > 0) return s;
+ }
+ // Auto: one order of magnitude below current value
+ if (!isFinite(currentValue) || currentValue === 0) return 1;
+ var exp = Math.floor(Math.log10(Math.abs(currentValue)));
+ return Math.pow(10, exp - 1);
+ }
+
+ document.addEventListener('mousedown', function (e) {
+ var btn = e.target.closest('.sci-num-btn');
+ if (!btn) return;
+
+ // Find the sibling input inside the same Mantine Input wrapper
+ var wrapper = btn.closest('[class*="Input-wrapper"], [class*="inputWrapper"]');
+ if (!wrapper) {
+ // Fallback: walk up until we find a parent that contains the input
+ var parent = btn.parentElement;
+ while (parent && !wrapper) {
+ if (parent.querySelector('input[data-scinotation]')) wrapper = parent;
+ parent = parent.parentElement;
+ }
+ }
+ if (!wrapper) return;
+
+ var input = wrapper.querySelector('input[data-scinotation="true"]');
+ if (!input || input.disabled) return;
+
+ e.preventDefault(); // prevent input from losing focus
+
+ var current = parseSci(input.value);
+ if (isNaN(current)) current = 0;
+
+ var step = computeStep(input, current);
+ var isUp = btn.classList.contains('sci-num-btn-up');
+ var newVal = isUp ? current + step : current - step;
+
+ // Clamp to min/max if specified
+ var minAttr = input.dataset.min;
+ var maxAttr = input.dataset.max;
+ if (minAttr && minAttr !== 'None') newVal = Math.max(parseFloat(minAttr), newVal);
+ if (maxAttr && maxAttr !== 'None') newVal = Math.min(parseFloat(maxAttr), newVal);
+
+ setInputValue(input, formatSci(newVal));
+ }, true);
+
+ // ------------------------------------------------------------------ //
+ // Attach blur listeners to all tagged inputs (existing + future)
+ // ------------------------------------------------------------------ //
+
+ function scanAndAttach() {
+ document.querySelectorAll('input[data-scinotation="true"]')
+ .forEach(attachBlurListener);
+ }
+
+ var observer = new MutationObserver(function (mutations) {
+ if (mutations.some(function (m) { return m.addedNodes.length > 0; })) {
+ scanAndAttach();
+ }
+ });
+
+ function init() {
+ scanAndAttach();
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/src/deeranalysis/components/dataset_form.py b/src/deeranalysis/components/dataset_form.py
index 7956854..dd88d0e 100644
--- a/src/deeranalysis/components/dataset_form.py
+++ b/src/deeranalysis/components/dataset_form.py
@@ -33,11 +33,13 @@ def update_tmin(tmin, dataset_store):
# ---------------------------------------------------------------------------
@callback(
Output({'type': 'delays-grid', 'page': MATCH}, 'rowData', allow_duplicate=True),
+ Output({'type': 'dataset-store', 'page': MATCH}, 'data', allow_duplicate=True),
Input({'type': 'experiment-type-dropdown', 'page': MATCH}, 'value'),
State({'type': 'delays-grid', 'page': MATCH}, 'rowData'),
+ State({'type': 'dataset-store', 'page': MATCH}, 'data'),
prevent_initial_call=True,
)
-def check_delays(exp_type, delays_row_data):
+def check_delays(exp_type, delays_row_data, dataset_store):
def _get_required(options, value):
opt = next((o for o in options if o.get('value') == value), None)
return opt.get('delays') if opt else []
@@ -48,8 +50,24 @@ def _get_required(options, value):
for delay in required:
if delay not in delays:
new_delays[delay] = 0
- return [{'parameter': k, 'value': v} for k, v in new_delays.items()]
+ delays_dict =[{'parameter': k, 'value': v} for k, v in new_delays.items()]
+ dataset_store['delays'] = new_delays
+ return delays_dict, dataset_store
+
+@callback(
+ Output({'type': 'dataset-store', 'page': MATCH}, 'data', allow_duplicate=True),
+ Input({'type': 'delays-grid', 'page': MATCH}, 'cellValueChanged'),
+ State({'type': 'delays-grid', 'page': MATCH}, 'rowData'),
+ State({'type': 'dataset-store', 'page': MATCH}, 'data'),
+ prevent_initial_call=True,
+)
+def update_delays(_, delays_row_data, dataset_store):
+ if dataset_store is None or delays_row_data is None:
+ return dash.no_update
+ delays = {row['parameter']: row['value'] for row in delays_row_data}
+ dataset_store['delays'] = delays
+ return dataset_store
# ---------------------------------------------------------------------------
# Store / experiment type change → check tmin plausibility
diff --git a/src/deeranalysis/components/fit_page_components.py b/src/deeranalysis/components/fit_page_components.py
index 963e380..07918cc 100644
--- a/src/deeranalysis/components/fit_page_components.py
+++ b/src/deeranalysis/components/fit_page_components.py
@@ -1,6 +1,6 @@
import dash_mantine_components as dmc
from dash_iconify import DashIconify
-from deeranalysis.utils.deerlab_options import regparam_options, plotly_deerlab, plotly_goodness_of_fit,plotly_lcurve
+from deeranalysis.utils.deerlab_options import regparam_options, plotly_deerlab, plotly_goodness_of_fit,plotly_lcurve,plotly_dipolar_spectrum
from deeranalysis.utils.database import get_session, Dataset
from deeranalysis.utils import dataarray_from_database_entry
@@ -149,7 +149,8 @@ def fit_results_tab(page_id):
dmc.CodeHighlight(
id={"type": "fit-results-code", "page": page_id},
code=DEFAULT_FIT_RESULTS_CODE,
- language="bash",
+ language="plaintext",
+ withCopyButton=True,
)
], style={'flex': '1', 'minHeight': 0, 'overflow': 'auto'})
return tabstab, panel
@@ -226,6 +227,17 @@ def L_curve_tab(page_id):
], style={'flex': '1', 'display': 'flex', 'flexDirection': 'column', 'minHeight': 0})
return tabstab, panel
+def dipolar_spectrum_tab(page_id):
+ tabstab = dmc.TabsTab("Dipolar Spectrum", value="dip-spectrum")
+ panel = dmc.TabsPanel(value="dip-spectrum", children=[
+ dcc.Graph(
+ id={"type": "dip-spectrum-plot", "page": page_id},
+ figure=plotly_dipolar_spectrum(None),
+ style={'height': '100%'},
+ config={'responsive': True},
+ )
+ ], style={'flex': '1', 'display': 'flex', 'flexDirection': 'column', 'minHeight': 0})
+ return tabstab, panel
def fit_results_tabs(*tabs):
"""
diff --git a/src/deeranalysis/components/fpc_global.py b/src/deeranalysis/components/fpc_global.py
index 7775886..5a8328a 100644
--- a/src/deeranalysis/components/fpc_global.py
+++ b/src/deeranalysis/components/fpc_global.py
@@ -4,7 +4,7 @@
import dash_mantine_components as dmc
from dash_iconify import DashIconify
-from deeranalysis.utils.deerlab_options import regparam_options, plotly_deerlab, plotly_goodness_of_fit,plotly_lcurve
+from deeranalysis.utils.deerlab_options import regparam_options, plotly_deerlab, plotly_goodness_of_fit,plotly_lcurve,plotly_dipolar_spectrum
from deeranalysis.utils.database import get_session, Dataset
from deeranalysis.utils import dataarray_from_database_entry
from deeranalysis.utils.deerlab_population import determine_pop_P
@@ -169,17 +169,89 @@ def dist_stats_tab_pagination(page_id):
Input({"type": "fit-plot-pagination", "page": MATCH}, "value"),
prevent_initial_call=True,
)
-def update_multi_dist_stats_table(fit_results_data:list,page_value):
+def update_multi_dist_stats_table(fit_results_data,page_value):
if fit_results_data is None:
return plotly_goodness_of_fit()
- if 'data' not in fit_results_data:
- return plotly_goodness_of_fit()
+ if 'dist_stats' not in fit_results_data:
+ return {"head": ["Statistic", "Value", "Confidence Interval (95%)"], "body": []}
+
+
+ dist_stats_dict = fit_results_data['dist_stats'][page_value-1]
+ dist_stats_output = {
+ "head": ["Statistic", "Value", "Confidence Interval (95%)"],
+ "body": [
+ [k, f"{v['value']:.3f}", f"[{v['ci'][0]:.3f}, {v['ci'][1]:.3f}]" if v['ci'] else "N/A"]
+ for k, v in dist_stats_dict.items()
+ ]
+ }
+
+ return dist_stats_output
- fit = dl.json_loads(fit_results_data['data'])
+def dipolar_spectrum_tab_pagination(page_id):
+ tabstab = dmc.TabsTab("Dipolar Spectrum", value="dip-spectrum")
+ panel = dmc.TabsPanel(value="dip-spectrum", children=[
+
+ dcc.Graph(
+ id={"type": "dip-spectrum-plot-multi", "page": page_id},
+ figure=plotly_dipolar_spectrum(),
+ style={'height': '100%'},
+ config={'responsive': True},
+ ),
+ ], style={'flex': '1', 'display': 'flex', 'flexDirection': 'column', 'minHeight': 0})
+ return tabstab, panel
+
+@callback(
+ Output({"type": "dip-spectrum-plot-multi", "page": MATCH}, "figure"),
+ Input({'type': 'fit-results-store-multi', 'page': MATCH}, 'data'),
+ Input({"type": "fit-plot-pagination", "page": MATCH}, "value"),
+ prevent_initial_call=True,
+)
+def update_multi_dipolar_spectrum_plot(fit_result_data,page_value):
+ if fit_result_data is None:
+ return plotly_dipolar_spectrum()
+
+ if 'data' not in fit_result_data:
+ return plotly_dipolar_spectrum()
+
+ fit = dl.json_loads(fit_result_data['data'])
+ fig = plotly_dipolar_spectrum(fit,index=page_value-1)
+ return fig
+
+
+def l_curve_pagination(page_id):
+
+ tabstab = dmc.TabsTab("L-Curve", value="l-curve")
+ panel = dmc.TabsPanel(value="l-curve", children=[
+
+ dcc.Graph(
+ id={"type": "l-curve-plot-multi", "page": page_id},
+ figure=plotly_lcurve(),
+ style={'height': '100%'},
+ config={'responsive': True},
+ ),
+ ], style={'flex': '1', 'display': 'flex', 'flexDirection': 'column', 'minHeight': 0})
+ return tabstab, panel
+
+@callback(
+ Output({"type": "l-curve-plot-multi", "page": MATCH}, "figure"),
+ Input({'type': 'fit-results-store-multi', 'page': MATCH}, 'data'),
+ Input({"type": "fit-plot-pagination", "page": MATCH}, "value"),
+ prevent_initial_call=True,
+)
+def update_multi_l_curve_plot(fit_result_data,page_value):
+ if fit_result_data is None:
+ return plotly_lcurve()
+
+ if 'data' not in fit_result_data:
+ return plotly_lcurve()
+
+ fit = dl.json_loads(fit_result_data['data'])
+ fig = plotly_lcurve(fit)
+ return fig
# --------------------------------------------------------------
# Overview pagation component for population fits
diff --git a/src/deeranalysis/components/model_edit_modal.py b/src/deeranalysis/components/model_edit_modal.py
index d1372aa..085485b 100644
--- a/src/deeranalysis/components/model_edit_modal.py
+++ b/src/deeranalysis/components/model_edit_modal.py
@@ -3,6 +3,9 @@
from dash import html, dcc, callback, Output, Input, State, no_update, MATCH, ALL, ctx
import deerlab as dl
from dash_iconify import DashIconify
+from deeranalysis.components.scientific_number_input import (
+ ScientificNumberInput, parse_sci, format_sci,
+)
exp_model_links = {
@@ -16,6 +19,11 @@
b_model_links = {
'bg_hom3d': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_hom3d.html',
'bg_exp': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_exp.html',
+ 'bg_hom3dex': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_hom3dex.html',
+ 'bg_hom3dex': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_hom3dex.html',
+ 'bg_hom3dex': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_hom3dex.html',
+ 'bg_hom3dex': 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.bg_hom3dex.html',
+
}
p_model_links = {
@@ -42,9 +50,10 @@ def make_btn(label, href):
return dmc.Anchor(btn, href=href, target="_blank", underline="never")
return btn
+ base_path = 'https://jeschkelab.github.io/DeerLab/_autosummary/deerlab.{model}.html'
return [
- make_btn("Experiment Model", exp_model_links.get(exp_type)),
- make_btn("Background Model", b_model_links.get(bg_model)),
+ make_btn("Experiment Model", base_path.format(model=exp_type) if (exp_type and exp_type != 'single') else None),
+ make_btn("Background Model", base_path.format(model=bg_model) if bg_model else None),
make_btn("Distance Distribution Model", p_model_links.get(p_model)),
]
@@ -60,13 +69,21 @@ def _make_bound_input(value, bound_id, step, suffix, precision):
'page': bound_id['page'],
}
return dmc.Group([
- dmc.NumberInput(
+ # dmc.NumberInput(
+ # value=None if is_inf else value,
+ # id=bound_id,
+ # size="xs", step=step, suffix=suffix, decimalScale=precision,
+ # disabled=is_inf,
+ # placeholder=inf_label if is_inf else None,
+ # style={"flex": 1, "minWidth": 70},
+ # ),
+ ScientificNumberInput(
value=None if is_inf else value,
id=bound_id,
- size="xs", step=step, suffix=suffix, decimalScale=precision,
+ step=step, suffix=suffix, min=None, max=None,
disabled=is_inf,
placeholder=inf_label if is_inf else None,
- style={"flex": 1, "minWidth": 70},
+ style={"flex": 1, "minWidth": 70},
),
dmc.Tooltip(
dmc.ActionIcon(
@@ -99,10 +116,16 @@ def create_table_row_from_dict(name, param_data, page_id):
return dmc.TableTr([
dmc.TableTd(name),
- dmc.TableTd(dmc.NumberInput(
+ # dmc.TableTd(dmc.NumberInput(
+ # value=param_data.get('par0'),
+ # id={'type': 'param-par0', 'name': name, 'page': page_id},
+ # size="xs", step=step, suffix=suffix, decimalScale=precision,
+ # )),
+ dmc.TableTd(ScientificNumberInput(
value=param_data.get('par0'),
id={'type': 'param-par0', 'name': name, 'page': page_id},
- size="xs", step=step, suffix=suffix, decimalScale=precision,
+ step=step, suffix=suffix, min=None, max=None,
+ size="xs",
)),
dmc.TableTd(_make_bound_input(
param_data.get('lb'),
@@ -226,9 +249,9 @@ def save_model_params(n_clicks, par0_vals, lb_vals, ub_vals, frozen_vals):
errors = []
for i in range(len(param_names)):
- par0 = par0_vals[i] if i < len(par0_vals) else None
- lb = lb_vals[i] if i < len(lb_vals) else None
- ub = ub_vals[i] if i < len(ub_vals) else None
+ par0 = parse_sci(par0_vals[i]) if i < len(par0_vals) else None
+ lb = parse_sci(lb_vals[i]) if i < len(lb_vals) else None
+ ub = parse_sci(ub_vals[i]) if i < len(ub_vals) else None
if par0 is not None and ((lb is not None and par0 < lb) or (ub is not None and par0 > ub)):
errors.append("Must be between lb and ub")
else:
@@ -240,9 +263,9 @@ def save_model_params(n_clicks, par0_vals, lb_vals, ub_vals, frozen_vals):
overrides = {}
for i, name in enumerate(param_names):
overrides[name] = {
- 'par0': par0_vals[i] if i < len(par0_vals) else None,
- 'lb': lb_vals[i] if i < len(lb_vals) else None,
- 'ub': ub_vals[i] if i < len(ub_vals) else None,
+ 'par0': parse_sci(par0_vals[i]) if i < len(par0_vals) else None,
+ 'lb': parse_sci(lb_vals[i]) if i < len(lb_vals) else None,
+ 'ub': parse_sci(ub_vals[i]) if i < len(ub_vals) else None,
'frozen': frozen_vals[i] if i < len(frozen_vals) else False,
}
diff --git a/src/deeranalysis/components/scientific_number_input.py b/src/deeranalysis/components/scientific_number_input.py
new file mode 100644
index 0000000..69d23e3
--- /dev/null
+++ b/src/deeranalysis/components/scientific_number_input.py
@@ -0,0 +1,182 @@
+"""ScientificNumberInput – a dmc.TextInput that displays extreme numbers in sci notation.
+
+Renders values whose absolute magnitude is < 1e-3 or >= 1e6 as "1.23 E-6"
+(mantissa space E sign exponent). All other values are shown as plain decimals.
+
+Re-formatting and step-button behaviour are handled client-side by
+assets/scinotation.js.
+
+Usage
+-----
+ from deeranalysis.components.scientific_number_input import (
+ ScientificNumberInput, parse_sci, format_sci,
+ )
+
+ # In a layout:
+ ScientificNumberInput(value=5.3e-7, id="my-input", suffix=" µM", step=1e-8)
+
+ # In a callback reading the value back (value is a string):
+ @callback(Output(...), Input("my-input", "value"))
+ def cb(raw):
+ number = parse_sci(raw) # -> float or None
+ ...
+"""
+import math
+
+import dash_mantine_components as dmc
+from dash import html
+from dash_iconify import DashIconify
+
+_SCI_LOWER = 1e-3
+_SCI_UPPER = 1e6
+
+# ---------------------------------------------------------------------------
+# Public helpers
+# ---------------------------------------------------------------------------
+
+def format_sci(value) -> str | None:
+ """Format *value* as a string, switching to sci notation at extreme magnitudes."""
+ if value is None:
+ return None
+ try:
+ v = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ if not math.isfinite(v):
+ return str(v)
+ if v == 0:
+ return "0"
+ abs_v = abs(v)
+ if abs_v < _SCI_LOWER or abs_v >= _SCI_UPPER:
+ exp = int(math.floor(math.log10(abs_v)))
+ mantissa = round(v / 10 ** exp, 2)
+ sign = "+" if exp >= 0 else ""
+ return f"{mantissa:g} E{sign}{exp}"
+ return f"{v:g}"
+
+
+def parse_sci(text) -> float | None:
+ """Parse a number string; accepts plain floats and '1.23 E-6' / '1.23e-6' forms."""
+ if text is None or str(text).strip() == "":
+ return None
+ normalised = str(text).replace(" ", "").upper().replace("E+", "e").replace("E-", "e-").replace("E", "e")
+ try:
+ return float(normalised)
+ except ValueError:
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _step_section(suffix: str | None):
+ """Build the stacked ▲/▼ spinner controls (+ optional suffix text)."""
+ btn_base = {
+ "cursor": "pointer",
+ "display": "flex",
+ "alignItems": "center",
+ "justifyContent": "center",
+ "flex": "1",
+ "paddingInline": "3px",
+ "userSelect": "none",
+ "color": "var(--mantine-color-dimmed)",
+ "lineHeight": "1",
+ }
+ # Spinner: fixed 20 px wide, never shrinks, always on the right edge.
+ spinner = html.Div(
+ [
+ html.Div(
+ DashIconify(icon="mdi:chevron-up", width=11),
+ className="sci-num-btn sci-num-btn-up",
+ style=btn_base,
+ ),
+ html.Div(
+ DashIconify(icon="mdi:chevron-down", width=11),
+ className="sci-num-btn sci-num-btn-down",
+ style=btn_base,
+ ),
+ ],
+ style={
+ "display": "flex",
+ "flexDirection": "column",
+ "height": "100%",
+ "width": "20px",
+ "flexShrink": "0",
+ "borderLeft": "1px solid var(--mantine-color-default-border)",
+ },
+ )
+ if not suffix:
+ return spinner
+ return html.Div(
+ [
+ html.Span(
+ suffix,
+ style={
+ "flex": "1",
+ "minWidth": "0",
+ "overflow": "hidden",
+ "textOverflow": "ellipsis",
+ "whiteSpace": "nowrap",
+ "fontSize": "var(--input-fz, var(--mantine-font-size-sm))",
+ "color": "var(--mantine-color-dimmed)",
+ },
+ ),
+ spinner,
+ ],
+ style={
+ "display": "flex",
+ "alignItems": "stretch",
+ "height": "100%",
+ "width": "100%",
+ "gap": "6px",
+ },
+ )
+
+
+# ---------------------------------------------------------------------------
+# Component factory
+# ---------------------------------------------------------------------------
+
+def ScientificNumberInput(value=None, id=None, step="auto", suffix=None,
+ min=None, max=None, **kwargs):
+ """A dmc.TextInput pre-formatted for scientific notation with step arrows.
+
+ Parameters
+ ----------
+ value : float | int | None
+ Numeric value. Displayed as '1.23 E-6' when |value| < 1e-3 or >= 1e6.
+ id : any
+ Dash component id, forwarded unchanged to the TextInput.
+ step : float | "auto"
+ Arrow increment. ``"auto"`` (default) increments by one order of magnitude
+ below the current value (e.g. value=5e-7 → step=1e-7).
+ suffix : str | None
+ Optional unit label shown to the left of the arrows (e.g. ``" µM"``).
+ min, max : float | None
+ Optional bounds enforced by the step buttons.
+ **kwargs
+ All other dmc.TextInput props (size, label, disabled, …).
+ """
+ existing_input_props = kwargs.pop("inputProps", {}) or {}
+ tagged_input_props = {
+ **existing_input_props,
+ "data-scinotation": "true",
+ "data-step": str(step),
+ **({"data-min": str(min)} if min is not None else {}),
+ **({"data-max": str(max)} if max is not None else {}),
+ }
+
+ # Let the browser measure the content; arrows are pinned at a fixed 20 px
+ # so they're always visible regardless of suffix length.
+ right_section_width = "fit-content" if suffix else 22
+
+ return dmc.TextInput(
+ value=format_sci(value),
+ id=id,
+ inputProps=tagged_input_props,
+ rightSection=_step_section(suffix),
+ rightSectionWidth=right_section_width,
+ rightSectionPointerEvents="all",
+ **kwargs,
+ )
diff --git a/src/deeranalysis/components/setup_modal_desktop.py b/src/deeranalysis/components/setup_modal_desktop.py
index 4821cb4..23ef896 100644
--- a/src/deeranalysis/components/setup_modal_desktop.py
+++ b/src/deeranalysis/components/setup_modal_desktop.py
@@ -65,7 +65,7 @@ def download_deernet_models():
print(f"Could not get DeerAnalysis version: {e}")
_version = "latest"
- URL_version = rf"https://github.com/JeschkeLab/DeerAnalysis/releases/download/V{_version}/deernet_models.zip"
+ URL_version = rf"https://github.com/JeschkeLab/DeerAnalysis/releases/download/v{_version}/deernet_models.zip"
URL_latest = r"https://github.com/JeschkeLab/DeerAnalysis/releases/latest/download/deernet_models.zip"
# download the file and save it to the deernet directory
diff --git a/src/deeranalysis/main.py b/src/deeranalysis/main.py
index 2c37ed5..c543dc5 100644
--- a/src/deeranalysis/main.py
+++ b/src/deeranalysis/main.py
@@ -3,7 +3,10 @@
import webview
import os
import sys
+import base64
from pathlib import Path
+from urllib.parse import unquote
+from flask import request as _flask_request, jsonify as _jsonify
#os.environ['PYWEBVIEW_GUI'] = 'cocoa' # force macOS backend
os.environ['DEERANALYSIS_PYWEBVIEW'] = '1'
@@ -62,6 +65,55 @@ def _splash_html():
SPLASH_HTML = _splash_html()
+class FigureSaveApi:
+ """Bridges Plotly's modebar download button to a native save dialog."""
+
+ def __init__(self):
+ self.window = None
+
+ def save_figure(self, data_url, suggested_name='figure', fmt='svg'):
+ if self.window is None:
+ return None
+ safe = ''.join(c for c in (suggested_name or 'figure')
+ if c.isalnum() or c in ('-', '_', ' ', '.')).strip() or 'figure'
+ fmt = fmt if fmt in ('svg', 'png') else 'svg'
+ file_types = (f'{fmt.upper()} file (*.{fmt})',)
+ result = self.window.create_file_dialog(
+ webview.FileDialog.SAVE,
+ save_filename=f'{safe}.{fmt}',
+ file_types=file_types,
+ )
+ if not result:
+ return None
+ path = result[0] if isinstance(result, (list, tuple)) else result
+ if not os.path.splitext(path)[1]:
+ path += f'.{fmt}'
+ if not data_url or ',' not in data_url:
+ return None
+ header, encoded = data_url.split(',', 1)
+ if ';base64' in header:
+ payload = base64.b64decode(encoded)
+ else:
+ payload = unquote(encoded).encode('utf-8')
+ with open(path, 'wb') as f:
+ f.write(payload)
+ return path
+
+
+figure_api = FigureSaveApi()
+
+
+@app.server.route('/save-figure', methods=['POST'])
+def _save_figure_route():
+ data = _flask_request.get_json(force=True, silent=True) or {}
+ result = figure_api.save_figure(
+ data.get('data_url', ''),
+ data.get('suggested_name', 'figure'),
+ data.get('fmt', 'svg'),
+ )
+ return _jsonify({'path': result})
+
+
def run_dash():
app.run(port=PORT, debug=False, use_reloader=False)
@@ -93,8 +145,14 @@ def wait_for_dash(window):
fullscreen=False,
min_size=(800, 600),
)
+ figure_api.window = window
# Start a thread that waits for Dash and then swaps the content
threading.Thread(target=wait_for_dash, args=(window,), daemon=True).start()
+ # After create_window, inject the pywebview flag on every page load:
+ def _on_loaded():
+ window.evaluate_js('window.DEERANALYSIS_PYWEBVIEW = true;')
+ window.events.loaded += _on_loaded
+
webview.start()
\ No newline at end of file
diff --git a/src/deeranalysis/pages/background.py b/src/deeranalysis/pages/background.py
index 6a6a463..fa4c3bd 100644
--- a/src/deeranalysis/pages/background.py
+++ b/src/deeranalysis/pages/background.py
@@ -113,10 +113,11 @@ def open_model_edit_modal(n_clicks, dataset_id, bg_model_name, existing_override
@callback(
Output({'type': 'fit-options', 'page': page_id}, 'data'),
- Input({'type': 'bg_model', 'page': page_id}, 'value'),
+ Input({'type': 'bg-model', 'page': page_id}, 'value'),
prevent_initial_call=True
)
def update_fit_options(bg_model_option):
+ print(f"Selected background model: {bg_model_option}")
return {
'bg_model': bg_model_option,
}
@@ -157,7 +158,6 @@ def run_fit(n_clicks, dataset_id, fit_options, model_params):
bg_model_option = fit_options.get('bg_model', 'bg_hom3d') if fit_options else 'bg_hom3d'
Bmodel = getattr(dl, bg_model_option, dl.bg_hom3d)
-
try:
fit = deerlab_background_only(dataset, bg_model=Bmodel, mask=mask, model_overrides=model_params)
except Exception as e:
diff --git a/src/deeranalysis/pages/configuration.py b/src/deeranalysis/pages/configuration.py
index d403d58..af10464 100644
--- a/src/deeranalysis/pages/configuration.py
+++ b/src/deeranalysis/pages/configuration.py
@@ -7,8 +7,9 @@
import sys
from deeranalysis.utils.logs_plugin import get_logs_api_db, set_logs_api_key
from deeranalysis.utils.database import get_appearance_settings, save_appearance_settings
-
+from deerlab import show_config
dash.register_page(__name__, path='/config')
+page_id= 'config'
try:
@@ -406,6 +407,7 @@
prevent_initial_call=True,
)
+
# Callbacks for saving and resetting configuration
@callback(
Output("config-notification", "action"),
@@ -567,4 +569,5 @@ def close_db_reset_modal(n_clicks_cancel, n_clicks_confirm):
elif button_id == "db-reset-cancel-btn" and n_clicks_cancel:
return False # Close the modal if cancel is clicked
- return dash.no_update # Do not change modal state for other cases
\ No newline at end of file
+ return dash.no_update # Do not change modal state for other cases
+
diff --git a/src/deeranalysis/pages/dataset_detail.py b/src/deeranalysis/pages/dataset_detail.py
index be65ae5..89e5a7f 100644
--- a/src/deeranalysis/pages/dataset_detail.py
+++ b/src/deeranalysis/pages/dataset_detail.py
@@ -16,6 +16,7 @@
from deeranalysis.utils import create_subplot_figure
from deeranalysis.components.metadata_table import build_metadata_section,build_delays_table, metadata_long_values_model,build_delays_AGgrid,delays_columnDefs
from deeranalysis.components.download_modal import create_fit_download_modal, create_dataset_download_modal
+from deeranalysis.utils.deerlab_options import experiment_type_options
from deeranalysis.components.data_viewer import plot_upload,data_viewer_layout
dash.register_page(__name__, path_template="/dataset/")
@@ -128,7 +129,7 @@ def layout(dataset_id=None):
href="/",
underline=False,
),
- dmc.Title(dataset.name, order=1),
+ dmc.Title(dataset.name, order=2),
dmc.Space(style={"flex": 1}),
dmc.Button(
"Download",
@@ -177,7 +178,7 @@ def layout(dataset_id=None):
id="dd-exp",
label="Experiment",
value=dataset.exp or "Unknown",
- data=["4pDEER", "5pDEER", "3pDEER", "RIDME", "Unknown"],
+ data=experiment_type_options,
allowDeselect=False,
disabled=True,
),
diff --git a/src/deeranalysis/pages/deernet.py b/src/deeranalysis/pages/deernet.py
index ba9b367..6df8342 100644
--- a/src/deeranalysis/pages/deernet.py
+++ b/src/deeranalysis/pages/deernet.py
@@ -12,7 +12,7 @@
from deeranalysis.components.setup_modal_desktop import get_DeerAnalysis_directory
from deeranalysis.components.download_modal import create_fit_download_modal
-from deeranalysis.utils.deerlab_options import plotly_goodness_of_fit, plotly_deerlab, dists_stats_to_list, fit_to_dict,name_dataset_from_dict
+from deeranalysis.utils.deerlab_options import plotly_goodness_of_fit, plotly_deerlab, dists_stats_to_list, fit_to_dict,name_dataset_from_dict, plotly_dipolar_spectrum
from deeranalysis.utils.database import get_session, Dataset, Fit
from deeranalysis.utils import create_subplot_figure, dataarray_from_database_entry
from deeranalysis.utils.deernet import deernet,deernet2
@@ -80,6 +80,7 @@
fpc.overview_tab(page_id),
fpc.goodness_of_fit_tab(page_id),
fpc.dist_stats_tab(page_id),
+ fpc.dipolar_spectrum_tab(page_id),
),
], style={'display': 'flex', 'flexDirection': 'column', 'height': 'calc(100vh - 160px)', 'gap': '12px'})
], width=9), # dbc.col
@@ -198,6 +199,7 @@ def save_fit(n_clicks, dataset_id,dataset_store):
@callback(
Output({"type": "gof-plot", "page": page_id}, 'figure', allow_duplicate=True),
Output({"type": "dist-stats-table", "page": page_id}, 'data', allow_duplicate=True),
+ Output({"type": "dip-spectrum-plot", "page": page_id}, 'figure', allow_duplicate=True),
Input({'type': 'fit-results-store', 'page': page_id}, 'data'),
prevent_initial_call=True
)
@@ -207,6 +209,7 @@ def update_plots_tables(fit_dict):
return dash.no_update
fit = dl.json_loads(fit_dict['data'])
gof_fig = plotly_goodness_of_fit(fit)
+ dip_spectrum_fig = plotly_dipolar_spectrum(fit)
dist_stats_dict = fit_dict['dist_stats']
dist_stats_output = {
@@ -216,4 +219,4 @@ def update_plots_tables(fit_dict):
for k, v in dist_stats_dict.items()
]
}
- return gof_fig, dist_stats_output
+ return gof_fig, dist_stats_output,dip_spectrum_fig
diff --git a/src/deeranalysis/pages/fit_detail.py b/src/deeranalysis/pages/fit_detail.py
index 01a364c..e37b021 100644
--- a/src/deeranalysis/pages/fit_detail.py
+++ b/src/deeranalysis/pages/fit_detail.py
@@ -16,14 +16,19 @@
from deeranalysis.utils.database import get_session, Dataset,check_delays, Fit,fit_global_datasets
from deeranalysis.utils import create_subplot_figure,plotly_deerlab
+from deeranalysis.utils.deerlab_options import plotly_goodness_of_fit, plotly_lcurve, plotly_dipolar_spectrum
from deeranalysis.components.metadata_table import build_metadata_section,build_delays_table, metadata_long_values_model,build_delays_AGgrid,delays_columnDefs
from deeranalysis.components.download_modal import create_fit_download_modal
+import deerlab as dl
from deerlab.classes import UQResult
dash.register_page(__name__, path_template="/fit/")
page_id = "fit-detail"
-
+# Title-sized styling for the fit-name input lives in assets/dmc.css under #fd-fit-name —
+# scoping by id beats Mantine's .mantine-Input-input rule on specificity without !important.
+_TITLE_READONLY_STYLES = {"input": {"cursor": "default", "opacity": 1}}
+_TITLE_EDIT_STYLES = {}
def layout(fit_id=None):
@@ -52,33 +57,34 @@ def layout(fit_id=None):
create_fit_download_modal(page_id=page_id),
# ---- header ---------------------------------------------------------
+ # dmc.Breadcrumbs(
+ # children=[
+ # dmc.Anchor("Datasets", href="/", underline=False),
+ # dmc.Anchor(f"Dataset {ds_id}", href=f"/dataset/{ds_id}", underline=False),
+ # dmc.Anchor(f"Fit-{fit_id}", href=f"/fit/{fit_id}", underline=False),
+ # ],
+ # separator="->",
+ # mb="xs",
+ # ),
dmc.Group([
- dmc.Breadcrumbs(
- children=[
- dmc.Anchor("Datasets", href="/", underline=False),
- dmc.Anchor(f"Dataset {ds_id}", href=f"/dataset/{ds_id}", underline=False),
- dmc.Anchor(f"Fit - {fit_id}", href=f"/fit/{fit_id}", underline=False),
-
- ],
- separator="->",
+ dmc.Anchor(
+ dmc.ActionIcon(
+ DashIconify(icon="mdi:arrow-left", width=20),
+ variant="subtle",
+ size="lg",
+ # title="Back to Datasets",
),
+ href=f"/dataset/{ds_id}",
+ underline=False,
+ ),
dmc.TextInput(
id="fd-fit-name",
value=fit.name or "",
readOnly=True,
variant="unstyled",
- styles={
- "input": {
- "fontSize": "2.5rem", # Matches dmc.Title order=1
- "fontWeight": 700,
- "color": "#212529",
- "opacity": 1,
- "cursor": "default",
- "lineHeight": 1.2,
- "letterSpacing": "-0.02em",
- "marginBottom": "0.5rem"
- }
- },
+ size="lg",
+ style={"minWidth": "300px", "width": f"calc({len(fit.name or '') + 3}ch)"},
+ styles=_TITLE_READONLY_STYLES,
),
dmc.ActionIcon(
DashIconify(icon="mdi:pencil", width=18),
@@ -99,7 +105,7 @@ def layout(fit_id=None):
color="red",
id="fd-delete-btn",
),
- ], mb="md"),
+ ], align="center", mb="md"),
dmc.Divider(mb="md"),
# ---- notification area ----------------------------------------------
@@ -112,11 +118,14 @@ def layout(fit_id=None):
_fit_description(fit),
_fit_gof_stats(fit),
_fit_dist_stats(fit),
- ], width=6),
+ _global_datasets(fit),
+ ], width=5),
dbc.Col([
_fit_plot(fit,dataset),
- _global_datasets(fit)
- ], width=6)
+ _fit_gof_plot(fit),
+ _fit_pake_plot(fit),
+ _fit_lcurve_plot(fit),
+ ], width=7)
])
], style={"padding": "20px"})
@@ -276,9 +285,10 @@ def _fit_description(fit):
),
], justify="space-between", mb="sm"),
dmc.Collapse(
- dmc.CodeHighlight(fit.model_description or "No description provided.", language="bash"),
+ dmc.CodeHighlight(fit.model_description or "No description provided.", language="plaintext",withCopyButton=True,),
id="fd-description-collapse",
opened=True,
+
),
], p="md", mb="md", withBorder=True, radius="md")
@@ -437,39 +447,27 @@ def _dataset_card(dataset_id, primary=True):
prevent_initial_call=True,
)
def toggle_name_edit(n_clicks, is_readonly, fit_name, fit_id):
- title_styles = {
- "input": {
- "fontSize": "2.5rem",
- "fontWeight": 700,
- "color": "#212529",
- "opacity": 1,
- "cursor": "default",
- "lineHeight": 1.2,
- "letterSpacing": "-0.02em",
- # "marginBottom": "0.5rem",
- }
- }
- edit_styles = {
- "input": {
- "fontSize": "2.5rem",
- "fontWeight": 700,
- "color": "#212529",
- "lineHeight": 1.2,
- "letterSpacing": "-0.02em",
- # "marginBottom": "0.5rem",
- }
- }
if is_readonly:
- return False, "default", edit_styles, DashIconify(icon="mdi:check", width=18)
+ return False, "default", _TITLE_EDIT_STYLES, DashIconify(icon="mdi:check", width=18)
else:
- # Edit the fit name in the database
session = get_session()
fit = session.query(Fit).filter_by(id=fit_id).first()
fit.name = fit_name
session.commit()
session.close()
+ return True, "unstyled", _TITLE_READONLY_STYLES, DashIconify(icon="mdi:pencil", width=18)
+
- return True, "unstyled", title_styles, DashIconify(icon="mdi:pencil", width=18)
+clientside_callback(
+ """
+ function(value) {
+ var len = value ? value.length : 5;
+ return {minWidth: '200px', width: 'calc((' + (len + 3) + ') * 1ch)'};
+ }
+ """,
+ Output("fd-fit-name", "style"),
+ Input("fd-fit-name", "value"),
+)
@callback(
Output("fd-description-collapse", "opened"),
@@ -583,6 +581,115 @@ def _fits_and_dataset_to_dict(dataset, fit=None):
return output
+def _load_fitresult(fit):
+ """Reconstruct a DeerLab FitResult from the stored JSON blob, or return None."""
+ if not fit.data:
+ return None
+ try:
+ return dl.json_loads(fit.data)
+ except Exception:
+ return None
+
+
+def _fit_gof_plot(fit):
+ fitresult = _load_fitresult(fit)
+ fig = plotly_goodness_of_fit(fitresult, legend_pos='bottom')
+ content = dcc.Graph(figure=fig, config={"displayModeBar": False})
+ return dmc.Paper([
+ dmc.Group([
+ dmc.Title("Goodness-of-Fit Plot", order=4, mb="sm"),
+ dmc.Button(
+ DashIconify(icon="tabler:chevron-down"),
+ id="fd-gof-plot-toggle",
+ variant="subtle",
+ color="gray",
+ size="sm",
+ p=0,
+ ),
+ ], justify="space-between", mb="sm"),
+ dmc.Collapse(content, id="fd-gof-plot-collapse", opened=True),
+ ], p="md", mb="md", withBorder=True, radius="md")
+
+
+def _fit_pake_plot(fit):
+ if not fit.background:
+ return html.Div()
+ fitresult = _load_fitresult(fit)
+ if fitresult is None:
+ return html.Div()
+ try:
+ fig = plotly_dipolar_spectrum(fitresult)
+ except Exception:
+ return html.Div()
+ content = dcc.Graph(figure=fig, config={"displayModeBar": False})
+ return dmc.Paper([
+ dmc.Group([
+ dmc.Title("Pake Pattern (Dipolar Spectrum)", order=4, mb="sm"),
+ dmc.Button(
+ DashIconify(icon="tabler:chevron-down"),
+ id="fd-pake-toggle",
+ variant="subtle",
+ color="gray",
+ size="sm",
+ p=0,
+ ),
+ ], justify="space-between", mb="sm"),
+ dmc.Collapse(content, id="fd-pake-collapse", opened=True),
+ ], p="md", mb="md", withBorder=True, radius="md")
+
+
+def _fit_lcurve_plot(fit):
+ fitresult = _load_fitresult(fit)
+ if fitresult is None or not hasattr(fitresult, 'regparam_stats') or fitresult.regparam_stats is None:
+ return html.Div()
+ fig = plotly_lcurve(fitresult)
+ content = dcc.Graph(figure=fig, config={"displayModeBar": False})
+ return dmc.Paper([
+ dmc.Group([
+ dmc.Title("L-Curve", order=4, mb="sm"),
+ dmc.Button(
+ DashIconify(icon="tabler:chevron-down"),
+ id="fd-lcurve-toggle",
+ variant="subtle",
+ color="gray",
+ size="sm",
+ p=0,
+ ),
+ ], justify="space-between", mb="sm"),
+ dmc.Collapse(content, id="fd-lcurve-collapse", opened=True),
+ ], p="md", mb="md", withBorder=True, radius="md")
+
+
+@callback(
+ Output("fd-gof-plot-collapse", "opened"),
+ Input("fd-gof-plot-toggle", "n_clicks"),
+ State("fd-gof-plot-collapse", "opened"),
+ prevent_initial_call=True,
+)
+def toggle_gof_plot_collapse(n_clicks, opened):
+ return not opened
+
+
+@callback(
+ Output("fd-pake-collapse", "opened"),
+ Input("fd-pake-toggle", "n_clicks"),
+ State("fd-pake-collapse", "opened"),
+ prevent_initial_call=True,
+)
+def toggle_pake_collapse(n_clicks, opened):
+ return not opened
+
+
+@callback(
+ Output("fd-lcurve-collapse", "opened"),
+ Input("fd-lcurve-toggle", "n_clicks"),
+ State("fd-lcurve-collapse", "opened"),
+ prevent_initial_call=True,
+)
+def toggle_lcurve_collapse(n_clicks, opened):
+ return not opened
+
+
# ---- Download and Deletion Callbacks -------------------------------------------------------
@callback(
diff --git a/src/deeranalysis/pages/global.py b/src/deeranalysis/pages/global.py
index 8e70684..be1a7e3 100644
--- a/src/deeranalysis/pages/global.py
+++ b/src/deeranalysis/pages/global.py
@@ -112,7 +112,9 @@
fpcg.overview_tab_global(page_id),
fpc.fit_results_tab(page_id),
fpcg.goodness_of_fit_tab_pagination(page_id),
- fpc.dist_stats_tab(page_id),
+ fpcg.dist_stats_tab_pagination(page_id),
+ fpcg.l_curve_pagination(page_id),
+ fpcg.dipolar_spectrum_tab_pagination(page_id),
),
], style={'display': 'flex', 'flexDirection': 'column', 'height': 'calc(100vh - 160px)', 'gap': '12px'})
], width=9) # dbc.col
@@ -233,7 +235,7 @@ def update_fit_options(bg_model_option,compactness,distance_axis,pathways_option
else:
output['regparam'] = regparam_method
- searchrange = [1e-8,1e2]
+ searchrange = [1e-8,1e3]
if search_method == 'grid':
output['regparamrange'] = 10**np.linspace(np.log10(searchrange[0]),np.log10(searchrange[1]),grid_size)
elif search_method == 'brent':
diff --git a/src/deeranalysis/pages/nonparametric.py b/src/deeranalysis/pages/nonparametric.py
index 5cce09c..119d495 100644
--- a/src/deeranalysis/pages/nonparametric.py
+++ b/src/deeranalysis/pages/nonparametric.py
@@ -12,9 +12,8 @@
from deeranalysis.utils import dataarray_from_database_entry
from deeranalysis.components.dataset_search_model import create_dataset_modal
from deeranalysis.components.download_modal import create_fit_download_modal
-from deeranalysis.components.fit_page_components import fit_results_tabs, fit_results_tab, goodness_of_fit_tab, dist_stats_tab, L_curve_tab
from deeranalysis.components.model_edit_modal import create_model_edit_modal
-from deeranalysis.utils.deerlab_options import regparam_options,background_models, plotly_goodness_of_fit, dists_stats_to_list, fit_to_dict,name_dataset_from_dict, build_model_data, plotly_lcurve
+from deeranalysis.utils.deerlab_options import background_models, plotly_goodness_of_fit, dists_stats_to_list, fit_to_dict,name_dataset_from_dict, build_model_data, plotly_lcurve, plotly_dipolar_spectrum
import deeranalysis.components.fit_page_components as fpc
@@ -69,12 +68,13 @@
dbc.Col([
html.Div([
fpc.fit_plot(page_id),
- fit_results_tabs(
+ fpc.fit_results_tabs(
fpc.overview_tab(page_id),
- fit_results_tab(page_id),
- goodness_of_fit_tab(page_id),
- dist_stats_tab(page_id),
- L_curve_tab(page_id),
+ fpc.fit_results_tab(page_id),
+ fpc.goodness_of_fit_tab(page_id),
+ fpc.dist_stats_tab(page_id),
+ fpc.L_curve_tab(page_id),
+ fpc.dipolar_spectrum_tab(page_id)
)
], style={'display': 'flex', 'flexDirection': 'column', 'height': 'calc(100vh - 160px)', 'gap': '12px'})
@@ -114,7 +114,7 @@ def update_adv_options(regparam_method,search_method, grid_size, fixed_alpha):
else:
output['regparam'] = regparam_method
- searchrange = [1e-8,1e2]
+ searchrange = [1e-8,1e3]
if search_method == 'grid':
output['regparamrange'] = 10**np.linspace(np.log10(searchrange[0]),np.log10(searchrange[1]),grid_size)
elif search_method == 'brent':
@@ -144,7 +144,8 @@ def open_model_edit_modal(n_clicks, dataset_id, bg_model_name, pathways, distanc
session.close()
pathways_int = [int(p) for p in pathways] if pathways else [1]
- model_data = build_model_data(dataset, bg_model_name, pathways_int, distance_axis, existing_overrides)
+ model_data = build_model_data(dataset, bg_model_name, pathways_int,
+ distance_axis, existing_overrides=existing_overrides)
return True, model_data
@@ -224,6 +225,8 @@ def run_fit(n_clicks, dataset_id, bg_model_option, compactness, distance_axis, p
mask=mask,
**adv_options)
except Exception as e:
+ import traceback
+ print(traceback.format_exc())
print(f"Error during fitting: {e}")
return dash.no_update, f"Error during fitting: {e}", True, True, False
@@ -293,6 +296,7 @@ def save_fit(n_clicks, dataset_id,dataset_store):
Output({"type": "gof-plot", "page": page_id}, 'figure', allow_duplicate=True),
Output({"type": "l-curve-plot", "page": page_id}, 'figure', allow_duplicate=True),
Output({"type": "dist-stats-table", "page": page_id}, 'data', allow_duplicate=True),
+ Output({"type": "dip-spectrum-plot", "page": page_id}, 'figure', allow_duplicate=True),
Input({'type': 'fit-results-store', 'page': page_id}, 'data'),
prevent_initial_call=True
)
@@ -306,6 +310,7 @@ def update_plots_tables(fit_dict):
l_curve_fig = plotly_lcurve(fit)
else:
l_curve_fig = plotly_lcurve(None)
+ dip_spectrum_fig = plotly_dipolar_spectrum(fit)
dist_stats_dict = fit_dict['dist_stats']
dist_stats_output = {
@@ -315,7 +320,7 @@ def update_plots_tables(fit_dict):
for k, v in dist_stats_dict.items()
]
}
- return gof_fig, l_curve_fig, dist_stats_output
+ return gof_fig, l_curve_fig, dist_stats_output, dip_spectrum_fig
diff --git a/src/deeranalysis/pages/parametric.py b/src/deeranalysis/pages/parametric.py
index b0dc6af..3e81c6d 100644
--- a/src/deeranalysis/pages/parametric.py
+++ b/src/deeranalysis/pages/parametric.py
@@ -17,7 +17,7 @@
from deeranalysis.utils.deerlab_options import (
regparam_options, background_models, parametric_models,
plotly_goodness_of_fit, plotly_deerlab, fit_to_dict, dists_stats_to_list,
- name_dataset_from_dict, build_model_data,
+ name_dataset_from_dict, build_model_data,plotly_dipolar_spectrum
)
from deeranalysis.components.fit_page_components import (
fit_save_download_buttons, distance_slider, adv_fit_options_parametric,
@@ -106,6 +106,7 @@
fpc.fit_results_tab(page_id),
fpc.goodness_of_fit_tab(page_id),
fpc.dist_stats_tab(page_id),
+ fpc.dipolar_spectrum_tab(page_id)
),
], style={'display': 'flex', 'flexDirection': 'column', 'height': 'calc(100vh - 160px)', 'gap': '12px'})
], width=9)
@@ -287,6 +288,7 @@ def save_fit(n_clicks, dataset_id, dataset_store):
@callback(
Output({"type": "gof-plot", "page": page_id}, 'figure', allow_duplicate=True),
Output({"type": "dist-stats-table", "page": page_id}, 'data', allow_duplicate=True),
+ Output({"type": "dip-spectrum-plot", "page": page_id}, 'figure', allow_duplicate=True),
Input({'type': 'fit-results-store', 'page': page_id}, 'data'),
prevent_initial_call=True
)
@@ -296,6 +298,7 @@ def update_plots_tables(fit_dict):
return dash.no_update
fit = dl.json_loads(fit_dict['data'])
gof_fig = plotly_goodness_of_fit(fit)
+ dip_spectrum_fig = plotly_dipolar_spectrum(fit)
dist_stats_dict = fit_dict['dist_stats']
dist_stats_output = {
@@ -305,4 +308,4 @@ def update_plots_tables(fit_dict):
for k, v in dist_stats_dict.items()
]
}
- return gof_fig, dist_stats_output
\ No newline at end of file
+ return gof_fig, dist_stats_output,dip_spectrum_fig
\ No newline at end of file
diff --git a/src/deeranalysis/pages/population.py b/src/deeranalysis/pages/population.py
index 551f27f..db053eb 100644
--- a/src/deeranalysis/pages/population.py
+++ b/src/deeranalysis/pages/population.py
@@ -123,6 +123,7 @@
fpcg.overview_tab_population(page_id),
fpc.fit_results_tab(page_id),
fpcg.goodness_of_fit_tab_pagination(page_id),
+ fpcg.dipolar_spectrum_tab_pagination(page_id)
),
], style={'display': 'flex', 'flexDirection': 'column', 'height': 'calc(100vh - 160px)', 'gap': '12px'})
], width=9) # dbc.col
diff --git a/src/deeranalysis/pages/upload.py b/src/deeranalysis/pages/upload.py
index 7ba2ce8..ff5fc51 100644
--- a/src/deeranalysis/pages/upload.py
+++ b/src/deeranalysis/pages/upload.py
@@ -15,7 +15,7 @@
from deeranalysis.components.data_viewer import data_viewer_layout, plot_upload
from deeranalysis.utils.deerlab_options import experiment_type_options
from deeranalysis.utils.csv_loader import parse_csv_raw, build_csv_store
-import deeranalysis.components.dataset_form # registers shared MATCH callbacks
+import deeranalysis.components.dataset_form as df # registers shared MATCH callbacks
dash.register_page(__name__)
page_id = 'upload'
@@ -83,7 +83,7 @@
DashIconify(icon="material-symbols:upload-file-outline", width=36, color="var(--mantine-color-blue-6)"),
dmc.Stack([
dmc.Text("Drag and drop files or click to select", size="sm", fw=500),
- dmc.Text(".DSC/.DTA, .h5, .csv — select both .DSC and .DTA together", size="xs", c="dimmed"),
+ dmc.Text(".DSC/.DTA, .h5, .csv, .txt, .dat — select both .DSC and .DTA together", size="xs", c="dimmed"),
], gap=2),
], px="md", py="sm"),
style={
@@ -213,7 +213,7 @@ def handle_file_upload(contents_list, filenames_list):
return *no_update_9[:6], alert, dash.no_update, dash.no_update
elif filenames_list[0].endswith('.h5'):
file_format = 'hdf5'
- elif filenames_list[0].endswith('.csv'):
+ elif filenames_list[0].endswith('.csv') or filenames_list[0].endswith('.txt') or filenames_list[0].endswith('.dat'):
csv_store = {'content': contents_list[0], 'filename': filenames_list[0]}
return *no_update_9[:7], csv_store, True
else:
@@ -235,6 +235,9 @@ def handle_file_upload(contents_list, filenames_list):
delays = get_delays_dict(dataarray)
tmin = dataarray.attrs.get('deadtime', 0)
+ if dataarray.ndim ==2:
+ dataarray = dataarray.sum('Y')
+
elif file_format == 'hdf5':
decoded = base64.b64decode(contents_list[0].split(',')[1])
dataarray = pyepr.eprload(io.BytesIO(decoded), type='HDF5')
@@ -262,6 +265,7 @@ def handle_file_upload(contents_list, filenames_list):
'masked_indices': [],
}
delays_data = [{'parameter': k, 'value': v} for k, v in delays.items()]
+ delays_data, store_data = df.check_delays('4pDEER',delays_data,store_data)
return store_data, metadata_children, long_values_store, delays_data, dataarray.attrs.get('title', ''), tmin, alert, dash.no_update, dash.no_update
diff --git a/src/deeranalysis/utils/deerlab_normal.py b/src/deeranalysis/utils/deerlab_normal.py
index 40d3b5c..33a2146 100644
--- a/src/deeranalysis/utils/deerlab_normal.py
+++ b/src/deeranalysis/utils/deerlab_normal.py
@@ -297,9 +297,9 @@ def deerlab_fitting(dataset, compactness=True, model=None, exp_type='5pDEER', ve
param = getattr(Vmodel, param_name)
if not hasattr(param, 'set'):
continue
- lb = param_data.get('lb')
- ub = param_data.get('ub')
- par0 = param_data.get('par0')
+ lb = float(param_data.get('lb'))
+ ub = float(param_data.get('ub'))
+ par0 = float(param_data.get('par0'))
frozen = param_data.get('frozen', False)
if lb is not None and ub is not None:
param.set(lb=lb, ub=ub)
diff --git a/src/deeranalysis/utils/deerlab_options.py b/src/deeranalysis/utils/deerlab_options.py
index 77e4bc7..6157b9b 100644
--- a/src/deeranalysis/utils/deerlab_options.py
+++ b/src/deeranalysis/utils/deerlab_options.py
@@ -73,7 +73,7 @@ def resolve_plot_template(color_scheme=None, plot_theme=None):
return f"{plot_theme}+compact"
return "plotly_dark+compact" if color_scheme == "dark" else "plotly_white+compact"
-def plotly_goodness_of_fit(results=None, index=None):
+def plotly_goodness_of_fit(results=None, index=None, legend_pos = 'right'):
"""
Returns a plotly version of the goodness of fit plot for a DeerLab fit result object `dl.plot(gof=True)`.
@@ -138,6 +138,18 @@ def plotly_goodness_of_fit(results=None, index=None):
fig.update_xaxes(range=[-0.5, maxLag],title_text="Lags", row=1, col=3)
fig.update_yaxes(visible=False, row=1, col=3)
+ if legend_pos == 'bottom':
+ fig.update_layout(showlegend=True,
+ legend=dict(orientation="h",
+ yanchor="bottom",
+ xanchor="center",
+ y=-0.35,
+))
+ else:
+ fig.update_layout(showlegend=True,
+ legend=dict(orientation="v",
+ yanchor="top",
+ xanchor="right",))
return fig
@@ -593,13 +605,38 @@ def build_model_data(dataset, bg_model_name, pathways, r_range,
tau1 = attrs['tau1'] / 1e3
pathways = [p for p in pathways if p <= 2]
exp_info = dl.ex_3pdeer(tau=tau1, pathways=pathways)
- elif seq_name is not None and 'tau1' in attrs and 'tau2' in attrs:
+ elif seq_name == '4pDEER':
# Default / 4pDEER
exp_type = '4pDEER'
tau1 = attrs.get('tau1', 400) / 1e3
tau2 = attrs.get('tau2', 2400) / 1e3
pathways = [p for p in pathways if p <= 4]
exp_info = dl.ex_4pdeer(tau1, tau2, pathways=pathways)
+ elif seq_name == 'single':
+ exp_type = 'single'
+ exp_info = None
+ elif seq_name == 'ridme':
+ exp_type = 'ridme'
+ tau1 = attrs['tau1'] / 1e3
+ tau2 = attrs['tau2'] / 1e3
+ pathways = [p for p in pathways if p <= 2]
+ exp_info = dl.ex_ridme(tau=tau1, tau2=tau2, pathways=pathways)
+ elif seq_name == 'dqc':
+ exp_type = 'dqc'
+ tau1 = attrs['tau1'] / 1e3
+ tau2 = attrs['tau2'] / 1e3
+ tau3 = attrs['tau3'] / 1e3
+ pathways = [p for p in pathways if p <= 2]
+ exp_info = dl.ex_dqc(tau=tau1, tau2=tau2, tau3=tau3, pathways=pathways)
+ elif seq_name == 'sifter':
+ exp_type = 'sifter'
+ tau1 = attrs['tau1'] / 1e3
+ tau2 = attrs['tau2'] / 1e3
+ pathways = [p for p in pathways if p <= 2]
+ exp_info = dl.ex_sifter(tau=tau1, tau2=tau2, pathways=pathways)
+ else:
+ raise ValueError(f"Could not determine experiment type from dataset attributes. seq_name: {seq_name}, required tau values: {['tau1', 'tau2', 'tau3']}")
+
bg_model = (
getattr(dl, bg_model_name, None)
@@ -709,4 +746,53 @@ def plotly_lcurve(fitresult=None, orientation='h'):
customdata=[alphas_lcurve[selected_idx_lcurve]],
hovertemplate='Residual Norm: %{x:.3e}
Solution Norm: %{y:.3e}
α: %{customdata:.3e}'
), row=r2, col=c2)
+ return fig
+
+def plotly_dipolar_spectrum(fitresult=None, index=None, linewidth=3):
+ """Returns a plotly figure with the Pake pattern data."""
+
+ import plotly.graph_objects as go
+ from plotly.subplots import make_subplots
+
+ fig = make_subplots(rows=1, cols=1, subplot_titles=["Dipolar Spectrum (Background Divided)"], horizontal_spacing=0.1)
+ fig.update_xaxes(title_text="Frequency (MHz)", row=1, col=1)
+ fig.update_yaxes(title_text="Intensity (a.u.)", row=1, col=1)
+
+ if fitresult is None:
+ return fig
+
+ if isinstance(fitresult, dl.FitResult):
+ idx = index if index is not None else 0
+
+ data_t = fitresult.t[idx] if isinstance(fitresult.t, list) else fitresult.t
+ data_V = fitresult.Vexp[idx] if isinstance(fitresult.Vexp, list) else fitresult.Vexp
+ model_t = data_t
+
+ if hasattr(fitresult, 'model'):
+ data_model = fitresult.model[idx] if isinstance(fitresult.model, list) else fitresult.model
+
+ if (hasattr(fitresult,'bg') and isinstance(fitresult.bg, list)):
+ background = fitresult.bg[idx]
+ elif hasattr(fitresult,'bg') and fitresult.bg is not None:
+ background = fitresult.bg
+ else:
+ background = None
+ else:
+ raise ValueError("fitresult must be either a DeerLab FitResult object or an xarray DataArray, not {}".format(type(fitresult)))
+
+
+ data_V_bg = data_V / background -1 if background is not None else data_V
+ data_model_bg = data_model / background -1 if background is not None and data_model is not None else data_model
+
+ data_V_bg_fft = np.fft.fftshift(np.fft.fft(data_V_bg))
+ data_freqs = np.fft.fftshift(np.fft.fftfreq(len(data_t), d=(data_t[1]-data_t[0])))
+ model_V_bg_fft = np.fft.fftshift(np.fft.fft(data_model_bg)) if data_model_bg is not None else None
+ model_freqs = data_freqs if data_model_bg is not None else None
+
+ fig.add_trace(go.Scatter(x=data_freqs, y=np.abs(data_V_bg_fft), mode='markers', name='Data', line={'color':colour_scheme_light[0], 'width':linewidth}), row=1, col=1)
+ if data_model_bg is not None:
+ fig.add_trace(go.Scatter(x=model_freqs, y=np.abs(model_V_bg_fft), mode='lines', name='Model', line={'color':colour_scheme_dark[0], 'width':linewidth}), row=1, col=1)
+
+ fig.update_xaxes(range=[-15, 15], row=1, col=1)
+
return fig
\ No newline at end of file
diff --git a/src/deeranalysis/utils/io.py b/src/deeranalysis/utils/io.py
index c60146d..433e4d3 100644
--- a/src/deeranalysis/utils/io.py
+++ b/src/deeranalysis/utils/io.py
@@ -5,7 +5,7 @@
from numpy import savetxt, column_stack
from deeranalysis.utils import dataarray_from_database_entry
-
+import deerlab as dl
def save_bruker_bes3t(filename, x, data, title='', mw_freq=np.nan):
"""Save data in Bruker BES3T format (.DTA + .DSC files).
@@ -250,7 +250,7 @@ def output_to_file(file, output, format_type):
dist_buffer = io.StringIO()
header = 'r,P'
data_list = [output['r'], output['P']]
- if 'P_lb' in output and 'P_ub' in output:
+ if 'P_lb' in output and 'P_ub' in output and output['P_lb'] is not None and output['P_ub'] is not None:
header += ',lb,ub'
data_list.append(output['P_lb'])
data_list.append(output['P_ub'])
@@ -298,6 +298,14 @@ def FitResult_to_file(file, fitresult, format_type, uncert=True):
output_to_file(file, output, format_type)
+def _convert_lists_in_dicts_to_arrays(d):
+ """Recursively convert lists in a dict to numpy arrays."""
+ if isinstance(d, dict):
+ return {k: _convert_lists_in_dicts_to_arrays(v) for k, v in d.items()}
+ elif isinstance(d, list):
+ return np.array(d)
+ else:
+ return d
def fitSQL_to_file(file, fit_entry,dataset_entry, format_type, uncert=True):
@@ -308,13 +316,18 @@ def fitSQL_to_file(file, fit_entry,dataset_entry, format_type, uncert=True):
output['Vmodel'] = np.array(fit_entry.model)
output['r'] = np.array(fit_entry.r)
output['P'] = np.array(fit_entry.P_model)
- output['P_lb'] = np.array(fit_entry.P_model['lb']) if fit_entry.P_model and 'lb' in fit_entry.P_model else None
- output['P_ub'] = np.array(fit_entry.P_model['ub']) if fit_entry.P_model and 'ub' in fit_entry.P_model else None
+ if isinstance(fit_entry.PUncert, dict): #
+ PUncert = PUncert = dl.UQResult.from_dict(_convert_lists_in_dicts_to_arrays(fit_entry.PUncert))
+ output['P_lb'] = np.array(PUncert.ci(95)[:,0])
+ output['P_ub'] = np.array(PUncert.ci(95)[:,1])
+ output['bg'] = np.array(fit_entry.background) if fit_entry.background is not None else None
if fit_entry.engine == 'DeerNet':
# Either resample the fit to the dataset's t axis or
Vt = np.array(fit_entry.t)
output['Vmodel'] = np.interp(output['t'], Vt, output['Vmodel'])
+ if output['bg'] is not None:
+ output['bg'] = np.interp(output['t'], Vt, output['bg'])
output_to_file(file, output, format_type)
diff --git a/src/deeranalysis/utils/pulsespel_parser.py b/src/deeranalysis/utils/pulsespel_parser.py
index 7bd4058..bc6868b 100644
--- a/src/deeranalysis/utils/pulsespel_parser.py
+++ b/src/deeranalysis/utils/pulsespel_parser.py
@@ -66,10 +66,12 @@ def search_variable(var_dict, search_terms, variable_type,one_item=False):
return found_items
-def extract_value_ns(var_dict):
+def extract_value_ns(var_dict,default=None):
keys = list(var_dict.keys())
- if len(keys) == 0:
+ if len(keys) == 0 and default is not None:
+ return default
+ elif len(keys) == 0:
raise ValueError("No matching variable found in var_dict.")
elif len(keys) == 1:
key = keys[0]
@@ -95,12 +97,12 @@ def parse_PulseSpel(def_text):
return {}
var_dict = PulseSpelDef_to_dict(def_text)
tau1_dict = search_variable(var_dict, 'tau1', 'delay',one_item=True)
- tau1 = extract_value_ns(tau1_dict)
+ tau1 = extract_value_ns(tau1_dict,default=0.0)
tau2_dict = search_variable(var_dict, 'tau2', 'delay',one_item=True)
- tau2 = extract_value_ns(tau2_dict)
+ tau2 = extract_value_ns(tau2_dict,default=0.0)
deadtime_dict = search_variable(var_dict, ['deadtime','zerotime','tmin'], 'delay',one_item=True)
- deadtime = extract_value_ns(deadtime_dict)
+ deadtime = extract_value_ns(deadtime_dict,default=0.0)
return {'tau1': tau1,
'tau2': tau2,