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,