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; }), };