diff --git a/app/static/src/app/components/fit/FitTab.vue b/app/static/src/app/components/fit/FitTab.vue
index e0577c0f7..289763544 100644
--- a/app/static/src/app/components/fit/FitTab.vue
+++ b/app/static/src/app/components/fit/FitTab.vue
@@ -21,6 +21,7 @@
{{cancelledMsg}}
+
@@ -39,10 +40,12 @@ import { ModelFitMutation } from "../../store/modelFit/mutations";
import { fitRequirementsExplanation, fitUpdateRequiredExplanation } from "./support";
import { allTrue, anyTrue } from "../../utils";
import LoadingButton from "../LoadingButton.vue";
+import ErrorInfo from "../ErrorInfo.vue";
export default defineComponent({
name: "FitTab",
components: {
+ ErrorInfo,
LoadingSpinner,
FitPlot,
ActionRequiredMessage,
@@ -57,6 +60,7 @@ export default defineComponent({
const canFitModel = computed(() => allTrue(fitRequirements.value));
const compileRequired = computed(() => store.state.model.compileRequired);
const fitUpdateRequired = computed(() => store.state.modelFit.fitUpdateRequired);
+ const error = computed(() => store.state.modelFit.error);
const fitModel = () => store.dispatch(`${namespace}/${ModelFitAction.FitModel}`);
const cancelFit = () => store.commit(`${namespace}/${ModelFitMutation.SetFitting}`, false);
@@ -68,6 +72,10 @@ export default defineComponent({
const sumOfSquares = computed(() => store.state.modelFit.sumOfSquares);
const actionRequiredMessage = computed(() => {
+ if (error.value) {
+ return userMessages.modelFit.errorOccurred;
+ }
+
if (!allTrue(fitRequirements.value)) {
return fitRequirementsExplanation(fitRequirements.value);
}
@@ -119,6 +127,7 @@ export default defineComponent({
cancelledMsg,
sumOfSquares,
actionRequiredMessage,
+ error,
iconType,
iconClass
};
diff --git a/app/static/src/app/serialise.ts b/app/static/src/app/serialise.ts
index 7585566c4..f768e98a4 100644
--- a/app/static/src/app/serialise.ts
+++ b/app/static/src/app/serialise.ts
@@ -130,7 +130,8 @@ function serialiseModelFit(modelFit: ModelFitState): SerialisedModelFitState {
converged: modelFit.converged,
sumOfSquares: modelFit.sumOfSquares,
paramsToVary: modelFit.paramsToVary,
- result: serialiseSolutionResult(modelFit.result)
+ result: serialiseSolutionResult(modelFit.result),
+ error: modelFit.error
};
}
diff --git a/app/static/src/app/store/appState/mutations.ts b/app/static/src/app/store/appState/mutations.ts
index 67128a618..93de9390e 100644
--- a/app/static/src/app/store/appState/mutations.ts
+++ b/app/static/src/app/store/appState/mutations.ts
@@ -21,7 +21,8 @@ export enum AppStateMutation {
export const StateUploadMutations = [
AppStateMutation.ClearQueuedStateUpload,
AppStateMutation.SetQueuedStateUpload,
- AppStateMutation.SetStateUploadInProgress
+ AppStateMutation.SetStateUploadInProgress,
+ AppStateMutation.SetPersisted
] as string[];
export const appStateMutations: MutationTree = {
diff --git a/app/static/src/app/store/modelFit/actions.ts b/app/static/src/app/store/modelFit/actions.ts
index a7c8ca8e2..16822b78a 100644
--- a/app/static/src/app/store/modelFit/actions.ts
+++ b/app/static/src/app/store/modelFit/actions.ts
@@ -50,24 +50,28 @@ export const actions: ActionTree = {
const { advancedSettings } = rootState.run;
const advancedSettingsOdin = convertAdvancedSettingsToOdin(advancedSettings, pars.base);
- const simplex = odinRunnerOde!.wodinFit(
- odin!,
- data,
- pars,
- linkedVariable,
- advancedSettingsOdin,
- {}
- );
-
- const inputs = {
- data,
- endTime,
- link
- };
-
- commit(ModelFitMutation.SetFitUpdateRequired, null);
- commit(ModelFitMutation.SetInputs, inputs);
- dispatch(ModelFitAction.FitModelStep, simplex);
+ try {
+ const simplex = odinRunnerOde!.wodinFit(
+ odin!,
+ data,
+ pars,
+ linkedVariable,
+ advancedSettingsOdin,
+ {}
+ );
+
+ const inputs = {
+ data,
+ endTime,
+ link
+ };
+ commit(ModelFitMutation.SetFitUpdateRequired, null);
+ commit(ModelFitMutation.SetInputs, inputs);
+ dispatch(ModelFitAction.FitModelStep, simplex);
+ } catch (e: unknown) {
+ commit(ModelFitMutation.SetError, { error: "Model fit error", detail: (e as Error).message });
+ commit(ModelFitMutation.SetFitting, false);
+ }
}
},
diff --git a/app/static/src/app/store/modelFit/modelFit.ts b/app/static/src/app/store/modelFit/modelFit.ts
index a5bca25b3..0048dc8c6 100644
--- a/app/static/src/app/store/modelFit/modelFit.ts
+++ b/app/static/src/app/store/modelFit/modelFit.ts
@@ -18,7 +18,8 @@ export const defaultState: ModelFitState = {
sumOfSquares: null,
paramsToVary: [],
inputs: null,
- result: null
+ result: null,
+ error: null
};
export const modelFit = {
diff --git a/app/static/src/app/store/modelFit/mutations.ts b/app/static/src/app/store/modelFit/mutations.ts
index 976ec1e88..cde61b45c 100644
--- a/app/static/src/app/store/modelFit/mutations.ts
+++ b/app/static/src/app/store/modelFit/mutations.ts
@@ -1,6 +1,6 @@
import { MutationTree } from "vuex";
import { ModelFitInputs, ModelFitState, FitUpdateRequiredReasons } from "./state";
-import { SimplexResult } from "../../types/responseTypes";
+import { SimplexResult, WodinError } from "../../types/responseTypes";
export enum ModelFitMutation {
SetFitting = "SetFitting",
@@ -8,12 +8,16 @@ export enum ModelFitMutation {
SetInputs = "SetInputs",
SetParamsToVary = "SetParamsToVary",
SetSumOfSquares = "SetSumOfSquares",
- SetFitUpdateRequired = "SetFitUpdateRequired"
+ SetFitUpdateRequired = "SetFitUpdateRequired",
+ SetError = "SetError"
}
export const mutations: MutationTree = {
[ModelFitMutation.SetFitting](state: ModelFitState, payload: boolean) {
state.fitting = payload;
+ if (payload) {
+ state.error = null;
+ }
},
[ModelFitMutation.SetResult](state: ModelFitState, payload: SimplexResult) {
@@ -43,6 +47,10 @@ export const mutations: MutationTree = {
state.sumOfSquares = payload;
},
+ [ModelFitMutation.SetError](state: ModelFitState, payload: WodinError) {
+ state.error = payload;
+ },
+
[ModelFitMutation.SetFitUpdateRequired](state: ModelFitState, payload: null | Partial) {
if (payload === null) {
state.fitUpdateRequired = {
diff --git a/app/static/src/app/store/modelFit/state.ts b/app/static/src/app/store/modelFit/state.ts
index e2fe35acf..cb264a2f6 100644
--- a/app/static/src/app/store/modelFit/state.ts
+++ b/app/static/src/app/store/modelFit/state.ts
@@ -1,5 +1,6 @@
import { OdinFitResult } from "../../types/wrapperTypes";
import type { FitData, FitDataLink } from "../fitData/state";
+import { WodinError } from "../../types/responseTypes";
export interface ModelFitInputs {
data: FitData;
@@ -25,6 +26,7 @@ export interface ModelFitState {
paramsToVary: string[],
inputs: ModelFitInputs | null, // all inputs except parameters, which vary
result: OdinFitResult | null, // full solution for current best fit,
+ error: null | WodinError
}
export interface ModelFitRequirements {
diff --git a/app/static/src/app/types/serialisationTypes.ts b/app/static/src/app/types/serialisationTypes.ts
index 19fc54d5e..8f242a946 100644
--- a/app/static/src/app/types/serialisationTypes.ts
+++ b/app/static/src/app/types/serialisationTypes.ts
@@ -77,7 +77,8 @@ export interface SerialisedModelFitState {
converged: boolean | null,
sumOfSquares: number | null,
paramsToVary: string[],
- result: SerialisedRunResult | null
+ result: SerialisedRunResult | null,
+ error: null | WodinError
}
export interface SerialisedAppState {
diff --git a/app/static/src/app/userMessages.ts b/app/static/src/app/userMessages.ts
index d6c9968c9..f4c43cf1e 100644
--- a/app/static/src/app/userMessages.ts
+++ b/app/static/src/app/userMessages.ts
@@ -56,6 +56,7 @@ export default {
notFittedYet: "Model has not been fitted.",
selectParamToVary: "Please select at least one parameter to vary during model fit.",
compileRequired: "Model code has been updated. Compile code and Fit Model for updated best fit.",
+ errorOccurred: "An error occurred during model fit.",
updateFitReasons: {
prefix: "Fit is out of date:",
unknown: "unknown reasons, contact the administrator, as this is unexpected",
diff --git a/app/static/tests/e2e/fit.etest.ts b/app/static/tests/e2e/fit.etest.ts
index cf38deb17..c6618064f 100644
--- a/app/static/tests/e2e/fit.etest.ts
+++ b/app/static/tests/e2e/fit.etest.ts
@@ -163,6 +163,41 @@ test.describe("Wodin App model fit tests", () => {
expect(newSumOfSquares).not.toEqual(sumOfSquares);
});
+ test("can show model fit error", async ({ page }) => {
+ // Upload data
+ await uploadCSVData(page, realisticFitData);
+ await page.click(":nth-match(.wodin-right .nav-tabs a, 2)");
+
+ // link variables
+ await page.click(":nth-match(.wodin-left .nav-tabs a, 3)");
+ const linkContainer = await page.locator(":nth-match(.collapse .container, 1)");
+ const select1 = await linkContainer.locator(":nth-match(select, 1)");
+ await select1.selectOption("E");
+
+ await expect(await page.innerText("#optimisation label#target-fit-label")).toBe("Cases ~ E");
+
+ // select param to vary
+ await page.click(":nth-match(.wodin-right .nav-tabs a, 2)");
+ await expect(await page.innerText("#select-param-msg"))
+ .toBe("Please select at least one parameter to vary during model fit.");
+ await page.click(":nth-match(input.vary-param-check, 1)");
+ await page.click(":nth-match(input.vary-param-check, 2)");
+
+ // change advanced setting to sabotage fit
+ await page.click(":nth-match(.collapse-title, 6)"); // Open Advanced Settings
+ const advancedSettingPanel = await page.locator("#advanced-settings-panel");
+ const input1 = await advancedSettingPanel.locator(":nth-match(input, 1)");
+ const input2 = await advancedSettingPanel.locator(":nth-match(input, 2)");
+ await input1.fill("7");
+ await input2.fill("-1");
+
+ await page.click(".wodin-right .wodin-content div.mt-4 button#fit-btn");
+
+ await expect(await page.locator(".fit-tab .action-required-msg"))
+ .toHaveText("An error occurred during model fit.", { timeout });
+ await expect(await page.innerText(".fit-tab #error-info")).toContain("Model fit error: Integration failure");
+ });
+
test("can see expected update required messages when code changes", async ({ page }) => {
await runFit(page);
await expectUpdateFitMsg(page, "");
diff --git a/app/static/tests/mocks.ts b/app/static/tests/mocks.ts
index f62fe4b54..959c8bb32 100644
--- a/app/static/tests/mocks.ts
+++ b/app/static/tests/mocks.ts
@@ -239,6 +239,7 @@ export const mockModelFitState = (state: Partial = {}): ModelFitS
paramsToVary: [],
inputs: null,
result: null,
+ error: null,
...state
};
};
diff --git a/app/static/tests/unit/components/fit/fitTab.test.ts b/app/static/tests/unit/components/fit/fitTab.test.ts
index b0c5d99bf..3408250fe 100644
--- a/app/static/tests/unit/components/fit/fitTab.test.ts
+++ b/app/static/tests/unit/components/fit/fitTab.test.ts
@@ -11,6 +11,8 @@ import ActionRequiredMessage from "../../../../src/app/components/ActionRequired
import LoadingSpinner from "../../../../src/app/components/LoadingSpinner.vue";
import FitPlot from "../../../../src/app/components/fit/FitPlot.vue";
import { mockFitState, mockGraphSettingsState } from "../../../mocks";
+import { WodinError } from "../../../../src/app/types/responseTypes";
+import ErrorInfo from "../../../../src/app/components/ErrorInfo.vue";
describe("Fit Tab", () => {
const getWrapper = (
@@ -22,7 +24,8 @@ describe("Fit Tab", () => {
fitting = false,
sumOfSquares: number | null = 2.1,
mockFitModel = jest.fn(),
- mockSetFitting = jest.fn()
+ mockSetFitting = jest.fn(),
+ error: WodinError | null = null
) => {
const store = new Vuex.Store({
state: mockFitState(),
@@ -45,6 +48,7 @@ describe("Fit Tab", () => {
fitting,
sumOfSquares,
fitUpdateRequired,
+ error,
result: {
solution: jest.fn()
}
@@ -82,6 +86,7 @@ describe("Fit Tab", () => {
expect(fitPlot.findAll("span").at(0)!.text()).toBe("Iterations: 10");
expect(fitPlot.findAll("span").at(1)!.text()).toBe("Sum of squares: 2.1");
expect(fitPlot.find("#fit-cancelled-msg").exists()).toBe(false);
+ expect(wrapper.findComponent(ErrorInfo).props("error")).toBe(null);
});
it("renders as expected when fit is running", () => {
@@ -169,6 +174,14 @@ describe("Fit Tab", () => {
expect(fitPlot.findAll("span").at(1)!.text()).toBe("Sum of squares: 2.1");
});
+ it("renders model fit error as expected", () => {
+ const error = { error: "test error", detail: "test detail" };
+ const wrapper = getWrapper({}, false, {}, 10, true, false, 2.1, jest.fn(), jest.fn(), error);
+ expect(wrapper.findComponent(ErrorInfo).props("error")).toStrictEqual(error);
+ expect(wrapper.findComponent(ActionRequiredMessage).props("message"))
+ .toBe("An error occurred during model fit.");
+ });
+
it("dispatches fit action on click button", async () => {
const mockFitModel = jest.fn();
const wrapper = getWrapper({}, false, false, null, null, false, null, mockFitModel);
diff --git a/app/static/tests/unit/serialiser.test.ts b/app/static/tests/unit/serialiser.test.ts
index 318603c40..31778b3b8 100644
--- a/app/static/tests/unit/serialiser.test.ts
+++ b/app/static/tests/unit/serialiser.test.ts
@@ -292,6 +292,7 @@ describe("serialise", () => {
const modelFitState = {
fitting: false,
+ error: { error: "fit run error", detail: "fit run detail" },
fitUpdateRequired: {
modelChanged: false,
dataChanged: false,
@@ -482,6 +483,7 @@ describe("serialise", () => {
};
const expectedModelFit = {
+ error: { error: "fit run error", detail: "fit run detail" },
fitUpdateRequired: {
modelChanged: false,
dataChanged: false,
diff --git a/app/static/tests/unit/store/modelFit/actions.test.ts b/app/static/tests/unit/store/modelFit/actions.test.ts
index cfc1c02a2..72dffa13c 100644
--- a/app/static/tests/unit/store/modelFit/actions.test.ts
+++ b/app/static/tests/unit/store/modelFit/actions.test.ts
@@ -135,6 +135,39 @@ describe("ModelFit actions", () => {
expect(mockWodinFit).not.toHaveBeenCalled();
});
+ it("FitModel commits error thrown during wodinFit", () => {
+ const runner = {
+ wodinFit: jest.fn().mockImplementation(() => { throw new Error("TEST ERROR"); }),
+ wodinFitValue: mockWodinFitValue
+ } as any;
+ const errorModelState = mockModelState({
+ odin: mockOdin,
+ odinRunnerOde: runner
+ });
+
+ const errorRootState = { ...rootState, model: errorModelState };
+
+ const getters = { fitRequirements: {} };
+ const link = { time: "t", data: "v", model: "S" };
+ const rootGetters = {
+ "fitData/link": link,
+ "fitData/dataEnd": 100
+ };
+ const commit = jest.fn();
+ const dispatch = jest.fn();
+ (actions[ModelFitAction.FitModel] as any)({
+ commit, dispatch, state, rootState: errorRootState, rootGetters, getters
+ });
+
+ expect(commit).toHaveBeenCalledTimes(3);
+ expect(commit.mock.calls[0][0]).toBe(ModelFitMutation.SetFitting);
+ expect(commit.mock.calls[0][1]).toBe(true);
+ expect(commit.mock.calls[1][0]).toBe(ModelFitMutation.SetError);
+ expect(commit.mock.calls[1][1]).toStrictEqual({ error: "Model fit error", detail: "TEST ERROR" });
+ expect(commit.mock.calls[2][0]).toBe(ModelFitMutation.SetFitting);
+ expect(commit.mock.calls[2][1]).toBe(false);
+ });
+
it("FitModelStep commits expected changes and dispatches further step if not converged", (done) => {
const result = {
converged: false,
diff --git a/app/static/tests/unit/store/modelFit/mutations.test.ts b/app/static/tests/unit/store/modelFit/mutations.test.ts
index ea6e0b5f2..53fc89945 100644
--- a/app/static/tests/unit/store/modelFit/mutations.test.ts
+++ b/app/static/tests/unit/store/modelFit/mutations.test.ts
@@ -18,6 +18,22 @@ describe("ModelFit mutations", () => {
expect(state.fitting).toBe(true);
});
+ it("SetFitting sets error to null if fitting is true", () => {
+ const error = { error: "test", detail: "test detail" };
+ const state = mockModelFitState({ error });
+ mutations.SetFitting(state, false);
+ expect(state.error).toBe(error);
+ mutations.SetFitting(state, true);
+ expect(state.error).toBe(null);
+ });
+
+ it("sets error", () => {
+ const error = { error: "test", detail: "test detail" };
+ const state = mockModelFitState();
+ mutations.SetError(state, error);
+ expect(state.error).toBe(error);
+ });
+
it("sets model fit inputs", () => {
const state = mockModelFitState();
mutations.SetInputs(state, mockInputs);