UNPKG

smart-grid-controller

Version:

Intelligent AC grid management for Victron MultiPlus II with load-based switching, battery protection, and scheduled charging

526 lines (468 loc) 26.6 kB
/** * Signal K Smart Grid Controller Plugin * * PURPOSE: * Intelligently controls when grid AC power is allowed to flow to your Victron MultiPlus II. * This plugin acts as an automated grid management system that decides when to connect/disconnect * grid power based on battery state, power demand, time scheduling, and multiple safety protections. * * HOW IT WORKS: * The plugin monitors your battery voltage, AC load, and time, then controls a relay (virtual switch) * that enables/disables grid AC input to your MultiPlus. It reads data from your Victron system * via Signal K and sends control commands back through the Cerbo GX. * * FOUR ENABLE CONDITIONS (Grid turns ON if ANY condition is met): * * 1. HIGH LOAD CONDITION * - Enables when: AC load > 2500W for 3+ seconds * - Disables when: AC load drops below 1750W * - Purpose: Allows grid to help with high power demands (heat pumps, electric vehicles, etc.) * - Hysteresis gap: 750W (2500W↔1750W) prevents rapid switching * * 2. LOW VOLTAGE CONDITION * - Enables when: DC voltage < 50.83V for 3+ seconds * - Disables when: DC voltage rises above 53.1V * - Purpose: Prevents battery from going too low (around 10% SoC) * - Hysteresis gap: 2.27V prevents rapid switching * * 3. LOW STATE OF CHARGE CONDITION * - Enables when: Battery SoC < 10% for 3+ seconds * - Disables when: Battery SoC rises above 30% * - Purpose: Backup protection based on calculated state of charge * - Hysteresis gap: 20% prevents rapid switching * * 4. SCHEDULED CHARGING WINDOW * - Enables when: UK time between 00:00 and 06:00 * - Disables when: Outside this time window * - Purpose: Allows cheap overnight charging during off-peak electricity rates * - No delay: Immediate enable/disable at time boundaries * * BATTERY PROTECTION LAYERS: * * STANDARD PROTECTION (Can be overridden by high loads): * - Triggers when: Voltage > 61.5V OR SoC > 95% * - Clears when: Voltage < 60.0V AND SoC < 90% * - Override: High load condition (>2500W) can keep grid on * - Purpose: Prevents routine overcharging while allowing critical loads * * EMERGENCY PROTECTION (Cannot be overridden): * - Triggers when: Voltage > 63.0V * - Clears when: Voltage < 62.0V * - Override: NONE - immediately disconnects grid regardless of load * - Purpose: Final safety protection against battery damage * * TIMING BEHAVIOR: * * Enable Delays (prevents false triggers): * - Load, voltage, SoC conditions: 3 seconds * - Time condition: Immediate * - Purpose: Avoids reacting to momentary spikes/dips in readings * * Disable Delays: * - Normal conditions: 30 seconds after ALL conditions clear * - Battery protection: Immediate when triggered * - Emergency protection: Immediate when triggered * - Purpose: Provides stability, prevents rapid cycling * * Startup Behavior: * - Grid enabled immediately on plugin start * - 30-second grace period before normal evaluation begins * - Purpose: Ensures reliable operation during system restarts * * HYSTERESIS EXPLANATION: * Hysteresis means different thresholds for turning ON vs turning OFF. * Example: Load enables at 2500W but disables at 1750W. * This prevents rapid on/off cycling when readings hover near a single threshold. * * BATTERY CONFIGURATION: * Currently configured for Li-NCM 15S and LiFePO4 16S battery packs. * - Li-NCM 15S: Voltage-to-SoC mapping based on real discharge curve data * - LiFePO4 16S: Based on real LiFePO4 cell characteristics (3.1V-3.4V per cell operating range) * Uses the incredibly flat LiFePO4 discharge curve for accurate SoC estimation * * HARDWARE CONNECTIONS: * - Reads from: electrical.chargers.275.voltage (DC voltage) * - Reads from: electrical.inverters.275.acout.power (AC load) * - Controls (MultiPlus II GX): electrical.inverters.275.acState.ignoreAcIn1.state * - Controls (Cerbo GX): electrical.switches.relay1.state (grid AC enable/disable) * - Maps to: com.victronenergy.vebus.ttyS3 (MultiPlus II GX) or com.victronenergy.system /Relay/1/State (Cerbo GX) */ module.exports = function smartGridController(app) { // Logging helper with consistent formatting function log(level, message, data = {}) { const timestamp = new Date().toISOString(); const prefix = 'Smart Grid Controller'; if (Object.keys(data).length > 0) { console[level](`[${timestamp}] ${prefix}: ${message}`, data); } else { console[level](`[${timestamp}] ${prefix}: ${message}`); } } // Start with grid enabled on startup let gridState = true; // Track which conditions are currently active let enabledByLoad = false; let enabledByVoltage = false; let enabledBySoC = false; let enabledByTime = false; // Track battery protection state let batteryProtectionActive = false; // Track emergency battery protection (cannot be overridden) let emergencyProtectionActive = false; // Timers for condition evaluation let loadEnableTimer = null; let voltageEnableTimer = null; let socEnableTimer = null; // Timer for relay disable delay let disableTimer = null; // Startup grace period let startupGraceTimer = null; // Get plugin configuration with defaults function getConfig() { const config = app.readPluginOptions(); // Set battery-specific defaults based on type let defaults = {}; switch (config.batteryType || 'li-ncm-15s') { case 'li-ncm-15s': defaults = { voltageThresholds: { lowVoltageEnable: 50.83, lowVoltageDisable: 53.1, highVoltageProtection: 61.5, emergencyVoltage: 63.0 } }; break; case 'lifepo4-16s': defaults = { voltageThresholds: { lowVoltageEnable: 49.6, // 3.1V per cell = 9% SoC (second flat area ends) lowVoltageDisable: 51.0, // 3.188V per cell = 30% SoC (flat area ends) highVoltageProtection: 54.4, // 3.4V per cell = 97.5% SoC (aggressive rise starts) emergencyVoltage: 55.2 // 3.45V per cell = practical full charge limit } }; break; } const finalConfig = { batteryType: config.batteryType || 'li-ncm-15s', loadThresholds: { enableWatts: config.loadThresholds?.enableWatts || 2500, disableWatts: config.loadThresholds?.disableWatts || 1750 }, voltageThresholds: defaults.voltageThresholds, socThresholds: { lowSocEnable: config.socThresholds?.lowSocEnable || 10, lowSocDisable: config.socThresholds?.lowSocDisable || 30, highSocProtection: config.socThresholds?.highSocProtection || 95 }, scheduleSettings: { timezone: config.scheduleSettings?.timezone || 'Europe/London', startHour: config.scheduleSettings?.startHour || 0, endHour: config.scheduleSettings?.endHour || 6 }, controlMethod: config.controlMethod || 'auto' }; // Validate configuration for safety if (finalConfig.loadThresholds.enableWatts <= finalConfig.loadThresholds.disableWatts) { log('warn', `Invalid load thresholds - enable (${finalConfig.loadThresholds.enableWatts}W) must be > disable (${finalConfig.loadThresholds.disableWatts}W), using defaults`); finalConfig.loadThresholds.enableWatts = 2500; finalConfig.loadThresholds.disableWatts = 1750; } if (finalConfig.socThresholds.lowSocEnable >= finalConfig.socThresholds.lowSocDisable) { log('warn', `Invalid SoC thresholds - enable (${finalConfig.socThresholds.lowSocEnable}%) must be < disable (${finalConfig.socThresholds.lowSocDisable}%), using defaults`); finalConfig.socThresholds.lowSocEnable = 10; finalConfig.socThresholds.lowSocDisable = 30; } if (finalConfig.voltageThresholds.lowVoltageEnable >= finalConfig.voltageThresholds.lowVoltageDisable) { log('warn', 'Invalid voltage thresholds detected, using battery defaults'); finalConfig.voltageThresholds = defaults.voltageThresholds; } return finalConfig; } // Calculate SoC from voltage based on battery type function calculateSoC(voltage, batteryType) { switch (batteryType) { case 'li-ncm-15s': // Li-NCM 15S (55.5V nominal) - based on provided discharge curve if (voltage >= 63) return 100; else if (voltage >= 60.0) return 90 + ((voltage - 60.0) / (63.0 - 60.0)) * 10; // 90-100% else if (voltage >= 58.0) return 80 + ((voltage - 58.0) / (60.0 - 58.0)) * 10; // 80-90% else if (voltage >= 56.75) return 70 + ((voltage - 56.75) / (58.0 - 56.75)) * 10; // 70-80% else if (voltage >= 55.5) return 60 + ((voltage - 55.5) / (56.75 - 55.5)) * 10; // 60-70% else if (voltage >= 54.4) return 50 + ((voltage - 54.4) / (55.5 - 54.4)) * 10; // 50-60% else if (voltage >= 53.75) return 40 + ((voltage - 53.75) / (54.4 - 53.75)) * 10; // 40-50% else if (voltage >= 53.1) return 30 + ((voltage - 53.1) / (53.75 - 53.1)) * 10; // 30-40% else if (voltage >= 52.2) return 20 + ((voltage - 52.2) / (53.1 - 52.2)) * 10; // 20-30% else if (voltage >= 50.83) return 10 + ((voltage - 50.83) / (52.2 - 50.83)) * 10; // 10-20% else if (voltage >= 42.5) return 0 + ((voltage - 42.5) / (50.83 - 42.5)) * 10; // 0-10% else return 0; case 'lifepo4-16s': // LiFePO4 16S (51.2V nominal) - based on real discharge/charge curve data // Per cell voltages × 16 for 16S pack if (voltage >= 58.4) return 100; // 3.65V per cell - full charge else if (voltage >= 54.4) return 97.5 + ((voltage - 54.4) / (58.4 - 54.4)) * 2.5; // 97.5-100% (aggressive rise) else if (voltage >= 54.096) return 92.7 + ((voltage - 54.096) / (54.4 - 54.096)) * 4.8; // 92.7-97.5% (second flat ends) else if (voltage >= 53.2) return 27.3 + ((voltage - 53.2) / (54.096 - 53.2)) * 65.4; // 27.3-92.7% (long flat area) else if (voltage >= 51.2) return 5.5 + ((voltage - 51.2) / (53.2 - 51.2)) * 21.8; // 5.5-27.3% (first flat part) else if (voltage >= 51.008) return 9.0 + ((voltage - 51.008) / (51.2 - 51.008)) * -3.5; // 9-5.5% (transition zone) else if (voltage >= 49.6) return 3.6 + ((voltage - 49.6) / (51.008 - 49.6)) * 5.4; // 3.6-9% (second flat area) else if (voltage >= 48.0) return 0 + ((voltage - 48.0) / (49.6 - 48.0)) * 3.6; // 0-3.6% (aggressive drop) else return 0; default: // Fallback to Li-NCM calculation if battery type not recognized if (voltage >= 63) return 100; else if (voltage >= 60.0) return 90 + ((voltage - 60.0) / (63.0 - 60.0)) * 10; else if (voltage >= 58.0) return 80 + ((voltage - 58.0) / (60.0 - 58.0)) * 10; else if (voltage >= 56.75) return 70 + ((voltage - 56.75) / (58.0 - 56.75)) * 10; else if (voltage >= 55.5) return 60 + ((voltage - 55.5) / (56.75 - 55.5)) * 10; else if (voltage >= 54.4) return 50 + ((voltage - 54.4) / (55.5 - 54.4)) * 10; else if (voltage >= 53.75) return 40 + ((voltage - 53.75) / (54.4 - 53.75)) * 10; else if (voltage >= 53.1) return 30 + ((voltage - 53.1) / (53.75 - 53.1)) * 10; else if (voltage >= 52.2) return 20 + ((voltage - 52.2) / (53.1 - 52.2)) * 10; else if (voltage >= 50.83) return 10 + ((voltage - 50.83) / (52.2 - 50.83)) * 10; else if (voltage >= 42.5) return 0 + ((voltage - 42.5) / (50.83 - 42.5)) * 10; else return 0; } } // Helper function to safely send control commands (supports both Cerbo GX and MultiPlus II GX) function setGridState(enabled, reason) { try { const config = getConfig(); // Method 1: Try MultiPlus II GX direct AC input control (preferred for built-in GX) if (config.controlMethod === 'multiplus-gx' || config.controlMethod === 'auto') { // Primary control: ignoreAcIn1 state (0=enabled, 1=ignored/disabled) app.handleMessage('smart-grid-controller', { updates: [{ values: [{ path: 'electrical.inverters.275.acState.ignoreAcIn1.state', value: enabled ? 0 : 1 // 0=don't ignore AC input, 1=ignore AC input }] }] }); log('info', `MultiPlus II GX AC input ${enabled ? 'ENABLED' : 'DISABLED'} - ${reason}`); } // Method 2: Fallback to Cerbo GX relay control (for external GX units) if (config.controlMethod === 'cerbo-gx' || config.controlMethod === 'auto') { app.handleMessage('smart-grid-controller', { updates: [{ values: [{ path: 'electrical.switches.relay1.state', value: enabled ? 1 : 0 }] }] }); if (config.controlMethod === 'cerbo-gx') { log('info', `Cerbo GX Relay 1 ${enabled ? 'ENABLED' : 'DISABLED'} - ${reason}`); } } } catch (error) { log('error', `Error setting grid state to ${enabled} (${reason}) - ${error.message}`); } } return { id: 'smart-grid-controller', name: 'Smart Grid Controller', description: 'Intelligent AC grid management for Victron MultiPlus II with load-based switching, battery protection, and scheduled charging', start: function() { // Enable grid immediately on startup log('info', 'Grid AC ENABLED on startup - 30s grace period active'); setGridState(true, 'Startup - 30s grace period active'); // Start 30-second startup grace period startupGraceTimer = setTimeout(() => { startupGraceTimer = null; log('info', 'Startup grace period ended - normal condition evaluation active'); }, 30000); // Subscribe to Victron data app.signalk.on('delta', (delta) => { try { const voltage = app.getSelfPath('electrical.chargers.275.voltage')?.value || 0; const load = app.getSelfPath('electrical.inverters.275.acout.power')?.value || 0; // Validate data - skip processing if values are clearly invalid if (voltage < 0 || voltage > 100 || load < 0 || load > 50000) { log('warn', `Invalid data - Voltage: ${voltage}V, Load: ${load}W - skipping cycle`); return; } const config = getConfig(); // Calculate SoC from voltage based on battery type let soc = calculateSoC(voltage, config.batteryType); // Ensure SoC is within valid range soc = Math.max(0, Math.min(100, soc)); // Get time in configured timezone with error handling let localTime, hours, isChargingWindow; try { const now = new Date(); localTime = new Date(now.toLocaleString("en-US", {timeZone: config.scheduleSettings.timezone})); hours = localTime.getHours(); isChargingWindow = (hours >= config.scheduleSettings.startHour && hours < config.scheduleSettings.endHour); } catch (timezoneError) { log('warn', `Invalid timezone ${config.scheduleSettings.timezone}, falling back to local time`); localTime = new Date(); hours = localTime.getHours(); isChargingWindow = (hours >= config.scheduleSettings.startHour && hours < config.scheduleSettings.endHour); } // Condition 1: Load > threshold for 3 seconds (enable) / < threshold (disable immediately) if (load > config.loadThresholds.enableWatts && !enabledByLoad) { if (!loadEnableTimer) { loadEnableTimer = setTimeout(() => { enabledByLoad = true; loadEnableTimer = null; }, 3000); } } else if (load < config.loadThresholds.disableWatts) { enabledByLoad = false; clearTimeout(loadEnableTimer); loadEnableTimer = null; } else if (load >= config.loadThresholds.disableWatts && load <= config.loadThresholds.enableWatts && loadEnableTimer) { // In hysteresis zone - clear timer but don't change state clearTimeout(loadEnableTimer); loadEnableTimer = null; } // Condition 2: Voltage < threshold for 3 seconds (enable) / > threshold (disable immediately) if (voltage < config.voltageThresholds.lowVoltageEnable && !enabledByVoltage) { if (!voltageEnableTimer) { voltageEnableTimer = setTimeout(() => { enabledByVoltage = true; voltageEnableTimer = null; }, 3000); } } else if (voltage > config.voltageThresholds.lowVoltageDisable) { enabledByVoltage = false; clearTimeout(voltageEnableTimer); voltageEnableTimer = null; } else if (voltage >= config.voltageThresholds.lowVoltageEnable && voltage <= config.voltageThresholds.lowVoltageDisable && voltageEnableTimer) { // In hysteresis zone - clear timer but don't change state clearTimeout(voltageEnableTimer); voltageEnableTimer = null; } // Condition 3: SoC < threshold for 3 seconds (enable) / > threshold (disable immediately) if (soc < config.socThresholds.lowSocEnable && !enabledBySoC) { if (!socEnableTimer) { socEnableTimer = setTimeout(() => { enabledBySoC = true; socEnableTimer = null; }, 3000); } } else if (soc > config.socThresholds.lowSocDisable) { enabledBySoC = false; clearTimeout(socEnableTimer); socEnableTimer = null; } else if (soc >= config.socThresholds.lowSocEnable && soc <= config.socThresholds.lowSocDisable && socEnableTimer) { // In hysteresis zone - clear timer but don't change state clearTimeout(socEnableTimer); socEnableTimer = null; } // Condition 4: Scheduled charging window (immediate enable/disable) enabledByTime = isChargingWindow; // Battery protection with hysteresis: configurable thresholds const highVoltageRecovery = config.voltageThresholds.highVoltageProtection - 1.5; // 1.5V hysteresis const highSocRecovery = config.socThresholds.highSocProtection - 5; // 5% hysteresis if ((voltage > config.voltageThresholds.highVoltageProtection || soc > config.socThresholds.highSocProtection) && !batteryProtectionActive) { batteryProtectionActive = true; } else if (voltage < highVoltageRecovery && soc < highSocRecovery && batteryProtectionActive) { batteryProtectionActive = false; } // Emergency battery protection: configurable emergency voltage const emergencyRecovery = config.voltageThresholds.emergencyVoltage - 1.0; // 1V hysteresis if (voltage > config.voltageThresholds.emergencyVoltage && !emergencyProtectionActive) { emergencyProtectionActive = true; } else if (voltage < emergencyRecovery && emergencyProtectionActive) { emergencyProtectionActive = false; } // Determine if any condition is active const anyConditionActive = enabledByLoad || enabledByVoltage || enabledBySoC || enabledByTime; // Battery protection overrides other conditions (unless load condition active) const batteryProtectionTriggered = batteryProtectionActive && !enabledByLoad; // Emergency protection overrides everything (including load condition) const emergencyProtectionTriggered = emergencyProtectionActive; // Handle grid control logic if (emergencyProtectionTriggered && gridState) { // Emergency protection triggered - disable grid immediately, no exceptions clearTimeout(disableTimer); disableTimer = null; gridState = false; log('info', `Emergency protection triggered - Critical voltage ${voltage.toFixed(2)}V > ${config.voltageThresholds.emergencyVoltage}V`); setGridState(false, 'Emergency protection triggered'); } else if (anyConditionActive && !gridState && !batteryProtectionTriggered && !emergencyProtectionTriggered) { // Conditions want to enable grid and no protection active - turn on immediately clearTimeout(disableTimer); disableTimer = null; gridState = true; // Log which conditions are active const activeConditions = []; if (enabledByLoad) activeConditions.push(`Load: ${load.toFixed(1)}W`); if (enabledByVoltage) activeConditions.push(`Voltage: ${voltage.toFixed(2)}V`); if (enabledBySoC) activeConditions.push(`SoC: ${soc.toFixed(1)}%`); if (enabledByTime) activeConditions.push(`Time: ${hours.toString().padStart(2, '0')}:${localTime.getMinutes().toString().padStart(2, '0')}`); log('info', `Active conditions: ${activeConditions.join(', ')}`); setGridState(true, `Active conditions: ${activeConditions.join(', ')}`); } else if (batteryProtectionTriggered && gridState) { // Battery protection triggered - disable grid immediately clearTimeout(disableTimer); disableTimer = null; gridState = false; const protectionReasons = []; if (voltage > config.voltageThresholds.highVoltageProtection) protectionReasons.push(`High voltage: ${voltage.toFixed(2)}V > ${config.voltageThresholds.highVoltageProtection}V`); if (soc > config.socThresholds.highSocProtection) protectionReasons.push(`High SoC: ${soc.toFixed(1)}% > ${config.socThresholds.highSocProtection}%`); log('info', `Battery protection: ${protectionReasons.join(', ')} (Load condition: ${enabledByLoad ? 'Active' : 'Inactive'})`); setGridState(false, `Battery protection: ${protectionReasons.join(', ')} (Load condition: ${enabledByLoad ? 'Active' : 'Inactive'})`); } else if (!anyConditionActive && gridState && !startupGraceTimer) { // No conditions want grid enabled and startup grace period is over - start 30 second disable timer if (!disableTimer) { disableTimer = setTimeout(() => { gridState = false; disableTimer = null; // Log which conditions were cleared const clearedConditions = []; if (!enabledByLoad) clearedConditions.push(`Load: ${load.toFixed(1)}W < ${config.loadThresholds.disableWatts}W`); if (!enabledByVoltage) clearedConditions.push(`Voltage: ${voltage.toFixed(2)}V > ${config.voltageThresholds.lowVoltageDisable}V`); if (!enabledBySoC) clearedConditions.push(`SoC: ${soc.toFixed(1)}% > ${config.socThresholds.lowSocDisable}%`); if (!enabledByTime) clearedConditions.push(`Time: ${hours.toString().padStart(2, '0')}:${localTime.getMinutes().toString().padStart(2, '0')} outside ${config.scheduleSettings.startHour.toString().padStart(2, '0')}:00-${config.scheduleSettings.endHour.toString().padStart(2, '0')}:00`); log('info', `Cleared conditions: ${clearedConditions.join(', ')}`); setGridState(false, `Cleared conditions: ${clearedConditions.join(', ')}`); }, 30000); } } else if (anyConditionActive && gridState && !batteryProtectionTriggered && !emergencyProtectionTriggered) { // Conditions still want grid enabled and no protection active - clear any pending disable timer clearTimeout(disableTimer); disableTimer = null; } } catch (error) { log('error', `Error processing data - ${error.message}`); } }); // Map Signal K control to both MultiPlus II GX and Cerbo GX Relay app.registerPutHandler('v1', 'electrical.switches.relay1.state', (context, path, value) => { try { app.putSelfPath('electrical.switches.relay1.state', value); if (app.dbus && app.dbus.setValue) { app.dbus.setValue('com.victronenergy.system', '/Relay/1/State', value); } else { log('warn', 'D-Bus not available - relay state not updated on Cerbo GX'); } } catch (error) { log('error', `Error setting relay state - ${error.message}`); } }); // MultiPlus II GX AC input control handlers app.registerPutHandler('v1', 'electrical.inverters.275.acState.ignoreAcIn1.state', (context, path, value) => { try { app.putSelfPath('electrical.inverters.275.acState.ignoreAcIn1.state', value); if (app.dbus && app.dbus.setValue) { // MultiPlus II GX ignore AC input control (0=enabled, 1=disabled) app.dbus.setValue('com.victronenergy.vebus.ttyS3', '/Ac/State/IgnoreAcIn1', value); } } catch (error) { log('error', `Error setting AC input ignore state - ${error.message}`); } }); }, stop: function() { clearTimeout(loadEnableTimer); clearTimeout(voltageEnableTimer); clearTimeout(socEnableTimer); clearTimeout(disableTimer); clearTimeout(startupGraceTimer); } }; };