test_inverter_control.py•12.8 kB
"""
Unit tests for inverter control functionality.
"""
import pytest
from opendss_mcp.utils.inverter_control import (
load_curve,
configure_volt_var_control,
configure_volt_watt_control,
list_available_curves,
get_inverter_status,
)
from opendss_mcp.tools.feeder_loader import load_ieee_test_feeder
import opendssdirect as dss
def test_load_ieee1547_curve():
"""Test loading IEEE 1547 control curve."""
# Load the curve
curve_points = load_curve("IEEE1547")
# Verify correct number of points
assert len(curve_points) == 4, "IEEE1547 curve should have 4 points"
# Verify points are tuples
for point in curve_points:
assert isinstance(point, tuple), "Each point should be a tuple"
assert len(point) == 2, "Each point should have 2 values (voltage, var)"
# Verify expected values
expected_points = [(0.92, 0.44), (0.98, 0.0), (1.02, 0.0), (1.08, -0.44)]
assert (
curve_points == expected_points
), "IEEE1547 curve points don't match expected values"
def test_load_rule21_curve():
"""Test loading Rule 21 control curve."""
# Load the curve (case insensitive)
curve_points = load_curve("rule21")
# Verify correct number of points
assert len(curve_points) == 4, "RULE21 curve should have 4 points"
# Verify expected values
expected_points = [(0.95, 0.44), (0.99, 0.0), (1.01, 0.0), (1.05, -0.44)]
assert (
curve_points == expected_points
), "RULE21 curve points don't match expected values"
def test_load_invalid_curve():
"""Test loading a non-existent curve raises appropriate error."""
with pytest.raises(FileNotFoundError):
load_curve("NONEXISTENT_CURVE")
def test_list_available_curves():
"""Test listing available standard curves."""
curves = list_available_curves()
# Should have at least 2 standard curves
assert len(curves) >= 2, "Should have at least IEEE1547 and RULE21"
# Verify structure
for curve in curves:
assert "name" in curve
assert "file" in curve
assert "description" in curve
assert "type" in curve
# Verify IEEE1547 is present
ieee_curve = next((c for c in curves if c["name"] == "IEEE1547"), None)
assert ieee_curve is not None, "IEEE1547 should be in available curves"
assert ieee_curve["type"] == "volt-var"
def test_configure_volt_var():
"""Test configuring volt-var control on a PV system."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"], f"Failed to load feeder: {load_result.get('errors')}"
# Add a test PV system
dss.Text.Command(
"New PVSystem.TestPV Bus1=675 Phases=3 kV=4.16 kVA=500 Pmpp=500 irradiance=1.0"
)
# Solve power flow
dss.Solution.Solve()
assert dss.Solution.Converged(), "Power flow should converge"
# Load curve and configure volt-var control
curve_points = load_curve("IEEE1547")
configure_volt_var_control("TestPV", curve_points, response_time=10.0)
# Verify XYCurve was created
all_curves = dss.XYCurves.AllNames()
assert any(
"testpv" in curve.lower() for curve in all_curves
), "XYCurve should be created"
# Verify power flow still solves with control
dss.Solution.Solve()
assert dss.Solution.Converged(), "Power flow should converge with volt-var control"
def test_configure_volt_var_with_rule21():
"""Test configuring volt-var control with Rule 21 curve."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
# Add a test PV system
dss.Text.Command(
"New PVSystem.TestPV2 Bus1=611 Phases=3 kV=4.16 kVA=300 Pmpp=300 irradiance=1.0"
)
# Load Rule 21 curve and configure
curve_points = load_curve("RULE21")
configure_volt_var_control("TestPV2", curve_points, response_time=5.0)
# Verify XYCurve was created
all_curves = dss.XYCurves.AllNames()
assert any(
"testpv2" in curve.lower() for curve in all_curves
), "XYCurve should be created for TestPV2"
# Solve and verify convergence
dss.Solution.Solve()
assert dss.Solution.Converged()
def test_configure_volt_watt():
"""Test configuring volt-watt control on a PV system."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
# Add a test PV system
dss.Text.Command(
"New PVSystem.TestPV3 Bus1=652 Phases=1 kV=2.4 kVA=200 Pmpp=200 irradiance=1.0"
)
# Define volt-watt curve (curtail above 1.06 pu)
vw_curve = [(0.0, 1.0), (1.06, 1.0), (1.10, 0.2)]
# Configure volt-watt control
configure_volt_watt_control("TestPV3", vw_curve)
# Verify XYCurve was created
all_curves = dss.XYCurves.AllNames()
assert any(
"testpv3" in curve.lower() for curve in all_curves
), "XYCurve should be created for volt-watt"
# Solve and verify convergence
dss.Solution.Solve()
assert dss.Solution.Converged()
def test_get_inverter_status():
"""Test getting inverter status."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
# Add a test PV system
dss.Text.Command(
"New PVSystem.TestPV4 Bus1=675 Phases=3 kV=4.16 kVA=500 Pmpp=500 irradiance=1.0"
)
# Solve power flow
dss.Solution.Solve()
assert dss.Solution.Converged()
# Get inverter status
status = get_inverter_status("TestPV4")
# Verify status structure
assert status["success"], "Status retrieval should succeed"
assert status["pv_name"] == "TestPV4"
assert "kw" in status
assert "kvar" in status
assert "kva" in status
assert "pf" in status
assert "voltage_pu" in status
# Verify power output is reasonable
# Note: kW may be negative due to sign convention (negative = generating)
assert abs(status["kw"]) > 0, "PV should be generating real power"
assert status["kva"] >= abs(status["kw"]), "kVA should be >= |kW|"
assert 0 <= abs(status["pf"]) <= 1.0, "Power factor should be between 0 and 1"
assert status["voltage_pu"] > 0.9, "Voltage should be reasonable"
def test_get_inverter_status_with_volt_var():
"""Test getting inverter status with volt-var control active."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
# Add a test PV system
dss.Text.Command(
"New PVSystem.TestPV5 Bus1=675 Phases=3 kV=4.16 kVA=500 Pmpp=500 irradiance=1.0"
)
# Configure volt-var control
curve_points = load_curve("IEEE1547")
configure_volt_var_control("TestPV5", curve_points, response_time=10.0)
# Solve power flow
dss.Solution.Solve()
assert dss.Solution.Converged()
# Get inverter status
status = get_inverter_status("TestPV5")
# Verify status
assert status["success"]
assert abs(status["kw"]) > 0, "PV should be generating real power"
# Note: kvar may be non-zero due to volt-var control
# The exact value depends on the voltage at the bus
def test_configure_multiple_inverters():
"""Test configuring multiple inverters with different curves."""
# Load IEEE13 feeder
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
# Add multiple PV systems
dss.Text.Command("New PVSystem.PV_A Bus1=675 Phases=3 kV=4.16 kVA=400 Pmpp=400")
dss.Text.Command("New PVSystem.PV_B Bus1=611 Phases=3 kV=4.16 kVA=300 Pmpp=300")
dss.Text.Command("New PVSystem.PV_C Bus1=652 Phases=1 kV=2.4 kVA=200 Pmpp=200")
# Configure with different curves
ieee_curve = load_curve("IEEE1547")
rule21_curve = load_curve("RULE21")
configure_volt_var_control("PV_A", ieee_curve, response_time=10.0)
configure_volt_var_control("PV_B", rule21_curve, response_time=5.0)
configure_volt_var_control("PV_C", ieee_curve, response_time=15.0)
# Verify all XYCurves were created
all_curves = dss.XYCurves.AllNames()
assert len(all_curves) >= 3, "Should have at least 3 XYCurves"
# Solve (may have control iteration warnings with multiple controls, but that's OK)
try:
dss.Solution.Solve()
# If it converges, great. If not, that's acceptable for this test
# since we're just verifying the controls were configured
except Exception:
pass # Control iteration limits can be exceeded with multiple VVC controls
def test_curve_points_format():
"""Test that curve points are in correct format."""
# Load curve
curve = load_curve("IEEE1547")
# Verify format
for voltage, var in curve:
assert isinstance(voltage, (int, float)), "Voltage should be numeric"
assert isinstance(var, (int, float)), "Var should be numeric"
assert 0.8 <= voltage <= 1.2, "Voltage should be in reasonable range"
assert -0.5 <= var <= 0.5, "Var should be in typical range"
def test_load_curve_insufficient_points():
"""Test that curves with < 2 points raise ValueError."""
import tempfile
import json
from pathlib import Path
with tempfile.TemporaryDirectory() as tmpdir:
# Create invalid curve with 1 point
curve_file = Path(tmpdir) / "invalid_curve.json"
with open(curve_file, "w") as f:
json.dump({"points": [[0.95, 0.0]]}, f)
with pytest.raises(ValueError, match="must have at least 2 points"):
load_curve(str(curve_file))
def test_load_curve_missing_points_field():
"""Test that curves without points field raise ValueError."""
import tempfile
import json
from pathlib import Path
with tempfile.TemporaryDirectory() as tmpdir:
# Create curve without points field
curve_file = Path(tmpdir) / "no_points.json"
with open(curve_file, "w") as f:
json.dump({"name": "test", "type": "volt-var"}, f)
with pytest.raises(ValueError, match="missing 'points' field"):
load_curve(str(curve_file))
def test_load_curve_invalid_json():
"""Test that invalid JSON raises ValueError."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmpdir:
# Create file with invalid JSON
curve_file = Path(tmpdir) / "bad.json"
with open(curve_file, "w") as f:
f.write("{ invalid json }")
with pytest.raises(ValueError, match="Invalid JSON"):
load_curve(str(curve_file))
def test_configure_volt_var_insufficient_points():
"""Test that volt-var control with < 2 points raises error."""
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
dss.Text.Command("New PVSystem.TestPV Bus1=675 Phases=3 kV=4.16 kVA=500")
with pytest.raises(RuntimeError):
configure_volt_var_control("TestPV", [(0.95, 0.0)], response_time=10.0)
def test_configure_volt_watt_insufficient_points():
"""Test that volt-watt control with < 2 points raises error."""
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
dss.Text.Command("New PVSystem.TestPV Bus1=675 Phases=3 kV=4.16 kVA=500")
with pytest.raises(RuntimeError):
configure_volt_watt_control("TestPV", [(1.0, 1.0)])
def test_get_inverter_status_nonexistent_pv():
"""Test getting status for non-existent PV system."""
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
status = get_inverter_status("NONEXISTENT_PV")
assert status["success"] is False
assert len(status["errors"]) > 0
assert "not found" in status["errors"][0].lower()
def test_configure_volt_watt_out_of_range_warning(caplog):
"""Test that watt values outside [0, 1] log a warning and raise RuntimeError."""
import logging
caplog.set_level(logging.WARNING)
load_result = load_ieee_test_feeder("IEEE13")
assert load_result["success"]
dss.Text.Command("New PVSystem.TestPV Bus1=675 Phases=3 kV=4.16 kVA=500")
# Curve with value > 1.0 should log warning and then raise error from OpenDSS
bad_curve = [(0.0, 1.0), (1.10, 1.5)]
with pytest.raises(RuntimeError):
configure_volt_watt_control("TestPV", bad_curve)
# Check that warning was logged before the error
assert any("outside typical range" in record.message for record in caplog.records)
if __name__ == "__main__":
pytest.main([__file__, "-v"])