Auto-pause physics engine when no one is viewing

- Physics pauses after IDLE_PAUSE_SECONDS (default 30s) of inactivity
- Resumes instantly when someone views the dashboard
- No container restart needed - just pauses the simulation loop
- CPU usage drops to ~0% when paused
This commit is contained in:
2026-01-29 21:36:38 +00:00
parent cc5a8191b0
commit 9d6086a4e5

View File

@@ -8,7 +8,6 @@ thermal-electrical coupling in real-time using instrument interfaces.
import asyncio import asyncio
import atexit import atexit
import os import os
import sys
import threading import threading
import time import time
from collections import deque from collections import deque
@@ -29,40 +28,36 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest
# Thread pool for background test execution # Thread pool for background test execution
_test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner") _test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner")
# Idle shutdown configuration # Idle pause configuration
# Set IDLE_TIMEOUT_MINUTES=0 to disable auto-shutdown # Physics engine pauses after IDLE_PAUSE_SECONDS of no activity (default: 30s)
IDLE_TIMEOUT_MINUTES = int(os.environ.get("IDLE_TIMEOUT_MINUTES", "0")) IDLE_PAUSE_SECONDS = int(os.environ.get("IDLE_PAUSE_SECONDS", "30"))
_last_activity_time: float = time.time() _last_activity_time: float = time.time()
_idle_monitor_started = False _idle_checker_started = False
_server_ref: SimulationServer | None = None # Reference for idle checker thread
def _update_activity() -> None: def _idle_checker() -> None:
"""Update the last activity timestamp.""" """Background thread that pauses physics when idle."""
global _last_activity_time global _last_activity_time, _server_ref
_last_activity_time = time.time()
def _idle_monitor() -> None:
"""Background thread that exits the app after idle timeout."""
global _last_activity_time
while True: while True:
time.sleep(60) # Check every minute time.sleep(5) # Check every 5 seconds
if IDLE_TIMEOUT_MINUTES <= 0: if _server_ref is None:
continue continue
idle_minutes = (time.time() - _last_activity_time) / 60
if idle_minutes >= IDLE_TIMEOUT_MINUTES: idle_seconds = time.time() - _last_activity_time
print(f"Idle timeout reached ({IDLE_TIMEOUT_MINUTES} minutes). Shutting down.") if idle_seconds > IDLE_PAUSE_SECONDS and not _server_ref.paused:
os._exit(0) _server_ref.paused = True
print(f"Physics engine paused (idle for {idle_seconds:.0f}s)")
def _start_idle_monitor() -> None: def _start_idle_checker(server: SimulationServer) -> None:
"""Start the idle monitor thread if timeout is configured.""" """Start the idle checker thread."""
global _idle_monitor_started global _idle_checker_started, _server_ref
if IDLE_TIMEOUT_MINUTES > 0 and not _idle_monitor_started: _server_ref = server
_idle_monitor_started = True if not _idle_checker_started:
thread = threading.Thread(target=_idle_monitor, daemon=True) _idle_checker_started = True
thread = threading.Thread(target=_idle_checker, daemon=True)
thread.start() thread.start()
print(f"Idle auto-shutdown enabled: {IDLE_TIMEOUT_MINUTES} minutes")
# History buffer size for charts # History buffer size for charts
HISTORY_SIZE = 500 HISTORY_SIZE = 500
@@ -159,12 +154,21 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
return server, thread return server, thread
def _update_activity() -> None:
"""Update activity timestamp and resume physics if paused."""
global _last_activity_time
_last_activity_time = time.time()
# Resume physics if it was paused
if "server" in st.session_state:
server = st.session_state.server
if server.paused:
server.paused = False
print("Physics engine resumed (user activity detected)")
def init_session_state() -> None: def init_session_state() -> None:
"""Initialise Streamlit session state.""" """Initialise Streamlit session state."""
# Start idle monitor for auto-shutdown
_start_idle_monitor()
_update_activity()
if "server" not in st.session_state: if "server" not in st.session_state:
with st.spinner("Starting simulation server..."): with st.spinner("Starting simulation server..."):
st.session_state.server, st.session_state.server_thread = start_embedded_server() st.session_state.server, st.session_state.server_thread = start_embedded_server()
@@ -174,6 +178,9 @@ def init_session_state() -> None:
st.error("Failed to start simulation server. Please refresh the page.") st.error("Failed to start simulation server. Please refresh the page.")
st.stop() st.stop()
# Start idle checker to pause physics when no one's viewing
_start_idle_checker(st.session_state.server)
# Register cleanup # Register cleanup
def cleanup() -> None: def cleanup() -> None:
if hasattr(st.session_state, "server") and st.session_state.server is not None: if hasattr(st.session_state, "server") and st.session_state.server is not None:
@@ -427,7 +434,7 @@ def display_controls() -> None:
@st.fragment(run_every=0.1) @st.fragment(run_every=0.1)
def simulation_display() -> None: def simulation_display() -> None:
"""Fragment that displays and updates simulation state.""" """Fragment that displays and updates simulation state."""
_update_activity() # Track that someone is viewing the dashboard _update_activity() # Track activity and resume physics if paused
if "server" not in st.session_state: if "server" not in st.session_state:
st.warning("Initializing simulation server...") st.warning("Initializing simulation server...")