Skip to main content
Glama
inverter_control.py18.3 kB
""" Inverter control utilities for OpenDSS. This module provides functions for configuring smart inverter control modes including volt-var and volt-watt control for distributed energy resources (DER). """ import json import logging from pathlib import Path from typing import Any import opendssdirect as dss logger = logging.getLogger(__name__) # Directory containing standard control curves CURVES_DIR = Path(__file__).parent.parent / "data" / "control_curves" # Mapping of standard curve names to JSON files STANDARD_CURVES = { "IEEE1547": "ieee1547.json", "RULE21": "rule21.json", } def load_curve(curve_name: str) -> list[tuple[float, float]]: """Load control curve points from a JSON file. This function loads volt-var or volt-watt curve definitions from JSON files. It supports standard curves (IEEE1547, RULE21) by name or custom curves by file path. Args: curve_name: Name of standard curve ("IEEE1547", "RULE21") or path to a custom JSON file. Curve names are case-insensitive. Returns: List of (x, y) tuples representing curve points where: - For volt-var: (voltage_pu, var_pu) - For volt-watt: (voltage_pu, watt_pu) Raises: FileNotFoundError: If the specified curve file doesn't exist ValueError: If the JSON file is invalid or missing required fields KeyError: If the curve name is not recognized Example: >>> # Load standard IEEE 1547 curve >>> points = load_curve("IEEE1547") >>> print(points) [(0.92, 0.44), (0.98, 0.0), (1.02, 0.0), (1.08, -0.44)] >>> >>> # Load custom curve by path >>> points = load_curve("path/to/my_custom_curve.json") >>> >>> # Use curve points in configuration >>> configure_volt_var_control("PV1", points) Note: - Standard curves are located in src/opendss_mcp/data/control_curves/ - Custom curve files must follow the JSON format defined in README.md - Curve points are returned as-is from the JSON file (no validation) """ try: # Check if it's a standard curve name curve_upper = curve_name.upper() if curve_upper in STANDARD_CURVES: curve_file = CURVES_DIR / STANDARD_CURVES[curve_upper] logger.info(f"Loading standard curve: {curve_upper}") else: # Treat as file path curve_file = Path(curve_name) logger.info(f"Loading custom curve from: {curve_file}") # Check if file exists if not curve_file.exists(): available = ", ".join(STANDARD_CURVES.keys()) raise FileNotFoundError( f"Curve file not found: {curve_file}\n" f"Available standard curves: {available}\n" f"Or provide a valid path to a custom JSON file." ) # Load and parse JSON with open(curve_file, "r") as f: data = json.load(f) # Validate required fields if "points" not in data: raise ValueError(f"Invalid curve file {curve_file}: missing 'points' field") # Extract points and convert to tuples points = [tuple(point) for point in data["points"]] if len(points) < 2: raise ValueError(f"Invalid curve {curve_file}: must have at least 2 points") logger.info( f"Loaded curve '{data.get('name', 'unknown')}' with {len(points)} points" ) return points except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in curve file {curve_name}: {e}") except Exception as e: logger.error(f"Error loading curve {curve_name}: {e}") raise def configure_volt_var_control( pv_name: str, curve_points: list[tuple[float, float]], response_time: float = 10.0, curve_name: str | None = None, ) -> None: """Configure volt-var control for a PV system or inverter in OpenDSS. This function creates the necessary OpenDSS objects (XYCurve and InvControl) to enable autonomous volt-var control on a PVSystem element. The inverter will automatically adjust reactive power output based on local voltage according to the specified curve. OpenDSS Commands Executed: 1. New XYCurve: Defines the voltage-var relationship - npts: Number of points in the curve - xarray: Voltage values in per-unit - yarray: Reactive power values in per-unit 2. New InvControl: Smart inverter controller - PVSystemList: Which PV system(s) to control - Mode: VOLTVAR (reactive power control based on voltage) - voltage_curvex_ref: XYCurve name for volt-var function - AvgWindowLen: Averaging window for voltage measurements - DeltaQ_Factor: Response time / ramp rate for var changes - RefReactivePower: Reference point (VARAVAL = available vars) Args: pv_name: Name of the PVSystem element to control (without "PVSystem." prefix) curve_points: List of (voltage_pu, var_pu) tuples defining the curve. Voltage in per-unit, vars in per-unit of inverter kVA rating. response_time: Response time in seconds for reactive power changes (default: 10.0). Smaller values = faster response, larger = slower/smoother. curve_name: Optional name for the XYCurve. If None, auto-generated from pv_name. Returns: None Example: >>> # Load IEEE 1547 curve and apply to a PV system >>> curve = load_curve("IEEE1547") >>> configure_volt_var_control("PV_675", curve, response_time=5.0) >>> >>> # After configuration, run power flow to see volt-var in action >>> dss.Text.Command("Solve") >>> >>> # Custom curve for specific voltage support >>> custom_curve = [(0.95, 0.44), (0.98, 0.0), (1.02, 0.0), (1.05, -0.44)] >>> configure_volt_var_control("PV_611", custom_curve) Note: - PVSystem must already exist in the circuit - Curve points should be sorted by voltage (ascending) - Positive vars = absorb (inductive), negative = inject (capacitive) - Response time affects how quickly the inverter adjusts to voltage changes - The InvControl object updates every OpenDSS control iteration """ try: # Generate curve name if not provided if curve_name is None: curve_name = f"vv_{pv_name}" # Validate curve points if len(curve_points) < 2: raise ValueError("Curve must have at least 2 points") # Extract x and y arrays x_values = [point[0] for point in curve_points] y_values = [point[1] for point in curve_points] # Check if x values are sorted if x_values != sorted(x_values): logger.warning(f"Curve points for {pv_name} are not sorted by voltage") # Format arrays for OpenDSS x_array_str = "[" + ", ".join(f"{x:.6f}" for x in x_values) + "]" y_array_str = "[" + ", ".join(f"{y:.6f}" for y in y_values) + "]" # Create XYCurve object logger.info(f"Creating XYCurve '{curve_name}' with {len(curve_points)} points") dss.Text.Command( f"New XYCurve.{curve_name} " f"npts={len(curve_points)} " f"xarray={x_array_str} " f"yarray={y_array_str}" ) # Create InvControl object inv_control_name = f"InvCtrl_{pv_name}" logger.info( f"Creating InvControl '{inv_control_name}' for PVSystem '{pv_name}'" ) # Calculate DeltaQ_Factor from response time # DeltaQ_Factor controls the ramp rate: deltaQ = DeltaQ_Factor * (Qdesired - Qcurrent) # Typical range: 0.1 (slow) to 1.0 (fast) delta_q_factor = min(1.0, max(0.01, 1.0 / response_time)) # Note: InvControl parameter names differ from DSS documentation # Using 'vvc_curve1' instead of 'voltage_curvex_ref' for volt-var dss.Text.Command( f"New InvControl.{inv_control_name} " f"PVSystemList=[{pv_name}] " f"Mode=VOLTVAR " f"vvc_curve1={curve_name} " f"AvgWindowLen=1 " f"DeltaQ_Factor={delta_q_factor:.4f} " f"RefReactivePower=VARAVAL" ) logger.info( f"Volt-var control configured for {pv_name} " f"(curve: {curve_name}, response: {response_time}s)" ) except Exception as e: error_msg = f"Error configuring volt-var control for {pv_name}: {e}" logger.error(error_msg) raise RuntimeError(error_msg) def configure_volt_watt_control( pv_name: str, curve_points: list[tuple[float, float]], curve_name: str | None = None ) -> None: """Configure volt-watt control for a PV system or inverter in OpenDSS. This function creates the necessary OpenDSS objects (XYCurve and InvControl) to enable volt-watt curtailment control. The inverter will automatically reduce real power output when voltage exceeds specified thresholds, helping prevent overvoltage conditions. OpenDSS Commands Executed: 1. New XYCurve: Defines the voltage-watt relationship - npts: Number of points in the curve - xarray: Voltage values in per-unit - yarray: Power output values in per-unit (of rated) 2. New InvControl: Smart inverter controller - PVSystemList: Which PV system(s) to control - Mode: VOLTWATT (real power curtailment based on voltage) - voltage_curvex_ref: XYCurve name for volt-watt function - AvgWindowLen: Averaging window for voltage measurements Args: pv_name: Name of the PVSystem element to control (without "PVSystem." prefix) curve_points: List of (voltage_pu, watt_pu) tuples defining the curve. Voltage in per-unit, watts in per-unit of rated power (0.0-1.0). curve_name: Optional name for the XYCurve. If None, auto-generated from pv_name. Returns: None Example: >>> # Typical volt-watt curve: curtail when voltage > 1.06 pu >>> vw_curve = [ ... (0.00, 1.00), # Normal operation below 1.06 pu ... (1.06, 1.00), # Start curtailment ... (1.10, 0.20) # 80% curtailment at 1.10 pu ... ] >>> configure_volt_watt_control("PV_675", vw_curve) >>> >>> # Aggressive curtailment for high-voltage areas >>> aggressive_curve = [(0.0, 1.0), (1.05, 1.0), (1.08, 0.0)] >>> configure_volt_watt_control("PV_611", aggressive_curve) Note: - PVSystem must already exist in the circuit - Curve points should be sorted by voltage (ascending) - Watt values are per-unit: 1.0 = full power, 0.0 = fully curtailed - Volt-watt is typically used for overvoltage prevention - Standard range: curtail above 1.06-1.10 pu (per IEEE 1547-2018) - Can be combined with volt-var (requires separate InvControl objects) """ try: # Generate curve name if not provided if curve_name is None: curve_name = f"vw_{pv_name}" # Validate curve points if len(curve_points) < 2: raise ValueError("Curve must have at least 2 points") # Extract x and y arrays x_values = [point[0] for point in curve_points] y_values = [point[1] for point in curve_points] # Check if x values are sorted if x_values != sorted(x_values): logger.warning(f"Curve points for {pv_name} are not sorted by voltage") # Validate watt values (should be 0.0 to 1.0) for y in y_values: if y < 0.0 or y > 1.0: logger.warning( f"Watt value {y} outside typical range [0.0, 1.0] for {pv_name}" ) # Format arrays for OpenDSS x_array_str = "[" + ", ".join(f"{x:.6f}" for x in x_values) + "]" y_array_str = "[" + ", ".join(f"{y:.6f}" for y in y_values) + "]" # Create XYCurve object logger.info(f"Creating XYCurve '{curve_name}' with {len(curve_points)} points") dss.Text.Command( f"New XYCurve.{curve_name} " f"npts={len(curve_points)} " f"xarray={x_array_str} " f"yarray={y_array_str}" ) # Create InvControl object inv_control_name = f"InvCtrl_{pv_name}" logger.info( f"Creating InvControl '{inv_control_name}' for PVSystem '{pv_name}'" ) # Note: InvControl parameter names differ from DSS documentation # Using 'voltwatt_curve' for volt-watt mode dss.Text.Command( f"New InvControl.{inv_control_name} " f"PVSystemList=[{pv_name}] " f"Mode=VOLTWATT " f"voltwatt_curve={curve_name} " f"AvgWindowLen=1" ) logger.info(f"Volt-watt control configured for {pv_name} (curve: {curve_name})") except Exception as e: error_msg = f"Error configuring volt-watt control for {pv_name}: {e}" logger.error(error_msg) raise RuntimeError(error_msg) def get_inverter_status(pv_name: str) -> dict[str, Any]: """Get current status of a PVSystem inverter including control outputs. This function retrieves the current operating state of a PVSystem element, including real and reactive power output, voltage, and power factor. Args: pv_name: Name of the PVSystem element (without "PVSystem." prefix) Returns: Dictionary containing: - success: Boolean indicating if status was retrieved - pv_name: The PVSystem name - kw: Real power output in kW - kvar: Reactive power output in kvar - kva: Apparent power in kVA - pf: Power factor - voltage_pu: Terminal voltage in per-unit - errors: List of error messages if any Example: >>> status = get_inverter_status("PV_675") >>> if status["success"]: ... print(f"PV Output: {status['kw']:.2f} kW, {status['kvar']:.2f} kvar") ... print(f"Voltage: {status['voltage_pu']:.4f} pu, PF: {status['pf']:.3f}") Note: - Requires a solved power flow (call dss.Solution.Solve() first) - Returns zeros if PVSystem is not found or circuit not solved """ try: # Set active element element_name = f"PVSystem.{pv_name}" result = dss.Circuit.SetActiveElement(element_name) if result < 0: return { "success": False, "pv_name": pv_name, "kw": 0.0, "kvar": 0.0, "kva": 0.0, "pf": 0.0, "voltage_pu": 0.0, "errors": [f"PVSystem '{pv_name}' not found in circuit"], } # Get power output powers = dss.CktElement.Powers() if len(powers) >= 2: # Powers array format: [P1, Q1, P2, Q2, ...] for each terminal # Sum the phases (typically first terminal for PV) kw = sum(powers[i] for i in range(0, len(powers), 2)) kvar = sum(powers[i] for i in range(1, len(powers), 2)) else: kw = 0.0 kvar = 0.0 # Calculate kVA and power factor kva = (kw**2 + kvar**2) ** 0.5 if (kw != 0 or kvar != 0) else 0.0 pf = abs(kw / kva) if kva > 0 else 1.0 # Get terminal voltage bus_name = dss.CktElement.BusNames()[0].split(".")[ 0 ] # Get bus name without phase dss.Circuit.SetActiveBus(bus_name) voltages = dss.Bus.puVmagAngle() voltage_pu = voltages[0] if voltages else 0.0 return { "success": True, "pv_name": pv_name, "kw": round(kw, 4), "kvar": round(kvar, 4), "kva": round(kva, 4), "pf": round(pf, 4), "voltage_pu": round(voltage_pu, 4), "errors": [], } except Exception as e: logger.error(f"Error getting inverter status for {pv_name}: {e}") return { "success": False, "pv_name": pv_name, "kw": 0.0, "kvar": 0.0, "kva": 0.0, "pf": 0.0, "voltage_pu": 0.0, "errors": [str(e)], } def list_available_curves() -> list[dict[str, str]]: """List all available standard control curves. Returns: List of dictionaries containing curve information: - name: Curve name (for use with load_curve) - file: JSON filename - description: Curve description from JSON file - type: Control type (volt-var, volt-watt, etc.) Example: >>> curves = list_available_curves() >>> for curve in curves: ... print(f"{curve['name']}: {curve['description']}") IEEE1547: IEEE 1547-2018 Category B volt-var curve RULE21: California Rule 21 volt-var curve """ curves_info = [] for name, filename in STANDARD_CURVES.items(): try: curve_path = CURVES_DIR / filename with open(curve_path, "r") as f: data = json.load(f) curves_info.append( { "name": name, "file": filename, "description": data.get("description", "No description"), "type": data.get("type", "unknown"), } ) except Exception as e: logger.warning(f"Could not load curve info for {name}: {e}") return curves_info

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ahmedelshazly27/opendss-mcp-server1'

If you have feedback or need assistance with the MCP directory API, please join our Discord server