diff --git a/src/App.tsx b/src/App.tsx
index bf40e640..4b3451b9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -85,6 +85,7 @@ const App: React.FC = () => {
const [showNewSimulation, setShowNewSimulation] = useState(false);
const [showShareSimulation, setShowShareSimulation] = useState(false);
const running = useStoreState((state) => state.simulation.running);
+ const finished = useStoreState((state) => state.simulation.finished);
const simulation = useStoreState((state) => state.simulation.simulation);
const selectedFile = useStoreState((state) => state.app.selectedFile);
const setSelectedFile = useStoreActions(
@@ -102,6 +103,7 @@ const App: React.FC = () => {
const selectedMenu = useStoreState((state) => state.app.selectedMenu);
const run = useStoreActions((actions) => actions.simulation.run);
+ const continueSimulation = useStoreActions((actions) => actions.simulation.continueSimulation);
const { isEmbeddedMode } = useEmbeddedMode();
@@ -147,6 +149,18 @@ const App: React.FC = () => {
running === false,
);
+ const continueButton = getItem(
+ "Continue simulation",
+ "continue",
+ ,
+ undefined,
+ () => {
+ continueSimulation(undefined);
+ setPreferredView("view");
+ },
+ !finished || running,
+ );
+
const newSimulationButton = getItem(
"New simulation",
"newsimulation",
@@ -208,6 +222,7 @@ const App: React.FC = () => {
simulation == null,
),
pauseButton,
+ continueButton,
];
useEffect(() => {
diff --git a/src/store/simulation.test.ts b/src/store/simulation.test.ts
new file mode 100644
index 00000000..d6183fb5
--- /dev/null
+++ b/src/store/simulation.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { action, createStore } from 'easy-peasy';
+import type { SimulationModel, Simulation } from './simulation';
+import type { LammpsWeb } from '../types';
+
+describe('SimulationModel', () => {
+ describe('finished state', () => {
+ it('should initialize with finished as false', () => {
+ // Arrange
+ const store = createStore>({
+ finished: false,
+ setFinished: action((state, value: boolean) => {
+ state.finished = value;
+ }),
+ });
+
+ // Act
+ const state = store.getState();
+
+ // Assert
+ expect(state.finished).toBe(false);
+ });
+
+ it('should set finished state to true', () => {
+ // Arrange
+ const store = createStore>({
+ finished: false,
+ setFinished: action((state, value: boolean) => {
+ state.finished = value;
+ }),
+ });
+
+ // Act
+ store.getActions().setFinished(true);
+
+ // Assert
+ expect(store.getState().finished).toBe(true);
+ });
+
+ it('should set finished state to false', () => {
+ // Arrange
+ const store = createStore>({
+ finished: true,
+ setFinished: action((state, value: boolean) => {
+ state.finished = value;
+ }),
+ });
+
+ // Act
+ store.getActions().setFinished(false);
+
+ // Assert
+ expect(store.getState().finished).toBe(false);
+ });
+
+ it('should reset finished state on reset action', () => {
+ // Arrange
+ const store = createStore>({
+ finished: true,
+ files: ['test.in'],
+ lammps: undefined,
+ reset: action((state) => {
+ state.files = [];
+ state.lammps = undefined;
+ state.finished = false;
+ }),
+ });
+
+ // Act
+ store.getActions().reset(undefined);
+
+ // Assert
+ expect(store.getState().finished).toBe(false);
+ expect(store.getState().files).toEqual([]);
+ });
+ });
+
+ describe('continueSimulation types', () => {
+ it('should accept undefined timesteps parameter', () => {
+ // This test verifies that the type signature allows undefined
+ // The actual thunk implementation is tested through integration tests
+
+ // Arrange
+ type ContinueSimulationAction = (timesteps: number | undefined) => void;
+
+ // Act & Assert - This should compile without errors
+ const mockAction: ContinueSimulationAction = (timesteps) => {
+ if (timesteps === undefined) {
+ // Default behavior
+ } else {
+ // Custom timesteps
+ }
+ };
+
+ // Verify both call signatures work
+ mockAction(1000);
+ mockAction(undefined);
+
+ expect(mockAction).toBeDefined();
+ });
+ });
+});
diff --git a/src/store/simulation.ts b/src/store/simulation.ts
index 90ceb85f..cd608f8e 100644
--- a/src/store/simulation.ts
+++ b/src/store/simulation.ts
@@ -40,6 +40,7 @@ export interface Simulation {
export interface SimulationModel {
running: boolean;
paused: boolean;
+ finished: boolean;
showConsole: boolean;
simulation?: Simulation;
files: string[];
@@ -50,6 +51,7 @@ export interface SimulationModel {
addLammpsOutput: Action;
setShowConsole: Action;
setPaused: Action;
+ setFinished: Action;
setCameraPosition: Action;
setCameraTarget: Action;
setSimulation: Action;
@@ -60,6 +62,7 @@ export interface SimulationModel {
syncFilesWasm: Thunk;
syncFilesJupyterLite: Thunk;
run: Thunk;
+ continueSimulation: Thunk;
newSimulation: Thunk;
lammps?: LammpsWeb;
reset: Action;
@@ -68,6 +71,7 @@ export interface SimulationModel {
export const simulationModel: SimulationModel = {
running: false,
paused: false,
+ finished: false,
showConsole: false,
files: [],
lammpsOutput: [],
@@ -83,6 +87,9 @@ export const simulationModel: SimulationModel = {
setPaused: action((state, value: boolean) => {
state.paused = value;
}),
+ setFinished: action((state, value: boolean) => {
+ state.finished = value;
+ }),
setCameraPosition: action((state, cameraPosition: THREE.Vector3) => {
state.cameraPosition = cameraPosition;
}),
@@ -318,6 +325,7 @@ export const simulationModel: SimulationModel = {
allActions.simulationStatus.reset();
actions.setShowConsole(false);
actions.resetLammpsOutput();
+ actions.setFinished(false);
await actions.syncFilesWasm(undefined);
@@ -389,6 +397,7 @@ export const simulationModel: SimulationModel = {
// Simulation got canceled.
actions.setRunning(false);
actions.setShowConsole(true);
+ actions.setFinished(false);
track("Simulation.Stop", {
simulationId: simulation?.id,
stopReason: "canceled",
@@ -401,6 +410,7 @@ export const simulationModel: SimulationModel = {
});
actions.setRunning(false);
actions.setShowConsole(true);
+ actions.setFinished(false);
track("Simulation.Stop", {
simulationId: simulation?.id,
stopReason: "failed",
@@ -412,6 +422,7 @@ export const simulationModel: SimulationModel = {
allActions.processing.runPostTimestep(true);
actions.setRunning(false);
actions.setShowConsole(true);
+ actions.setFinished(true);
track("Simulation.Stop", {
simulationId: simulation?.id,
stopReason: "completed",
@@ -421,6 +432,106 @@ export const simulationModel: SimulationModel = {
actions.syncFilesJupyterLite();
allActions.simulationStatus.setLastCommand(undefined);
}),
+ continueSimulation: thunk(
+ async (actions, timesteps: number | undefined, { getStoreState, getStoreActions }) => {
+ // @ts-ignore
+ const simulation = getStoreState().simulation.simulation as Simulation;
+ if (!simulation) {
+ return;
+ }
+ // @ts-ignore
+ const lammps = getStoreState().simulation.lammps as LammpsWeb;
+ if (!lammps || lammps.getIsRunning()) {
+ return;
+ }
+
+ const allActions = getStoreActions() as Actions;
+
+ actions.setShowConsole(false);
+ actions.setFinished(false);
+
+ lammps.start();
+ actions.setRunning(true);
+ track("Simulation.Continue", {
+ simulationId: simulation?.id,
+ timesteps: timesteps,
+ ...getEmbeddingParams()
+ });
+ time_event("Simulation.Stop");
+
+ let errorMessage: string | undefined = undefined;
+ const startTime = performance.now();
+
+ try {
+ // Use the suggested command format: run with pre no post no
+ // This continues the simulation without re-initializing
+ const runCommand = timesteps ? `run ${timesteps} pre no post no` : `run 1000 pre no post no`;
+ lammps.runCommand(runCommand);
+ } catch (exception: any) {
+ console.log("Got exception: ", exception);
+ errorMessage = lammps.getExceptionMessage(exception);
+ console.log("Got error running LAMMPS: ", errorMessage);
+ }
+
+ if (!errorMessage) {
+ errorMessage = lammps.getErrorMessage();
+ }
+
+ // @ts-ignore
+ const computes = getStoreState().simulationStatus.computes as Compute[];
+
+ const endTime = performance.now();
+ const duration = (endTime - startTime) / 1000; // seconds
+ const metricsData = {
+ timesteps: lammps.getTimesteps(),
+ timestepsPerSecond: (lammps.getTimesteps() / duration).toFixed(3),
+ numAtoms: lammps.getNumAtoms(),
+ computes: Object.keys(computes),
+ };
+ actions.setPaused(false);
+
+ if (errorMessage) {
+ if (errorMessage.includes("Atomify::canceled")) {
+ allActions.processing.runPostTimestep(true);
+ // Simulation got canceled.
+ actions.setRunning(false);
+ actions.setShowConsole(true);
+ actions.setFinished(false);
+ track("Simulation.Stop", {
+ simulationId: simulation?.id,
+ stopReason: "canceled",
+ ...metricsData,
+ });
+ } else {
+ notification.error({
+ message: errorMessage,
+ duration: 5,
+ });
+ actions.setRunning(false);
+ actions.setShowConsole(true);
+ actions.setFinished(false);
+ track("Simulation.Stop", {
+ simulationId: simulation?.id,
+ stopReason: "failed",
+ errorMessage,
+ ...metricsData,
+ });
+ }
+ } else {
+ allActions.processing.runPostTimestep(true);
+ actions.setRunning(false);
+ actions.setShowConsole(true);
+ actions.setFinished(true);
+ track("Simulation.Stop", {
+ simulationId: simulation?.id,
+ stopReason: "completed",
+ ...metricsData,
+ });
+ }
+ actions.syncFilesJupyterLite();
+ allActions.simulationStatus.setLastCommand(undefined);
+ },
+ ),
newSimulation: thunk(
async (
actions,
@@ -517,5 +628,6 @@ export const simulationModel: SimulationModel = {
reset: action((state) => {
state.files = [];
state.lammps = undefined;
+ state.finished = false;
}),
};