diff --git a/src/Geant4Worker/DatasetDownloadSpeed.ts b/src/Geant4Worker/DatasetDownloadSpeed.ts new file mode 100644 index 000000000..13f130450 --- /dev/null +++ b/src/Geant4Worker/DatasetDownloadSpeed.ts @@ -0,0 +1,67 @@ +export interface SpeedHistoryEntry { + /** Progress at the last recorded change, as a fraction in the 0..1 range. */ + lastProgressFraction: number; + /** Epoch timestamp in milliseconds of the last recorded progress change. */ + lastTimestampMs: number; + /** Smoothed download speed, expressed as progress fraction per second. */ + speedFractionPerSecond: number; +} + +export type SpeedHistory = Record; + +/** Weight of the newest speed sample- the higher, the faster but jumpier the reaction. */ +export const SPEED_SMOOTHING = 0.1; + +export function nextSpeedHistoryEntry( + prev: SpeedHistoryEntry | undefined, + progressFraction: number, + timestampMs: number +): SpeedHistoryEntry { + if (!prev) { + return { + lastProgressFraction: progressFraction, + lastTimestampMs: timestampMs, + speedFractionPerSecond: 0 + }; + } + + const elapsedSeconds = (timestampMs - prev.lastTimestampMs) / 1000; + const progressFractionDelta = progressFraction - prev.lastProgressFraction; + + if (progressFractionDelta < 0) { + return { ...prev, lastProgressFraction: progressFraction, lastTimestampMs: timestampMs }; + } + + if (progressFractionDelta === 0 || elapsedSeconds <= 0) { + return prev; + } + + const instantSpeedFractionPerSecond = progressFractionDelta / elapsedSeconds; + const speedFractionPerSecond = + prev.speedFractionPerSecond === 0 + ? instantSpeedFractionPerSecond + : prev.speedFractionPerSecond * (1 - SPEED_SMOOTHING) + + instantSpeedFractionPerSecond * SPEED_SMOOTHING; + + return { + lastProgressFraction: progressFraction, + lastTimestampMs: timestampMs, + speedFractionPerSecond + }; +} + +export function estimateSecondsRemaining( + entry: SpeedHistoryEntry, + progressFraction: number, + timestampMs: number +): number | undefined { + const remainingFraction = 1 - progressFraction; + + if (entry.speedFractionPerSecond <= 0 || remainingFraction <= 0) { + return undefined; + } + + const stalledSeconds = Math.max(0, (timestampMs - entry.lastTimestampMs) / 1000); + + return remainingFraction / entry.speedFractionPerSecond + stalledSeconds; +} diff --git a/src/Geant4Worker/Geant4DatasetManager.ts b/src/Geant4Worker/Geant4DatasetManager.ts index d44a42362..c76e8146c 100644 --- a/src/Geant4Worker/Geant4DatasetManager.ts +++ b/src/Geant4Worker/Geant4DatasetManager.ts @@ -1,8 +1,21 @@ // Additional credits: // - @kmichalik -import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; +import { + Dispatch, + MutableRefObject, + SetStateAction, + useCallback, + useEffect, + useRef, + useState +} from 'react'; +import { + estimateSecondsRemaining, + nextSpeedHistoryEntry, + SpeedHistory +} from './DatasetDownloadSpeed'; import { checkAllDatasetsCacheStatus, clearDatasetCache, @@ -40,29 +53,57 @@ export interface DatasetStatus { total?: number; totalSizeMB?: number; cached?: boolean; + estimatedSecondsRemaining?: number; } async function fetchProgress( worker: Geant4Worker, - setDatasetStates: Dispatch>> + setDatasetStates: Dispatch>>, + speedHistoryRef: MutableRefObject ) { if (!worker.getIsInitialized()) return; const progress = await worker.pollDatasetProgress(); if (progress) { + const currentTimeMs = Date.now(); + setDatasetStates(prev => { const newStates: Record = { ...prev }; for (const [datasetName, datasetProgress] of Object.entries(progress)) { - let status = statusTypeMap[datasetProgress.stage] ?? DatasetDownloadStatus.IDLE; + const status = statusTypeMap[datasetProgress.stage] ?? DatasetDownloadStatus.IDLE; + const progressFraction = datasetProgress.progress; + const done = Math.floor(progressFraction * 100); + const total = 100; + + let estimatedSecondsRemaining: number | undefined; + + if (status === DatasetDownloadStatus.DOWNLOADING) { + const speedEntry = nextSpeedHistoryEntry( + speedHistoryRef.current[datasetName], + progressFraction, + currentTimeMs + ); + + speedHistoryRef.current[datasetName] = speedEntry; + + estimatedSecondsRemaining = estimateSecondsRemaining( + speedEntry, + progressFraction, + currentTimeMs + ); + } else { + delete speedHistoryRef.current[datasetName]; + } newStates[datasetName] = { ...newStates[datasetName], name: datasetName, status, - done: Math.floor(datasetProgress.progress * 100), - total: 100 + done, + total, + estimatedSecondsRemaining }; } @@ -77,6 +118,7 @@ type StartDownloadArgs = { setManagerState: Dispatch>; setDatasetStates: Dispatch>>; setIdle: Dispatch>; + speedHistoryRef: MutableRefObject; }; function startDownload({ @@ -84,23 +126,26 @@ function startDownload({ managerState, setManagerState, setDatasetStates, - setIdle + setIdle, + speedHistoryRef }: StartDownloadArgs) { if (managerState !== DownloadManagerStatus.IDLE || !worker.getIsInitialized()) { return; } + speedHistoryRef.current = {}; + const loadDepsPromise = worker.loadDeps(); const interval = setInterval(async () => { - await fetchProgress(worker, setDatasetStates); + await fetchProgress(worker, setDatasetStates, speedHistoryRef); }, 500); loadDepsPromise .then(async () => { clearInterval(interval); - await fetchProgress(worker, setDatasetStates); + await fetchProgress(worker, setDatasetStates, speedHistoryRef); setManagerState(DownloadManagerStatus.FINISHED); worker.markSafeForTermination(); @@ -109,6 +154,22 @@ function startDownload({ console.error('Dataset download error:', error); setManagerState(DownloadManagerStatus.ERROR); clearInterval(interval); + + speedHistoryRef.current = {}; + setDatasetStates(prev => { + const newStates: Record = { ...prev }; + + for (const [datasetName, datasetState] of Object.entries(newStates)) { + if (datasetState.estimatedSecondsRemaining !== undefined) { + newStates[datasetName] = { + ...datasetState, + estimatedSecondsRemaining: undefined + }; + } + } + + return newStates; + }); }); setManagerState(DownloadManagerStatus.WORKING); setIdle(false); @@ -135,6 +196,7 @@ export function useDatasetManager(): UseDatasetManagerResult { const [idle, setIdle] = useState(false); const [worker] = useState(new Geant4Worker()); const initCalledRef = useRef(false); + const speedHistoryRef = useRef({}); const [storageEstimate, setStorageEstimate] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -196,6 +258,7 @@ export function useDatasetManager(): UseDatasetManagerResult { setIdle(true); setManagerState(DownloadManagerStatus.IDLE); setDatasetStates({}); + speedHistoryRef.current = {}; await refresh(); } @@ -225,7 +288,14 @@ export function useDatasetManager(): UseDatasetManagerResult { if (!idle) return; if (!worker.getIsInitialized()) return; - startDownload({ worker, managerState, setManagerState, setDatasetStates, setIdle }); + startDownload({ + worker, + managerState, + setManagerState, + setDatasetStates, + setIdle, + speedHistoryRef + }); }, [worker, idle, managerState]); return { diff --git a/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx b/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx index 4027aa479..ef27b349c 100644 --- a/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx +++ b/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx @@ -31,6 +31,7 @@ import { useDialog } from '../../../services/DialogService'; import { useSharedDatasetManager } from '../../../services/Geant4DatasetContextProvider'; import StyledAccordion from '../../../shared/components/StyledAccordion'; import { StyledExclusiveToggleButtonGroup } from '../../../shared/components/StyledExclusiveToggleButtonGroup'; +import { secondsToShortDurationString } from '../../../util/time'; export enum Geant4DatasetsType { PARTIAL, @@ -40,6 +41,11 @@ export enum Geant4DatasetsType { function DatasetCurrentStatus(props: { status: DatasetStatus }) { const { status } = props; + const estimatedTimeRemaining = + status.status === DatasetDownloadStatus.DOWNLOADING && status.estimatedSecondsRemaining + ? ` (est. ${secondsToShortDurationString(status.estimatedSecondsRemaining)} remaining)` + : ''; + const idleIcon = status.cached ? ( ) : ( @@ -75,6 +81,14 @@ function DatasetCurrentStatus(props: { status: DatasetStatus }) { {status.name} + + {estimatedTimeRemaining && ( + + {estimatedTimeRemaining} + + )} {datasetStatusIcon.get(status.status)} @@ -324,7 +338,10 @@ export function Geant4Datasets() { }}> } - onClick={() => setOpen(!open)}> + onClick={e => { + e.stopPropagation(); + setOpen(prevOpen => !prevOpen); + }}> diff --git a/src/util/time.ts b/src/util/time.ts index 6f0d8133e..4afe6c7cd 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -5,3 +5,27 @@ export const millisecondsToTimeString = (milliseconds: number): string => { return [hours, minutes % 60, seconds % 60].map(v => v.toString().padStart(2, '0')).join(':'); // hh:mm:ss }; + +export const secondsToShortDurationString = (seconds: number): string => { + if (seconds < 1) { + return '<1s'; + } + + const totalSeconds = Math.ceil(seconds); + + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + const totalMinutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + + if (totalMinutes < 60) { + return `${totalMinutes}m ${remainingSeconds}s`; + } + + const hours = Math.floor(totalMinutes / 60); + const remainingMinutes = totalMinutes % 60; + + return `${hours}h ${remainingMinutes}m`; +};