Add physics engine tests

Integration tests for thermal-electrical coupling:
- Thermal settling (chamber, case, junction)
- Self-heating effects with power dissipation
- Temperature-dependent electrical behaviour
- Complete thermal-electrical feedback loop
This commit is contained in:
2025-03-13 16:42:17 +00:00
parent ea5070132d
commit bdd7cae0da

View File

@@ -0,0 +1,300 @@
"""Integration tests for the physics engine with thermal-electrical coupling.
Tests the complete physics simulation including:
- Thermal settling behaviour (chamber, case, junction)
- Self-heating effects from power dissipation
- Temperature-dependent electrical behaviour
"""
import pytest
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
from py_dvt_ate.simulation.physics.models.ldo import LDOModel, LDOParameters
class TestThermalSettling:
"""Tests for thermal settling behaviour."""
def test_chamber_approaches_setpoint(self) -> None:
"""Test chamber temperature approaches setpoint over time."""
engine = PhysicsEngine(update_rate_hz=100.0)
# Set a new setpoint
engine.set_chamber_setpoint(85.0)
# Initial state
initial_state = engine.get_thermal_state()
assert initial_state.chamber_temperature == pytest.approx(25.0)
# Simulate for 1 second (100 steps at 100Hz)
for _ in range(100):
engine.step()
state_1s = engine.get_thermal_state()
# Chamber should have moved towards 85°C but not reached it yet
# With tau=30s, after 1s: T = 25 + (85-25)*(1 - e^(-1/30)) ≈ 27.0°C
assert state_1s.chamber_temperature > 25.0
assert state_1s.chamber_temperature < 85.0
# Simulate for another 89 seconds (total 90s = 3*tau)
for _ in range(8900):
engine.step()
state_90s = engine.get_thermal_state()
# After 3 time constants, should be ~95% of the way there
# T = 25 + (85-25)*(1 - e^(-3)) ≈ 82.0°C
assert state_90s.chamber_temperature > 80.0
assert state_90s.chamber_temperature < 85.0
def test_case_follows_chamber(self) -> None:
"""Test case temperature follows chamber temperature."""
engine = PhysicsEngine(update_rate_hz=100.0)
# Set chamber to high temperature (no power, so no self-heating)
engine.set_chamber_setpoint(85.0)
# Simulate for 200 seconds (well past both time constants)
for _ in range(20000):
engine.step()
state = engine.get_thermal_state()
# With no power, case should approach chamber temperature
assert state.case_temperature == pytest.approx(
state.chamber_temperature, abs=0.5
)
def test_junction_equals_case_with_no_power(self) -> None:
"""Test junction equals case temperature when no power dissipated."""
engine = PhysicsEngine(update_rate_hz=100.0)
# No power applied (output disabled by default)
state = engine.get_thermal_state()
# Junction should equal case (no θ_jc rise with zero power)
assert state.junction_temperature == pytest.approx(state.case_temperature)
class TestSelfHeating:
"""Tests for self-heating effects from power dissipation."""
def test_junction_higher_than_case_with_power(self) -> None:
"""Test junction temperature rises above case when power is dissipated."""
engine = PhysicsEngine(update_rate_hz=100.0)
# Apply power: 5V input, 100mA load
engine.set_input_voltage(5.0)
engine.set_load_current(0.1)
engine.set_output_enabled(True)
# Let it settle
for _ in range(1000):
engine.step()
thermal_state = engine.get_thermal_state()
electrical_state = engine.get_electrical_state()
# Power dissipation = (Vin - Vout) * Iload + Vin * Iq
# With Vout ≈ 3.3V, P ≈ (5-3.3)*0.1 ≈ 0.17W
assert electrical_state.power_dissipation > 0
# Junction should be higher than case by P * θ_jc
# θ_jc = 15°C/W, so with 0.17W, ΔT ≈ 2.5°C
assert (
thermal_state.junction_temperature > thermal_state.case_temperature
)
def test_self_heating_raises_case_temperature(self) -> None:
"""Test self-heating raises case temperature above ambient."""
engine = PhysicsEngine(update_rate_hz=100.0)
# Apply significant power: 5V input, 300mA load
engine.set_input_voltage(5.0)
engine.set_load_current(0.3)
engine.set_output_enabled(True)
# Let thermal state settle (many time constants)
for _ in range(50000): # 500 seconds at 100Hz
engine.step()
thermal_state = engine.get_thermal_state()
# Power ≈ (5-3.3)*0.3 ≈ 0.51W
# Steady-state case rise = P * θ_ca = 0.51 * 5 ≈ 2.5°C
# Case should be above chamber temperature
assert (
thermal_state.case_temperature > thermal_state.chamber_temperature
)
def test_self_heating_increases_with_load(self) -> None:
"""Test that self-heating increases with higher load current."""
engine1 = PhysicsEngine(update_rate_hz=100.0)
engine2 = PhysicsEngine(update_rate_hz=100.0)
# Both at 5V, but different loads
for engine, load in [(engine1, 0.1), (engine2, 0.3)]:
engine.set_input_voltage(5.0)
engine.set_load_current(load)
engine.set_output_enabled(True)
# Let settle
for _ in range(50000):
engine.step()
state1 = engine1.get_thermal_state()
state2 = engine2.get_thermal_state()
# Higher load should give higher junction temperature
assert (
state2.junction_temperature > state1.junction_temperature
)
class TestTemperatureDependentElectrical:
"""Tests for temperature-dependent electrical behaviour."""
def test_output_voltage_varies_with_temperature(self) -> None:
"""Test output voltage changes with junction temperature."""
# Create custom LDO with higher tempco for observable effect
params = LDOParameters(
nominal_output_voltage=3.3,
tempco_ppm_per_c=100.0, # 100 ppm/°C for visible effect
)
ldo = LDOModel(params=params)
# Test at different temperatures
vout_25 = ldo.calculate_output_voltage(25.0)
vout_85 = ldo.calculate_output_voltage(85.0)
# At 85°C with 100 ppm/°C:
# ΔV = 3.3 * 100e-6 * 60 = 19.8mV
delta_v = vout_85 - vout_25
expected_delta = 3.3 * 100e-6 * 60
assert delta_v == pytest.approx(expected_delta, rel=0.01)
def test_quiescent_current_varies_with_temperature(self) -> None:
"""Test quiescent current changes with junction temperature."""
ldo = LDOModel()
# Test at different temperatures
iq_25 = ldo.calculate_quiescent_current(25.0)
iq_85 = ldo.calculate_quiescent_current(85.0)
# Default tempco is 0.003/°C (0.3%/°C)
# At 85°C: Iq = Iq_25 * (1 + 0.003 * 60) = Iq_25 * 1.18
expected_ratio = 1.0 + 0.003 * 60
assert iq_85 / iq_25 == pytest.approx(expected_ratio, rel=0.01)
def test_power_dissipation_calculation(self) -> None:
"""Test power dissipation is calculated correctly."""
ldo = LDOModel()
ldo.input_voltage = 5.0
# P = (Vin - Vout) * Iload + Vin * Iq
# At 25°C: Vout ≈ 3.3V, Iq ≈ 50µA
# With 100mA load: P ≈ (5-3.3)*0.1 + 5*50e-6 ≈ 0.170W
p_diss = ldo.calculate_power_dissipation(
input_voltage=5.0,
load_current=0.1,
junction_temperature=25.0,
)
expected_p = (5.0 - 3.3) * 0.1 + 5.0 * 50e-6
assert p_diss == pytest.approx(expected_p, rel=0.01)
class TestPhysicsEngineCoupling:
"""Tests for complete thermal-electrical coupling in the engine."""
def test_thermal_electrical_feedback(self) -> None:
"""Test that thermal and electrical states are coupled.
Higher junction temperature affects Vout, which affects power
dissipation, which affects junction temperature - a feedback loop.
"""
engine = PhysicsEngine(update_rate_hz=100.0)
# Apply power at hot chamber
engine.set_chamber_setpoint(85.0)
engine.set_input_voltage(5.0)
engine.set_load_current(0.2)
engine.set_output_enabled(True)
# Let settle completely
for _ in range(100000): # 1000 seconds
engine.step()
thermal = engine.get_thermal_state()
electrical = engine.get_electrical_state()
# Verify the coupling:
# 1. Chamber should be near 85°C
assert thermal.chamber_temperature == pytest.approx(85.0, abs=0.5)
# 2. Case should be higher than chamber due to self-heating
assert thermal.case_temperature > thermal.chamber_temperature
# 3. Junction should be higher than case
assert thermal.junction_temperature > thermal.case_temperature
# 4. Output voltage should reflect temperature-adjusted value
assert electrical.output_voltage > 0
assert electrical.output_voltage < 5.0 # Less than input
# 5. Power should be non-zero
assert electrical.power_dissipation > 0
def test_engine_with_custom_dut_model(self) -> None:
"""Test engine works with custom DUT model parameters."""
params = LDOParameters(
nominal_output_voltage=1.8, # Different voltage
tempco_ppm_per_c=25.0,
)
ldo = LDOModel(params=params)
engine = PhysicsEngine(update_rate_hz=100.0, dut_model=ldo)
engine.set_input_voltage(3.3)
engine.set_load_current(0.1)
engine.set_output_enabled(True)
for _ in range(1000):
engine.step()
electrical = engine.get_electrical_state()
# Should output approximately 1.8V
assert electrical.output_voltage == pytest.approx(1.8, abs=0.01)
def test_simulation_time_accuracy(self) -> None:
"""Test simulation time accumulates correctly."""
engine = PhysicsEngine(update_rate_hz=1000.0) # 1ms timestep
for _ in range(1000):
engine.step()
# Should be exactly 1 second
assert engine.simulation_time == pytest.approx(1.0, abs=1e-6)
def test_multiple_setpoint_changes(self) -> None:
"""Test engine handles multiple setpoint changes correctly."""
engine = PhysicsEngine(update_rate_hz=100.0)
# Start at 25°C, go to 85°C
engine.set_chamber_setpoint(85.0)
for _ in range(10000): # 100 seconds
engine.step()
hot_state = engine.get_thermal_state()
assert hot_state.chamber_temperature > 75.0
# Now cool down to -40°C
engine.set_chamber_setpoint(-40.0)
for _ in range(20000): # 200 seconds
engine.step()
cold_state = engine.get_thermal_state()
assert cold_state.chamber_temperature < -30.0