From 7ec6a464c27ecde9673748827facd1e369f2a678 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 13 Mar 2025 16:42:17 +0000 Subject: [PATCH] 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 --- tests/unit/test_physics_engine.py | 300 ++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/unit/test_physics_engine.py diff --git a/tests/unit/test_physics_engine.py b/tests/unit/test_physics_engine.py new file mode 100644 index 0000000..68204cc --- /dev/null +++ b/tests/unit/test_physics_engine.py @@ -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