From be2396a8f0bf364b1b4c8bfd1ac6031372578e92 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Wed, 21 May 2025 22:55:40 +0000 Subject: [PATCH] Add multimeter simulator tests Comprehensive test coverage for MultimeterSim including MEAS:VOLT:DC, MEAS:CURR:DC, CONF, and READ commands. Tests both standalone operation and physics engine integration including temperature-dependent measurements. --- tests/unit/test_multimeter.py | 306 ++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/unit/test_multimeter.py diff --git a/tests/unit/test_multimeter.py b/tests/unit/test_multimeter.py new file mode 100644 index 0000000..887944d --- /dev/null +++ b/tests/unit/test_multimeter.py @@ -0,0 +1,306 @@ +"""Unit tests for multimeter simulator.""" + +import pytest + +from py_dvt_ate.simulation.physics.engine import PhysicsEngine +from py_dvt_ate.simulation.virtual.multimeter import MultimeterSim + + +class TestMultimeterSimBasic: + """Tests for MultimeterSim without physics engine.""" + + @pytest.fixture + def dmm(self) -> MultimeterSim: + """Create multimeter instance without physics engine.""" + return MultimeterSim() + + def test_creation(self, dmm: MultimeterSim) -> None: + """Test multimeter can be created.""" + assert dmm is not None + assert dmm.model == "DMM-SIM-001" + assert dmm.manufacturer == "PyDVTATE" + + def test_idn_query(self, dmm: MultimeterSim) -> None: + """Test *IDN? returns identification string.""" + response = dmm.process("*IDN?") + + assert "PyDVTATE" in response + assert "DMM-SIM-001" in response + + def test_rst_command(self, dmm: MultimeterSim) -> None: + """Test *RST resets to defaults.""" + # Set non-default config + dmm.process("CONF:CURR:DC") + assert dmm.process("CONF?") == '"CURR:DC"' + + # Reset + response = dmm.process("*RST") + assert response == "" + + # Check defaults restored + assert dmm.process("CONF?") == '"VOLT:DC"' + + def test_opc_query(self, dmm: MultimeterSim) -> None: + """Test *OPC? returns 1.""" + response = dmm.process("*OPC?") + assert response == "1" + + def test_unknown_command(self, dmm: MultimeterSim) -> None: + """Test unknown command returns error.""" + response = dmm.process("INVALID:CMD") + + assert response.startswith("ERROR:") + assert "Unknown command" in response + + +class TestMultimeterMeasVoltDC: + """Tests for MEAS:VOLT:DC command.""" + + @pytest.fixture + def dmm(self) -> MultimeterSim: + """Create multimeter instance without physics engine.""" + return MultimeterSim() + + def test_meas_volt_dc_query_no_engine(self, dmm: MultimeterSim) -> None: + """Test MEAS:VOLT:DC? returns 0 without physics engine.""" + response = dmm.process("MEAS:VOLT:DC?") + + assert response == "0.000000" + + def test_meas_volt_dc_sets_config(self, dmm: MultimeterSim) -> None: + """Test MEAS:VOLT:DC? sets configuration to VOLT:DC.""" + dmm.process("CONF:CURR:DC") + dmm.process("MEAS:VOLT:DC?") + + assert dmm.process("CONF?") == '"VOLT:DC"' + + def test_meas_volt_dc_as_command_fails(self, dmm: MultimeterSim) -> None: + """Test MEAS:VOLT:DC (without ?) returns error.""" + response = dmm.process("MEAS:VOLT:DC 1.0") + + assert response.startswith("ERROR:") + assert "query only" in response + + +class TestMultimeterMeasCurrDC: + """Tests for MEAS:CURR:DC command.""" + + @pytest.fixture + def dmm(self) -> MultimeterSim: + """Create multimeter instance without physics engine.""" + return MultimeterSim() + + def test_meas_curr_dc_query_no_engine(self, dmm: MultimeterSim) -> None: + """Test MEAS:CURR:DC? returns 0 without physics engine.""" + response = dmm.process("MEAS:CURR:DC?") + + assert response == "0.000000" + + def test_meas_curr_dc_sets_config(self, dmm: MultimeterSim) -> None: + """Test MEAS:CURR:DC? sets configuration to CURR:DC.""" + dmm.process("MEAS:CURR:DC?") + + assert dmm.process("CONF?") == '"CURR:DC"' + + def test_meas_curr_dc_as_command_fails(self, dmm: MultimeterSim) -> None: + """Test MEAS:CURR:DC (without ?) returns error.""" + response = dmm.process("MEAS:CURR:DC 0.1") + + assert response.startswith("ERROR:") + assert "query only" in response + + +class TestMultimeterConf: + """Tests for CONF commands.""" + + @pytest.fixture + def dmm(self) -> MultimeterSim: + """Create multimeter instance without physics engine.""" + return MultimeterSim() + + def test_conf_query_default(self, dmm: MultimeterSim) -> None: + """Test CONF? returns default configuration.""" + response = dmm.process("CONF?") + + assert response == '"VOLT:DC"' + + def test_conf_volt_dc(self, dmm: MultimeterSim) -> None: + """Test CONF:VOLT:DC sets voltage measurement mode.""" + dmm.process("CONF:CURR:DC") + response = dmm.process("CONF:VOLT:DC") + + assert response == "" + assert dmm.process("CONF?") == '"VOLT:DC"' + + def test_conf_volt_dc_as_query_fails(self, dmm: MultimeterSim) -> None: + """Test CONF:VOLT:DC? returns error.""" + response = dmm.process("CONF:VOLT:DC?") + + assert response.startswith("ERROR:") + assert "command only" in response + + def test_conf_curr_dc(self, dmm: MultimeterSim) -> None: + """Test CONF:CURR:DC sets current measurement mode.""" + response = dmm.process("CONF:CURR:DC") + + assert response == "" + assert dmm.process("CONF?") == '"CURR:DC"' + + def test_conf_curr_dc_as_query_fails(self, dmm: MultimeterSim) -> None: + """Test CONF:CURR:DC? returns error.""" + response = dmm.process("CONF:CURR:DC?") + + assert response.startswith("ERROR:") + assert "command only" in response + + def test_conf_as_command_fails(self, dmm: MultimeterSim) -> None: + """Test CONF without subcommand returns error.""" + response = dmm.process("CONF") + + assert response.startswith("ERROR:") + + +class TestMultimeterRead: + """Tests for READ command.""" + + @pytest.fixture + def dmm(self) -> MultimeterSim: + """Create multimeter instance without physics engine.""" + return MultimeterSim() + + def test_read_query_volt_mode(self, dmm: MultimeterSim) -> None: + """Test READ? returns voltage when configured for voltage.""" + dmm.process("CONF:VOLT:DC") + response = dmm.process("READ?") + + assert response == "0.000000" + + def test_read_query_curr_mode(self, dmm: MultimeterSim) -> None: + """Test READ? returns current when configured for current.""" + dmm.process("CONF:CURR:DC") + response = dmm.process("READ?") + + assert response == "0.000000" + + def test_read_as_command_fails(self, dmm: MultimeterSim) -> None: + """Test READ (without ?) returns error.""" + response = dmm.process("READ") + + assert response.startswith("ERROR:") + assert "query only" in response + + +class TestMultimeterWithPhysicsEngine: + """Tests for MultimeterSim with physics engine integration.""" + + @pytest.fixture + def engine(self) -> PhysicsEngine: + """Create physics engine instance.""" + return PhysicsEngine(update_rate_hz=100.0) + + @pytest.fixture + def dmm(self, engine: PhysicsEngine) -> MultimeterSim: + """Create multimeter instance with physics engine.""" + return MultimeterSim(physics_engine=engine) + + def test_meas_volt_dc_returns_engine_voltage( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test MEAS:VOLT:DC? returns physics engine output voltage.""" + engine.set_input_voltage(5.0) + engine.set_output_enabled(True) + engine.step() + + response = dmm.process("MEAS:VOLT:DC?") + + # LDO model outputs ~3.3V nominal + voltage = float(response) + assert voltage > 3.0 + assert voltage < 4.0 + + def test_meas_volt_dc_returns_zero_when_disabled( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test MEAS:VOLT:DC? returns 0 when DUT output disabled.""" + engine.set_input_voltage(5.0) + engine.set_output_enabled(False) + engine.step() + + response = dmm.process("MEAS:VOLT:DC?") + + assert response == "0.000000" + + def test_meas_curr_dc_returns_engine_current( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test MEAS:CURR:DC? returns physics engine load current.""" + engine.set_input_voltage(5.0) + engine.set_load_current(0.1) + engine.set_output_enabled(True) + engine.step() + + response = dmm.process("MEAS:CURR:DC?") + + assert float(response) == pytest.approx(0.1, abs=0.001) + + def test_meas_curr_dc_returns_zero_when_disabled( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test MEAS:CURR:DC? returns 0 when DUT output disabled.""" + engine.set_input_voltage(5.0) + engine.set_load_current(0.1) + engine.set_output_enabled(False) + engine.step() + + response = dmm.process("MEAS:CURR:DC?") + + assert response == "0.000000" + + def test_read_uses_configured_function( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test READ? respects configured measurement function.""" + engine.set_input_voltage(5.0) + engine.set_load_current(0.1) + engine.set_output_enabled(True) + engine.step() + + # Configure for current + dmm.process("CONF:CURR:DC") + response = dmm.process("READ?") + + # Should return current, not voltage + assert float(response) == pytest.approx(0.1, abs=0.001) + + def test_reset_restores_voltage_mode( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test *RST restores default voltage measurement mode.""" + dmm.process("CONF:CURR:DC") + dmm.process("*RST") + + assert dmm.process("CONF?") == '"VOLT:DC"' + + def test_voltage_changes_with_temperature( + self, dmm: MultimeterSim, engine: PhysicsEngine + ) -> None: + """Test measured voltage changes with DUT temperature.""" + engine.set_input_voltage(5.0) + engine.set_output_enabled(True) + engine.step() + + # Measure at initial temperature + response1 = dmm.process("MEAS:VOLT:DC?") + v1 = float(response1) + + # Change chamber temperature and let settle + engine.set_chamber_setpoint(85.0) + for _ in range(5000): # Let temperature settle somewhat + engine.step() + + # Measure at elevated temperature + response2 = dmm.process("MEAS:VOLT:DC?") + v2 = float(response2) + + # Output voltage should have changed (LDO has tempco) + assert v1 != v2