From 5638a4af9ae22c5b5331c6aadf0b36f820a4f4e8 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 07:29:54 +0200 Subject: [PATCH 01/19] Added SVG and PNG figure downloading This was done by monkey-patching the default dash option --- src/deeranalysis/assets/figure_download.js | 124 +++++++++++++++++++++ src/deeranalysis/main.py | 42 +++++++ 2 files changed, 166 insertions(+) create mode 100644 src/deeranalysis/assets/figure_download.js diff --git a/src/deeranalysis/assets/figure_download.js b/src/deeranalysis/assets/figure_download.js new file mode 100644 index 0000000..bd10aec --- /dev/null +++ b/src/deeranalysis/assets/figure_download.js @@ -0,0 +1,124 @@ +// 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(); + window.pywebview.api.save_figure(svgUrl, name, 'svg'); + }); + picker.querySelector('#pywv-save-png').addEventListener('click', function (e) { + e.stopPropagation(); + dismiss(); + window.pywebview.api.save_figure(pngUrl, name, '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.pywebview || !window.pywebview.api || !window.pywebview.api.save_figure) { + window.alert('Figure download unavailable: pywebview API not loaded.'); + 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/main.py b/src/deeranalysis/main.py index 2c37ed5..47a2672 100644 --- a/src/deeranalysis/main.py +++ b/src/deeranalysis/main.py @@ -3,7 +3,9 @@ import webview import os import sys +import base64 from pathlib import Path +from urllib.parse import unquote #os.environ['PYWEBVIEW_GUI'] = 'cocoa' # force macOS backend os.environ['DEERANALYSIS_PYWEBVIEW'] = '1' @@ -62,6 +64,44 @@ 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() + + def run_dash(): app.run(port=PORT, debug=False, use_reloader=False) @@ -92,7 +132,9 @@ def wait_for_dash(window): resizable=True, fullscreen=False, min_size=(800, 600), + js_api=figure_api, ) + 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() From 18dde1e2d2140ce93dd4750bed93881411449b53 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 07:55:18 +0200 Subject: [PATCH 02/19] Data Upload Issues: - Fixed the issues with dataset uploading if the parser can't find certain files - Automatically adds tau1 and tau2 delays upon upload if not found - Updates tmin warning when inter-pulse delays change --- src/deeranalysis/components/dataset_form.py | 13 +++++++++++++ src/deeranalysis/pages/upload.py | 3 ++- src/deeranalysis/utils/pulsespel_parser.py | 12 +++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/deeranalysis/components/dataset_form.py b/src/deeranalysis/components/dataset_form.py index 7956854..582abab 100644 --- a/src/deeranalysis/components/dataset_form.py +++ b/src/deeranalysis/components/dataset_form.py @@ -50,6 +50,19 @@ def _get_required(options, value): new_delays[delay] = 0 return [{'parameter': k, 'value': v} for k, v in new_delays.items()] +@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/pages/upload.py b/src/deeranalysis/pages/upload.py index 7ba2ce8..9867b26 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' @@ -262,6 +262,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 = df.check_delays('4pDEER',delays_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/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, From f32b377007748ace36e128b9f1a0d09fe506093c Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 08:15:01 +0200 Subject: [PATCH 03/19] Fixed upload issues with DQC datasets --- src/deeranalysis/components/dataset_form.py | 9 +++++++-- src/deeranalysis/pages/dataset_detail.py | 3 ++- src/deeranalysis/pages/upload.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/deeranalysis/components/dataset_form.py b/src/deeranalysis/components/dataset_form.py index 582abab..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,7 +50,10 @@ 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), diff --git a/src/deeranalysis/pages/dataset_detail.py b/src/deeranalysis/pages/dataset_detail.py index be65ae5..4fdb383 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/") @@ -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/upload.py b/src/deeranalysis/pages/upload.py index 9867b26..902a15a 100644 --- a/src/deeranalysis/pages/upload.py +++ b/src/deeranalysis/pages/upload.py @@ -262,7 +262,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 = df.check_delays('4pDEER',delays_data) + 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 From e641304dc488481f2b72edfb177fd47aecc94826 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 18:16:44 +0200 Subject: [PATCH 04/19] Added sentence about installing MKL --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 877bced..fdabe6b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ DeerAnalysis 2026 is avaliable in pre-compiled binaries for Windows, Mac and Lin The Windows and MacOS versions are released unsigned, so you may need to bypass security warnings when running the installer or executable for the first time. On MacOS, you may need to allow the app in System Preferences > Security & Privacy after trying to run it. +### Windows (Intel Processors) +Windows users who have an Intel processor are recommended to download the Intel oneAPI Math Kernel Library (MKL) as well. This is a free library that provides optimised implementations of many mathematical functions, including those used in DeerAnalysis. Download the latest version of MKL from the [Intel oneAPI website](https://www.intel.com/content/www/us/en/developer/tools/oneapi/onemkl-download.html) and install it before running DeerAnalysis for the best performance. Speed-up can be 5-10x for large datasets and complex fits. + ### Linux (Flatpak) On Linux, DeerAnalysis is distributed as a [Flatpak](https://flatpak.org/) bundle. Download `DeerAnalysis.flatpak` from the [releases page](https://github.com/JeschkeLab/DeerAnalysis/releases/latest) and install it: From 3c6aad597c33e4d4278f12d2e49052aa18d13d97 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 18:17:12 +0200 Subject: [PATCH 05/19] Dipolar Spectrum Plot --- .../components/fit_page_components.py | 13 ++- src/deeranalysis/components/fpc_global.py | 82 +++++++++++++++++-- src/deeranalysis/pages/deernet.py | 7 +- src/deeranalysis/pages/global.py | 4 +- src/deeranalysis/pages/nonparametric.py | 18 ++-- src/deeranalysis/pages/parametric.py | 7 +- src/deeranalysis/pages/population.py | 1 + src/deeranalysis/utils/deerlab_options.py | 49 +++++++++++ 8 files changed, 162 insertions(+), 19 deletions(-) diff --git a/src/deeranalysis/components/fit_page_components.py b/src/deeranalysis/components/fit_page_components.py index 963e380..a951947 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 @@ -226,6 +226,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/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/global.py b/src/deeranalysis/pages/global.py index 8e70684..0a8c657 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 diff --git a/src/deeranalysis/pages/nonparametric.py b/src/deeranalysis/pages/nonparametric.py index 5cce09c..9d573b6 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'}) @@ -293,6 +293,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 +307,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 +317,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/utils/deerlab_options.py b/src/deeranalysis/utils/deerlab_options.py index 77e4bc7..4014381 100644 --- a/src/deeranalysis/utils/deerlab_options.py +++ b/src/deeranalysis/utils/deerlab_options.py @@ -709,4 +709,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 From b51d9fddabf0375b996fa3f19ead94329aaddc3f Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 18:28:10 +0200 Subject: [PATCH 06/19] Add support for 2D datasets --- src/deeranalysis/pages/upload.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/deeranalysis/pages/upload.py b/src/deeranalysis/pages/upload.py index 902a15a..40d579c 100644 --- a/src/deeranalysis/pages/upload.py +++ b/src/deeranalysis/pages/upload.py @@ -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') From cfb917dedddfc8d528b599386b0e2e809aa448e1 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 18:50:31 +0200 Subject: [PATCH 07/19] CSV export bug fixes --- src/deeranalysis/utils/io.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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) From 746aa0bd0c9045c3785a905bfa452f96f8ac4d51 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 18:52:42 +0200 Subject: [PATCH 08/19] Allow `.txt` and `.dat` --- src/deeranalysis/pages/upload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deeranalysis/pages/upload.py b/src/deeranalysis/pages/upload.py index 40d579c..ff5fc51 100644 --- a/src/deeranalysis/pages/upload.py +++ b/src/deeranalysis/pages/upload.py @@ -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: From 062225953c4bd1bdaf14cc3ee665367404dd3ccc Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 20:45:44 +0200 Subject: [PATCH 09/19] Adjust reg param search range --- src/deeranalysis/pages/global.py | 2 +- src/deeranalysis/pages/nonparametric.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/deeranalysis/pages/global.py b/src/deeranalysis/pages/global.py index 0a8c657..be1a7e3 100644 --- a/src/deeranalysis/pages/global.py +++ b/src/deeranalysis/pages/global.py @@ -235,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 9d573b6..a23148b 100644 --- a/src/deeranalysis/pages/nonparametric.py +++ b/src/deeranalysis/pages/nonparametric.py @@ -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': From 95db6cc185e333da9179164f84a8778cbd462787 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 20:46:24 +0200 Subject: [PATCH 10/19] Fixed typo preventing background fitting from working --- src/deeranalysis/pages/background.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From 11275140ea7bcaab2a99f042f2df8b409c276441 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Thu, 4 Jun 2026 20:47:30 +0200 Subject: [PATCH 11/19] Fixxed model edit for large numbers Very large and small number are now shows with a scientific notation and are always passed to deerlab as floats not ints --- src/deeranalysis/assets/scinotation.js | 147 ++++++++++++++ .../components/model_edit_modal.py | 49 +++-- .../components/scientific_number_input.py | 182 ++++++++++++++++++ src/deeranalysis/pages/nonparametric.py | 5 +- src/deeranalysis/utils/deerlab_normal.py | 6 +- src/deeranalysis/utils/deerlab_options.py | 27 ++- 6 files changed, 398 insertions(+), 18 deletions(-) create mode 100644 src/deeranalysis/assets/scinotation.js create mode 100644 src/deeranalysis/components/scientific_number_input.py 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/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/pages/nonparametric.py b/src/deeranalysis/pages/nonparametric.py index a23148b..119d495 100644 --- a/src/deeranalysis/pages/nonparametric.py +++ b/src/deeranalysis/pages/nonparametric.py @@ -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 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 4014381..62fc798 100644 --- a/src/deeranalysis/utils/deerlab_options.py +++ b/src/deeranalysis/utils/deerlab_options.py @@ -593,13 +593,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) From 7a1f431021671a24662809f0c55c9262ecf5df93 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 10:44:50 +0200 Subject: [PATCH 12/19] Added infomation on BLAS to the config tab --- src/deeranalysis/pages/configuration.py | 70 ++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/deeranalysis/pages/configuration.py b/src/deeranalysis/pages/configuration.py index d403d58..cefad4b 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: @@ -26,6 +27,33 @@ except Exception: _current_deerlab_version = "unknown" +dl_confg = show_config('dicts') +try: + numpy_blas_info = dl_confg['numpy']['BLAS'] +except Exception: + numpy_blas_info = 'unknown' + +try: + CPU_cores = dl_confg['cpu_cores'] +except Exception: + CPU_cores = 'unknown' + +BLAS_info_text = """ +The BLAS (Basic Linear Algebra Subprograms) engine used by NumPy can significantly impact the performance of linear algebra operations, which are used extensively in DeerLab and DeerAnalysis. +Using the right BLAS implementation can significantly speed up computations however, the options depend on your OS and processor architecture. + +- **MacOS:** +MacOS use the Accelerate framework by default, which is optimized for Apple hardware. This is normally the best choice for Mac users. + +- **Windows and Linux:** + -**Intel Processors:** If you have an Intel CPU, MKL (Math Kernel Library) is often the best choice. + This is a free library that provides optimised implementations of many mathematical functions, including those used in DeerAnalysis. + Download the latest version of MKL from the [Intel oneAPI website](https://www.intel.com/content/www/us/en/developer/tools/oneapi/onemkl-download.html) and install it before running DeerAnalysis for the best performance. + + -**AMD Processors:** For AMD CPUs, OpenBLAS is a good option. It is an open-source BLAS library that performs well on a variety of hardware, and is the default BLAS. + + +""" layout = dmc.Container([ @@ -36,6 +64,17 @@ multiple=True, value=["about","general"], children=[ + dmc.Modal( + id={"type": "BLAS-Engine-Info", "page": page_id}, + title=dmc.Text("BLAS Linear Algenbra Engine", fw=600, size="lg"), + size="50%", + opened=False, + children=[ + dmc.TypographyStylesProvider( + dcc.Markdown(BLAS_info_text, dangerously_allow_html=False, link_target="_blank", id="default"), + ), + ], + ), # About / Version dmc.AccordionItem( value="about", @@ -73,6 +112,23 @@ dmc.Text("DeerLab version", size="sm", w=160, c="dimmed"), dmc.Badge(_current_deerlab_version, color="blue", variant="light"), ], gap="xs"), + dmc.Group([ + dmc.Text("Numpy BLAS Engine", size="sm", w=160, c="dimmed"), + dmc.Badge(numpy_blas_info, color="blue", variant="light"), + dmc.Button( + DashIconify(icon="mdi:information-outline",width=20,height=20,), + id={"type": "config-blas-info-btn", "page": page_id}, + variant="subtle", + color="blue", + # title="Format information", + mt=2, + p=0, + ), + ], gap="xs"), + dmc.Group([ + dmc.Text("Number of CPU Cores", size="sm", w=160, c="dimmed"), + dmc.Badge(CPU_cores, color="blue", variant="light"), + ], gap="xs"), ], gap="sm"), ]), ], @@ -406,6 +462,15 @@ prevent_initial_call=True, ) +@callback( + Output({"type": "BLAS-Engine-Info", "page": page_id}, "opened", allow_duplicate=True), + Input({"type": "config-blas-info-btn", "page": page_id}, "n_clicks"), + prevent_initial_call=True, +) +def _open_fit_info_modal(n_clicks): + return True if n_clicks else dash.no_update + + # Callbacks for saving and resetting configuration @callback( Output("config-notification", "action"), @@ -567,4 +632,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 + From 11e5a21082210bcc393d17dc49aee0f6036e6518 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 11:24:20 +0200 Subject: [PATCH 13/19] Fit detail page update --- src/deeranalysis/assets/dmc.css | 17 ++ .../components/fit_page_components.py | 3 +- src/deeranalysis/pages/dataset_detail.py | 2 +- src/deeranalysis/pages/fit_detail.py | 209 +++++++++++++----- src/deeranalysis/utils/deerlab_options.py | 12 +- 5 files changed, 189 insertions(+), 54 deletions(-) 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/components/fit_page_components.py b/src/deeranalysis/components/fit_page_components.py index a951947..07918cc 100644 --- a/src/deeranalysis/components/fit_page_components.py +++ b/src/deeranalysis/components/fit_page_components.py @@ -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 diff --git a/src/deeranalysis/pages/dataset_detail.py b/src/deeranalysis/pages/dataset_detail.py index 4fdb383..89e5a7f 100644 --- a/src/deeranalysis/pages/dataset_detail.py +++ b/src/deeranalysis/pages/dataset_detail.py @@ -129,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", 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/utils/deerlab_options.py b/src/deeranalysis/utils/deerlab_options.py index 62fc798..c0d8fdc 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,16 @@ 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", + y=-0.35, +)) + else: + fig.update_layout(showlegend=True, + legend=dict(orientation="v", + yanchor="right",)) return fig From b6774671672280a5aeb0b167a03614911726077c Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 11:26:21 +0200 Subject: [PATCH 14/19] Build test action --- .github/workflows/build-test.yml | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/build-test.yml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..08e6836 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,65 @@ +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 + + 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: 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 From 68b30d5b5f549a7c7e1a2e8a3325b465c4570ed8 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 11:31:27 +0200 Subject: [PATCH 15/19] Upload test-build artifacts for 1day --- .github/workflows/build-test.yml | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 08e6836..9e1e0cc 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -37,6 +37,32 @@ jobs: run: pyinstaller "${{ matrix.spec }}" --distpath dist --workpath build working-directory: packaging/pyinstaller + - 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 @@ -63,3 +89,10 @@ jobs: --force-clean \ flatpak_app \ packaging/flatpak/io.github.JeschkeLab.DeerAnalysis.yml + + - name: Upload Flatpak artifact + uses: actions/upload-artifact@v7 + with: + name: DeerAnalysis-${{ steps.get_version.outputs.version }}-linux + path: DeerAnalysis.flatpak + retention-days: 1 From bd980e95516160405d3d6ca42ef4cd1eb5158b70 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 11:36:02 +0200 Subject: [PATCH 16/19] Build test nameing bug --- .github/workflows/build-test.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 9e1e0cc..711f4e3 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -36,6 +36,24 @@ jobs: - 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' @@ -79,6 +97,12 @@ jobs: - 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: | From 204754fdde19ab05794e04345cf0c8cdf2096879 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 12:56:02 +0200 Subject: [PATCH 17/19] Fix figure saving API for windows --- src/deeranalysis/assets/figure_download.js | 20 ++++++++++++++------ src/deeranalysis/main.py | 18 +++++++++++++++++- src/deeranalysis/utils/deerlab_options.py | 4 +++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/deeranalysis/assets/figure_download.js b/src/deeranalysis/assets/figure_download.js index bd10aec..5ff655e 100644 --- a/src/deeranalysis/assets/figure_download.js +++ b/src/deeranalysis/assets/figure_download.js @@ -64,12 +64,20 @@ picker.querySelector('#pywv-save-svg').addEventListener('click', function (e) { e.stopPropagation(); dismiss(); - window.pywebview.api.save_figure(svgUrl, name, 'svg'); + 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(); - window.pywebview.api.save_figure(pngUrl, name, 'png'); + 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); @@ -83,10 +91,10 @@ if (!btn || !isDownloadButton(btn)) return; var gd = btn.closest('.js-plotly-plot') || btn.closest('.plot-container'); if (!gd || !window.Plotly) return; - if (!window.pywebview || !window.pywebview.api || !window.pywebview.api.save_figure) { - window.alert('Figure download unavailable: pywebview API not loaded.'); - return; - } + if (!window.DEERANALYSIS_PYWEBVIEW) { + window.alert('Figure download unavailable: not running in pywebview.'); + return; + } e.preventDefault(); e.stopImmediatePropagation(); diff --git a/src/deeranalysis/main.py b/src/deeranalysis/main.py index 47a2672..c543dc5 100644 --- a/src/deeranalysis/main.py +++ b/src/deeranalysis/main.py @@ -6,6 +6,7 @@ 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' @@ -102,6 +103,17 @@ def save_figure(self, data_url, suggested_name='figure', fmt='svg'): 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) @@ -132,11 +144,15 @@ def wait_for_dash(window): resizable=True, fullscreen=False, min_size=(800, 600), - js_api=figure_api, ) 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/utils/deerlab_options.py b/src/deeranalysis/utils/deerlab_options.py index c0d8fdc..6157b9b 100644 --- a/src/deeranalysis/utils/deerlab_options.py +++ b/src/deeranalysis/utils/deerlab_options.py @@ -142,12 +142,14 @@ def plotly_goodness_of_fit(results=None, index=None, legend_pos = 'right'): 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="right",)) + yanchor="top", + xanchor="right",)) return fig From 049d1fb3d2a90effe72775a3257d8ae7d55fac47 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 12:55:27 +0200 Subject: [PATCH 18/19] Remove MKL mentions. Too difficult --- README.md | 3 -- src/deeranalysis/pages/configuration.py | 63 ------------------------- 2 files changed, 66 deletions(-) diff --git a/README.md b/README.md index fdabe6b..877bced 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,6 @@ DeerAnalysis 2026 is avaliable in pre-compiled binaries for Windows, Mac and Lin The Windows and MacOS versions are released unsigned, so you may need to bypass security warnings when running the installer or executable for the first time. On MacOS, you may need to allow the app in System Preferences > Security & Privacy after trying to run it. -### Windows (Intel Processors) -Windows users who have an Intel processor are recommended to download the Intel oneAPI Math Kernel Library (MKL) as well. This is a free library that provides optimised implementations of many mathematical functions, including those used in DeerAnalysis. Download the latest version of MKL from the [Intel oneAPI website](https://www.intel.com/content/www/us/en/developer/tools/oneapi/onemkl-download.html) and install it before running DeerAnalysis for the best performance. Speed-up can be 5-10x for large datasets and complex fits. - ### Linux (Flatpak) On Linux, DeerAnalysis is distributed as a [Flatpak](https://flatpak.org/) bundle. Download `DeerAnalysis.flatpak` from the [releases page](https://github.com/JeschkeLab/DeerAnalysis/releases/latest) and install it: diff --git a/src/deeranalysis/pages/configuration.py b/src/deeranalysis/pages/configuration.py index cefad4b..af10464 100644 --- a/src/deeranalysis/pages/configuration.py +++ b/src/deeranalysis/pages/configuration.py @@ -27,33 +27,6 @@ except Exception: _current_deerlab_version = "unknown" -dl_confg = show_config('dicts') -try: - numpy_blas_info = dl_confg['numpy']['BLAS'] -except Exception: - numpy_blas_info = 'unknown' - -try: - CPU_cores = dl_confg['cpu_cores'] -except Exception: - CPU_cores = 'unknown' - -BLAS_info_text = """ -The BLAS (Basic Linear Algebra Subprograms) engine used by NumPy can significantly impact the performance of linear algebra operations, which are used extensively in DeerLab and DeerAnalysis. -Using the right BLAS implementation can significantly speed up computations however, the options depend on your OS and processor architecture. - -- **MacOS:** -MacOS use the Accelerate framework by default, which is optimized for Apple hardware. This is normally the best choice for Mac users. - -- **Windows and Linux:** - -**Intel Processors:** If you have an Intel CPU, MKL (Math Kernel Library) is often the best choice. - This is a free library that provides optimised implementations of many mathematical functions, including those used in DeerAnalysis. - Download the latest version of MKL from the [Intel oneAPI website](https://www.intel.com/content/www/us/en/developer/tools/oneapi/onemkl-download.html) and install it before running DeerAnalysis for the best performance. - - -**AMD Processors:** For AMD CPUs, OpenBLAS is a good option. It is an open-source BLAS library that performs well on a variety of hardware, and is the default BLAS. - - -""" layout = dmc.Container([ @@ -64,17 +37,6 @@ multiple=True, value=["about","general"], children=[ - dmc.Modal( - id={"type": "BLAS-Engine-Info", "page": page_id}, - title=dmc.Text("BLAS Linear Algenbra Engine", fw=600, size="lg"), - size="50%", - opened=False, - children=[ - dmc.TypographyStylesProvider( - dcc.Markdown(BLAS_info_text, dangerously_allow_html=False, link_target="_blank", id="default"), - ), - ], - ), # About / Version dmc.AccordionItem( value="about", @@ -112,23 +74,6 @@ dmc.Text("DeerLab version", size="sm", w=160, c="dimmed"), dmc.Badge(_current_deerlab_version, color="blue", variant="light"), ], gap="xs"), - dmc.Group([ - dmc.Text("Numpy BLAS Engine", size="sm", w=160, c="dimmed"), - dmc.Badge(numpy_blas_info, color="blue", variant="light"), - dmc.Button( - DashIconify(icon="mdi:information-outline",width=20,height=20,), - id={"type": "config-blas-info-btn", "page": page_id}, - variant="subtle", - color="blue", - # title="Format information", - mt=2, - p=0, - ), - ], gap="xs"), - dmc.Group([ - dmc.Text("Number of CPU Cores", size="sm", w=160, c="dimmed"), - dmc.Badge(CPU_cores, color="blue", variant="light"), - ], gap="xs"), ], gap="sm"), ]), ], @@ -462,14 +407,6 @@ prevent_initial_call=True, ) -@callback( - Output({"type": "BLAS-Engine-Info", "page": page_id}, "opened", allow_duplicate=True), - Input({"type": "config-blas-info-btn", "page": page_id}, "n_clicks"), - prevent_initial_call=True, -) -def _open_fit_info_modal(n_clicks): - return True if n_clicks else dash.no_update - # Callbacks for saving and resetting configuration @callback( From 153f71493cca261f39b2ccea0bbc226f50d90c22 Mon Sep 17 00:00:00 2001 From: Hugo Karas Date: Fri, 5 Jun 2026 12:56:16 +0200 Subject: [PATCH 19/19] Bump version --- .github/workflows/build-test.yml | 6 ++++-- pyproject.toml | 2 +- src/deeranalysis/components/setup_modal_desktop.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 711f4e3..4e7565d 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -97,7 +97,7 @@ jobs: - 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: | @@ -113,7 +113,9 @@ jobs: --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: 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/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