"""Streamlit dashboard application for physics simulation visualisation. This module provides an interactive dashboard for visualising the physics engine directly, demonstrating thermal-electrical coupling in real-time. """ import time from collections import deque from dataclasses import dataclass, field import streamlit as st from py_dvt_ate.simulation.physics.engine import PhysicsEngine # History buffer size for charts HISTORY_SIZE = 500 @dataclass class SimulationHistory: """Stores time series data for visualisation.""" time: deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_SIZE)) chamber_temp: deque[float] = field( default_factory=lambda: deque(maxlen=HISTORY_SIZE) ) case_temp: deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_SIZE)) junction_temp: deque[float] = field( default_factory=lambda: deque(maxlen=HISTORY_SIZE) ) output_voltage: deque[float] = field( default_factory=lambda: deque(maxlen=HISTORY_SIZE) ) power_dissipation: deque[float] = field( default_factory=lambda: deque(maxlen=HISTORY_SIZE) ) def init_session_state() -> None: """Initialise Streamlit session state.""" if "engine" not in st.session_state: st.session_state.engine = PhysicsEngine(update_rate_hz=100.0) if "history" not in st.session_state: st.session_state.history = SimulationHistory() if "running" not in st.session_state: st.session_state.running = False if "last_update" not in st.session_state: st.session_state.last_update = time.time() # Note: time_multiplier, temp_setpoint, input_voltage, output_enabled, # load_current are managed by their respective widgets via keys def step_simulation() -> None: """Advance the simulation based on elapsed real time and multiplier.""" engine: PhysicsEngine = st.session_state.engine history: SimulationHistory = st.session_state.history multiplier: float = st.session_state.get("time_multiplier", 10) # Calculate how much simulation time to advance current_time = time.time() elapsed_real = current_time - st.session_state.last_update st.session_state.last_update = current_time # Simulation time to advance (capped to prevent huge jumps) sim_time_to_advance = min(elapsed_real * multiplier, 2.0) # Calculate number of steps needed steps = int(sim_time_to_advance / engine.dt) steps = max(1, min(steps, 1000)) # Clamp between 1 and 1000 steps for _ in range(steps): engine.step() # Record current state in history thermal = engine.get_thermal_state() electrical = engine.get_electrical_state() history.time.append(thermal.timestamp) history.chamber_temp.append(thermal.chamber_temperature) history.case_temp.append(thermal.case_temperature) history.junction_temp.append(thermal.junction_temperature) history.output_voltage.append(electrical.output_voltage) history.power_dissipation.append(electrical.power_dissipation) def sync_engine_from_session_state() -> None: """Sync engine parameters from session state (called by fragment).""" engine: PhysicsEngine = st.session_state.engine engine.set_chamber_setpoint(st.session_state.get("temp_setpoint", 25.0)) engine.set_input_voltage(st.session_state.get("input_voltage", 5.0)) engine.set_output_enabled(st.session_state.get("output_enabled", False)) engine.set_load_current(st.session_state.get("load_current", 100.0) / 1000.0) def display_controls() -> None: """Display simulation control panel in sidebar.""" st.sidebar.header("Simulation Controls") # Start/Stop button if st.session_state.running: if st.sidebar.button( "Stop Simulation", type="primary", use_container_width=True ): st.session_state.running = False else: if st.sidebar.button( "Start Simulation", type="primary", use_container_width=True ): st.session_state.running = True st.session_state.last_update = time.time() # Reset button if st.sidebar.button("Reset", use_container_width=True): st.session_state.engine = PhysicsEngine(update_rate_hz=100.0) st.session_state.history = SimulationHistory() st.session_state.running = False st.session_state.last_update = time.time() st.sidebar.divider() # Time multiplier st.sidebar.subheader("Simulation Speed") st.sidebar.select_slider( "Time Multiplier", options=[1, 2, 5, 10, 20, 50, 100], value=10, format_func=lambda x: f"{x}x", key="time_multiplier", ) st.sidebar.caption( f"1 real second = {st.session_state.get('time_multiplier', 10)} simulation seconds" ) st.sidebar.divider() # Temperature setpoint st.sidebar.subheader("Thermal Chamber") st.sidebar.slider( "Temperature Setpoint (C)", min_value=-40.0, max_value=125.0, value=25.0, step=5.0, key="temp_setpoint", ) st.sidebar.divider() # Power supply controls st.sidebar.subheader("Power Supply") st.sidebar.slider( "Input Voltage (V)", min_value=0.0, max_value=12.0, value=5.0, step=0.1, key="input_voltage", ) st.sidebar.toggle( "Output Enabled", value=False, key="output_enabled", ) st.sidebar.divider() # Load controls st.sidebar.subheader("Electronic Load") st.sidebar.slider( "Load Current (mA)", min_value=0.0, max_value=500.0, value=100.0, step=10.0, key="load_current", ) @st.fragment(run_every=0.1) def simulation_display() -> None: """Fragment that displays and updates simulation state.""" engine: PhysicsEngine = st.session_state.engine history: SimulationHistory = st.session_state.history # Sync engine parameters from UI controls sync_engine_from_session_state() # Step simulation if running if st.session_state.running: step_simulation() # Get current state thermal = engine.get_thermal_state() electrical = engine.get_electrical_state() # Current state metrics st.subheader("Current State") col1, col2, col3, col4 = st.columns(4) with col1: st.metric("Chamber Temp", f"{thermal.chamber_temperature:.2f} C") with col2: st.metric("Case Temp", f"{thermal.case_temperature:.2f} C") with col3: st.metric("Junction Temp", f"{thermal.junction_temperature:.2f} C") with col4: st.metric("Output Voltage", f"{electrical.output_voltage:.4f} V") col5, col6, col7, col8 = st.columns(4) with col5: st.metric("Input Voltage", f"{electrical.input_voltage:.2f} V") with col6: st.metric("Load Current", f"{electrical.load_current * 1000:.1f} mA") with col7: st.metric("Power Diss.", f"{electrical.power_dissipation * 1000:.2f} mW") with col8: status = "Running" if st.session_state.running else "Stopped" st.metric( "Sim Time", f"{engine.simulation_time:.1f} s", delta=f"{status} @ {st.session_state.get('time_multiplier', 10):.0f}x", ) # Temperature chart st.subheader("Temperature History") if len(history.time) < 2: st.info("Start the simulation to see temperature data") else: chart_data = { "Time (s)": list(history.time), "Chamber": list(history.chamber_temp), "Case": list(history.case_temp), "Junction": list(history.junction_temp), } st.line_chart( chart_data, x="Time (s)", y=["Chamber", "Case", "Junction"], color=["#1f77b4", "#ff7f0e", "#d62728"], ) # Self-heating demonstration st.subheader("Self-Heating Demonstration") delta_t_jc = thermal.junction_temperature - thermal.case_temperature delta_t_ca = thermal.case_temperature - thermal.chamber_temperature col1, col2 = st.columns(2) with col1: st.markdown("#### Self-Heating Analysis") st.markdown( f""" | Parameter | Value | |-----------|-------| | Junction-Case Rise (dT_jc) | **{delta_t_jc:.2f} C** | | Case-Ambient Rise (dT_ca) | **{delta_t_ca:.2f} C** | | Power Dissipation | {electrical.power_dissipation * 1000:.1f} mW | | theta_jc (junction-case) | 15 C/W | | theta_ca (case-ambient) | 5 C/W | """ ) st.markdown( """ **Thermal Coupling:** The junction temperature rises above the case temperature due to power dissipation. This is governed by: `T_junction = T_case + P_diss x theta_jc` Try increasing the load current or input voltage to see self-heating effects! """ ) with col2: st.markdown("#### Power Dissipation") if len(history.time) < 2: st.info("Start the simulation to see power data") else: power_data = { "Time (s)": list(history.time), "Power (mW)": [p * 1000 for p in history.power_dissipation], } st.line_chart( power_data, x="Time (s)", y="Power (mW)", color="#2ca02c", ) def main() -> None: """Main entry point for the Streamlit dashboard.""" st.set_page_config( page_title="py-dvt-ate Virtual Lab Bench", page_icon="🔬", layout="wide", ) st.title("py-dvt-ate Virtual Lab Bench") st.markdown( """ Interactive physics simulation demonstrating coupled thermal-electrical behaviour of an LDO voltage regulator. """ ) init_session_state() # Sidebar controls (static - doesn't need fragment) display_controls() # Dynamic simulation display (uses fragment for smooth updates) simulation_display() if __name__ == "__main__": main()