diff --git a/pyproject.toml b/pyproject.toml index 5b5b322..6d4d7d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deeranalysis" -version = "2026.0.3" +version = "2026.1.0b1" 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/dashAgGridComponentFunctions.js b/src/deeranalysis/assets/dashAgGridComponentFunctions.js index d0f73b6..caeb7eb 100644 --- a/src/deeranalysis/assets/dashAgGridComponentFunctions.js +++ b/src/deeranalysis/assets/dashAgGridComponentFunctions.js @@ -23,4 +23,38 @@ dagcomponentfuncs.TagsCellRenderer = function (props) { ); }) ); +}; + +// Warning counts with icons. props.value is {critical, moderate}, where a +// count of null means the fit was not checked for warnings and shows as "–". +// Levels with no warnings are left out. +dagcomponentfuncs.WarningsCellRenderer = function (props) { + const value = props.value; + const unchecked = value === null || value === undefined; + const levels = [ + { key: "critical", icon: "mdi:alert-circle-outline", color: "red" }, + { key: "moderate", icon: "mdi:alert-outline", color: "orange" }, + ]; + const children = []; + levels.forEach(function (level) { + const n = unchecked ? null : value[level.key]; + if (!unchecked && !n) { + return; + } + children.push( + React.createElement(window.dash_iconify.DashIconify, { + key: level.key + "-icon", icon: level.icon, width: 16, color: level.color, + }), + React.createElement( + "span", + { key: level.key + "-count", style: { color: level.color, fontWeight: 700 } }, + unchecked ? "–" : n + ) + ); + }); + return React.createElement( + "div", + { style: { display: "flex", gap: "4px", alignItems: "center", height: "100%" } }, + children + ); }; \ 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 da1c3f5..b73967f 100644 --- a/src/deeranalysis/components/fit_page_components.py +++ b/src/deeranalysis/components/fit_page_components.py @@ -3,6 +3,8 @@ 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.components.warnings import number_of_warnings_card, number_of_warnings_children +from deeranalysis.utils.deerlab_fitwarnings import count_by_level, warnings_from_dict from dash import dcc, html, callback, Input, Output, State, ALL, MATCH, no_update import deerlab as dl @@ -526,7 +528,7 @@ def _overview_card_grids(page_id): """Returns the two SimpleGrid rows of overview cards (shared between overview_tab and overview_tab_global).""" return [ dmc.SimpleGrid( - cols={"base": 1, "sm": 2, "lg": 4}, + cols={"base": 1, "sm": 3, "lg": 4}, mt="md", spacing="md", children=[ @@ -534,17 +536,20 @@ def _overview_card_grids(page_id): overview_card("lambda", page_id), overview_card("chi2", page_id), overview_card("rmsd", page_id), - ], - ), - dmc.SimpleGrid( - cols={"base": 1, "sm": 2}, - mb="md", - spacing="md", - children=[ overview_card("mean_dist", page_id), overview_card("std_dist", page_id), + number_of_warnings_card(None, None, page_id), ], ), + # dmc.SimpleGrid( + # cols={"base": 1, "sm": 2, "lg": 4}, + # mb="md", + # spacing="md", + # children=[ + # + + # ], + # ), ] @@ -593,7 +598,14 @@ def _dist(key): mean_val, mean_unc = _dist('mean') std_val, std_unc = _dist('std') + stored_warnings = store_data.get('warnings') + if stored_warnings is None: + warning_counts = {'critical': None, 'moderate': None} + else: + warning_counts = count_by_level(warnings_from_dict(stored_warnings)) + return { + 'warnings': warning_counts, 'mnr': {'value': _gof('MNR', 'mnr'), 'uncertainty': None}, 'chi2': {'value': _gof('chi2red', 'chi2'), 'uncertainty': None}, 'rmsd': {'value': _gof('RMSD', 'rmsd'), 'uncertainty': None}, @@ -608,6 +620,11 @@ def _render_cards(outputs, metrics): result = [] for out in outputs: metric = out["id"]["metric"] + if metric == "warnings": + counts = metrics.get("warnings") or {} + result.append(number_of_warnings_children( + counts.get("critical"), counts.get("moderate"), out["id"]["page"])) + continue config = METRIC_CONFIG.get(metric, {"title": metric, "description": None, "thresholds": None}) metric_data = metrics.get(metric, {}) result.append(_make_card_children( diff --git a/src/deeranalysis/components/fpc_global.py b/src/deeranalysis/components/fpc_global.py index 5a8328a..4b3c5b3 100644 --- a/src/deeranalysis/components/fpc_global.py +++ b/src/deeranalysis/components/fpc_global.py @@ -8,6 +8,7 @@ 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 +from deeranalysis.components.warnings import number_of_warnings_card from dash import dcc, html, callback, Input, Output, State, MATCH import deerlab as dl @@ -416,7 +417,7 @@ def overview_tab_population(page_id): # Dummy store so the shared overview-card callback can find all its MATCH inputs dcc.Store(id={"type": "fit-results-store", "page": page_id}), dmc.SimpleGrid( - cols={"base": 1, "sm": 2, "lg": 4}, + cols={"base": 1, "sm": 3, "lg": 5}, mt="md", spacing="md", children=[ @@ -424,13 +425,14 @@ def overview_tab_population(page_id): overview_card("lambda", page_id), overview_card("chi2", page_id), overview_card("rmsd", page_id), + number_of_warnings_card(None, None, page_id), ], ), dmc.Text("Population Metrics", size="lg", fw=700, mt="xl"), dmc.Space(h='md'), dmc.SimpleGrid( id={"type": "population-cards-grid", "page": page_id}, - cols={"base": 1, "sm": 2, "lg": 3}, + cols={"base": 1, "md": 2, "lg": 3}, mb="md", spacing="md", children=[], diff --git a/src/deeranalysis/components/warnings.py b/src/deeranalysis/components/warnings.py new file mode 100644 index 0000000..3237c86 --- /dev/null +++ b/src/deeranalysis/components/warnings.py @@ -0,0 +1,184 @@ +# Elements for displaying fitwarnings in the GUI. + +import dash_mantine_components as dmc +import dash +from dash import html, dcc, callback, Input, Output, State,clientside_callback, MATCH, ALL, ALLSMALLER +from dash_iconify import DashIconify +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict, warnings_from_dict,FitWarning + +def warning_card(warning): + if issubclass(type(warning), FitWarning): + warning_title = warning.title + warning_message = warning.message + level = warning.level + elif isinstance(warning, dict): + warning_title = warning.get('title', 'Warning') + warning_message = warning.get('message', 'This is a warning message.') + level = warning.get('level', 'moderate') + else: + raise ValueError("Warning must be a FitWarning instance or a dictionary.") + if level == 'critical': + card_color = "#F8D7DA" + card_icon = DashIconify(icon="mdi:alert-circle-outline", width=36, color="#721C24") + else: + card_color = "#FFF3CD" + card_icon = DashIconify(icon="mdi:alert-circle-outline", width=36, color="#856404") + # warning_title = dmc.Text("Conc Parameter Warning", c="#856404",fw=700) + # warning_message = dmc.Text("This is a warning message.", c="#856404") + + card = dmc.Card( + children=[ + dmc.Group( + children=[ + dmc.Box(card_icon, style={"flexShrink": 0}), + dmc.Stack( + children=[ + dmc.Text(warning_title, c="#856404",fw=700), + dmc.Text(warning_message, c="#856404") + ], + gap="xs", + style={"flex": 1, "minWidth": 0}, + ) + ], + gap="xs", + align="flex-start", + wrap="nowrap", + ) + ], + shadow="sm", + p="md", + style={"backgroundColor": card_color, "borderRadius": "8px"} + ) + return card + + +def number_of_warnings_children(critical, moderate, page_id): + """ + Children of the number of warnings card, so that ``update_overview_cards`` + can re-render them when the fit results change. + + ``critical`` and ``moderate`` are the counts, or ``None`` when the fit has + not been checked for warnings, which is shown as "–". + """ + title = "Warnings" + + critical_color = "red" + moderate_color = "orange" + + expand_button = dmc.ActionIcon( + DashIconify(icon="mdi:arrow-expand", width=20, color="gray"), + variant="transparent", + size="lg", + id={"type": "n_warnings_overview-expand-button", "page": page_id}, + ) + + def _count(n): + return "–" if n is None else f"{n}" + + value_line = dmc.Group( + [ + dmc.Tooltip(DashIconify(icon="mdi:alert-circle-outline", width=20, color=critical_color),label="Critical Warnings", position="top", withArrow=True), + dmc.Text(_count(critical), size="xl", fw=700, c=critical_color), + dmc.Tooltip(DashIconify(icon="mdi:alert-outline", width=20, color=moderate_color),label="Moderate Warnings", position="top", withArrow=True), + dmc.Text(_count(moderate), size="xl", fw=700, c=moderate_color), + ], + align="center", + justify="center" + ) + + return [ + dmc.Box([ + dmc.Text(title, size="lg", fw=700, ta="center"), + dmc.Box(expand_button, style={"position": "absolute", "top": 0, "right": 0}), + ], + style={"position": "relative"} + ), + value_line, + ] + + +def number_of_warnings_card(critical, moderate, page_id): + """ + based on fit_page_components overview_card + + Shares the ``overview-card`` id type with the other overview cards, under + the metric ``"warnings"``, so that ``update_overview_cards`` updates it. + """ + return dmc.Paper( + number_of_warnings_children(critical, moderate, page_id), + id={"type": "overview-card", "metric": "warnings", "page": page_id}, + withBorder=True, + p="md", + style={"textAlign": "center", "minWidth": 120}, + ) + + + +def list_of_warnings_card(warnings): + + scroll_area_children = [] + for warning in warnings: + card = warning_card(warning) + scroll_area_children.append(card) + + + return dmc.ScrollArea( + dmc.Stack(scroll_area_children, gap="md"), + mah=400, + scrollbars="y", + type="auto", + ) + + + +def list_of_warnings_modal(page_id): + + children = dmc.Text("No warnings to display.", c="gray", size="md") + modal = dmc.Modal( + title="Warnings", + id={"type": "n_warnings_overview-modal", "page": page_id}, + size="80%", + children=[ + html.Div(children, style={"maxHeight": "400px", "overflowY": "auto"},id={"type": "n_warnings_overview-modal-content", "page": page_id}), + ], + centered=False, + overlayProps={"opacity": 0.55, "blur": 3}, + ) + + return modal + +@callback( + Output({"type": "n_warnings_overview-modal-content", "page": MATCH}, "children"), + Input({"type": "n_warnings_overview-expand-button", "page": MATCH}, "n_clicks"), + State({"type": "fit-results-store", "page": MATCH}, "data"), + State({"type": "fit-results-store-multi", "page": MATCH}, "data"), + prevent_initial_call=True, +) +def update_warnings_modal_content(n_, fit_results_store, fit_results_store_multi): + # Every fit page renders both stores (single-fit pages use a dummy multi store), + # so a single callback handles both and prefers the multi store when populated. + if fit_results_store_multi is not None: + fit_results_store = fit_results_store_multi + if fit_results_store is None: + return dash.no_update + warnings = warnings_from_dict(fit_results_store.get('warnings', [])) + + if len(warnings) == 0: + children = dmc.Text("No warnings to display.", c="gray", size="md") + else: + children = list_of_warnings_card(warnings) + + return children + + +@callback( + Output({"type": "n_warnings_overview-modal", "page": MATCH}, "opened"), + Input({"type": "n_warnings_overview-expand-button", "page": MATCH}, "n_clicks"), + State({"type": "n_warnings_overview-modal", "page": MATCH}, "opened"), + prevent_initial_call=True, +) +def open_warnings_modal(n_, opened): + + if n_: + return not opened + return opened diff --git a/src/deeranalysis/pages/background.py b/src/deeranalysis/pages/background.py index fa4c3bd..a36b3bb 100644 --- a/src/deeranalysis/pages/background.py +++ b/src/deeranalysis/pages/background.py @@ -15,6 +15,8 @@ from deeranalysis.components.fit_page_components import fit_results_tabs, fit_results_tab, goodness_of_fit_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.components.warnings import list_of_warnings_modal +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict import deeranalysis.components.fit_page_components as fpc @@ -32,6 +34,7 @@ create_dataset_modal(page_id=page_id), create_fit_download_modal(page_id=page_id), create_model_edit_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), html.Div([ dmc.Select(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", style={'flex': '1 1 0'}), dmc.ActionIcon(DashIconify(icon='material-symbols:search', width=20), @@ -164,9 +167,10 @@ def run_fit(n_clicks, dataset_id, fit_options, model_params): print(f"Error during fitting: {e}") return dash.no_update, f"Error during fitting: {e}", True, True, False - + warnings = check_fit_results(fit, fit.Bmodel) fit_dict = fit_to_dict(fit,background_only=True) fit_dict['gof'] = fit.stats + fit_dict['warnings'] = warnings_to_dict(warnings) return fit_dict, fit.__str__(), False, False, False diff --git a/src/deeranalysis/pages/comparison.py b/src/deeranalysis/pages/comparison.py index fd2aee3..9221908 100644 --- a/src/deeranalysis/pages/comparison.py +++ b/src/deeranalysis/pages/comparison.py @@ -8,6 +8,8 @@ from deeranalysis.components.fit_finder import fit_select from deeranalysis.components.dataset_search_model import create_dataset_modal, search_fit_modal from deeranalysis.utils.deerlab_options import plotly_comparison, colour_scheme_dark, colour_scheme_light +from deeranalysis.utils.deerlab_fitwarnings import warnings_from_dict, count_by_level +from deeranalysis.components.warnings import list_of_warnings_card, list_of_warnings_modal from deerlab import UQResult, noiselevel @@ -23,10 +25,13 @@ layout = html.Div([ dcc.Store(id='comp-n-slots', data=N_SLOTS_DEFAULT), dcc.Store(id='comp-search-target-slot'), + # Saved warnings of each compared fit, in table column order + dcc.Store(id='comp-warnings-store'), # ── Hidden: modals & drawer ──────────────────────────────────────────── create_dataset_modal(PAGE_ID, select_btn_id={'type': 'comp-select-dataset-btn', 'page': PAGE_ID}), search_fit_modal(), + list_of_warnings_modal(PAGE_ID), dmc.Drawer( id='comp-options-drawer', @@ -244,6 +249,7 @@ def get_options(dataset_id): @callback( Output('comp-plot', 'figure'), Output('comp-stats-table', 'children'), + Output('comp-warnings-store', 'data'), Input({'type': 'dataset-dropdown', 'page': PAGE_ID, 'index': ALL}, 'value'), Input({'type': 'fit-dropdown', 'page': PAGE_ID, 'index': ALL}, 'value'), Input('comp-n-slots', 'data'), @@ -279,7 +285,35 @@ def compare_fits(dataset_ids, fit_ids, n_slots, offset, ci_str, show_ci): fig.update_layout(title=None) stats = _build_stats_tables(data_dicts, titles, n_slots) - return fig, stats + warnings = [dd.get('warnings') for dd in data_dicts] + return fig, stats, warnings + + +@callback( + # The shared modal's outputs are also targeted by the MATCH callbacks in + # components/warnings.py, hence allow_duplicate. + Output({'type': 'n_warnings_overview-modal', 'page': PAGE_ID}, 'opened', allow_duplicate=True), + Output({'type': 'n_warnings_overview-modal', 'page': PAGE_ID}, 'title', allow_duplicate=True), + Output({'type': 'n_warnings_overview-modal-content', 'page': PAGE_ID}, 'children', allow_duplicate=True), + Input({'type': 'comp-warnings-expand-btn', 'index': ALL}, 'n_clicks'), + State('comp-warnings-store', 'data'), + prevent_initial_call=True, +) +def open_comp_warnings_modal(n_clicks_list, warnings_store): + # The buttons are re-created with the stats table, which fires this with + # n_clicks=None, so only open on a real click. + if not ctx.triggered_id or not any(n_clicks_list) or not warnings_store: + return dash.no_update, dash.no_update, dash.no_update + index = ctx.triggered_id['index'] + if not ctx.triggered[0]['value'] or index >= len(warnings_store): + return dash.no_update, dash.no_update, dash.no_update + + warnings = warnings_from_dict(warnings_store[index] or []) + if len(warnings) == 0: + children = dmc.Text("No warnings to display.", c="gray", size="md") + else: + children = list_of_warnings_card(warnings) + return True, f"Warnings – Dataset {index + 1}", children @callback( @@ -352,11 +386,14 @@ def _fit_to_dict(dataset, fit): out['P'] = None out['PUncert'] = None out['background'] = None + out['warnings'] = None return out out['model_t'] = np.array(fit.t, dtype=float) out['dist_stats'] = fit.dist_stats or {} out['gof'] = fit.gof or {} + # None for fits saved before warnings were recorded, shown as "–" + out['warnings'] = fit.warnings background_only = getattr(fit, 'fit_type', None) == 'background' @@ -432,6 +469,30 @@ def _format_dist_stat(entry): return val +def _warnings_cell(warnings, index): + """ + Critical and moderate warning counts, styled like ``number_of_warnings_card``, + with a button that opens the list of warnings for the fit at ``index``. + """ + if warnings is None: + return dmc.Text("–", size="sm") + counts = count_by_level(warnings_from_dict(warnings)) + expand_button = dmc.ActionIcon( + DashIconify(icon="mdi:arrow-expand", width=14, color="gray"), + id={'type': 'comp-warnings-expand-btn', 'index': index}, + variant="transparent", + size="sm", + disabled=len(warnings) == 0, + ) + return dmc.Group([ + dmc.Group([DashIconify(icon="mdi:alert-circle-outline", width=16, color="red"), + dmc.Text(f"{counts['critical']}", size="sm", fw=700, c="red"), + DashIconify(icon="mdi:alert-outline", width=16, color="orange"), + dmc.Text(f"{counts['moderate']}", size="sm", fw=700, c="orange"),], gap=4, wrap="nowrap"), + expand_button,], + justify="space-between") + + def _header_cells(titles): cells = [dmc.TableTh("Metric")] for i, title in enumerate(titles): @@ -445,7 +506,10 @@ def _header_cells(titles): def _make_time_table(data_dicts, titles): - body_rows = [] + cells = [dmc.TableTd(dmc.Text("Warnings", size="sm", fw=500))] + for i, dd in enumerate(data_dicts): + cells.append(dmc.TableTd(_warnings_cell(dd.get('warnings'), i))) + body_rows = [dmc.TableTr(cells)] for key in TIME_LABELS: label = TIME_LABELS[key] diff --git a/src/deeranalysis/pages/dataset_detail.py b/src/deeranalysis/pages/dataset_detail.py index 89e5a7f..84a6d95 100644 --- a/src/deeranalysis/pages/dataset_detail.py +++ b/src/deeranalysis/pages/dataset_detail.py @@ -17,6 +17,7 @@ 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.utils.deerlab_fitwarnings import warnings_from_dict, count_by_level from deeranalysis.components.data_viewer import plot_upload,data_viewer_layout dash.register_page(__name__, path_template="/dataset/") @@ -25,10 +26,11 @@ # Column definitions for the fits sub-table # --------------------------------------------------------------------------- fits_columnDefs = [ - {"field": "Name", "filter": "agTextColumnFilter", "flex": 2}, + {"field": "Name", "filter": "agTextColumnFilter", "flex": 3}, {"field": "Type", "filter": "agTextColumnFilter", "flex": 1}, {"field": "Engine", "filter": "agTextColumnFilter", "flex": 1}, - {"field": "RMSD", "flex": 1}, + {"field": "Warnings", "width": 90, "suppressSizeToFit": True, "cellRenderer": "WarningsCellRenderer", "sortable": False}, + {"field": "RMSD", "width": 75, "suppressSizeToFit": True}, {"field": "Date", "flex": 2}, { "field": "Open", @@ -302,11 +304,21 @@ def _build_fits_rows(dataset): if fit.gof and isinstance(fit.gof, dict): rmsd_val = fit.gof.get("rmsd", fit.gof.get("RMSD", "")) rmsd = f"{rmsd_val:.4f}" if isinstance(rmsd_val, float) else str(rmsd_val) + + warnings = getattr(fit, "warnings", None) + n_warnings = count_by_level(warnings_from_dict(warnings)) if warnings is not None else None + timestamp = getattr(fit, "created_at", None) + timestamp_iso = dt.datetime.fromisoformat(str(timestamp)) if timestamp else None + if timestamp_iso: + timestamp_str = timestamp_iso.strftime("%Y-%m-%d %H:%M:%S") + else: + timestamp_str = "" rows.append({ "Name": getattr(fit, "name", ""), "Type": getattr(fit, "fit_type", ""), "Engine": getattr(fit, "engine", ""), - "Date": str(getattr(fit, "created_at", "")), + "Warnings": n_warnings, + "Date": timestamp_str, "id": fit.id, "RMSD": rmsd, }) diff --git a/src/deeranalysis/pages/deernet.py b/src/deeranalysis/pages/deernet.py index 6df8342..8759063 100644 --- a/src/deeranalysis/pages/deernet.py +++ b/src/deeranalysis/pages/deernet.py @@ -19,6 +19,8 @@ import deerlab as dl dash.register_page(__name__) import deeranalysis.components.fit_page_components as fpc +from deeranalysis.components.warnings import list_of_warnings_modal +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict import os page_id='deernet' @@ -31,6 +33,7 @@ dbc.Col([ create_dataset_modal(page_id=page_id), create_fit_download_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), html.Div([ dmc.Select(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", style={'flex': '1 1 0'}), dmc.ActionIcon(DashIconify(icon='material-symbols:search', width=20), @@ -155,16 +158,20 @@ def run_fit(n_clicks, dataset_id,model_size): try: fit = deernet2(dataset, model_size, model_dir=deernet_folder, providor=['CPUExecutionProvider']) + except Exception as e: + alert = dmc.Alert(f"Error running DeerNet fit: {e}", title='Error!', color="red", duration=10000,withCloseButton=True) print(f"Error running DeerNet fit: {e}") - return dash.no_update, dash.no_update, dash.no_update, True, True + return dash.no_update, True, True, alert + warnings = check_fit_results(fit, None) dist_stats = dl.diststats(fit.r, fit.P, fit.PUncert) dist_stats_dict = dists_stats_to_list(*dist_stats) fit_dict = fit_to_dict(fit) fit_dict['dist_stats'] = dist_stats_dict fit_dict['gof'] = fit.stats + fit_dict['warnings'] = warnings_to_dict(warnings) # return fit_dict, gof_fig, dist_stats_output, False, False return fit_dict, False, False,None diff --git a/src/deeranalysis/pages/fit_detail.py b/src/deeranalysis/pages/fit_detail.py index e37b021..92bd986 100644 --- a/src/deeranalysis/pages/fit_detail.py +++ b/src/deeranalysis/pages/fit_detail.py @@ -19,6 +19,9 @@ 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 +from deeranalysis.utils.deerlab_fitwarnings import warnings_from_dict, count_by_level +from deeranalysis.components.warnings import list_of_warnings_card + import deerlab as dl from deerlab.classes import UQResult @@ -116,9 +119,11 @@ def layout(fit_id=None): dbc.Col([ _basic_fit_info(fit), _fit_description(fit), + _warnings(fit), _fit_gof_stats(fit), _fit_dist_stats(fit), _global_datasets(fit), + ], width=5), dbc.Col([ _fit_plot(fit,dataset), @@ -261,6 +266,7 @@ def _basic_fit_info(fit): readOnly=True, variant="default", styles=input_styles, + valueFormat = "YYYY-MM-DD HH:mm:ss", ), span=6, ), @@ -435,6 +441,45 @@ def _dataset_card(dataset_id, primary=True): return output +def _warnings(fit): + """ + Creates a collapsable card that lists all warnings associated with the fit. + + """ + + warnings = warnings_from_dict(getattr(fit, "warnings", None)) + counts = count_by_level(warnings) if warnings is not None else None + if counts is None: + counts = {"critical": "-", "moderate": "-"} + + counts_sec = dmc.Group([DashIconify(icon="mdi:alert-circle-outline", width=16, color="red"), + dmc.Text(f"{counts['critical']}", size="sm", fw=700, c="red"), + DashIconify(icon="mdi:alert-outline", width=16, color="orange"), + dmc.Text(f"{counts['moderate']}", size="sm", fw=700, c="orange"),], gap=4, wrap="nowrap") + + output = dmc.Paper([ + dmc.Group([ + dmc.Group([dmc.Title("Warnings", order=4, mb="sm"),counts_sec]), + dmc.Button( + DashIconify(icon="tabler:chevron-down"), + id="fd-warnings-toggle", + variant="subtle", + color="gray", + size="sm", + p=0, + ), + ], justify="space-between", mb="sm"), + dmc.Collapse( + html.Div([ + list_of_warnings_card(warnings) + ]), + id="fd-warnings-collapse", + opened=True, + ), + ], p="md", mb="md", withBorder=True, radius="md") + + return output + @callback( Output("fd-fit-name", "readOnly"), Output("fd-fit-name", "variant"), @@ -523,6 +568,15 @@ def toggle_plot_collapse(n_clicks, opened): def toggle_datasets_collapse(n_clicks, opened): return not opened +@callback( + Output("fd-warnings-collapse", "opened"), + Input("fd-warnings-toggle", "n_clicks"), + State("fd-warnings-collapse", "opened"), + prevent_initial_call=True, +) +def toggle_warnings_collapse(n_clicks, opened): + return not opened + def _fit_plot(fit,dataset): if not fit.t or not fit.model: content = dmc.Text("No time-domain data available for plotting.") diff --git a/src/deeranalysis/pages/global.py b/src/deeranalysis/pages/global.py index d5a4931..d7900f2 100644 --- a/src/deeranalysis/pages/global.py +++ b/src/deeranalysis/pages/global.py @@ -16,6 +16,8 @@ import deeranalysis.components.fpc_global as fpcg from deeranalysis.utils.deerlab_options import background_models, fit_to_dict,name_dataset_from_dict,dists_stats_to_list from deeranalysis.utils.deerlab_global import deerlab_global_fitting, extract_global_P, build_global_model_data +from deeranalysis.components.warnings import list_of_warnings_modal +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict import traceback @@ -49,6 +51,7 @@ create_dataset_modal(page_id=page_id), dummy_download_modal, create_model_edit_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), html.Div([ dmc.Group([dmc.MultiSelect(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", description="Between 2 and 5 dataset should selected.")],style={'flex': '1 1 0',"flexGrow":1}), dmc.ActionIcon(DashIconify(icon='material-symbols:search', width=20), @@ -299,8 +302,9 @@ def run_fit(n_clicks, dataset_id, fit_options, model_overrides): traceback.print_exc() return dash.no_update, f"Error during fitting: {e}", True, True - + warnings = check_fit_results(fit, fit.Vmodel) fit_store = fit_to_dict(fit,n_datasets) + fit_store['warnings'] = warnings_to_dict(warnings) return fit_store, fit.__str__(), False, False # figures_store = [] diff --git a/src/deeranalysis/pages/nonparametric.py b/src/deeranalysis/pages/nonparametric.py index 119d495..04d4447 100644 --- a/src/deeranalysis/pages/nonparametric.py +++ b/src/deeranalysis/pages/nonparametric.py @@ -13,8 +13,11 @@ from deeranalysis.components.dataset_search_model import create_dataset_modal from deeranalysis.components.download_modal import create_fit_download_modal from deeranalysis.components.model_edit_modal import create_model_edit_modal +from deeranalysis.components.warnings import list_of_warnings_modal 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 +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict, warnings_from_dict + import deeranalysis.components.fit_page_components as fpc dash.register_page(__name__) @@ -31,6 +34,7 @@ create_dataset_modal(page_id=page_id), create_fit_download_modal(page_id=page_id), create_model_edit_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), html.Div([ dmc.Select(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", style={'flex': '1 1 0'}), dmc.ActionIcon(DashIconify(icon='material-symbols:search', width=20), @@ -229,13 +233,16 @@ def run_fit(n_clicks, dataset_id, bg_model_option, compactness, distance_axis, p print(traceback.format_exc()) print(f"Error during fitting: {e}") return dash.no_update, f"Error during fitting: {e}", True, True, False - + + warnings = check_fit_results(fit, fit.Vmodel) + print(f"Found n warnings: {len(warnings)}") dist_stats = dl.diststats(r,fit.P,fit.PUncert) dist_stats_dict = dists_stats_to_list(*dist_stats) fit_dict = fit_to_dict(fit) fit_dict['dist_stats'] = dist_stats_dict fit_dict['gof'] = fit.stats + fit_dict['warnings'] = warnings_to_dict(warnings) # fit_dict['dataset'] = dataset.to_dict() return fit_dict, fit.__str__(), False, False, False diff --git a/src/deeranalysis/pages/parametric.py b/src/deeranalysis/pages/parametric.py index f37e959..ae9587b 100644 --- a/src/deeranalysis/pages/parametric.py +++ b/src/deeranalysis/pages/parametric.py @@ -25,6 +25,8 @@ dist_stats_tab, ) import deeranalysis.components.fit_page_components as fpc +from deeranalysis.components.warnings import list_of_warnings_modal +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict from deeranalysis.utils.deerlab_normal import deerlab_fitting import dash_mantine_components as dmc @@ -43,6 +45,7 @@ create_dataset_modal(page_id=page_id), create_fit_download_modal(page_id=page_id), create_model_edit_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), html.Div([ dmc.Select(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", style={'flex': '1 1 0'}), dmc.ActionIcon(DashIconify(icon='material-symbols:search', width=20), @@ -243,6 +246,7 @@ def run_fit(n_clicks, dataset_id, fit_options, model_params): return dash.no_update, f"Error during fitting: {e}", True, True, False r = fit.r + warnings = check_fit_results(fit, fit.Vmodel) dist_stats = dl.diststats(r, fit.P, fit.PUncert) dist_stats_dict = dists_stats_to_list(*dist_stats) @@ -256,6 +260,7 @@ def run_fit(n_clicks, dataset_id, fit_options, model_params): fit_dict = fit_to_dict(fit) fit_dict['dist_stats'] = dist_stats_dict fit_dict['gof'] = fit.stats + fit_dict['warnings'] = warnings_to_dict(warnings) return fit_dict, fit.__str__(), False, False, False diff --git a/src/deeranalysis/pages/population.py b/src/deeranalysis/pages/population.py index f18d823..04286a5 100644 --- a/src/deeranalysis/pages/population.py +++ b/src/deeranalysis/pages/population.py @@ -19,6 +19,9 @@ import deeranalysis.components.fpc_global as fpcg from deeranalysis.utils.deerlab_population import deerlab_population_fitting, determine_pop_P, build_population_model_data +from deeranalysis.components.warnings import list_of_warnings_modal +from deeranalysis.utils.deerlab_fitwarnings import check_fit_results, warnings_to_dict + dash.register_page(__name__) @@ -58,6 +61,7 @@ startup_message, create_dataset_modal(page_id=page_id), create_model_edit_modal(page_id=page_id), + list_of_warnings_modal(page_id=page_id), dummy_download_modal, html.Div([ dmc.Group([dmc.MultiSelect(id={'type': 'dataset-dropdown', 'page': page_id}, label="Select a dataset", description="Between 2 and 5 dataset should selected.")],style={'flex': '1 1 0',"flexGrow":1}), @@ -282,9 +286,11 @@ def run_fit(n_clicks, dataset_id, fit_options, model_params): print(f"Error during fitting: {e}") return dash.no_update, f"Error during fitting: {e}", True, True - + warnings = check_fit_results(fit, fit.Vmodel) + fit_store = fit_to_dict(fit,n_datasets) fit_store['populations'] = calc_population_fractions(fit) + fit_store['warnings'] = warnings_to_dict(warnings) return fit_store, fit.__str__(), False, False diff --git a/src/deeranalysis/utils/database.py b/src/deeranalysis/utils/database.py index 6378985..6f6f25d 100644 --- a/src/deeranalysis/utils/database.py +++ b/src/deeranalysis/utils/database.py @@ -70,11 +70,16 @@ class Fit(Base): model_description = Column(JSON, nullable=True) parameters = Column(JSON, nullable=True) fit_results = Column(JSON, nullable=True) # Fitted model, residuals, stats + warnings = Column(JSON, nullable=True) # Any warnings generated during the fit created_at = Column(DateTime, default=datetime.now(timezone.utc)) data = Column(JSON,nullable=True, default=None) # JSONified data of the FitResult object, including model, uncertainties, regparam, etc. dataset = relationship("Dataset", back_populates="fits") + @property + def n_warnings(self): + return len(self.warnings) if self.warnings else 0 + class Settings(Base): diff --git a/src/deeranalysis/utils/deerlab_fitwarnings.py b/src/deeranalysis/utils/deerlab_fitwarnings.py new file mode 100644 index 0000000..02c6728 --- /dev/null +++ b/src/deeranalysis/utils/deerlab_fitwarnings.py @@ -0,0 +1,556 @@ +""" +Warnings raised after inspecting a DeerLab fit result. + +Each warning carries the key details of one specific problem with a fit +(the offending value, the parameter name, the thresholds that were crossed) +together with a ``title``, a human readable ``message`` and a severity +``level``, so that a message box can be built directly from the warning +object:: + + for warning in check_fit_results(fit, model): + card = warning_card(warning) # uses .title / .message / .level + +Every warning is one of two levels: + +``WarningLevel.MODERATE`` + The fit is usable but should be treated with care. +``WarningLevel.CRITICAL`` + The fit, or at least the affected parameter, should not be trusted. + +The thresholds that separate the two levels live on the warning classes as +class attributes, so they can be tuned in one place, and each class provides a +``check`` classmethod that returns either a warning instance or ``None``. +""" + +from enum import Enum +import numpy as np + + +class WarningLevel(str, Enum): + """Severity of a fit warning.""" + + MODERATE = "moderate" + CRITICAL = "critical" + + @property + def severity(self): + """Integer rank, so warnings can be sorted worst-first.""" + return 2 if self is WarningLevel.CRITICAL else 1 + + +MODERATE = WarningLevel.MODERATE +CRITICAL = WarningLevel.CRITICAL + +#: Warning classes by name, filled in by ``FitWarning.__init_subclass__`` and +#: used by :meth:`FitWarning.from_dict` to restore the right class. +_WARNING_TYPES = {} + + +class FitWarning(Warning): + """ + Base class for all fit warnings. + + Attributes + ---------- + title : str + Short heading for the message box. + message : str + Full explanation, ready to be displayed to the user. + level : WarningLevel + Either ``MODERATE`` or ``CRITICAL``. + details : dict + The raw values behind the warning (parameter name, fitted value, + thresholds, ...) for tooltips, logs or the database. + """ + + title = "Fit Warning" + level = WarningLevel.MODERATE + + def __init__(self, message, *, title=None, level=None, details=None): + self.message = message + if title is not None: + self.title = title + if level is not None: + self.level = WarningLevel(level) + self.details = dict(details) if details else {} + super().__init__(message) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + _WARNING_TYPES[cls.__name__] = cls + + @property + def is_critical(self): + return self.level is WarningLevel.CRITICAL + + def to_dict(self): + """Serialisable form of the warning, e.g. for storage or callbacks.""" + return { + "type": type(self).__name__, + "title": self.title, + "message": self.message, + "level": self.level.value, + "details": self.details, + } + + @classmethod + def from_dict(cls, data): + """ + Rebuild a warning from the output of :meth:`to_dict`. + + The class named by ``data["type"]`` is restored, falling back to the + class this is called on if the name is unknown. The stored values are + taken as they are rather than being recomputed, so a warning read back + from the database says exactly what it said when it was raised, even if + the thresholds have since been changed. + """ + klass = _WARNING_TYPES.get(data.get("type"), cls) + details = dict(data.get("details") or {}) + warning = klass.__new__(klass) + # Bypass the subclass constructors: they expect the fitted quantities, + # which are recovered from ``details`` below. + FitWarning.__init__( + warning, + data["message"], + title=data.get("title"), + level=data.get("level"), + details=details, + ) + for key, value in details.items(): + setattr(warning, key, value) + return warning + + def __str__(self): + return f"{self.title}: {self.message}" + + def __repr__(self): + return f"{type(self).__name__}({self.level.value!r}, {self.message!r})" + + +_WARNING_TYPES[FitWarning.__name__] = FitWarning + + +# ----------------------------------------------------------------------------- +# Goodness-of-fit warnings +# ----------------------------------------------------------------------------- + +class StatWarning(FitWarning): + """ + Base class for all statistical warnings. + + Goodness-of-fit statistics are reported per dataset, so these warnings + carry the number of the dataset they belong to. ``dataset`` is ``None`` + for a fit of a single dataset, and 1-based for a global fit, matching the + ``_1``, ``_2``, ... suffixes DeerLab gives the per-dataset parameters. + """ + + def __init__(self, message, *, dataset=None, level=None, details=None): + self.dataset = dataset + title = self.title if dataset is None else f"{self.title} (Dataset {dataset})" + details = dict(details) if details else {} + details.setdefault("dataset", dataset) + super().__init__(message, title=title, level=level, details=details) + + +class Chi2Warning(StatWarning): + """Warning for high reduced chi-squared values.""" + + title = "High Reduced Chi-Squared Value" + moderate_above = 1.5 + critical_above = 3.0 + + def __init__(self, chi2red, dataset=None): + self.chi2red = float(chi2red) + level = CRITICAL if self.chi2red >= self.critical_above else MODERATE + if level is CRITICAL: + message = ( + f"The reduced chi-squared value is {self.chi2red:.3f}. " + "The model cannot describe the data; the fit should not be used." + ) + else: + message = ( + f"The reduced chi-squared value is {self.chi2red:.3f}, above the " + f"expected value of 1. The model may not fully describe the data." + ) + super().__init__( + message, + dataset=dataset, + level=level, + details={ + "chi2red": self.chi2red, + "moderate_above": self.moderate_above, + "critical_above": self.critical_above, + }, + ) + + @classmethod + def check(cls, chi2red, dataset=None): + """Return a :class:`Chi2Warning` if ``chi2red`` is too high, else ``None``.""" + if chi2red is None or not np.isfinite(chi2red): + return None + return cls(chi2red, dataset=dataset) if chi2red > cls.moderate_above else None + + +class R2Warning(StatWarning): + """Warning for low R-squared values.""" + + title = "Low R-Squared Value" + moderate_below = 0.90 + critical_below = 0.70 + + def __init__(self, r2, dataset=None): + self.r2 = float(r2) + level = CRITICAL if self.r2 <= self.critical_below else MODERATE + if level is CRITICAL: + message = ( + f"The R-squared value is {self.r2:.3f}. The fit does not follow the " + "data and should not be used." + ) + else: + message = ( + f"The R-squared value is {self.r2:.3f}, below the expected " + f"{self.moderate_below:.2f}. The fit does not follow the data closely." + ) + super().__init__( + message, + dataset=dataset, + level=level, + details={ + "r2": self.r2, + "moderate_below": self.moderate_below, + "critical_below": self.critical_below, + }, + ) + + @classmethod + def check(cls, r2, dataset=None): + """Return an :class:`R2Warning` if ``r2`` is too low, else ``None``.""" + if r2 is None or not np.isfinite(r2): + return None + return cls(r2, dataset=dataset) if r2 < cls.moderate_below else None + + +# ----------------------------------------------------------------------------- +# Parameter warnings +# ----------------------------------------------------------------------------- + +class ParameterWarning(FitWarning): + """Base class for all warnings concerning a single fitted parameter.""" + + title = "Parameter Warning" + + def __init__(self, parameter_name, message, *, title=None, level=None, details=None): + self.parameter_name = parameter_name + details = dict(details) if details else {} + details.setdefault("parameter_name", parameter_name) + super().__init__(message, title=title, level=level, details=details) + + +class UnfittedParameterWarning(ParameterWarning): + """ + Warning for a parameter that had no influence on the fit. + + Identified from a zero column in the Jacobian: the residuals do not change + when the parameter changes, so its value is not determined by the data. + """ + + title = "Unfitted Parameter" + level = CRITICAL + + def __init__(self, parameter_name, value=None): + self.value = value + value_str = f" (reported as {value:.4g})" if value is not None else "" + message = ( + f"Parameter '{parameter_name}'{value_str} had no effect on the fit. " + "Its value is not determined by the data and carries no meaning." + ) + super().__init__(parameter_name, message, details={"value": value}) + + @classmethod + def check(cls, parameter_name, value, std): + """Return an :class:`UnfittedParameterWarning` if the parameter was unfitted.""" + if std is None: + return None + # ``std`` comes back from DeerLab as an array, one entry per element of + # the parameter, so reduce it rather than testing it for truth. + if np.all(np.asarray(std) == 0): + return cls(parameter_name, value) + return None + + + +class ParameterBoundsWarning(ParameterWarning): + """ + Warning for a parameter that ended the fit sitting on one of its bounds. + + A parameter pinned at a bound means the optimiser wanted to move further + than it was allowed to, so the reported value is set by the bound and not + by the data. + """ + + title = "Parameter At Its Bounds" + level = CRITICAL + #: Relative distance below which a value counts as sitting on a bound. + tolerance = 1e-3 + + def __init__(self, parameter_name, value, lower_bound, upper_bound, bound=None): + self.value = float(value) + self.lower_bound = lower_bound + self.upper_bound = upper_bound + self.bound = bound or self._which_bound(self.value, lower_bound, upper_bound) + bound_value = lower_bound if self.bound == "lower" else upper_bound + message = ( + f"Parameter '{parameter_name}' is pinned at its {self.bound} bound " + f"({bound_value:.4g}) within the range ({lower_bound:.4g}, " + f"{upper_bound:.4g}). Its value is set by the bound, not by the data." + ) + super().__init__( + parameter_name, + message, + details={ + "value": self.value, + "lower_bound": lower_bound, + "upper_bound": upper_bound, + "bound": self.bound, + }, + ) + + @classmethod + def _which_bound(cls, value, lower_bound, upper_bound): + return "lower" if abs(value - lower_bound) <= abs(value - upper_bound) else "upper" + + @classmethod + def _at_bound(cls, value, bound, span): + return abs(value - bound) <= cls.tolerance * span + + @classmethod + def check(cls, parameter_name, value, lower_bound, upper_bound): + """Return a :class:`ParameterBoundsWarning` if ``value`` sits on a bound.""" + if value is None or not np.isfinite(value): + return None + if lower_bound is None or upper_bound is None: + return None + if not (np.isfinite(lower_bound) and np.isfinite(upper_bound)): + return None + span = abs(upper_bound - lower_bound) or 1.0 + for bound, bound_value in (("lower", lower_bound), ("upper", upper_bound)): + if cls._at_bound(value, bound_value, span): + return cls(parameter_name, value, lower_bound, upper_bound, bound=bound) + return None + + +class ParameterUncertaintyWarning(ParameterWarning): + """ + Warning for a parameter whose uncertainty is large compared to its value. + + ``uncertainty`` is the half-width of the confidence interval, as obtained + from the covariance matrix. + """ + + title = "Uncertain Parameter" + #: Relative uncertainty (uncertainty / |value|) above which to warn. + moderate_above = 0.5 + critical_above = 1.0 + + def __init__(self, parameter_name, value, uncertainty, title=None,level=None): + self.value = value + self.uncertainty = uncertainty + self.relative_uncertainty = self._relative(value, uncertainty) + rel = self.relative_uncertainty + unbounded = rel is None or not np.isfinite(rel) + if level is None: + level = CRITICAL if unbounded or rel >= self.critical_above else MODERATE + if unbounded: + message = ( + f"The uncertainty of parameter '{parameter_name}' could not be " + "bounded. Its value should not be interpreted." + ) + else: + message = ( + f"Parameter '{parameter_name}' is {value:.4g} ± {uncertainty:.4g} " + f"({rel:.0%} of its value). " + + ( + "Its value is not meaningfully determined by the data." + if level is CRITICAL + else "Its value is only loosely determined by the data." + ) + ) + super().__init__( + parameter_name, + message, + title=title, + level=level, + details={ + "value": value, + "uncertainty": uncertainty, + "relative_uncertainty": rel, + "moderate_above": self.moderate_above, + "critical_above": self.critical_above, + }, + ) + + @staticmethod + def _relative(value, uncertainty): + if uncertainty is None or not np.isfinite(uncertainty): + return float("inf") + if value is None or not np.isfinite(value) or value == 0: + return float("inf") + return abs(uncertainty / value) + + @classmethod + def check(cls, parameter_name, value, uncertainty): + """Return a warning if the relative uncertainty is too large, else ``None``.""" + if uncertainty is None: + return None + rel = cls._relative(value, uncertainty) + if not np.isfinite(rel) or rel > cls.moderate_above: + return cls(parameter_name, value, uncertainty) + return None + + +class ConcUncertaintyWarning(ParameterUncertaintyWarning): + """Warning for a spin concentration with a large uncertainty.""" + + title = "Uncertain Spin Concentration" + + def __init__(self, value, uncertainty, parameter_name="conc"): + super().__init__(parameter_name, value, uncertainty, title=self.title,level=WarningLevel.MODERATE) + + @classmethod + def check(cls, value, uncertainty, parameter_name="conc"): + """Return a :class:`ConcUncertaintyWarning` if the uncertainty is too large.""" + if uncertainty is None: + return None + rel = cls._relative(value, uncertainty) + if not np.isfinite(rel) or rel > cls.moderate_above: + return cls(value, uncertainty, parameter_name=parameter_name) + return None + + +# ----------------------------------------------------------------------------- +# Helpers for the GUI +# ----------------------------------------------------------------------------- + +def count_by_level(warnings): + """ + Count warnings per level. + + Returns a ``dict`` with the keys ``"critical"`` and ``"moderate"``, ready + for ``number_of_warnings_card``. + """ + counts = {level.value: 0 for level in WarningLevel} + for warning in warnings: + counts[WarningLevel(warning.level).value] += 1 + return counts + + +def sort_by_level(warnings): + """Return the warnings ordered worst-first, keeping the original order within a level.""" + return sorted(warnings, key=lambda w: -WarningLevel(w.level).severity) + + +#: Parameters skipped by :func:`check_fit_results`, because they are nuisance +#: parameters rather than a fitted quantity the user would act on. +SKIPPED_PARAMETERS = ('P', 'P_scale' 'scale') + + +def _named(param_name, names): + """ + Whether ``param_name`` is one of ``names``. + + A global fit suffixes the parameters that are not linked across datasets, + so ``'P_scale_2'`` counts as ``'P_scale'``. + """ + return any(param_name == name or param_name.startswith(f'{name}_') for name in names) + + +def check_fit_results(fit_result, fit_model): + """ + Check the fit results for any warnings and returns a list of Warnings. + + Handles both a fit of a single dataset and a global fit of several: the + goodness-of-fit statistics are checked per dataset, and the warnings of a + global fit name the dataset they belong to. + + The checks that pass are dropped, and the warnings that remain are ordered + worst-first, so the result can be handed straight to the GUI. + + Parameters + ---------- + fit_result : DeerLab FitResult + The result of a DeerLab fit, either a single fit or a global fit. + fit_model : DeerLab Model + The model that was fitted, or ``None`` if the model is not available for DeerNet fits. In this case, only goodness-of-fit statistics are checked. + """ + warnings = [w for w in _iter_fit_checks(fit_result, fit_model) if w is not None] + return sort_by_level(warnings) + + +def _iter_fit_checks(fit_result, fit_model): + """ + Run every check against the fit result. + + Yields one result per check, which is ``None`` whenever the check passed; + :func:`check_fit_results` filters those out. + """ + + # A global fit reports one set of statistics per dataset, a single fit one + # dict for the one dataset it fitted. + stats = fit_result.stats + if isinstance(stats, dict): + stats = [stats] + for dataset, stat in enumerate(stats, start=1): + # Only number the datasets when there is more than one to tell apart. + dataset = dataset if len(stats) > 1 else None + if 'chi2red' in stat: + yield Chi2Warning.check(stat['chi2red'], dataset=dataset) + if 'R2' in stat: + yield R2Warning.check(stat['R2'], dataset=dataset) + + if not fit_model is None: + for param_name in fit_result.paramlist: + param_name = str(param_name) + if _named(param_name, SKIPPED_PARAMETERS): + continue + + pUncert = getattr(fit_result, f"{param_name}Uncert", None) + pModel = getattr(fit_model, param_name, None) + pValue = getattr(fit_result, param_name) + + # The distance distribution and any other vector-valued parameter hold + # one value per distance point, not a single fitted quantity. + if np.size(pValue) > 1: + continue + + if pUncert is not None: + yield UnfittedParameterWarning.check(param_name, pValue, pUncert.std) + + if pModel is not None: + yield ParameterBoundsWarning.check(param_name, pValue, pModel.lb, pModel.ub) + + if pModel is not None and pUncert is not None: + # Half-width of the 95% confidence interval + pm_ci = np.max(np.abs(pUncert.ci(95) - pValue)) + if _named(param_name, ('conc',)): + yield ConcUncertaintyWarning.check(pValue, pm_ci, parameter_name=param_name) + else: + yield ParameterUncertaintyWarning.check(param_name, pValue, pm_ci) + + + +def warnings_to_dict(warnings): + """ + Convert a list of warnings to a serialisable form. + + Returns a ``list`` of ``dict``s, each one the output of + :meth:`FitWarning.to_dict`. + """ + return [w.to_dict() for w in warnings] + +def warnings_from_dict(data): + """ + Rebuild a list of warnings from the output of :func:`warnings_to_dict`. + + Returns a ``list`` of :class:`FitWarning` instances. + """ + return [FitWarning.from_dict(w) for w in data] \ No newline at end of file diff --git a/src/deeranalysis/utils/deerlab_global.py b/src/deeranalysis/utils/deerlab_global.py index 7042e86..ab97b57 100644 --- a/src/deeranalysis/utils/deerlab_global.py +++ b/src/deeranalysis/utils/deerlab_global.py @@ -240,6 +240,7 @@ def deerlab_global_fitting(datasets,linked_params,bg_model=dl.bg_hom3d, verbosit fit = dl.fit(global_model, Vs,**kwargs) + fit.Vmodel = global_model fit.r = r fit.bg_model = bg_model fit.Vexp = Vs diff --git a/src/deeranalysis/utils/deerlab_normal.py b/src/deeranalysis/utils/deerlab_normal.py index caa1ab8..06d3e3e 100644 --- a/src/deeranalysis/utils/deerlab_normal.py +++ b/src/deeranalysis/utils/deerlab_normal.py @@ -458,14 +458,12 @@ def background_func(t, fit): def deerlab_background_only(dataset, bg_model=dl.bg_hom3d, verbosity=0, **kwargs): + Vexp:np.ndarray = dataset.data t = dataset['t'].data if np.iscomplexobj(Vexp): Vexp,Vexp_im,_ = dl.correctphase(Vexp,full_output=True) - elif remove_crossing: - Warning("Crossing removal only works with complex data. Skipping") - remove_crossing = False Vexp /= Vexp.max() diff --git a/src/deeranalysis/utils/deerlab_population.py b/src/deeranalysis/utils/deerlab_population.py index 6081108..2ffa42e 100644 --- a/src/deeranalysis/utils/deerlab_population.py +++ b/src/deeranalysis/utils/deerlab_population.py @@ -484,7 +484,7 @@ def deerlab_population_fitting(datasets, model=dl.dd_gauss, n_pops=2, bg_model=d # fit.datasets = datasets fit.Vexp = Vs fit.t = ts - # fit.Vmodels = Vmodels + fit.Vmodel = global_model fit.pathways = [pathways for i in range(Nsignals)] for i in range(len(fit.stats)): diff --git a/src/deeranalysis/utils/deernet.py b/src/deeranalysis/utils/deernet.py index 03f639c..d36086c 100644 --- a/src/deeranalysis/utils/deernet.py +++ b/src/deeranalysis/utils/deernet.py @@ -288,7 +288,7 @@ def signal_mode(x,ffs,ntraces): dataset.mdpths = mdpths dataset.mdpths_av = np.mean(mdpths,2) - dataset.mdpths_std = np.mean(mdpths,2) + dataset.mdpths_std = np.std(mdpths,2) dist_av = np.mean(distds,2) dist_lb = np.mean(distds,2) - 2 * np.std(distds,2) @@ -380,8 +380,10 @@ def deernet2(dataset, model_size, providor=None,model_dir=None) -> DEERnetResult exp_type = 'deer' elif dataset.attrs['seq_name'] == 'RIDME': exp_type = 'ridme' + elif dataset.attrs['seq_name'].lower() == 'single': + exp_type = 'deer' else: - raise ValueError(f'Experiment type {dataset.attrs['seq_name']} not supported for deernet2. Supported types are 4pDEER and RIDME.') + raise ValueError(f'Experiment type {dataset.attrs['seq_name']} not supported for deernet2. Supported types are 4pDEER, Single, RIDME.') if 't' in dataset.coords: @@ -527,7 +529,7 @@ def signal_mode(x,ffs,ntraces): dataset.mdpths = mdpths dataset.mdpths_av = np.mean(mdpths,2) - dataset.mdpths_std = np.mean(mdpths,2) + dataset.mdpths_std = np.std(mdpths,2) dist_av = np.mean(distds,2) dist_lb = np.mean(distds,2) - 2 * np.std(distds,2) diff --git a/tests/test_deerlab_normal.py b/tests/test_deerlab_normal.py index 00a9fff..ea9e7b5 100644 --- a/tests/test_deerlab_normal.py +++ b/tests/test_deerlab_normal.py @@ -387,7 +387,7 @@ def test_fit_to_dict(self, fit): fit_dict = fit_to_dict(fit, background_only=True) assert isinstance(fit_dict, dict) assert fit_dict['bg_model'] == 'bg_hom3d' - assert fit_dict['fit_type'] == 'background' + assert fit_dict['fit_type'] == 'Background' assert fit_dict['engine'] == 'DeerLab' assert fit_dict['pathways'] is None assert 'background' in fit_dict and fit_dict['background'] is not None diff --git a/tests/test_deerlab_population.py b/tests/test_deerlab_population.py index cde6ca1..03ffd01 100644 --- a/tests/test_deerlab_population.py +++ b/tests/test_deerlab_population.py @@ -8,7 +8,7 @@ test_data_folder = r'tests/data/population/' -@pytest.fixture +@pytest.fixture(scope="module") def datasets(): twostate_A = eprload(test_data_folder + 'example_twostate_data_1.DSC',test_data_folder + 'example_twostate_data_1.DTA') twostate_B = eprload(test_data_folder + 'example_twostate_data_2.DSC',test_data_folder + 'example_twostate_data_2.DTA') diff --git a/tests/test_deernet.py b/tests/test_deernet.py index 61cb099..5988e46 100644 --- a/tests/test_deernet.py +++ b/tests/test_deernet.py @@ -16,6 +16,19 @@ def fit(self): def test_returns_fit_object(self, fit): assert fit is not None assert isinstance(fit, dl.FitResult) + + def test_fit_gof(self, fit): + assert hasattr(fit, 'stats') + assert isinstance(fit.stats, dict) + assert 'R2' in fit.stats + assert 'SNR' in fit.stats + + # test MNR and lam in fit.stats and they are not None + assert 'MNR' in fit.stats + assert 'lam' in fit.stats + assert fit.stats['MNR'] is not None + assert fit.stats['lam'] is not None + def test_fit_to_dict(self, fit): fit_dict = fit_to_dict(fit) diff --git a/tests/test_warnings.py b/tests/test_warnings.py new file mode 100644 index 0000000..e49af12 --- /dev/null +++ b/tests/test_warnings.py @@ -0,0 +1,92 @@ +""" +Test the generation of warnings after bad fitting results. + +""" + + +import numpy as np +import pytest +import xarray as xr +import deerlab as dl + +from deeranalysis.utils.deerlab_normal import deerlab_background_only, deerlab_fitting +from deeranalysis.utils.deerlab_population import deerlab_population_fitting +from deeranalysis.utils.deerlab_options import background_models, fit_to_dict +from deeranalysis.utils.deerlab_fitwarnings import ( + check_fit_results, Chi2Warning) + +from test_deerlab_population import datasets as population_datasets + +from test_deerlab_normal import make_4pdeer_dataset, make_bg_only_dataset + + +class TestDeerLabWarning4pDEER: + + @pytest.fixture(scope="class") + def fit(self): + return deerlab_fitting(make_4pdeer_dataset(), compactness=False) + + @pytest.fixture(scope="class") + def fit_compact(self): + return deerlab_fitting(make_4pdeer_dataset(), compactness=True) + + + def test_unfitted_parameter_warning(self, fit): + pass + + +class TestDeerLabWarningPopulation: + + @pytest.fixture(scope="class") + def fit(self, population_datasets): + return deerlab_population_fitting(population_datasets, n_pops=2, pathways=[1], bg_model=dl.bg_hom3d, r=np.linspace(1.5,6,100)) + + @pytest.fixture(scope="class") + def fit_bad(self, population_datasets): + return deerlab_population_fitting(population_datasets, n_pops=2, pathways=[1], bg_model=dl.bg_hom3d, r=np.linspace(1.5,6,100), lin_maxiter=1,max_nfev=1) + + def test_unfitted_parameter_warning_good(self, fit): + + fit_warnings = check_fit_results(fit, fit.Vmodel) + + assert isinstance(fit_warnings, list) + assert len(fit_warnings) == 0 + + def test_unfitted_parameter_warning_bad(self, fit_bad): + + fit_warnings = check_fit_results(fit_bad, fit_bad.Vmodel) + + assert isinstance(fit_warnings, list) + assert len(fit_warnings) > 0 + # One chi-squared warning per dataset of the global fit + chi2_warnings = [w for w in fit_warnings if isinstance(w, Chi2Warning)] + assert len(chi2_warnings) == 2 + + + +class TestDeerLabWarningBackground: + + @pytest.fixture(scope="class") + def fit(self): + return deerlab_background_only(make_bg_only_dataset('bg_hom3d',params_override={'lam':0.3}), bg_model=dl.bg_hom3d) + + @pytest.fixture(scope="class") + def fit_bad(self): + return deerlab_background_only(make_bg_only_dataset('bg_hom3d',params_override={'lam':0.3}), bg_model=dl.bg_hom3d, lin_maxiter=1,max_nfev=1) + + def test_unfitted_parameter_warning_good(self, fit): + + fit_warnings = check_fit_results(fit, fit.Bmodel) + + assert isinstance(fit_warnings, list) + assert len(fit_warnings) == 0 + + def test_unfitted_parameter_warning_bad(self, fit_bad): + + fit_warnings = check_fit_results(fit_bad, fit_bad.Bmodel) + + assert isinstance(fit_warnings, list) + assert len(fit_warnings) > 0 + # One chi-squared warning per dataset of the global fit + chi2_warnings = [w for w in fit_warnings if isinstance(w, Chi2Warning)] + assert len(chi2_warnings) == 1 \ No newline at end of file