Files
py-dvt-ate/src/py_dvt_ate/app/dashboard/app.py
Kai Chappell d54ada18b2 Remove fragment from sidebar controls (not supported)
Sidebar controls cannot be in a fragment. Brief blank on
slider change is a Streamlit limitation.
2025-04-15 21:25:23 +00:00

322 lines
9.8 KiB
Python

"""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()