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