From ca48541b91b6e0985c2a4b670023cd9a12c18ed8 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Tue, 2 Dec 2025 13:27:41 +0000 Subject: [PATCH] Add thermal chamber simulator tests Tests for ThermalChamberSim SCPI command responses: - Basic IEEE 488.2 commands (*IDN?, *RST, *OPC?) - TEMP:SETPOINT set/query - TEMP:ACTUAL? query - TEMP:STAB? stability query - Physics engine integration tests --- tests/unit/test_thermal_chamber.py | 215 +++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/unit/test_thermal_chamber.py diff --git a/tests/unit/test_thermal_chamber.py b/tests/unit/test_thermal_chamber.py new file mode 100644 index 0000000..466053d --- /dev/null +++ b/tests/unit/test_thermal_chamber.py @@ -0,0 +1,215 @@ +"""Unit tests for thermal chamber simulator.""" + +import pytest + +from py_dvt_ate.simulation.physics.engine import PhysicsEngine +from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim + + +class TestThermalChamberSimBasic: + """Tests for ThermalChamberSim without physics engine.""" + + @pytest.fixture + def chamber(self) -> ThermalChamberSim: + """Create chamber instance without physics engine.""" + return ThermalChamberSim() + + def test_creation(self, chamber: ThermalChamberSim) -> None: + """Test chamber can be created.""" + assert chamber is not None + assert chamber.model == "TC-SIM-001" + assert chamber.manufacturer == "PyDVTATE" + + def test_idn_query(self, chamber: ThermalChamberSim) -> None: + """Test *IDN? returns identification string.""" + response = chamber.process("*IDN?") + + assert "PyDVTATE" in response + assert "TC-SIM-001" in response + + def test_rst_command(self, chamber: ThermalChamberSim) -> None: + """Test *RST resets to defaults.""" + # Set non-default value + chamber.process("TEMP:SETPOINT 85.0") + assert chamber.process("TEMP:SETPOINT?") == "85.00" + + # Reset + response = chamber.process("*RST") + assert response == "" + assert chamber.process("TEMP:SETPOINT?") == "25.00" + + def test_opc_query(self, chamber: ThermalChamberSim) -> None: + """Test *OPC? returns 1.""" + response = chamber.process("*OPC?") + assert response == "1" + + def test_unknown_command(self, chamber: ThermalChamberSim) -> None: + """Test unknown command returns error.""" + response = chamber.process("INVALID:CMD") + + assert response.startswith("ERROR:") + assert "Unknown command" in response + + +class TestThermalChamberSetpoint: + """Tests for TEMP:SETPOINT command.""" + + @pytest.fixture + def chamber(self) -> ThermalChamberSim: + """Create chamber instance without physics engine.""" + return ThermalChamberSim() + + def test_setpoint_query_default(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:SETPOINT? returns default value.""" + response = chamber.process("TEMP:SETPOINT?") + + assert response == "25.00" + + def test_setpoint_set(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:SETPOINT sets value.""" + response = chamber.process("TEMP:SETPOINT 85.0") + + assert response == "" + assert chamber.process("TEMP:SETPOINT?") == "85.00" + + def test_setpoint_set_negative(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:SETPOINT accepts negative values.""" + chamber.process("TEMP:SETPOINT -40.0") + + assert chamber.process("TEMP:SETPOINT?") == "-40.00" + + def test_setpoint_set_invalid_value(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:SETPOINT with invalid value returns error.""" + response = chamber.process("TEMP:SETPOINT abc") + + assert response.startswith("ERROR:") + assert "Invalid temperature" in response + + def test_setpoint_set_no_argument(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:SETPOINT without argument returns error.""" + response = chamber.process("TEMP:SETPOINT") + + assert response.startswith("ERROR:") + assert "requires a value" in response + + +class TestThermalChamberActual: + """Tests for TEMP:ACTUAL? query.""" + + @pytest.fixture + def chamber(self) -> ThermalChamberSim: + """Create chamber instance without physics engine.""" + return ThermalChamberSim() + + def test_actual_query_without_engine(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:ACTUAL? returns setpoint when no physics engine.""" + chamber.process("TEMP:SETPOINT 50.0") + response = chamber.process("TEMP:ACTUAL?") + + # Without physics engine, returns setpoint + assert response == "50.00" + + def test_actual_as_command_fails(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:ACTUAL (without ?) returns error.""" + response = chamber.process("TEMP:ACTUAL 25.0") + + assert response.startswith("ERROR:") + assert "query only" in response + + +class TestThermalChamberStability: + """Tests for TEMP:STAB? query.""" + + @pytest.fixture + def chamber(self) -> ThermalChamberSim: + """Create chamber instance without physics engine.""" + return ThermalChamberSim() + + def test_stab_query_without_engine(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:STAB? returns 1 when no physics engine.""" + response = chamber.process("TEMP:STAB?") + + # Without physics engine, assume stable + assert response == "1" + + def test_stab_as_command_fails(self, chamber: ThermalChamberSim) -> None: + """Test TEMP:STAB (without ?) returns error.""" + response = chamber.process("TEMP:STAB 1") + + assert response.startswith("ERROR:") + assert "query only" in response + + +class TestThermalChamberWithPhysicsEngine: + """Tests for ThermalChamberSim with physics engine integration.""" + + @pytest.fixture + def engine(self) -> PhysicsEngine: + """Create physics engine instance.""" + return PhysicsEngine(update_rate_hz=100.0) + + @pytest.fixture + def chamber(self, engine: PhysicsEngine) -> ThermalChamberSim: + """Create chamber instance with physics engine.""" + return ThermalChamberSim(physics_engine=engine) + + def test_setpoint_updates_engine( + self, chamber: ThermalChamberSim, engine: PhysicsEngine + ) -> None: + """Test TEMP:SETPOINT updates physics engine.""" + chamber.process("TEMP:SETPOINT 85.0") + + # Step the engine and check thermal state + thermal = engine.get_thermal_state() + # Initial chamber temp is 25, will start moving towards 85 + assert thermal.chamber_temperature == pytest.approx(25.0, abs=0.1) + + # After many steps, should approach setpoint + for _ in range(10000): # 100 seconds at 100Hz + engine.step() + + thermal = engine.get_thermal_state() + # Should be closer to setpoint (but not quite there due to time constant) + assert thermal.chamber_temperature > 80.0 + + def test_actual_query_returns_engine_temperature( + self, chamber: ThermalChamberSim, engine: PhysicsEngine + ) -> None: + """Test TEMP:ACTUAL? returns physics engine temperature.""" + response = chamber.process("TEMP:ACTUAL?") + + # Should match initial chamber temperature + assert response == "25.00" + + def test_stability_when_at_setpoint( + self, chamber: ThermalChamberSim, engine: PhysicsEngine + ) -> None: + """Test TEMP:STAB? returns 1 when at setpoint.""" + # Default setpoint is 25, engine starts at 25 + response = chamber.process("TEMP:STAB?") + + assert response == "1" + + def test_stability_when_settling( + self, chamber: ThermalChamberSim, engine: PhysicsEngine + ) -> None: + """Test TEMP:STAB? returns 0 when settling.""" + # Set new setpoint far from current temperature + chamber.process("TEMP:SETPOINT 85.0") + + # Step once to ensure engine updates + engine.step() + + # Should not be stable yet + response = chamber.process("TEMP:STAB?") + assert response == "0" + + def test_reset_updates_engine( + self, chamber: ThermalChamberSim, engine: PhysicsEngine + ) -> None: + """Test *RST resets both chamber and engine setpoint.""" + chamber.process("TEMP:SETPOINT 85.0") + chamber.process("*RST") + + # Check setpoint is back to default + assert chamber.process("TEMP:SETPOINT?") == "25.00"