diff --git a/decoupesurplus/V2/DEC.js b/decoupesurplus/V2/DEC.js new file mode 100644 index 0000000..5d17d50 --- /dev/null +++ b/decoupesurplus/V2/DEC.js @@ -0,0 +1,709 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = conditions.probeSuccessNow ? 'solar_only' : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : (activeSourceMode === 'hybrid_probe') + ? conditions.probeHoldGuardOk + : false; + + if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_up_grid_forced' + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Hausse prudente du courant en charge réseau forcée' + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? 'start_grid_forced_min_current' : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? 'Démarrage en charge réseau forcée' + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + state, + currentDecision, + dynamicOk, + mustStop, + reason, + nextBlockReason, + nextBlockedUntilStamp, + setCloudDebtStartedAt, + setProbeStartedAt, + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/decoupesurplus/V2/DEC1.js b/decoupesurplus/V2/DEC1.js new file mode 100644 index 0000000..811e262 --- /dev/null +++ b/decoupesurplus/V2/DEC1.js @@ -0,0 +1,129 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} diff --git a/decoupesurplus/V2/DEC2.js b/decoupesurplus/V2/DEC2.js new file mode 100644 index 0000000..5bcd7a4 --- /dev/null +++ b/decoupesurplus/V2/DEC2.js @@ -0,0 +1,419 @@ +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = conditions.probeSuccessNow ? 'solar_only' : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : (activeSourceMode === 'hybrid_probe') + ? conditions.probeHoldGuardOk + : false; + + if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_up_grid_forced' + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Hausse prudente du courant en charge réseau forcée' + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} diff --git a/decoupesurplus/V2/DEC3.js b/decoupesurplus/V2/DEC3.js new file mode 100644 index 0000000..603340a --- /dev/null +++ b/decoupesurplus/V2/DEC3.js @@ -0,0 +1,159 @@ +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? 'start_grid_forced_min_current' : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? 'Démarrage en charge réseau forcée' + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + state, + currentDecision, + dynamicOk, + mustStop, + reason, + nextBlockReason, + nextBlockedUntilStamp, + setCloudDebtStartedAt, + setProbeStartedAt, + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/decoupesurplus/V2/DECISION/decision.js b/decoupesurplus/V2/DECISION/decision.js new file mode 100644 index 0000000..0888593 --- /dev/null +++ b/decoupesurplus/V2/DECISION/decision.js @@ -0,0 +1,896 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} + +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; + + if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/decoupesurplus/V2/DECISION/decisionP1.js b/decoupesurplus/V2/DECISION/decisionP1.js new file mode 100644 index 0000000..8f6e500 --- /dev/null +++ b/decoupesurplus/V2/DECISION/decisionP1.js @@ -0,0 +1,224 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; + +const { + now, + nowMs, + parsed, + params, + history, + strategy, + lifecycle, + context, + derived, + locks, + cloud, + modes, + safeguards, + conditions, + batteryTune, + nightEvcs, +} = prev; + +// ===================================================== +// DECISION INIT +// ===================================================== +let desiredCharging = false; +let desiredPermission = false; +let desiredLatch = false; +let desiredCurrentA = params.currentMinA; + +let state = 'idle'; +let currentDecision = 'hold'; +let dynamicOk = false; +let mustStop = false; + +let reason = []; + +let setCloudDebtStartedAt = false; +let setProbeStartedAt = false; + +// EVCS night session memory +let setNightSessionStart = false; +let clearNightSessionStart = false; + +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = ''; + +let nextBlockReason = 'none'; +let nextBlockedUntilStamp = now.stamp; + +// ===================================================== +// HELPERS +// ===================================================== +function setOff({ + stateName, + decisionName = 'stop', + reasonText, + dynamic = false, + mustStopFlag = false, + blockReason = 'none', + blockedUntilStamp = now.stamp, +}) { + desiredCharging = false; + desiredPermission = false; + desiredLatch = false; + desiredCurrentA = params.currentMinA; + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + nextBlockReason = blockReason; + nextBlockedUntilStamp = blockedUntilStamp; + + if (reasonText) reason.push(reasonText); +} + +function setOn({ + stateName, + decisionName = 'hold', + currentA = params.currentMinA, + reasonText, + dynamic = true, + mustStopFlag = false, +}) { + desiredCharging = true; + desiredPermission = true; + desiredLatch = true; + desiredCurrentA = clamp( + currentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ); + + state = stateName; + currentDecision = decisionName; + dynamicOk = dynamic; + mustStop = mustStopFlag; + + if (reasonText) reason.push(reasonText); +} + +// ===================================================== +// SESSION MEMORY SIDE EFFECTS +// ===================================================== +if (nightEvcs?.nightSessionNeedsCapture) { + setNightSessionStart = true; +} + +if (nightEvcs?.nightSessionNeedsClear) { + clearNightSessionStart = true; +} + +// ===================================================== +// DECISION TREE +// ===================================================== +if (!parsed.controllerEnabled) { + desiredCharging = parsed.evcsChargingSwitchOn; + desiredPermission = parsed.chargePermissionNow; + desiredLatch = parsed.controlLatch; + desiredCurrentA = clamp( + derived.currentReferenceA || params.currentMinA, + params.currentMinA, + params.currentMaxAutoA + ); + + state = 'controller_disabled'; + currentDecision = 'hold'; + dynamicOk = false; + mustStop = false; + reason.push('Contrôleur désactivé'); +} + +else if (strategy.isStopMode) { + setOff({ + stateName: 'stopped_user', + decisionName: 'stop_stop_mode', + reasonText: 'Arrêt manuel via Stratégie STOP', + dynamic: false, + mustStopFlag: false, + blockReason: 'manual_stop', + blockedUntilStamp: now.stamp, + }); +} + +else if (parsed.evcsModeIsAuto) { + setOff({ + stateName: 'mode_auto_conflict', + decisionName: 'stop_auto_mode_conflict', + reasonText: 'Borne en mode Auto interne', + dynamic: false, + mustStopFlag: true, + blockReason: 'mode_auto_conflict', + blockedUntilStamp: now.stamp, + }); +} + +else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) { + setOff({ + stateName: 'vehicle_not_connected', + decisionName: 'stop_not_connected', + reasonText: 'Véhicule ou borne non connecté', + dynamic: false, + mustStopFlag: false, + blockReason: 'vehicle_not_connected', + blockedUntilStamp: now.stamp, + }); +} + +else if (safeguards.mustStopHard) { + setOff({ + stateName: + derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + 'hard_stop', + decisionName: 'stop_hard', + reasonText: 'Hard stop de protection', + dynamic: false, + mustStopFlag: true, + blockReason: + safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' : + derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' : + derived.anyVebusLowBattery ? 'vebus_low_battery_stop' : + derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' : + 'battery_protection_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000), + }); +} diff --git a/decoupesurplus/V2/DECISION/decisionP2.js b/decoupesurplus/V2/DECISION/decisionP2.js new file mode 100644 index 0000000..d5225c0 --- /dev/null +++ b/decoupesurplus/V2/DECISION/decisionP2.js @@ -0,0 +1,241 @@ +// ===================================================== +// ACTIVE SESSION BRANCH +// ===================================================== +else if (cloud.sessionObservedActive) { + if (cloud.enteringCloudDebt) { + setCloudDebtStartedAt = true; + } + + if (conditions.shouldStopOverloadAtMin) { + setOff({ + stateName: 'vebus_overload_stop_min_current', + decisionName: 'stop', + reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini', + dynamic: false, + mustStopFlag: true, + blockReason: 'vebus_overload_persistent', + blockedUntilStamp: formatParisStampFromMs( + nowMs + batteryTune.global.overloadStopAfterSeconds * 1000 + ), + }); + } + + else if (safeguards.gridBatteryProtectionStop) { + setOff({ + stateName: 'stop_grid_battery_guard', + decisionName: 'stop', + reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, + dynamic: false, + mustStopFlag: true, + blockReason: 'grid_battery_guard', + blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), + }); + } + + else if (conditions.probeFailedNow) { + setOff({ + stateName: 'probe_failed_lock', + decisionName: 'stop', + reasonText: 'Mode sonde échoué', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } + + else if (derived.physicalCapA < params.currentMinA) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_power_cap', + decisionName: 'stop_power_cap', + reasonText: 'Capacité système insuffisante', + dynamic: false, + mustStopFlag: true, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_power_cap', + decisionName: 'hold', + currentA: params.currentMinA, + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (conditions.probeWindowActive && !conditions.probeSuccessNow) { + if (!conditions.probeHoldGuardOk) { + setOff({ + stateName: 'probe_stop_guard', + decisionName: 'stop', + reasonText: 'Sonde interrompue: garde-fous non respectés', + dynamic: false, + mustStopFlag: true, + blockReason: 'probe_failed', + blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000), + }); + } else { + setOn({ + stateName: 'probe_hold', + decisionName: 'hold', + currentA: Math.min(params.probeCurrentA, derived.physicalCapA), + reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`, + dynamic: true, + mustStopFlag: false, + }); + } + } + + else { + const activeSourceMode = + conditions.probeSuccessNow + ? 'solar_only' + : context.sourceMode; + + const holdConditionsOk = + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') + ? conditions.gridHoldOk + : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') + ? conditions.solarHoldOk + : activeSourceMode === 'hybrid_probe' + ? conditions.probeHoldGuardOk + : activeSourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightHoldOk + : false; + + if (activeSourceMode === 'blocked') { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_source_lost', + decisionName: 'stop', + reasonText: 'Source autorisée perdue', + dynamic: false, + mustStopFlag: true, + blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable', + blockedUntilStamp: + context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_source_lost', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Source perdue mais maintien jusqu'au mini ON", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover_runtime' + : 'wait_grid_source_runtime', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? 'Relais réseau demandé pendant la session nuit EVCS' + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run_wait_grid_takeover', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au mini ON en attente du réseau", + dynamic: false, + mustStopFlag: false, + }); + } + } + + else if (!holdConditionsOk) { + if (lifecycle.minRunElapsed) { + setOff({ + stateName: 'stop_conditions_lost', + decisionName: 'stop', + reasonText: 'Conditions de maintien perdues', + dynamic: false, + mustStopFlag: true, + blockReason: 'hold_conditions_lost', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } else { + setOn({ + stateName: 'hold_min_run', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: "Maintien jusqu'au temps mini ON", + dynamic: true, + mustStopFlag: false, + }); + } + } + + else if ((derived.currentReferenceA > derived.physicalCapA) && lifecycle.currentCooldownElapsed) { + setOn({ + stateName: 'charging_step_down_system_cap', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction pour respecter la capacité système', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownOverload) { + setOn({ + stateName: 'charging_step_down_overload', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: surcharge convertisseur', + dynamic: true, + mustStopFlag: false, + }); + } + + else if (conditions.shouldStepDownVebusPower) { + setOn({ + stateName: 'charging_step_down_vebus_power', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: puissance VE.Bus élevée', + dynamic: true, + mustStopFlag: false, + }); + } diff --git a/decoupesurplus/V2/DECISION/decisionP3.js b/decoupesurplus/V2/DECISION/decisionP3.js new file mode 100644 index 0000000..246a599 --- /dev/null +++ b/decoupesurplus/V2/DECISION/decisionP3.js @@ -0,0 +1,429 @@ + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} + +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/decoupesurplus/V2/DECISION/decisionP4.js b/decoupesurplus/V2/DECISION/decisionP4.js new file mode 100644 index 0000000..baa14d8 --- /dev/null +++ b/decoupesurplus/V2/DECISION/decisionP4.js @@ -0,0 +1,199 @@ + else if (conditions.shouldStepDownVebusVoltage) { + setOn({ + stateName: 'charging_step_down_dc_voltage', + decisionName: 'step_down_fast', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3), + derived.physicalCapA + ) + ), + reasonText: 'Baisse rapide: tension DC basse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepDown + ) { + setOn({ + stateName: 'charging_step_down_battery_assist_night', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + nightEvcs?.batteryRecoveryUrgent + ? 'Réduction: approche départ, préparation takeover réseau' + : 'Réduction: assistance batterie nuit devient trop coûteuse', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepDownBattery + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? 'charging_step_down_grid_forced_guard' + : 'charging_step_down_grid_guard', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: + activeSourceMode === 'grid_forced' + ? 'Réduction réseau forcé: la batterie commence à participer' + : 'Réduction réseau: la batterie commence à participer', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownCloudDebt + ) { + setOn({ + stateName: 'charging_step_down_cloud_debt', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - Math.max(params.currentStepDownA, 2), + derived.physicalCapA + ) + ), + reasonText: 'Réduction: dette nuage trop élevée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepDownBattery + ) { + setOn({ + stateName: 'charging_step_down_battery', + decisionName: 'step_down', + currentA: Math.max( + params.currentMinA, + Math.min( + derived.currentReferenceA - params.currentStepDownA, + derived.physicalCapA + ) + ), + reasonText: 'Réduction: batterie trop sollicitée', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') && + conditions.gridShouldStepUp + ) { + setOn({ + stateName: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'charging_step_up_grid_forced' + ) + : 'charging_step_up_grid_hc', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: + activeSourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Hausse prudente: priorité départ, réseau forcé' + : 'Hausse prudente du courant en charge réseau forcée' + ) + : 'Hausse prudente du courant en HC réseau', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + activeSourceMode === 'battery_assist_night' && + conditions.batteryAssistNightShouldStepUp + ) { + setOn({ + stateName: 'charging_step_up_battery_assist_night', + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant en assistance batterie nuit', + dynamic: true, + mustStopFlag: false, + }); + } + + else if ( + (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') && + conditions.solarShouldStepUp + ) { + setOn({ + stateName: + conditions.probeSuccessNow + ? 'probe_success_step_up' + : (modes.batteryPriorityMode + ? 'charging_step_up_battery' + : 'charging_step_up_forecast'), + decisionName: 'step_up', + currentA: clamp( + derived.currentReferenceA + params.currentStepUpA, + params.currentMinA, + derived.physicalCapA + ), + reasonText: 'Hausse prudente du courant', + dynamic: true, + mustStopFlag: false, + }); + } + + else { + setOn({ + stateName: + conditions.probeSuccessNow ? 'probe_success_hold' : + activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' : + (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' : + cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' : + 'charging_hold', + decisionName: 'hold', + currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), + reasonText: + activeSourceMode === 'battery_assist_night' + ? 'Maintien du courant en assistance batterie nuit' + : 'Maintien du courant', + dynamic: true, + mustStopFlag: false, + }); + } + } +} diff --git a/decoupesurplus/V2/DECISION/decisionP5.js b/decoupesurplus/V2/DECISION/decisionP5.js new file mode 100644 index 0000000..5d769ce --- /dev/null +++ b/decoupesurplus/V2/DECISION/decisionP5.js @@ -0,0 +1,229 @@ +// ===================================================== +// START BRANCH +// ===================================================== +else { + const startConditionsOk = + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') + ? conditions.gridStartOk + : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') + ? conditions.solarStartOk + : context.sourceMode === 'hybrid_probe' + ? conditions.probeStartOk + : context.sourceMode === 'battery_assist_night' + ? conditions.batteryAssistNightStartOk + : false; + + if (!derived.canRunAtMinCurrent) { + setOff({ + stateName: 'blocked_by_capacity_min_current', + decisionName: 'hold', + reasonText: 'Capacité insuffisante pour courant mini', + dynamic: false, + mustStopFlag: false, + blockReason: 'system_capacity_stop', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (context.sourceMode === 'blocked') { + setOff({ + stateName: + context.profile === 'outside_window' + ? 'blocked_outside_window' + : (context.profile === 'night_hc' + ? 'night_no_grid_hc_lock' + : 'idle_wait_source'), + decisionName: 'hold', + reasonText: + context.profile === 'outside_window' + ? 'Hors fenêtre préférée sans source suffisante' + : context.profile === 'night_hc' + ? 'Nuit sans autorisation HC réseau' + : 'Source non autorisée ou insuffisante', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'outside_window' + ? 'outside_window' + : context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'source_unavailable', + blockedUntilStamp: + Number.isFinite(locks.nextAllowedWindowStartMs) + ? formatParisStampFromMs(locks.nextAllowedWindowStartMs) + : formatParisStampFromMs(nowMs + 15 * 60000), + }); + } + + else if ( + (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') && + !parsed.gridAvailable + ) { + setOff({ + stateName: + context.profile === 'night_hc' + ? 'night_wait_grid_takeover' + : 'wait_grid_source', + decisionName: 'hold', + reasonText: + context.profile === 'night_hc' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Réseau demandé: urgence recharge batterie avant départ' + : 'Prise de relais réseau demandée pour session nuit EVCS' + ) + : 'Source réseau demandée mais pas encore disponible', + dynamic: false, + mustStopFlag: false, + blockReason: + context.profile === 'night_hc' + ? 'grid_hc_unavailable' + : 'grid_unavailable', + blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000), + }); + } + + else if (locks.startBlockedByReason) { + setOff({ + stateName: 'wait_reason_lock', + decisionName: 'hold', + reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`, + dynamic: false, + mustStopFlag: false, + blockReason: history.lastBlockReason, + blockedUntilStamp: history.blockedUntilAt || now.stamp, + }); + } + + else if (!startConditionsOk) { + setOff({ + stateName: 'idle_wait_conditions', + decisionName: 'hold', + reasonText: + context.sourceMode === 'battery_assist_night' + ? 'Conditions de démarrage nuit batterie non réunies' + : 'Conditions de démarrage non réunies', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else if (!lifecycle.minStopElapsed) { + setOff({ + stateName: 'wait_min_stop', + decisionName: 'hold', + reasonText: 'Temps mini OFF non écoulé', + dynamic: false, + mustStopFlag: false, + blockReason: 'none', + blockedUntilStamp: now.stamp, + }); + } + + else { + const startCurrentA = + context.sourceMode === 'hybrid_probe' + ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA) + : params.currentMinA; + + setOn({ + stateName: + context.sourceMode === 'grid_forced' ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'night_departure_deadline_grid_forced' + : 'start_grid_forced_min_current' + ) : + context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' : + context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' : + context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' : + context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : + modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' : + modes.forecastCommitMode ? 'start_min_current_forecast_commit' : + 'start_min_current', + decisionName: 'start', + currentA: startCurrentA, + reasonText: + context.sourceMode === 'grid_forced' + ? ( + nightEvcs?.batteryRecoveryUrgent + ? 'Démarrage réseau forcé: priorité départ / recharge batterie' + : 'Démarrage en charge réseau forcée' + ) + : context.sourceMode === 'grid_hc_allowed' + ? 'Démarrage en fenêtre HC réseau' + : context.sourceMode === 'battery_assist_night' + ? 'Démarrage nuit en assistance batterie contrôlée' + : context.sourceMode === 'hybrid_probe' + ? 'Démarrage en mode sonde' + : context.sourceMode === 'solar_potential' + ? 'Démarrage par potentiel solaire confirmé' + : modes.batteryFullHarvestMode + ? 'Démarrage harvest solaire' + : modes.forecastCommitMode + ? 'Démarrage forecast commit' + : 'Démarrage prudent à courant mini', + dynamic: true, + mustStopFlag: false, + }); + + nextBlockReason = 'none'; + nextBlockedUntilStamp = now.stamp; + setProbeStartedAt = context.sourceMode === 'hybrid_probe'; + } +} + +// ===================================================== +// CONTEXT REASONS +// ===================================================== +if (nightEvcs?.nightSessionActive) { + reason.push( + `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` + ); + + if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { + reason.push( + `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts` + ); + } + + if (nightEvcs.batteryRecoveryUrgent) { + reason.push("Urgence départ: il faut anticiper la reprise réseau"); + } +} + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + decision: { + desiredCharging, + desiredPermission, + desiredLatch, + desiredCurrentA, + + state, + currentDecision, + dynamicOk, + mustStop, + reason, + + nextBlockReason, + nextBlockedUntilStamp, + + setCloudDebtStartedAt, + setProbeStartedAt, + + setNightSessionStart, + clearNightSessionStart, + + notifyWanted, + notifyTitle, + notifyMessage, + } + } +}]; + diff --git a/decoupesurplus/V2/HASERVICEOUTPUT.js b/decoupesurplus/V2/HASERVICEOUTPUT.js new file mode 100644 index 0000000..e29e010 --- /dev/null +++ b/decoupesurplus/V2/HASERVICEOUTPUT.js @@ -0,0 +1,719 @@ +function round1(v) { + return Math.round(v * 10) / 10; +} +function round2(v) { + return Math.round(v * 100) / 100; +} +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +function to2(n) { + return String(n).padStart(2, '0'); +} +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; +const { + input, + now, + nowMs, + cfg, + parsed, + params, + history, + strategy, + windows, + context, + derived, + batteryTune, + batteryPolicy, + locks, + cloud, + modes, + safeguards, + conditions, + decision, +} = prev; + +// ===================================================== +// FINAL ACTIONS +// ===================================================== +let desiredCurrentA = decision.desiredCurrentA; + +desiredCurrentA = decision.desiredCharging + ? clamp( + desiredCurrentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ) + : params.currentMinA; + +const chargingAction = + decision.desiredCharging === parsed.evcsChargingSwitchOn + ? 'none' + : decision.desiredCharging + ? 'turn_on' + : 'turn_off'; + +const permissionAction = + decision.desiredPermission === parsed.chargePermissionNow + ? 'none' + : decision.desiredPermission + ? 'turn_on' + : 'turn_off'; + +const latchAction = + decision.desiredLatch === parsed.controlLatch + ? 'none' + : decision.desiredLatch + ? 'turn_on' + : 'turn_off'; + +const autoStartAction = + parsed.controllerEnabled && + parsed.evcsVehicleConnected && + parsed.evcsAutoStartSwitchOn + ? 'turn_off' + : 'none'; + +const currentAction = + decision.desiredCharging && + ( + !Number.isFinite(derived.currentReferenceA) || + Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5 + ) + ? 'set' + : 'none'; + +const setLastStartAt = chargingAction === 'turn_on'; +const setLastStopAt = chargingAction === 'turn_off'; +const setLastCurrentChangeAt = currentAction === 'set'; + +// ===================================================== +// CLEAN BLOCKING LOGIC +// ===================================================== +const blockedUntilToWrite = + decision.nextBlockReason === 'none' + ? now.stamp + : decision.nextBlockedUntilStamp; + +const needsBlockReasonUpdate = + decision.nextBlockReason !== history.lastBlockReason; + +// ===================================================== +// GLOBAL EVCS PRIORITY +// AUTO + OVERRIDE MANUEL +// ===================================================== +const forceWindowMinutes = Math.max( + 30, + batteryTune?.global?.forceModeAutoRevertMinutes ?? 120 +); + +const activeChargeIntent = + decision.desiredCharging || + ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision); + +// ------------------------- +// PRIORITÉ AUTO +// ------------------------- +let autoEvcsPriorityActive = false; +let autoEnergyPriorityMode = 'normal'; +let autoEnergyPriorityReason = 'Aucune priorité EVCS'; +let autoEvcsPriorityUntil = now.stamp; + +if (parsed.evcsVehicleConnected && !strategy.isStopMode) { + if (strategy.forceChargeRequested || strategy.forceGridRequested) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = + strategy.forceGridRequested + ? 'EVCS prioritaire : force_grid_now' + : 'EVCS prioritaire : force_charge_now'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); + + } else if (context.departurePriorityActive && Number.isFinite(prev.lifecycle.minutesUntilDeparture)) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(prev.lifecycle.minutesUntilDeparture)} min`; + autoEvcsPriorityUntil = formatParisStampFromMs( + nowMs + Math.max(1, prev.lifecycle.minutesUntilDeparture) * 60000 + ); + + } else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000); + } +} + +// ------------------------- +// OVERRIDE MANUEL +// ------------------------- +const manualPriorityOverrideActive = + parsed.evcsPriorityManualOverride && + parsed.evcsPriorityManualMode === 'evcs_priority' && + history.evcsPriorityManualUntilMs !== null && + history.evcsPriorityManualUntilMs > nowMs + 1000; + +let evcsPriorityActive = autoEvcsPriorityActive; +let energyPriorityMode = autoEnergyPriorityMode; +let energyPriorityReason = autoEnergyPriorityReason; +let evcsPriorityUntil = autoEvcsPriorityUntil; +let evcsPrioritySource = 'auto'; + +if (manualPriorityOverrideActive) { + evcsPriorityActive = true; + energyPriorityMode = 'evcs_priority'; + energyPriorityReason = + parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel'; + evcsPriorityUntil = + history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil; + evcsPrioritySource = 'manual'; +} + + +// ===================================================== +// REASONS / LABELS / SIGNATURE +// ===================================================== +const reason = [...decision.reason]; + +reason.push(`Stratégie=${strategy.userStrategy}`); +reason.push(`Profil=${context.profile}`); +reason.push(`Source=${context.sourceMode}`); +reason.push( + `Mode=${ + modes.batteryFullHarvestMode + ? 'battery_full_harvest_mode' + : modes.forecastCommitMode + ? 'forecast_commit_mode' + : 'battery_priority_mode' + }` +); +reason.push(`SOC=${round1(parsed.soc)}%`); +reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`); +reason.push(`PV=${Math.round(parsed.pvPowerW)}W`); +reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`); +reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`); +reason.push(`Cap=${derived.physicalCapA}A`); +reason.push(`Ref=${round1(derived.currentReferenceA)}A`); +reason.push(`Target=${round1(desiredCurrentA)}A`); +reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`); +reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`); +reason.push(`EVCS mode=${parsed.evcsModeRaw}`); +reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`); +reason.push( + `VE.Bus DC=${ + Number.isFinite(parsed.vebusDcVoltageV) + ? round1(parsed.vebusDcVoltageV) + : 'n/a' + }V` +); +reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`); + + +const shortReason = reason.join(' | ').slice(0, 255); + +const sourceModeLabel = + context.sourceMode === 'blocked' ? 'Bloqué' : + context.sourceMode === 'solar_only' ? 'Solaire' : + context.sourceMode === 'solar_potential' ? 'Potentiel solaire' : + context.sourceMode === 'hybrid_probe' ? 'Probe hybride' : + context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : + context.sourceMode === 'grid_forced' ? 'Réseau forcé' : + context.sourceMode; + +const signature = [ + decision.state, + `profile=${context.profile}`, + `source=${context.sourceMode}`, + `chg=${decision.desiredCharging ? '1' : '0'}`, + `perm=${decision.desiredPermission ? '1' : '0'}`, + `l=${decision.desiredLatch ? '1' : '0'}`, + `cur=${round1(desiredCurrentA)}`, + `block=${decision.nextBlockReason}`, + `stop=${decision.mustStop ? '1' : '0'}`, + `prio=${evcsPriorityActive ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== history.lastSignature; + +// ===================================================== +// NOTIFICATIONS +// ===================================================== +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = shortReason; + +if (parsed.notificationsEnabled && signatureChanged) { + if (decision.currentDecision === 'start') { + notifyWanted = true; + notifyTitle = '⚡ EVCS démarrage'; + } else if (decision.currentDecision === 'step_up') { + notifyWanted = true; + notifyTitle = '⬆️ EVCS hausse courant'; + } else if ( + decision.currentDecision === 'step_down' || + decision.currentDecision === 'step_down_fast' + ) { + notifyWanted = true; + notifyTitle = '⬇️ EVCS baisse courant'; + } else if ( + decision.mustStop || + decision.currentDecision === 'stop' || + decision.currentDecision === 'stop_hard' || + decision.currentDecision === 'stop_power_cap' + ) { + notifyWanted = true; + notifyTitle = '⛔ EVCS arrêt'; + } else if (decision.state === 'mode_auto_conflict') { + notifyWanted = true; + notifyTitle = '⚠️ EVCS mode Auto'; + } +} + +// ===================================================== +// TIMESTAMPS TO WRITE +// ===================================================== +const cloudDebtStartedAtToWrite = + cloud.cloudDebtStartedMs + ? formatParisStampFromMs(cloud.cloudDebtStartedMs) + : now.stamp; + +const probeStartedAtToWrite = now.stamp; +const lastStartAtToWrite = now.stamp; +const lastStopAtToWrite = now.stamp; +const lastCurrentChangeAtToWrite = now.stamp; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + timestamp_paris: now.stamp, + inputs: input, + + computed: { + operating_mode: context.operatingMode, + profile: context.profile, + source_mode: context.sourceMode, + source_mode_label: sourceModeLabel, + user_strategy: strategy.userStrategy, + + in_night_hc_window: windows.inNightHcWindow, + in_morning_window: windows.inMorningWindow, + in_midday_window: windows.inMiddayWindow, + in_evening_window: windows.inEveningWindow, + + departure_priority_active: context.departurePriorityActive, + minutes_until_departure: prev.lifecycle.minutesUntilDeparture, + + battery_priority_mode: modes.batteryPriorityMode, + battery_full_harvest_mode: modes.batteryFullHarvestMode, + forecast_commit_mode: modes.forecastCommitMode, + + physical_cap_current_a: derived.physicalCapA, + can_run_at_min_current: derived.canRunAtMinCurrent, + + forecast_buffer_kwh: round2(derived.forecastBufferKwh), + projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge), + + cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh), + cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes), + cloud_debt_active: cloud.cloudDebtActive, + cloud_tolerance_ok: cloud.cloudToleranceOk, + + start_blocked_by_reason: locks.startBlockedByReason, + + probe_window_active: conditions.probeWindowActive, + probe_success_now: conditions.probeSuccessNow, + probe_failed_now: conditions.probeFailedNow, + + any_vebus_overload: derived.anyVebusOverload, + any_vebus_low_battery: derived.anyVebusLowBattery, + vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached, + vebus_ac_out_hard_reached: derived.vebusAcOutHardReached, + vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow, + vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow, + + soc_band: batteryPolicy.socBand, + batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA), + batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA), + batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA), + batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes), + batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds), + batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes), + grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA), + grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA), + house_spike_detected: batteryPolicy.houseSpikeDetected, + solar_potential_context: batteryPolicy.solarPotentialContext, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource, + evcs_priority_manual_override_active: manualPriorityOverrideActive, + + }, + + decision: { + state: decision.state, + current_decision: decision.currentDecision, + desired_charging: decision.desiredCharging, + desired_permission: decision.desiredPermission, + desired_latch: decision.desiredLatch, + desired_current_a: round1(desiredCurrentA), + + charging_action: chargingAction, + permission_action: permissionAction, + latch_action: latchAction, + current_action: currentAction, + auto_start_action: autoStartAction, + + set_last_start_at: setLastStartAt, + set_last_stop_at: setLastStopAt, + set_last_current_change_at: setLastCurrentChangeAt, + set_cloud_debt_started_at: decision.setCloudDebtStartedAt, + set_probe_started_at: decision.setProbeStartedAt, + + set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled, + block_reason: decision.nextBlockReason, + + set_blocked_until: true, + blocked_until: blockedUntilToWrite, + + dynamic_ok: decision.dynamicOk, + must_stop: decision.mustStop, + + short_reason: shortReason, + reason, + signature, + signature_changed: signatureChanged, + + should_notify: notifyWanted, + notify_title: notifyTitle, + notify_message: notifyMessage, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource, + }, + + ha_service_calls: { + helper_state: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_state', + value: decision.state + } + }, + + helper_current_decision: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_current_decision', + value: decision.currentDecision + } + }, + + helper_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_reason', + value: shortReason + } + }, + + helper_operating_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_operating_mode', + value: context.operatingMode + } + }, + + helper_source_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_source_mode', + value: sourceModeLabel + } + }, + + helper_target_current: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_target_current_auto', + value: round1(desiredCurrentA) + } + }, + + helper_budget_remaining: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_budget_remaining_kwh', + value: round2(derived.forecastBufferKwh) + } + }, + + helper_projected_soc: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', + value: round1(derived.projectedEndSocAfterCharge) + } + }, + + helper_cloud_debt: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_cloud_debt_kwh', + value: round2(cloud.cloudDebtNextKwh) + } + }, + + helper_dynamic_ok: { + execute: true, + domain: 'input_boolean', + service: decision.dynamicOk ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_dynamic_ok' + } + }, + + helper_must_stop: { + execute: true, + domain: 'input_boolean', + service: decision.mustStop ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_must_stop' + } + }, + + helper_cloud_debt_started_at: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', + datetime: cloudDebtStartedAtToWrite + } + }, + + helper_last_current_change_at: { + execute: setLastCurrentChangeAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_current_change_at', + datetime: lastCurrentChangeAtToWrite + } + }, + + helper_last_start_at: { + execute: setLastStartAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_start_at', + datetime: lastStartAtToWrite + } + }, + + helper_last_stop_at: { + execute: setLastStopAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_stop_at', + datetime: lastStopAtToWrite + } + }, + + helper_signature: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_control_last_signature', + value: signature + } + }, + + helper_block_reason: { + execute: needsBlockReasonUpdate || !parsed.controllerEnabled, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_last_block_reason', + value: decision.nextBlockReason + } + }, + + helper_blocked_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_blocked_until', + datetime: blockedUntilToWrite + } + }, + + helper_probe_started_at: { + execute: decision.setProbeStartedAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_probe_started_at', + datetime: probeStartedAtToWrite + } + }, + + helper_evcs_priority_active: { + execute: true, + domain: 'input_boolean', + service: evcsPriorityActive ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_priority_active' + } + }, + + helper_energy_priority_mode: { + execute: true, + domain: 'input_select', + service: 'select_option', + data: { + entity_id: 'input_select.victron_energy_priority_mode', + option: energyPriorityMode + } + }, + + helper_energy_priority_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_energy_priority_reason', + value: energyPriorityReason + } + }, + + helper_evcs_priority_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_priority_until', + datetime: evcsPriorityUntil + } + }, + + evcs_auto_start_off: { + execute: autoStartAction === 'turn_off', + domain: 'switch', + service: 'turn_off', + data: { + entity_id: 'switch.victron_evcs_auto_start' + } + }, + + evcs_charging: { + execute: chargingAction !== 'none', + domain: 'switch', + service: decision.desiredCharging ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.victron_evcs_charging' + } + }, + + helper_permission: { + execute: permissionAction !== 'none', + domain: 'input_boolean', + service: decision.desiredPermission ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_charge_permission_now' + } + }, + + helper_latch: { + execute: latchAction !== 'none', + domain: 'input_boolean', + service: decision.desiredLatch ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_control_latch' + } + }, + + evcs_set_current: { + execute: currentAction === 'set', + domain: 'number', + service: 'set_value', + data: { + entity_id: 'number.victron_evcs_set_current_command', + value: round1(desiredCurrentA) + } + }, + + logbook: { + execute: signatureChanged, + domain: 'logbook', + service: 'log', + data: { + name: 'Victron EVCS Surplus', + message: shortReason + } + }, + + mobile_notification: { + execute: notifyWanted, + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage || shortReason + } + } + } + } +}]; + diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP1.js b/decoupesurplus/V2/HASERVICEOUTPUTP1.js new file mode 100644 index 0000000..5fa1d68 --- /dev/null +++ b/decoupesurplus/V2/HASERVICEOUTPUTP1.js @@ -0,0 +1,191 @@ +function round1(v) { + return Math.round(v * 10) / 10; +} +function round2(v) { + return Math.round(v * 100) / 100; +} +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +function to2(n) { + return String(n).padStart(2, '0'); +} +function formatParisStampFromMs(ms) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; +} + +const prev = $input.first().json; +const { + input, + now, + nowMs, + cfg, + parsed, + params, + history, + strategy, + windows, + context, + derived, + batteryTune, + batteryPolicy, + locks, + cloud, + modes, + safeguards, + conditions, + decision, +} = prev; + +// ===================================================== +// FINAL ACTIONS +// ===================================================== +let desiredCurrentA = decision.desiredCurrentA; + +desiredCurrentA = decision.desiredCharging + ? clamp( + desiredCurrentA, + params.currentMinA, + Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) + ) + : params.currentMinA; + +const chargingAction = + decision.desiredCharging === parsed.evcsChargingSwitchOn + ? 'none' + : decision.desiredCharging + ? 'turn_on' + : 'turn_off'; + +const permissionAction = + decision.desiredPermission === parsed.chargePermissionNow + ? 'none' + : decision.desiredPermission + ? 'turn_on' + : 'turn_off'; + +const latchAction = + decision.desiredLatch === parsed.controlLatch + ? 'none' + : decision.desiredLatch + ? 'turn_on' + : 'turn_off'; + +const autoStartAction = + parsed.controllerEnabled && + parsed.evcsVehicleConnected && + parsed.evcsAutoStartSwitchOn + ? 'turn_off' + : 'none'; + +const currentAction = + decision.desiredCharging && + ( + !Number.isFinite(derived.currentReferenceA) || + Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5 + ) + ? 'set' + : 'none'; + +const setLastStartAt = chargingAction === 'turn_on'; +const setLastStopAt = chargingAction === 'turn_off'; +const setLastCurrentChangeAt = currentAction === 'set'; + +// ===================================================== +// CLEAN BLOCKING LOGIC +// ===================================================== +const blockedUntilToWrite = + decision.nextBlockReason === 'none' + ? now.stamp + : decision.nextBlockedUntilStamp; + +const needsBlockReasonUpdate = + decision.nextBlockReason !== history.lastBlockReason; + +// ===================================================== +// GLOBAL EVCS PRIORITY +// AUTO + OVERRIDE MANUEL +// ===================================================== +const forceWindowMinutes = Math.max( + 30, + batteryTune?.global?.forceModeAutoRevertMinutes ?? 120 +); + +const activeChargeIntent = + decision.desiredCharging || + ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision); + +// ------------------------- +// PRIORITÉ AUTO +// ------------------------- +let autoEvcsPriorityActive = false; +let autoEnergyPriorityMode = 'normal'; +let autoEnergyPriorityReason = 'Aucune priorité EVCS'; +let autoEvcsPriorityUntil = now.stamp; + +if (parsed.evcsVehicleConnected && !strategy.isStopMode) { + if (strategy.forceChargeRequested || strategy.forceGridRequested) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = + strategy.forceGridRequested + ? 'EVCS prioritaire : force_grid_now' + : 'EVCS prioritaire : force_charge_now'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); + + } else if (context.departurePriorityActive && Number.isFinite(prev.lifecycle.minutesUntilDeparture)) { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(prev.lifecycle.minutesUntilDeparture)} min`; + autoEvcsPriorityUntil = formatParisStampFromMs( + nowMs + Math.max(1, prev.lifecycle.minutesUntilDeparture) * 60000 + ); + + } else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') { + autoEvcsPriorityActive = true; + autoEnergyPriorityMode = 'evcs_priority'; + autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi'; + autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000); + } +} + +// ------------------------- +// OVERRIDE MANUEL +// ------------------------- +const manualPriorityOverrideActive = + parsed.evcsPriorityManualOverride && + parsed.evcsPriorityManualMode === 'evcs_priority' && + history.evcsPriorityManualUntilMs !== null && + history.evcsPriorityManualUntilMs > nowMs + 1000; + +let evcsPriorityActive = autoEvcsPriorityActive; +let energyPriorityMode = autoEnergyPriorityMode; +let energyPriorityReason = autoEnergyPriorityReason; +let evcsPriorityUntil = autoEvcsPriorityUntil; +let evcsPrioritySource = 'auto'; + +if (manualPriorityOverrideActive) { + evcsPriorityActive = true; + energyPriorityMode = 'evcs_priority'; + energyPriorityReason = + parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel'; + evcsPriorityUntil = + history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil; + evcsPrioritySource = 'manual'; +} diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP2.js b/decoupesurplus/V2/HASERVICEOUTPUTP2.js new file mode 100644 index 0000000..4a69560 --- /dev/null +++ b/decoupesurplus/V2/HASERVICEOUTPUTP2.js @@ -0,0 +1,184 @@ + +// ===================================================== +// REASONS / LABELS / SIGNATURE +// ===================================================== +const reason = [...decision.reason]; + +reason.push(`Stratégie=${strategy.userStrategy}`); +reason.push(`Profil=${context.profile}`); +reason.push(`Source=${context.sourceMode}`); +reason.push( + `Mode=${ + modes.batteryFullHarvestMode + ? 'battery_full_harvest_mode' + : modes.forecastCommitMode + ? 'forecast_commit_mode' + : 'battery_priority_mode' + }` +); +reason.push(`SOC=${round1(parsed.soc)}%`); +reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`); +reason.push(`PV=${Math.round(parsed.pvPowerW)}W`); +reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`); +reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`); +reason.push(`Cap=${derived.physicalCapA}A`); +reason.push(`Ref=${round1(derived.currentReferenceA)}A`); +reason.push(`Target=${round1(desiredCurrentA)}A`); +reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`); +reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`); +reason.push(`EVCS mode=${parsed.evcsModeRaw}`); +reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`); +reason.push( + `VE.Bus DC=${ + Number.isFinite(parsed.vebusDcVoltageV) + ? round1(parsed.vebusDcVoltageV) + : 'n/a' + }V` +); +reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`); + + +const shortReason = reason.join(' | ').slice(0, 255); + +const sourceModeLabel = + context.sourceMode === 'blocked' ? 'Bloqué' : + context.sourceMode === 'solar_only' ? 'Solaire' : + context.sourceMode === 'solar_potential' ? 'Potentiel solaire' : + context.sourceMode === 'hybrid_probe' ? 'Probe hybride' : + context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : + context.sourceMode === 'grid_forced' ? 'Réseau forcé' : + context.sourceMode; + +const signature = [ + decision.state, + `profile=${context.profile}`, + `source=${context.sourceMode}`, + `chg=${decision.desiredCharging ? '1' : '0'}`, + `perm=${decision.desiredPermission ? '1' : '0'}`, + `l=${decision.desiredLatch ? '1' : '0'}`, + `cur=${round1(desiredCurrentA)}`, + `block=${decision.nextBlockReason}`, + `stop=${decision.mustStop ? '1' : '0'}`, + `prio=${evcsPriorityActive ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== history.lastSignature; + +// ===================================================== +// NOTIFICATIONS +// ===================================================== +let notifyWanted = false; +let notifyTitle = 'Victron EVCS Surplus'; +let notifyMessage = shortReason; + +if (parsed.notificationsEnabled && signatureChanged) { + if (decision.currentDecision === 'start') { + notifyWanted = true; + notifyTitle = '⚡ EVCS démarrage'; + } else if (decision.currentDecision === 'step_up') { + notifyWanted = true; + notifyTitle = '⬆️ EVCS hausse courant'; + } else if ( + decision.currentDecision === 'step_down' || + decision.currentDecision === 'step_down_fast' + ) { + notifyWanted = true; + notifyTitle = '⬇️ EVCS baisse courant'; + } else if ( + decision.mustStop || + decision.currentDecision === 'stop' || + decision.currentDecision === 'stop_hard' || + decision.currentDecision === 'stop_power_cap' + ) { + notifyWanted = true; + notifyTitle = '⛔ EVCS arrêt'; + } else if (decision.state === 'mode_auto_conflict') { + notifyWanted = true; + notifyTitle = '⚠️ EVCS mode Auto'; + } +} + +// ===================================================== +// TIMESTAMPS TO WRITE +// ===================================================== +const cloudDebtStartedAtToWrite = + cloud.cloudDebtStartedMs + ? formatParisStampFromMs(cloud.cloudDebtStartedMs) + : now.stamp; + +const probeStartedAtToWrite = now.stamp; +const lastStartAtToWrite = now.stamp; +const lastStopAtToWrite = now.stamp; +const lastCurrentChangeAtToWrite = now.stamp; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + timestamp_paris: now.stamp, + inputs: input, + + computed: { + operating_mode: context.operatingMode, + profile: context.profile, + source_mode: context.sourceMode, + source_mode_label: sourceModeLabel, + user_strategy: strategy.userStrategy, + + in_night_hc_window: windows.inNightHcWindow, + in_morning_window: windows.inMorningWindow, + in_midday_window: windows.inMiddayWindow, + in_evening_window: windows.inEveningWindow, + + departure_priority_active: context.departurePriorityActive, + minutes_until_departure: prev.lifecycle.minutesUntilDeparture, + + battery_priority_mode: modes.batteryPriorityMode, + battery_full_harvest_mode: modes.batteryFullHarvestMode, + forecast_commit_mode: modes.forecastCommitMode, + + physical_cap_current_a: derived.physicalCapA, + can_run_at_min_current: derived.canRunAtMinCurrent, + + forecast_buffer_kwh: round2(derived.forecastBufferKwh), + projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge), + + cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh), + cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes), + cloud_debt_active: cloud.cloudDebtActive, + cloud_tolerance_ok: cloud.cloudToleranceOk, + + start_blocked_by_reason: locks.startBlockedByReason, + + probe_window_active: conditions.probeWindowActive, + probe_success_now: conditions.probeSuccessNow, + probe_failed_now: conditions.probeFailedNow, + + any_vebus_overload: derived.anyVebusOverload, + any_vebus_low_battery: derived.anyVebusLowBattery, + vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached, + vebus_ac_out_hard_reached: derived.vebusAcOutHardReached, + vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow, + vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow, + + soc_band: batteryPolicy.socBand, + batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA), + batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA), + batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA), + batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes), + batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds), + batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes), + grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA), + grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA), + house_spike_detected: batteryPolicy.houseSpikeDetected, + solar_potential_context: batteryPolicy.solarPotentialContext, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource, + evcs_priority_manual_override_active: manualPriorityOverrideActive, + + }, diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP3.js b/decoupesurplus/V2/HASERVICEOUTPUTP3.js new file mode 100644 index 0000000..f6ddc86 --- /dev/null +++ b/decoupesurplus/V2/HASERVICEOUTPUTP3.js @@ -0,0 +1,183 @@ + decision: { + state: decision.state, + current_decision: decision.currentDecision, + desired_charging: decision.desiredCharging, + desired_permission: decision.desiredPermission, + desired_latch: decision.desiredLatch, + desired_current_a: round1(desiredCurrentA), + + charging_action: chargingAction, + permission_action: permissionAction, + latch_action: latchAction, + current_action: currentAction, + auto_start_action: autoStartAction, + + set_last_start_at: setLastStartAt, + set_last_stop_at: setLastStopAt, + set_last_current_change_at: setLastCurrentChangeAt, + set_cloud_debt_started_at: decision.setCloudDebtStartedAt, + set_probe_started_at: decision.setProbeStartedAt, + + set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled, + block_reason: decision.nextBlockReason, + + set_blocked_until: true, + blocked_until: blockedUntilToWrite, + + dynamic_ok: decision.dynamicOk, + must_stop: decision.mustStop, + + short_reason: shortReason, + reason, + signature, + signature_changed: signatureChanged, + + should_notify: notifyWanted, + notify_title: notifyTitle, + notify_message: notifyMessage, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason, + evcs_priority_until: evcsPriorityUntil, + evcs_priority_source: evcsPrioritySource, + }, + + ha_service_calls: { + helper_state: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_state', + value: decision.state + } + }, + + helper_current_decision: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_current_decision', + value: decision.currentDecision + } + }, + + helper_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_surplus_reason', + value: shortReason + } + }, + + helper_operating_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_operating_mode', + value: context.operatingMode + } + }, + + helper_source_mode: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_source_mode', + value: sourceModeLabel + } + }, + + helper_target_current: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_target_current_auto', + value: round1(desiredCurrentA) + } + }, + + helper_budget_remaining: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_budget_remaining_kwh', + value: round2(derived.forecastBufferKwh) + } + }, + + helper_projected_soc: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', + value: round1(derived.projectedEndSocAfterCharge) + } + }, + + helper_cloud_debt: { + execute: true, + domain: 'input_number', + service: 'set_value', + data: { + entity_id: 'input_number.victron_evcs_cloud_debt_kwh', + value: round2(cloud.cloudDebtNextKwh) + } + }, + + helper_dynamic_ok: { + execute: true, + domain: 'input_boolean', + service: decision.dynamicOk ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_dynamic_ok' + } + }, + + helper_must_stop: { + execute: true, + domain: 'input_boolean', + service: decision.mustStop ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_must_stop' + } + }, + + helper_cloud_debt_started_at: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', + datetime: cloudDebtStartedAtToWrite + } + }, + + helper_last_current_change_at: { + execute: setLastCurrentChangeAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_current_change_at', + datetime: lastCurrentChangeAtToWrite + } + }, + + helper_last_start_at: { + execute: setLastStartAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_start_at', + datetime: lastStartAtToWrite + } + }, diff --git a/decoupesurplus/V2/HASERVICEOUTPUTP4.js b/decoupesurplus/V2/HASERVICEOUTPUTP4.js new file mode 100644 index 0000000..7b79dc5 --- /dev/null +++ b/decoupesurplus/V2/HASERVICEOUTPUTP4.js @@ -0,0 +1,158 @@ + helper_last_stop_at: { + execute: setLastStopAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_last_stop_at', + datetime: lastStopAtToWrite + } + }, + + helper_signature: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_control_last_signature', + value: signature + } + }, + + helper_block_reason: { + execute: needsBlockReasonUpdate || !parsed.controllerEnabled, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_evcs_last_block_reason', + value: decision.nextBlockReason + } + }, + + helper_blocked_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_blocked_until', + datetime: blockedUntilToWrite + } + }, + + helper_probe_started_at: { + execute: decision.setProbeStartedAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_probe_started_at', + datetime: probeStartedAtToWrite + } + }, + + helper_evcs_priority_active: { + execute: true, + domain: 'input_boolean', + service: evcsPriorityActive ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_priority_active' + } + }, + + helper_energy_priority_mode: { + execute: true, + domain: 'input_select', + service: 'select_option', + data: { + entity_id: 'input_select.victron_energy_priority_mode', + option: energyPriorityMode + } + }, + + helper_energy_priority_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_energy_priority_reason', + value: energyPriorityReason + } + }, + + helper_evcs_priority_until: { + execute: true, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_evcs_priority_until', + datetime: evcsPriorityUntil + } + }, + + evcs_auto_start_off: { + execute: autoStartAction === 'turn_off', + domain: 'switch', + service: 'turn_off', + data: { + entity_id: 'switch.victron_evcs_auto_start' + } + }, + + evcs_charging: { + execute: chargingAction !== 'none', + domain: 'switch', + service: decision.desiredCharging ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.victron_evcs_charging' + } + }, + + helper_permission: { + execute: permissionAction !== 'none', + domain: 'input_boolean', + service: decision.desiredPermission ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_charge_permission_now' + } + }, + + helper_latch: { + execute: latchAction !== 'none', + domain: 'input_boolean', + service: decision.desiredLatch ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_evcs_control_latch' + } + }, + + evcs_set_current: { + execute: currentAction === 'set', + domain: 'number', + service: 'set_value', + data: { + entity_id: 'number.victron_evcs_set_current_command', + value: round1(desiredCurrentA) + } + }, + + logbook: { + execute: signatureChanged, + domain: 'logbook', + service: 'log', + data: { + name: 'Victron EVCS Surplus', + message: shortReason + } + }, + + mobile_notification: { + execute: notifyWanted, + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage || shortReason + } + } + } + } +}]; + diff --git a/decoupesurplus/V2/INPUTPARSE/inputparse.js b/decoupesurplus/V2/INPUTPARSE/inputparse.js new file mode 100644 index 0000000..5edb275 --- /dev/null +++ b/decoupesurplus/V2/INPUTPARSE/inputparse.js @@ -0,0 +1,553 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function parseTimeToMinutes(v, fallback = null) { + const s = (v ?? '').toString().trim(); + const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (!m) return fallback; + + const hh = Number(m[1]); + const mm = Number(m[2]); + + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; + return (hh % 24) * 60 + mm; +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour, + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + minNow: hour * 60 + Number(parts.minute), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanText(v, fallback = '') { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback; + return s; +} + +function finiteOrNull(v) { + return Number.isFinite(v) ? v : null; +} + +const raw = $input.first().json; +const input = extractSnapshot(raw); + +const nowMs = Date.now(); +const now = getParisParts(nowMs); + +const CFG = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt', + + batteryUsableCapacityKwh: 17.5, + nominalAcVoltageV: 230, + evcsDedicatedMaxW: 4900, + systemReserveW: 300, + + vebusDcVoltageSoftFloorV: 49.0, + vebusDcVoltageHardFloorV: 48.6, + + departurePriorityWindowMinutes: 240, + sampleSeconds: 30, + + cloudDebtRecoveryThresholdKwh: 0.05, + cloudDebtMeaningfulThresholdKwh: 0.01, +}; + +// ===================================================== +// PARSED SNAPSHOT +// ===================================================== +const parsed = { + soc: clamp(num(input.soc_percent, 0), 0, 100), + batteryCurrentA: num(input.battery_current_a, 0), + + pvPowerW: Math.max(0, num(input.pv_power_w, 0)), + pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))), + pvTrendW: num(input.pv_trend_w, 0), + + houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)), + houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))), + gridPowerW: num(input.grid_power_w, 0), + + gridIsOn: low(input.grid_state || 'off') === 'on', + acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1, + gridAvailable: + (low(input.grid_state || 'off') === 'on') || + (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1), + + todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)), + + evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on', + evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on', + evcsStatusRaw: cleanText(input.evcs_status_raw), + evcsModeRaw: [ + input.evcs_mode_sensor, + input.evcs_mode_fallback, + input.evcs_mode + ] + .map(v => cleanText(v)) + .find(v => v) || 'unknown', + + evcsSetCurrentA: num(input.evcs_set_current_a, NaN), + evcsCommandCurrentA: num(input.evcs_command_current_a, NaN), + evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)), + evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)), + evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on', + evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on', + + controllerEnabled: low(input.controller_enabled || 'off') === 'on', + notificationsEnabled: low(input.notifications_enabled || 'off') === 'on', + hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on', + chargePermissionNow: low(input.charge_permission_now || 'off') === 'on', + controlLatch: low(input.control_latch || 'off') === 'on', + departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on', + forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on', + + evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on', + evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(), + evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason), + + vebusOverload: low(input.vebus_overload || 'off') === 'on', + vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on', + vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on', + vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on', + + vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)), + vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)), + vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV), + + vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN), + vebusDcCurrentA: num(input.vebus_dc_current_a, NaN), + vebusDcPowerW: num(input.vebus_dc_power_w, NaN), + + vebusState: num(input.vebus_state, NaN), + vebusMainState: num(input.vebus_main_state, NaN), +}; + +parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto'); + +// ===================================================== +// PARAMETERS +// ===================================================== +const params = { + userStrategyRaw: input.victron_evcs_strategy || 'auto_solar', + + targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32), + pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)), + pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)), + houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)), + + batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5), + batteryDischargeHardA: num(input.battery_discharge_hard_a, -15), + batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)), + batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)), + + socStartMin: clamp(num(input.soc_start_min, 60), 0, 100), + socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100), + + currentMinA: clamp(num(input.current_min_a, 6), 6, 32), + currentMaxAutoA: clamp( + num(input.current_max_auto_a, 16), + clamp(num(input.current_min_a, 6), 6, 32), + 32 + ), + currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5), + currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8), + + currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)), + minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)), + minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)), + + requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)), + socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100), + endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100), + baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)), + flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)), + endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100), + + acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)), + batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100), + batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)), + forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100), + forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)), + + cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)), + cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)), + cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)), + + departureTimeMin: parseTimeToMinutes(input.departure_time, null), + dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), + dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), + + hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60), + hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60), + morningStartMin: parseTimeToMinutes( + input.morning_start_time, + parseTimeToMinutes(input.day_start_time, 7 * 60 + 30) + ), + morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60), + hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21), + hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21), + eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60), + eveningStopMin: parseTimeToMinutes( + input.evening_stop_time, + parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30) + ), + + allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', + allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', + + probeEnabled: low(input.probe_enabled || 'off') === 'on', + probeMinutes: Math.max(1, num(input.probe_minutes, 3)), + probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), + probeCurrentA: clamp( + num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)), + clamp(num(input.current_min_a, 6), 6, 32), + clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32) + ), + + gridBatterySoftA: num(input.grid_battery_soft_a, -3), + gridBatteryHardA: num(input.grid_battery_hard_a, -6), + + morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)), + morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0), + middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)), + + morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100), + middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100), + eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100), + eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100), + + morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20), + middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40), + eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30), + + // ===================================================== + // EVCS NIGHT ENGINE — NEW PARAMETERS + // ===================================================== + evcsNightBatteryHardFloorPct: clamp( + num(input.evcs_night_battery_hard_floor_pct, 22), + 0, + 100 + ), + + evcsNightStrongForecastKwh: Math.max( + 0, + num(input.evcs_night_strong_forecast_kwh, 18) + ), + + evcsNightMediumForecastKwh: Math.max( + 0, + num(input.evcs_night_medium_forecast_kwh, 10) + ), + + evcsNightTargetStrongForecastPct: clamp( + num(input.evcs_night_target_strong_forecast_pct, 35), + 0, + 100 + ), + + evcsNightTargetMediumForecastPct: clamp( + num(input.evcs_night_target_medium_forecast_pct, 55), + 0, + 100 + ), + + evcsNightTargetWeakForecastPct: clamp( + num(input.evcs_night_target_weak_forecast_pct, 85), + 0, + 100 + ), + + evcsNightBatteryRecoveryPowerKw: Math.max( + 0.1, + num(input.evcs_night_battery_recovery_power_kw, 2.5) + ), + + evcsNightDepartureSafetyMin: Math.max( + 0, + num(input.evcs_night_departure_safety_min, 30) + ), +}; + +// ===================================================== +// HISTORY / PERSISTED HELPERS +// ===================================================== +const nightSessionStartSocRaw = num( + input.evcs_night_session_start_soc ?? input.night_session_start_soc, + NaN +); + +const nightSessionStartAtRaw = cleanText( + input.evcs_night_session_start_at ?? input.night_session_start_at +); + +const history = { + lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none', + blockedUntilAt: cleanText(input.blocked_until), + probeStartedAt: cleanText(input.probe_started_at), + lastStartAt: cleanText(input.last_start_at), + lastStopAt: cleanText(input.last_stop_at), + lastCurrentChangeAt: cleanText(input.last_current_change_at), + lastSignature: cleanText(input.last_signature), + cloudDebtStartedAt: cleanText(input.cloud_debt_started_at), + evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until), + + blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)), + probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)), + lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)), + lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)), + lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)), + cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)), + evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)), + + // ===================================================== + // EVCS NIGHT SESSION MEMORY + // ===================================================== + nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw) + ? clamp(nightSessionStartSocRaw, 0, 100) + : null, + + nightSessionStartAt: nightSessionStartAtRaw || '', + nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw), +}; + +// ===================================================== +// BATTERY TUNING +// ===================================================== +const batteryTune = { + global: { + houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)), + solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)), + stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)), + stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)), + forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)), + overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)), + overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)), + overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)), + }, + + bands: { + gt90: { + dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50), + dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)), + }, + + '80_90': { + dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40), + dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)), + }, + + '60_80': { + dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20), + dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)), + recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)), + }, + + '40_60': { + dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12), + dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)), + recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)), + }, + + '25_40': { + dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5), + dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)), + recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)), + }, + + lt25: { + dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3), + dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)), + recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)), + }, + }, +}; + +// ===================================================== +// OPTIONAL DEBUG / SUMMARY +// ===================================================== +const evcsNightConfig = { + batteryHardFloorPct: params.evcsNightBatteryHardFloorPct, + strongForecastKwh: params.evcsNightStrongForecastKwh, + mediumForecastKwh: params.evcsNightMediumForecastKwh, + targetStrongForecastPct: params.evcsNightTargetStrongForecastPct, + targetMediumForecastPct: params.evcsNightTargetMediumForecastPct, + targetWeakForecastPct: params.evcsNightTargetWeakForecastPct, + batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw, + departureSafetyMin: params.evcsNightDepartureSafetyMin, + sessionStartSocPct: history.nightSessionStartSocPct, + sessionStartAt: history.nightSessionStartAt || null, +}; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + raw, + input, + nowMs, + now, + cfg: CFG, + parsed, + params, + history, + batteryTune, + evcsNightConfig, + } +}]; + diff --git a/decoupesurplus/V2/INPUTPARSE/inputparseP1.js b/decoupesurplus/V2/INPUTPARSE/inputparseP1.js new file mode 100644 index 0000000..b19e3c4 --- /dev/null +++ b/decoupesurplus/V2/INPUTPARSE/inputparseP1.js @@ -0,0 +1,260 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function parseTimeToMinutes(v, fallback = null) { + const s = (v ?? '').toString().trim(); + const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (!m) return fallback; + + const hh = Number(m[1]); + const mm = Number(m[2]); + + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; + return (hh % 24) * 60 + mm; +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour, + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + minNow: hour * 60 + Number(parts.minute), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanText(v, fallback = '') { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback; + return s; +} + +function finiteOrNull(v) { + return Number.isFinite(v) ? v : null; +} + +const raw = $input.first().json; +const input = extractSnapshot(raw); + +const nowMs = Date.now(); +const now = getParisParts(nowMs); + +const CFG = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt', + + batteryUsableCapacityKwh: 17.5, + nominalAcVoltageV: 230, + evcsDedicatedMaxW: 4900, + systemReserveW: 300, + + vebusDcVoltageSoftFloorV: 49.0, + vebusDcVoltageHardFloorV: 48.6, + + departurePriorityWindowMinutes: 240, + sampleSeconds: 30, + + cloudDebtRecoveryThresholdKwh: 0.05, + cloudDebtMeaningfulThresholdKwh: 0.01, +}; + +// ===================================================== +// PARSED SNAPSHOT +// ===================================================== +const parsed = { + soc: clamp(num(input.soc_percent, 0), 0, 100), + batteryCurrentA: num(input.battery_current_a, 0), + + pvPowerW: Math.max(0, num(input.pv_power_w, 0)), + pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))), + pvTrendW: num(input.pv_trend_w, 0), + + houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)), + houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))), + gridPowerW: num(input.grid_power_w, 0), + + gridIsOn: low(input.grid_state || 'off') === 'on', + acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1, + gridAvailable: + (low(input.grid_state || 'off') === 'on') || + (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1), + + todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)), + + evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on', + evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on', + evcsStatusRaw: cleanText(input.evcs_status_raw), + evcsModeRaw: [ + input.evcs_mode_sensor, + input.evcs_mode_fallback, + input.evcs_mode + ] + .map(v => cleanText(v)) + .find(v => v) || 'unknown', + + evcsSetCurrentA: num(input.evcs_set_current_a, NaN), + evcsCommandCurrentA: num(input.evcs_command_current_a, NaN), + evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)), + evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)), + evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on', + evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on', + + controllerEnabled: low(input.controller_enabled || 'off') === 'on', + notificationsEnabled: low(input.notifications_enabled || 'off') === 'on', + hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on', + chargePermissionNow: low(input.charge_permission_now || 'off') === 'on', + controlLatch: low(input.control_latch || 'off') === 'on', + departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on', + forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on', + + evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on', + evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(), + evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason), + + vebusOverload: low(input.vebus_overload || 'off') === 'on', + vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on', + vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on', + vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on', + + vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)), + vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)), + vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV), + + vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN), + vebusDcCurrentA: num(input.vebus_dc_current_a, NaN), + vebusDcPowerW: num(input.vebus_dc_power_w, NaN), + + vebusState: num(input.vebus_state, NaN), + vebusMainState: num(input.vebus_main_state, NaN), +}; + +parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto'); diff --git a/decoupesurplus/V2/INPUTPARSE/inputparseP2.js b/decoupesurplus/V2/INPUTPARSE/inputparseP2.js new file mode 100644 index 0000000..3bfc6b8 --- /dev/null +++ b/decoupesurplus/V2/INPUTPARSE/inputparseP2.js @@ -0,0 +1,292 @@ +// ===================================================== +// PARAMETERS +// ===================================================== +const params = { + userStrategyRaw: input.victron_evcs_strategy || 'auto_solar', + + targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32), + pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)), + pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)), + houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)), + + batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5), + batteryDischargeHardA: num(input.battery_discharge_hard_a, -15), + batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)), + batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)), + + socStartMin: clamp(num(input.soc_start_min, 60), 0, 100), + socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100), + + currentMinA: clamp(num(input.current_min_a, 6), 6, 32), + currentMaxAutoA: clamp( + num(input.current_max_auto_a, 16), + clamp(num(input.current_min_a, 6), 6, 32), + 32 + ), + currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5), + currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8), + + currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)), + minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)), + minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)), + + requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)), + socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100), + endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100), + baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)), + flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)), + endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100), + + acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)), + batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100), + batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)), + forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100), + forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)), + + cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)), + cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)), + cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)), + + departureTimeMin: parseTimeToMinutes(input.departure_time, null), + dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), + dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), + + hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60), + hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60), + morningStartMin: parseTimeToMinutes( + input.morning_start_time, + parseTimeToMinutes(input.day_start_time, 7 * 60 + 30) + ), + morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60), + hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21), + hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21), + eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60), + eveningStopMin: parseTimeToMinutes( + input.evening_stop_time, + parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30) + ), + + allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', + allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', + + probeEnabled: low(input.probe_enabled || 'off') === 'on', + probeMinutes: Math.max(1, num(input.probe_minutes, 3)), + probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), + probeCurrentA: clamp( + num(input.probe_current_a, clamp(num(input.current_min_a, 6), 6, 32)), + clamp(num(input.current_min_a, 6), 6, 32), + clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32) + ), + + gridBatterySoftA: num(input.grid_battery_soft_a, -3), + gridBatteryHardA: num(input.grid_battery_hard_a, -6), + + morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)), + morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0), + middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)), + + morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100), + middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100), + eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100), + eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100), + + morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20), + middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40), + eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30), + + // ===================================================== + // EVCS NIGHT ENGINE — NEW PARAMETERS + // ===================================================== + evcsNightBatteryHardFloorPct: clamp( + num(input.evcs_night_battery_hard_floor_pct, 22), + 0, + 100 + ), + + evcsNightStrongForecastKwh: Math.max( + 0, + num(input.evcs_night_strong_forecast_kwh, 18) + ), + + evcsNightMediumForecastKwh: Math.max( + 0, + num(input.evcs_night_medium_forecast_kwh, 10) + ), + + evcsNightTargetStrongForecastPct: clamp( + num(input.evcs_night_target_strong_forecast_pct, 35), + 0, + 100 + ), + + evcsNightTargetMediumForecastPct: clamp( + num(input.evcs_night_target_medium_forecast_pct, 55), + 0, + 100 + ), + + evcsNightTargetWeakForecastPct: clamp( + num(input.evcs_night_target_weak_forecast_pct, 85), + 0, + 100 + ), + + evcsNightBatteryRecoveryPowerKw: Math.max( + 0.1, + num(input.evcs_night_battery_recovery_power_kw, 2.5) + ), + + evcsNightDepartureSafetyMin: Math.max( + 0, + num(input.evcs_night_departure_safety_min, 30) + ), +}; + +// ===================================================== +// HISTORY / PERSISTED HELPERS +// ===================================================== +const nightSessionStartSocRaw = num( + input.evcs_night_session_start_soc ?? input.night_session_start_soc, + NaN +); + +const nightSessionStartAtRaw = cleanText( + input.evcs_night_session_start_at ?? input.night_session_start_at +); + +const history = { + lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none', + blockedUntilAt: cleanText(input.blocked_until), + probeStartedAt: cleanText(input.probe_started_at), + lastStartAt: cleanText(input.last_start_at), + lastStopAt: cleanText(input.last_stop_at), + lastCurrentChangeAt: cleanText(input.last_current_change_at), + lastSignature: cleanText(input.last_signature), + cloudDebtStartedAt: cleanText(input.cloud_debt_started_at), + evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until), + + blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)), + probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)), + lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)), + lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)), + lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)), + cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)), + evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)), + + // ===================================================== + // EVCS NIGHT SESSION MEMORY + // ===================================================== + nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw) + ? clamp(nightSessionStartSocRaw, 0, 100) + : null, + + nightSessionStartAt: nightSessionStartAtRaw || '', + nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw), +}; + +// ===================================================== +// BATTERY TUNING +// ===================================================== +const batteryTune = { + global: { + houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)), + solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)), + stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)), + stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)), + forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)), + overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)), + overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)), + overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)), + }, + + bands: { + gt90: { + dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50), + dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)), + }, + + '80_90': { + dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40), + dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)), + recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)), + }, + + '60_80': { + dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20), + dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)), + recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)), + }, + + '40_60': { + dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12), + dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)), + recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)), + }, + + '25_40': { + dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5), + dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)), + recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)), + }, + + lt25: { + dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3), + dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8), + chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)), + softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)), + hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)), + recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)), + }, + }, +}; + +// ===================================================== +// OPTIONAL DEBUG / SUMMARY +// ===================================================== +const evcsNightConfig = { + batteryHardFloorPct: params.evcsNightBatteryHardFloorPct, + strongForecastKwh: params.evcsNightStrongForecastKwh, + mediumForecastKwh: params.evcsNightMediumForecastKwh, + targetStrongForecastPct: params.evcsNightTargetStrongForecastPct, + targetMediumForecastPct: params.evcsNightTargetMediumForecastPct, + targetWeakForecastPct: params.evcsNightTargetWeakForecastPct, + batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw, + departureSafetyMin: params.evcsNightDepartureSafetyMin, + sessionStartSocPct: history.nightSessionStartSocPct, + sessionStartAt: history.nightSessionStartAt || null, +}; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + raw, + input, + nowMs, + now, + cfg: CFG, + parsed, + params, + history, + batteryTune, + evcsNightConfig, + } +}]; + diff --git a/decoupesurplus/V2/batcloud.js b/decoupesurplus/V2/batcloud.js new file mode 100644 index 0000000..7d7540d --- /dev/null +++ b/decoupesurplus/V2/batcloud.js @@ -0,0 +1,667 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + + return { + date, + hour, + minute: Number(parts.minute), + second: Number(parts.second), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function hhmmssFromMinutes(minOfDay) { + const mm = ((minOfDay % 1440) + 1440) % 1440; + const hh = Math.floor(mm / 60); + const mi = mm % 60; + return `${to2(hh)}:${to2(mi)}:00`; +} + +function nextDailyOccurrenceMs(startMin, refMs) { + if (!Number.isFinite(startMin)) return null; + + const today = getParisParts(refMs).date; + const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; + const todayMs = parseParisStampToMs(todayStamp); + + if (todayMs !== null && todayMs > refMs + 1000) return todayMs; + + const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; + const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; + return parseParisStampToMs(tomorrowStamp); +} + +const prev = $input.first().json; +const { + nowMs, + cfg, + parsed, + params, + history, + batteryTune, + strategy, + derived, + context, + lifecycle, +} = prev; + +// ===================================================== +// SESSION / SOURCE +// ===================================================== +const sessionObservedActive = + parsed.evcsChargingSwitchOn || + parsed.controlLatch || + parsed.chargePermissionNow; + +const isGridSource = + context.sourceMode === 'grid_hc_allowed' || + context.sourceMode === 'grid_forced'; + +// ===================================================== +// SOC BAND / BATTERY DYNAMIC POLICY +// ===================================================== +const socBand = + parsed.soc > 90 ? 'gt90' : + parsed.soc > 80 ? '80_90' : + parsed.soc > 60 ? '60_80' : + parsed.soc > 40 ? '40_60' : + parsed.soc > 25 ? '25_40' : + 'lt25'; + +const band = batteryTune.bands[socBand]; + +const dischargeModeFactor = + strategy.forceChargeRequested ? 1.2 : + strategy.forceGridRequested ? 0.7 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const chargeReserveModeFactor = + strategy.forceChargeRequested ? 0.8 : + strategy.forceGridRequested ? 1.1 : + strategy.userStrategy === 'auto_solar' ? 1.1 : + 1.0; + +const toleranceModeFactor = + strategy.forceChargeRequested ? 1.25 : + strategy.forceGridRequested ? 0.8 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor; +const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor; +const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor); +const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor; +const batteryHardToleranceSeconds = band.hardToleranceSec; +const batteryRecoveryMinutes = band.recoveryMin; + +// ===================================================== +// CONTEXT HELPERS +// ===================================================== +const houseSpikeThresholdW = 1500; +const houseSpikeDetected = + (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW; + +const solarPotentialContext = + context.sourceMode === 'solar_potential' || + ( + parsed.soc >= 80 && + derived.forecastBufferKwh > 0 && + parsed.pvPowerW < context.effectivePvStartMinW && + derived.houseNonEvcsW < 1200 + ); + +// ===================================================== +// REASON-BASED LOCKS +// ===================================================== +const nextWindowCandidates = [ + nextDailyOccurrenceMs(params.morningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs), + nextDailyOccurrenceMs(params.eveningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcNightStartMin, nowMs), +].filter(v => Number.isFinite(v)); + +const nextAllowedWindowStartMs = + nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; + +const blockActive = + history.blockedUntilMs !== null && + history.blockedUntilMs > nowMs + 1000; + +const blockStillRelevant = + history.lastBlockReason === 'outside_window' + ? context.profile === 'outside_window' + : history.lastBlockReason === 'probe_failed' + ? context.sourceMode === 'hybrid_probe' + : history.lastBlockReason === 'battery_protection_stop' + ? ( + parsed.batteryCurrentA < -params.batteryNeutralBandA || + derived.anyVebusLowBattery || + derived.vebusDcVoltageSoftLow + ) + : history.lastBlockReason === 'grid_battery_guard' + ? isGridSource + : history.lastBlockReason === 'vebus_overload_persistent' + ? derived.anyVebusOverload + : false; + +const startBlockedByReason = blockActive && blockStillRelevant; + +// ===================================================== +// CLOUD DEBT +// ===================================================== +const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef; +let cloudDebtNextKwh = params.cloudDebtPrevKwh; + +if (sessionObservedActive) { + if (batteryNetPowerW < 0 && !isGridSource) { + cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000; + } else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; + } +} else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; +} + +cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4); + +const cloudDebtMeaningful = + cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh; + +const cloudEventDischargingNow = + sessionObservedActive && + !isGridSource && + parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) && + cloudDebtMeaningful; + +const continuingCloudEvent = + cloudEventDischargingNow && + history.cloudDebtStartedMsPrev !== null && + params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh; + +const enteringCloudDebt = + cloudEventDischargingNow && !continuingCloudEvent; + +const cloudDebtActive = cloudEventDischargingNow; + +const cloudDebtStartedMs = + cloudEventDischargingNow + ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs) + : null; + +const cloudDebtDurationMinutes = + cloudDebtActive && cloudDebtStartedMs !== null + ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) + : 0; + +const effectiveCloudToleranceMinutes = + params.cloudToleranceMinutes + + (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) + + (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0); + +const cloudToleranceOk = + cloudDebtNextKwh <= params.cloudDebtMaxKwh && + ( + !cloudDebtActive || + cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes + ); + +// ===================================================== +// LEGACY / FORECAST MODES +// ===================================================== +const batteryFullHarvestMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.batteryFullSoc && + parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2); + +const forecastCommitMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.forecastCommitSocFloor && + parsed.todayRemainingKwh >= params.forecastCommitMinKwh && + derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc; + +const batteryPriorityMode = + !batteryFullHarvestMode && !forecastCommitMode; + +// ===================================================== +// PROFILE-BASED THRESHOLDS +// ===================================================== +const profileSocStartFloor = + context.profile === 'morning_solar' ? params.morningSocMin : + context.profile === 'midday_hc_or_solar' ? params.middaySocMin : + context.profile === 'evening_high_soc' ? params.eveningSocStartMin : + params.socStartMin; + +const profileSocHoldFloor = + context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) : + context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) : + context.profile === 'evening_high_soc' ? params.eveningSocStopMin : + params.socStopMin; + +const profileSpecificDischargeFloorA = + context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA : + context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA : + context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA : + params.batteryDischargeSoftA; + +const profileBatteryDischargeFloorA = + Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA); + +// ===================================================== +// GRID RELAX IN force_charge_now +// ===================================================== +// Assouplissement uniquement pour la garde réseau, +// sans toucher aux sécurités dures globales. +const gridForceChargeRelaxSoftA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 28 : + parsed.soc >= 80 ? 16 : + parsed.soc >= 60 ? 8 : + parsed.soc >= 40 ? 4 : + 0 + ) + : 0; + +const gridForceChargeRelaxHardA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 39 : + parsed.soc >= 80 ? 29 : + parsed.soc >= 60 ? 18 : + parsed.soc >= 40 ? 8 : + 0 + ) + : 0; + +const gridBatterySoftDynamicABase = + Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA); + +const gridBatteryHardDynamicABase = + Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA); + +let gridBatterySoftDynamicA = + gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA; + +let gridBatteryHardDynamicA = + gridBatteryHardDynamicABase - gridForceChargeRelaxHardA; + +// Garantit que le hard reste plus permissif que le soft d'au moins 3 A +if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) { + gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3; +} + +// ===================================================== +// BASE SAFETY +// ===================================================== +const commonSafetyStartOk = + derived.canRunAtMinCurrent && + !derived.anyVebusOverload && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageSoftLow && + !derived.vebusAcOutSoftReached && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +const commonSafetyHoldOk = + derived.canRunAtMinCurrent && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageHardLow && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const solarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const probeWindowActive = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart < params.probeMinutes; + +const probeElapsedReached = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart >= params.probeMinutes; + +const probeSuccessNow = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + ( + parsed.pvPowerW >= params.pvHoldMinW && + parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !probeSuccessNow; + +// ===================================================== +// STEP-UP LOGIC +// ===================================================== +const stepUpByBatteryReserve = + batteryPriorityMode && + parsed.batteryCurrentA >= batteryChargeReserveDynamicA; + +const stepUpByForecastMode = + !batteryPriorityMode && + ( + parsed.pvPowerW >= params.pvHoldMinW || + parsed.pvPowerAvg10mW >= params.pvHoldMinW + ) && + derived.forecastBufferKwh >= -1 && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) && + cloudToleranceOk; + +const solarShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + !derived.houseLoadHighVsHeadroom && + ( + stepUpByBatteryReserve || + stepUpByForecastMode || + parsed.pvPowerW >= (context.effectivePvStartMinW + 400) + ); + +const gridShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + parsed.batteryCurrentA >= gridBatterySoftDynamicA && + !derived.houseLoadHighVsHeadroom; + +// ===================================================== +// STEP-DOWN LOGIC +// ===================================================== +const solarShouldStepDownBattery = + parsed.batteryCurrentA <= batteryDischargeSoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const solarShouldStepDownCloudDebt = + !cloudToleranceOk && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const gridShouldStepDownBattery = + parsed.batteryCurrentA < gridBatterySoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusPower = + derived.vebusAcOutSoftReached && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusVoltage = + derived.vebusDcVoltageSoftLow && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownOverload = + derived.anyVebusOverload && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStopOverloadAtMin = + derived.anyVebusOverload && + derived.currentReferenceA <= params.currentMinA && + sessionObservedActive; + +// ===================================================== +// HARD STOPS / PROTECTIONS +// ===================================================== +const batteryCurrentHardExceeded = + parsed.batteryCurrentA <= batteryDischargeHardDynamicA; + +// overload volontairement exclu du hard stop immédiat +const mustStopHard = + derived.anyVebusLowBattery || + derived.vebusDcVoltageHardLow || + derived.vebusAcOutHardReached || + batteryCurrentHardExceeded; + +const gridBatteryProtectionStop = + sessionObservedActive && + isGridSource && + parsed.batteryCurrentA < gridBatteryHardDynamicA; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + + batteryPolicy: { + socBand, + batteryDischargeSoftDynamicA, + batteryDischargeHardDynamicA, + batteryChargeReserveDynamicA, + batterySoftToleranceMinutes, + batteryHardToleranceSeconds, + batteryRecoveryMinutes, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + houseSpikeDetected, + solarPotentialContext, + }, + + locks: { + nextAllowedWindowStartMs, + blockActive, + blockStillRelevant, + startBlockedByReason, + }, + + cloud: { + sessionObservedActive, + batteryNetPowerW, + cloudDebtNextKwh, + cloudDebtMeaningful, + cloudEventDischargingNow, + continuingCloudEvent, + enteringCloudDebt, + cloudDebtActive, + cloudDebtStartedMs, + cloudDebtDurationMinutes, + effectiveCloudToleranceMinutes, + cloudToleranceOk, + }, + + modes: { + batteryFullHarvestMode, + forecastCommitMode, + batteryPriorityMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + solarShouldStepUp, + gridShouldStepUp, + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; + diff --git a/decoupesurplus/V2/batcloudP1.js b/decoupesurplus/V2/batcloudP1.js new file mode 100644 index 0000000..cd14859 --- /dev/null +++ b/decoupesurplus/V2/batcloudP1.js @@ -0,0 +1,293 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + + return { + date, + hour, + minute: Number(parts.minute), + second: Number(parts.second), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function hhmmssFromMinutes(minOfDay) { + const mm = ((minOfDay % 1440) + 1440) % 1440; + const hh = Math.floor(mm / 60); + const mi = mm % 60; + return `${to2(hh)}:${to2(mi)}:00`; +} + +function nextDailyOccurrenceMs(startMin, refMs) { + if (!Number.isFinite(startMin)) return null; + + const today = getParisParts(refMs).date; + const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; + const todayMs = parseParisStampToMs(todayStamp); + + if (todayMs !== null && todayMs > refMs + 1000) return todayMs; + + const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; + const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; + return parseParisStampToMs(tomorrowStamp); +} + +const prev = $input.first().json; +const { + nowMs, + cfg, + parsed, + params, + history, + batteryTune, + strategy, + derived, + context, + lifecycle, +} = prev; + +// ===================================================== +// SESSION / SOURCE +// ===================================================== +const sessionObservedActive = + parsed.evcsChargingSwitchOn || + parsed.controlLatch || + parsed.chargePermissionNow; + +const isGridSource = + context.sourceMode === 'grid_hc_allowed' || + context.sourceMode === 'grid_forced'; + +// ===================================================== +// SOC BAND / BATTERY DYNAMIC POLICY +// ===================================================== +const socBand = + parsed.soc > 90 ? 'gt90' : + parsed.soc > 80 ? '80_90' : + parsed.soc > 60 ? '60_80' : + parsed.soc > 40 ? '40_60' : + parsed.soc > 25 ? '25_40' : + 'lt25'; + +const band = batteryTune.bands[socBand]; + +const dischargeModeFactor = + strategy.forceChargeRequested ? 1.2 : + strategy.forceGridRequested ? 0.7 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const chargeReserveModeFactor = + strategy.forceChargeRequested ? 0.8 : + strategy.forceGridRequested ? 1.1 : + strategy.userStrategy === 'auto_solar' ? 1.1 : + 1.0; + +const toleranceModeFactor = + strategy.forceChargeRequested ? 1.25 : + strategy.forceGridRequested ? 0.8 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor; +const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor; +const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor); +const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor; +const batteryHardToleranceSeconds = band.hardToleranceSec; +const batteryRecoveryMinutes = band.recoveryMin; + +// ===================================================== +// CONTEXT HELPERS +// ===================================================== +const houseSpikeThresholdW = 1500; +const houseSpikeDetected = + (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW; + +const solarPotentialContext = + context.sourceMode === 'solar_potential' || + ( + parsed.soc >= 80 && + derived.forecastBufferKwh > 0 && + parsed.pvPowerW < context.effectivePvStartMinW && + derived.houseNonEvcsW < 1200 + ); + +// ===================================================== +// REASON-BASED LOCKS +// ===================================================== +const nextWindowCandidates = [ + nextDailyOccurrenceMs(params.morningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs), + nextDailyOccurrenceMs(params.eveningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcNightStartMin, nowMs), +].filter(v => Number.isFinite(v)); + +const nextAllowedWindowStartMs = + nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; + +const blockActive = + history.blockedUntilMs !== null && + history.blockedUntilMs > nowMs + 1000; + +const blockStillRelevant = + history.lastBlockReason === 'outside_window' + ? context.profile === 'outside_window' + : history.lastBlockReason === 'probe_failed' + ? context.sourceMode === 'hybrid_probe' + : history.lastBlockReason === 'battery_protection_stop' + ? ( + parsed.batteryCurrentA < -params.batteryNeutralBandA || + derived.anyVebusLowBattery || + derived.vebusDcVoltageSoftLow + ) + : history.lastBlockReason === 'grid_battery_guard' + ? isGridSource + : history.lastBlockReason === 'vebus_overload_persistent' + ? derived.anyVebusOverload + : false; + +const startBlockedByReason = blockActive && blockStillRelevant; + +// ===================================================== +// CLOUD DEBT +// ===================================================== +const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef; +let cloudDebtNextKwh = params.cloudDebtPrevKwh; + +if (sessionObservedActive) { + if (batteryNetPowerW < 0 && !isGridSource) { + cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000; + } else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; + } +} else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; +} + +cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4); + +const cloudDebtMeaningful = + cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh; + +const cloudEventDischargingNow = + sessionObservedActive && + !isGridSource && + parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) && + cloudDebtMeaningful; + +const continuingCloudEvent = + cloudEventDischargingNow && + history.cloudDebtStartedMsPrev !== null && + params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh; + +const enteringCloudDebt = + cloudEventDischargingNow && !continuingCloudEvent; + +const cloudDebtActive = cloudEventDischargingNow; + +const cloudDebtStartedMs = + cloudEventDischargingNow + ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs) + : null; + +const cloudDebtDurationMinutes = + cloudDebtActive && cloudDebtStartedMs !== null + ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) + : 0; + +const effectiveCloudToleranceMinutes = + params.cloudToleranceMinutes + + (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) + + (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0); + +const cloudToleranceOk = + cloudDebtNextKwh <= params.cloudDebtMaxKwh && + ( + !cloudDebtActive || + cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes + ); diff --git a/decoupesurplus/V2/batcloudP11.js b/decoupesurplus/V2/batcloudP11.js new file mode 100644 index 0000000..a0e4378 --- /dev/null +++ b/decoupesurplus/V2/batcloudP11.js @@ -0,0 +1,182 @@ +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisParts(ms = Date.now()) { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date(ms)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + + return { + date, + hour, + minute: Number(parts.minute), + second: Number(parts.second), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function hhmmssFromMinutes(minOfDay) { + const mm = ((minOfDay % 1440) + 1440) % 1440; + const hh = Math.floor(mm / 60); + const mi = mm % 60; + return `${to2(hh)}:${to2(mi)}:00`; +} + +function nextDailyOccurrenceMs(startMin, refMs) { + if (!Number.isFinite(startMin)) return null; + + const today = getParisParts(refMs).date; + const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`; + const todayMs = parseParisStampToMs(todayStamp); + + if (todayMs !== null && todayMs > refMs + 1000) return todayMs; + + const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date; + const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`; + return parseParisStampToMs(tomorrowStamp); +} + +const prev = $input.first().json; +const { + nowMs, + cfg, + parsed, + params, + history, + batteryTune, + strategy, + derived, + context, + lifecycle, +} = prev; + +// ===================================================== +// SESSION / SOURCE +// ===================================================== +const sessionObservedActive = + parsed.evcsChargingSwitchOn || + parsed.controlLatch || + parsed.chargePermissionNow; + +const isGridSource = + context.sourceMode === 'grid_hc_allowed' || + context.sourceMode === 'grid_forced'; + +// ===================================================== +// SOC BAND / BATTERY DYNAMIC POLICY +// ===================================================== +const socBand = + parsed.soc > 90 ? 'gt90' : + parsed.soc > 80 ? '80_90' : + parsed.soc > 60 ? '60_80' : + parsed.soc > 40 ? '40_60' : + parsed.soc > 25 ? '25_40' : + 'lt25'; + +const band = batteryTune.bands[socBand]; + +const dischargeModeFactor = + strategy.forceChargeRequested ? 1.2 : + strategy.forceGridRequested ? 0.7 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const chargeReserveModeFactor = + strategy.forceChargeRequested ? 0.8 : + strategy.forceGridRequested ? 1.1 : + strategy.userStrategy === 'auto_solar' ? 1.1 : + 1.0; + +const toleranceModeFactor = + strategy.forceChargeRequested ? 1.25 : + strategy.forceGridRequested ? 0.8 : + strategy.userStrategy === 'auto_solar' ? 0.8 : + 1.0; + +const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor; +const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor; +const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor); +const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor; +const batteryHardToleranceSeconds = band.hardToleranceSec; +const batteryRecoveryMinutes = band.recoveryMin; diff --git a/decoupesurplus/V2/batcloudP12.js b/decoupesurplus/V2/batcloudP12.js new file mode 100644 index 0000000..96856ae --- /dev/null +++ b/decoupesurplus/V2/batcloudP12.js @@ -0,0 +1,110 @@ +// ===================================================== +// CONTEXT HELPERS +// ===================================================== +const houseSpikeThresholdW = 1500; +const houseSpikeDetected = + (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW; + +const solarPotentialContext = + context.sourceMode === 'solar_potential' || + ( + parsed.soc >= 80 && + derived.forecastBufferKwh > 0 && + parsed.pvPowerW < context.effectivePvStartMinW && + derived.houseNonEvcsW < 1200 + ); + +// ===================================================== +// REASON-BASED LOCKS +// ===================================================== +const nextWindowCandidates = [ + nextDailyOccurrenceMs(params.morningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs), + nextDailyOccurrenceMs(params.eveningStartMin, nowMs), + nextDailyOccurrenceMs(params.hcNightStartMin, nowMs), +].filter(v => Number.isFinite(v)); + +const nextAllowedWindowStartMs = + nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null; + +const blockActive = + history.blockedUntilMs !== null && + history.blockedUntilMs > nowMs + 1000; + +const blockStillRelevant = + history.lastBlockReason === 'outside_window' + ? context.profile === 'outside_window' + : history.lastBlockReason === 'probe_failed' + ? context.sourceMode === 'hybrid_probe' + : history.lastBlockReason === 'battery_protection_stop' + ? ( + parsed.batteryCurrentA < -params.batteryNeutralBandA || + derived.anyVebusLowBattery || + derived.vebusDcVoltageSoftLow + ) + : history.lastBlockReason === 'grid_battery_guard' + ? isGridSource + : history.lastBlockReason === 'vebus_overload_persistent' + ? derived.anyVebusOverload + : false; + +const startBlockedByReason = blockActive && blockStillRelevant; + +// ===================================================== +// CLOUD DEBT +// ===================================================== +const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef; +let cloudDebtNextKwh = params.cloudDebtPrevKwh; + +if (sessionObservedActive) { + if (batteryNetPowerW < 0 && !isGridSource) { + cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000; + } else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; + } +} else if (batteryNetPowerW > 0) { + cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000; +} + +cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4); + +const cloudDebtMeaningful = + cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh; + +const cloudEventDischargingNow = + sessionObservedActive && + !isGridSource && + parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) && + cloudDebtMeaningful; + +const continuingCloudEvent = + cloudEventDischargingNow && + history.cloudDebtStartedMsPrev !== null && + params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh; + +const enteringCloudDebt = + cloudEventDischargingNow && !continuingCloudEvent; + +const cloudDebtActive = cloudEventDischargingNow; + +const cloudDebtStartedMs = + cloudEventDischargingNow + ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs) + : null; + +const cloudDebtDurationMinutes = + cloudDebtActive && cloudDebtStartedMs !== null + ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000) + : 0; + +const effectiveCloudToleranceMinutes = + params.cloudToleranceMinutes + + (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) + + (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0); + +const cloudToleranceOk = + cloudDebtNextKwh <= params.cloudDebtMaxKwh && + ( + !cloudDebtActive || + cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes + ); diff --git a/decoupesurplus/V2/batcloudP13.js b/decoupesurplus/V2/batcloudP13.js new file mode 100644 index 0000000..2e42b55 --- /dev/null +++ b/decoupesurplus/V2/batcloudP13.js @@ -0,0 +1,188 @@ +// ===================================================== +// LEGACY / FORECAST MODES +// ===================================================== +const batteryFullHarvestMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.batteryFullSoc && + parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2); + +const forecastCommitMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.forecastCommitSocFloor && + parsed.todayRemainingKwh >= params.forecastCommitMinKwh && + derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc; + +const batteryPriorityMode = + !batteryFullHarvestMode && !forecastCommitMode; + +// ===================================================== +// PROFILE-BASED THRESHOLDS +// ===================================================== +const profileSocStartFloor = + context.profile === 'morning_solar' ? params.morningSocMin : + context.profile === 'midday_hc_or_solar' ? params.middaySocMin : + context.profile === 'evening_high_soc' ? params.eveningSocStartMin : + params.socStartMin; + +const profileSocHoldFloor = + context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) : + context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) : + context.profile === 'evening_high_soc' ? params.eveningSocStopMin : + params.socStopMin; + +const profileSpecificDischargeFloorA = + context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA : + context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA : + context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA : + params.batteryDischargeSoftA; + +const profileBatteryDischargeFloorA = + Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA); + +// ===================================================== +// GRID RELAX IN force_charge_now +// ===================================================== +// Assouplissement uniquement pour la garde réseau, +// sans toucher aux sécurités dures globales. +const gridForceChargeRelaxSoftA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 28 : + parsed.soc >= 80 ? 16 : + parsed.soc >= 60 ? 8 : + parsed.soc >= 40 ? 4 : + 0 + ) + : 0; + +const gridForceChargeRelaxHardA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 39 : + parsed.soc >= 80 ? 29 : + parsed.soc >= 60 ? 18 : + parsed.soc >= 40 ? 8 : + 0 + ) + : 0; + +const gridBatterySoftDynamicABase = + Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA); + +const gridBatteryHardDynamicABase = + Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA); + +let gridBatterySoftDynamicA = + gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA; + +let gridBatteryHardDynamicA = + gridBatteryHardDynamicABase - gridForceChargeRelaxHardA; + +// Garantit que le hard reste plus permissif que le soft d'au moins 3 A +if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) { + gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3; +} + +// ===================================================== +// BASE SAFETY +// ===================================================== +const commonSafetyStartOk = + derived.canRunAtMinCurrent && + !derived.anyVebusOverload && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageSoftLow && + !derived.vebusAcOutSoftReached && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +const commonSafetyHoldOk = + derived.canRunAtMinCurrent && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageHardLow && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const solarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const probeWindowActive = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart < params.probeMinutes; + +const probeElapsedReached = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart >= params.probeMinutes; + +const probeSuccessNow = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + ( + parsed.pvPowerW >= params.pvHoldMinW && + parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !probeSuccessNow; diff --git a/decoupesurplus/V2/batcloudP14.js b/decoupesurplus/V2/batcloudP14.js new file mode 100644 index 0000000..c1f01bd --- /dev/null +++ b/decoupesurplus/V2/batcloudP14.js @@ -0,0 +1,184 @@ +// ===================================================== +// STEP-UP LOGIC +// ===================================================== +const stepUpByBatteryReserve = + batteryPriorityMode && + parsed.batteryCurrentA >= batteryChargeReserveDynamicA; + +const stepUpByForecastMode = + !batteryPriorityMode && + ( + parsed.pvPowerW >= params.pvHoldMinW || + parsed.pvPowerAvg10mW >= params.pvHoldMinW + ) && + derived.forecastBufferKwh >= -1 && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) && + cloudToleranceOk; + +const solarShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + !derived.houseLoadHighVsHeadroom && + ( + stepUpByBatteryReserve || + stepUpByForecastMode || + parsed.pvPowerW >= (context.effectivePvStartMinW + 400) + ); + +const gridShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + parsed.batteryCurrentA >= gridBatterySoftDynamicA && + !derived.houseLoadHighVsHeadroom; + +// ===================================================== +// STEP-DOWN LOGIC +// ===================================================== +const solarShouldStepDownBattery = + parsed.batteryCurrentA <= batteryDischargeSoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const solarShouldStepDownCloudDebt = + !cloudToleranceOk && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const gridShouldStepDownBattery = + parsed.batteryCurrentA < gridBatterySoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusPower = + derived.vebusAcOutSoftReached && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusVoltage = + derived.vebusDcVoltageSoftLow && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownOverload = + derived.anyVebusOverload && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStopOverloadAtMin = + derived.anyVebusOverload && + derived.currentReferenceA <= params.currentMinA && + sessionObservedActive; + +// ===================================================== +// HARD STOPS / PROTECTIONS +// ===================================================== +const batteryCurrentHardExceeded = + parsed.batteryCurrentA <= batteryDischargeHardDynamicA; + +// overload volontairement exclu du hard stop immédiat +const mustStopHard = + derived.anyVebusLowBattery || + derived.vebusDcVoltageHardLow || + derived.vebusAcOutHardReached || + batteryCurrentHardExceeded; + +const gridBatteryProtectionStop = + sessionObservedActive && + isGridSource && + parsed.batteryCurrentA < gridBatteryHardDynamicA; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + + batteryPolicy: { + socBand, + batteryDischargeSoftDynamicA, + batteryDischargeHardDynamicA, + batteryChargeReserveDynamicA, + batterySoftToleranceMinutes, + batteryHardToleranceSeconds, + batteryRecoveryMinutes, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + houseSpikeDetected, + solarPotentialContext, + }, + + locks: { + nextAllowedWindowStartMs, + blockActive, + blockStillRelevant, + startBlockedByReason, + }, + + cloud: { + sessionObservedActive, + batteryNetPowerW, + cloudDebtNextKwh, + cloudDebtMeaningful, + cloudEventDischargingNow, + continuingCloudEvent, + enteringCloudDebt, + cloudDebtActive, + cloudDebtStartedMs, + cloudDebtDurationMinutes, + effectiveCloudToleranceMinutes, + cloudToleranceOk, + }, + + modes: { + batteryFullHarvestMode, + forecastCommitMode, + batteryPriorityMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + solarShouldStepUp, + gridShouldStepUp, + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; + diff --git a/decoupesurplus/V2/batcloudP2.js b/decoupesurplus/V2/batcloudP2.js new file mode 100644 index 0000000..5009ba0 --- /dev/null +++ b/decoupesurplus/V2/batcloudP2.js @@ -0,0 +1,373 @@ +// ===================================================== +// LEGACY / FORECAST MODES +// ===================================================== +const batteryFullHarvestMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.batteryFullSoc && + parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2); + +const forecastCommitMode = + parsed.forecastModeEnabled && + context.profile !== 'night_hc' && + context.profile !== 'outside_window' && + parsed.soc >= params.forecastCommitSocFloor && + parsed.todayRemainingKwh >= params.forecastCommitMinKwh && + derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc; + +const batteryPriorityMode = + !batteryFullHarvestMode && !forecastCommitMode; + +// ===================================================== +// PROFILE-BASED THRESHOLDS +// ===================================================== +const profileSocStartFloor = + context.profile === 'morning_solar' ? params.morningSocMin : + context.profile === 'midday_hc_or_solar' ? params.middaySocMin : + context.profile === 'evening_high_soc' ? params.eveningSocStartMin : + params.socStartMin; + +const profileSocHoldFloor = + context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) : + context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) : + context.profile === 'evening_high_soc' ? params.eveningSocStopMin : + params.socStopMin; + +const profileSpecificDischargeFloorA = + context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA : + context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA : + context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA : + params.batteryDischargeSoftA; + +const profileBatteryDischargeFloorA = + Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA); + +// ===================================================== +// GRID RELAX IN force_charge_now +// ===================================================== +// Assouplissement uniquement pour la garde réseau, +// sans toucher aux sécurités dures globales. +const gridForceChargeRelaxSoftA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 28 : + parsed.soc >= 80 ? 16 : + parsed.soc >= 60 ? 8 : + parsed.soc >= 40 ? 4 : + 0 + ) + : 0; + +const gridForceChargeRelaxHardA = + strategy.forceChargeRequested && isGridSource + ? ( + parsed.soc >= 90 ? 39 : + parsed.soc >= 80 ? 29 : + parsed.soc >= 60 ? 18 : + parsed.soc >= 40 ? 8 : + 0 + ) + : 0; + +const gridBatterySoftDynamicABase = + Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA); + +const gridBatteryHardDynamicABase = + Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA); + +let gridBatterySoftDynamicA = + gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA; + +let gridBatteryHardDynamicA = + gridBatteryHardDynamicABase - gridForceChargeRelaxHardA; + +// Garantit que le hard reste plus permissif que le soft d'au moins 3 A +if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) { + gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3; +} + +// ===================================================== +// BASE SAFETY +// ===================================================== +const commonSafetyStartOk = + derived.canRunAtMinCurrent && + !derived.anyVebusOverload && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageSoftLow && + !derived.vebusAcOutSoftReached && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +const commonSafetyHoldOk = + derived.canRunAtMinCurrent && + !derived.anyVebusLowBattery && + !derived.vebusDcVoltageHardLow && + parsed.evcsDeviceConnected && + parsed.evcsVehicleConnected && + !parsed.evcsModeIsAuto; + +// ===================================================== +// START / HOLD CONDITIONS +// ===================================================== +const solarStartOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyStartOk && + parsed.soc >= profileSocStartFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + context.sourceMode === 'solar_potential' || + parsed.pvPowerW >= context.effectivePvStartMinW + ); + +const solarHoldOk = + (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') && + commonSafetyHoldOk && + parsed.soc >= profileSocHoldFloor && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA && + ( + parsed.pvPowerW >= params.pvHoldMinW || + cloudToleranceOk + ); + +const gridStartOk = + isGridSource && + commonSafetyStartOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatterySoftDynamicA; + +const gridHoldOk = + isGridSource && + commonSafetyHoldOk && + parsed.gridAvailable && + parsed.batteryCurrentA >= gridBatteryHardDynamicA; + +// ===================================================== +// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE +// ===================================================== +const probeModeActive = context.sourceMode === 'hybrid_probe'; + +const probeStartOk = + probeModeActive && + commonSafetyStartOk && + (context.morningProbeEligible || context.middayProbeEligible); + +const probeHoldGuardOk = + probeModeActive && + commonSafetyHoldOk && + parsed.batteryCurrentA >= profileBatteryDischargeFloorA; + +const probeWindowActive = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart < params.probeMinutes; + +const probeElapsedReached = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + lifecycle.minutesSinceProbeStart !== null && + lifecycle.minutesSinceProbeStart >= params.probeMinutes; + +const probeSuccessNow = + probeModeActive && + sessionObservedActive && + history.probeStartedMs !== null && + ( + parsed.pvPowerW >= params.pvHoldMinW && + parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA) + ); + +const probeFailedNow = + probeModeActive && + probeElapsedReached && + !probeSuccessNow; + +// ===================================================== +// STEP-UP LOGIC +// ===================================================== +const stepUpByBatteryReserve = + batteryPriorityMode && + parsed.batteryCurrentA >= batteryChargeReserveDynamicA; + +const stepUpByForecastMode = + !batteryPriorityMode && + ( + parsed.pvPowerW >= params.pvHoldMinW || + parsed.pvPowerAvg10mW >= params.pvHoldMinW + ) && + derived.forecastBufferKwh >= -1 && + derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) && + cloudToleranceOk; + +const solarShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + !derived.houseLoadHighVsHeadroom && + ( + stepUpByBatteryReserve || + stepUpByForecastMode || + parsed.pvPowerW >= (context.effectivePvStartMinW + 400) + ); + +const gridShouldStepUp = + lifecycle.currentCooldownElapsed && + derived.currentReferenceA < derived.physicalCapA && + derived.physicalCapA >= params.currentMinA && + !derived.vebusAcOutSoftReached && + !derived.vebusDcVoltageSoftLow && + parsed.batteryCurrentA >= gridBatterySoftDynamicA && + !derived.houseLoadHighVsHeadroom; + +// ===================================================== +// STEP-DOWN LOGIC +// ===================================================== +const solarShouldStepDownBattery = + parsed.batteryCurrentA <= batteryDischargeSoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const solarShouldStepDownCloudDebt = + !cloudToleranceOk && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const gridShouldStepDownBattery = + parsed.batteryCurrentA < gridBatterySoftDynamicA && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusPower = + derived.vebusAcOutSoftReached && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownVebusVoltage = + derived.vebusDcVoltageSoftLow && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStepDownOverload = + derived.anyVebusOverload && + lifecycle.currentCooldownElapsed && + derived.currentReferenceA > params.currentMinA; + +const shouldStopOverloadAtMin = + derived.anyVebusOverload && + derived.currentReferenceA <= params.currentMinA && + sessionObservedActive; + +// ===================================================== +// HARD STOPS / PROTECTIONS +// ===================================================== +const batteryCurrentHardExceeded = + parsed.batteryCurrentA <= batteryDischargeHardDynamicA; + +// overload volontairement exclu du hard stop immédiat +const mustStopHard = + derived.anyVebusLowBattery || + derived.vebusDcVoltageHardLow || + derived.vebusAcOutHardReached || + batteryCurrentHardExceeded; + +const gridBatteryProtectionStop = + sessionObservedActive && + isGridSource && + parsed.batteryCurrentA < gridBatteryHardDynamicA; + +// ===================================================== +// OUTPUT +// ===================================================== +return [{ + json: { + ...prev, + + batteryPolicy: { + socBand, + batteryDischargeSoftDynamicA, + batteryDischargeHardDynamicA, + batteryChargeReserveDynamicA, + batterySoftToleranceMinutes, + batteryHardToleranceSeconds, + batteryRecoveryMinutes, + + gridBatterySoftDynamicA, + gridBatteryHardDynamicA, + gridForceChargeRelaxSoftA, + gridForceChargeRelaxHardA, + + houseSpikeDetected, + solarPotentialContext, + }, + + locks: { + nextAllowedWindowStartMs, + blockActive, + blockStillRelevant, + startBlockedByReason, + }, + + cloud: { + sessionObservedActive, + batteryNetPowerW, + cloudDebtNextKwh, + cloudDebtMeaningful, + cloudEventDischargingNow, + continuingCloudEvent, + enteringCloudDebt, + cloudDebtActive, + cloudDebtStartedMs, + cloudDebtDurationMinutes, + effectiveCloudToleranceMinutes, + cloudToleranceOk, + }, + + modes: { + batteryFullHarvestMode, + forecastCommitMode, + batteryPriorityMode, + }, + + safeguards: { + isGridSource, + commonSafetyStartOk, + commonSafetyHoldOk, + profileSocStartFloor, + profileSocHoldFloor, + profileBatteryDischargeFloorA, + batteryCurrentHardExceeded, + mustStopHard, + gridBatteryProtectionStop, + }, + + conditions: { + solarStartOk, + solarHoldOk, + gridStartOk, + gridHoldOk, + probeStartOk, + probeHoldGuardOk, + probeWindowActive, + probeElapsedReached, + probeSuccessNow, + probeFailedNow, + solarShouldStepUp, + gridShouldStepUp, + solarShouldStepDownBattery, + solarShouldStepDownCloudDebt, + gridShouldStepDownBattery, + shouldStepDownVebusPower, + shouldStepDownVebusVoltage, + shouldStepDownOverload, + shouldStopOverloadAtMin, + } + } +}]; + diff --git a/decoupesurplus/V2/gridhyst.js b/decoupesurplus/V2/gridhyst.js new file mode 100644 index 0000000..14e85d4 --- /dev/null +++ b/decoupesurplus/V2/gridhyst.js @@ -0,0 +1,464 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function getParisNowParts() { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date()) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + }; +} + +function parseLocalStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + return new Date(y, mo - 1, d, h, mi, se).getTime(); +} + +const cfg = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt' +}; + +const raw = $input.first().json; +const input = extractSnapshot(raw); +const now = getParisNowParts(); +const nowMs = parseLocalStampToMs(now.stamp); + +const soc = clamp(num(input.soc_percent, 0), 0, 100); +const gridIsOn = low(input.grid_state || 'off') === 'on'; +const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); + +const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); +const gridPowerW = num(input.grid_power_w, 0); + +const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0)); +const evcsCharging = low(input.evcs_charging || 'off') === 'on'; +const evcsStatusRaw = num(input.evcs_status_raw, NaN); + +const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on'; +const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; +const latchIsOn = low(input.grid_latch || 'off') === 'on'; + +const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100); +const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100); +const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45)); +const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15)); +const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800)); + +const lastOnAt = (input.last_on_at ?? '').toString().trim(); +const lastOffAt = (input.last_off_at ?? '').toString().trim(); +const lastSignature = (input.last_signature ?? '').toString().trim(); + +const lastOnMs = parseLocalStampToMs(lastOnAt); +const lastOffMs = parseLocalStampToMs(lastOffAt); + +const minutesSinceLastOn = + lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null; + +const minutesSinceLastOff = + lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null; + +const minOnElapsed = + minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes; + +const cooldownElapsed = + minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes; + +const socBand = + soc <= gridSocOn ? 'low' : + soc < gridSocOff ? 'mid' : + 'high'; + +const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300; +const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW; + +let state = 'observe_only'; +let reason = []; +let desiredGrid = gridIsOn ? 'on' : 'off'; +let desiredLatch = latchIsOn ? 'on' : 'off'; + +// ====================================================== +// Core hysteresis logic V1.1 +// ====================================================== +if (!hysteresisEnabled) { + state = 'hysteresis_disabled'; + desiredGrid = gridIsOn ? 'on' : 'off'; + desiredLatch = latchIsOn ? 'on' : 'off'; + reason.push('Automatisme hystérésis réseau désactivé'); + +} else if (soc <= gridSocOn) { + // Seuil bas atteint => prise de main immédiate + desiredGrid = 'on'; + desiredLatch = 'on'; + + if (!cooldownElapsed && !gridIsOn) { + state = 'turn_on_low_soc_cooldown_bypassed'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`); + } else if (!gridIsOn) { + state = 'turn_on_low_soc'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`); + } else if (!latchIsOn) { + state = 'adopt_existing_grid_on_low_soc'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`); + } else { + state = 'hold_on_low_soc'; + reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`); + } + +} else if (gridIsOn && !latchIsOn) { + // NOUVEAU CAS V1.1 : + // Le réseau est déjà ON mais ce workflow n'a pas la main. + if (soc < gridSocOff) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'adopt_existing_grid_on'; + reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`); + } else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_stray_grid_on'; + reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`); + } + +} else if (latchIsOn) { + // Tant que le latch est actif, on garde la main + if (!minOnElapsed) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'hold_on_min_runtime'; + reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`); + } else if (soc < gridSocOff) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'hold_on_until_soc_off'; + reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`); + } else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_recovered'; + reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`); + } + +} else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'observe_only'; + reason.push('Aucune action : hors zone d’hystérésis'); +} + +// ====================================================== +// Compare desired vs current +// ====================================================== +const gridAction = + desiredGrid === (gridIsOn ? 'on' : 'off') + ? 'none' + : desiredGrid === 'on' + ? 'turn_on' + : 'turn_off'; + +const latchAction = + desiredLatch === (latchIsOn ? 'on' : 'off') + ? 'none' + : desiredLatch === 'on' + ? 'turn_on' + : 'turn_off'; + +// ====================================================== +// Timestamps +// ====================================================== +// On pose un nouveau "last_on_at" si : +// - on allume réellement EDF +// - ou on adopte un EDF déjà ON avec latch OFF +const setLastOnAt = + gridAction === 'turn_on' || + state === 'adopt_existing_grid_on' || + state === 'adopt_existing_grid_on_low_soc'; + +// On pose "last_off_at" si on coupe réellement EDF +const setLastOffAt = + gridAction === 'turn_off'; + +// ====================================================== +// Context / alerts +// ====================================================== +reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`); +reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`); +reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`); +reason.push(`PV ${Math.round(pvPowerW)}W`); +reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`); +reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`); +reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`); + +if (minutesSinceLastOn !== null) { + reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`); +} +if (minutesSinceLastOff !== null) { + reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`); +} + +if (acOverSafeLimit) { + reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`); +} + +// ====================================================== +// Notifications +// ====================================================== +let notifyTitle = 'Victron Grid Hysteresis'; +let notifyMessage = ''; +let notifyWanted = false; + +if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') { + notifyTitle = '⚡ EDF activé sur SOC bas'; + notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`; + notifyWanted = true; + +} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') { + notifyTitle = '🔁 EDF ON repris par hystérésis'; + notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`; + notifyWanted = true; + +} else if (state === 'turn_off_recovered') { + notifyTitle = '✅ EDF coupé après recharge'; + notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`; + notifyWanted = true; + +} else if (state === 'turn_off_stray_grid_on') { + notifyTitle = '✅ EDF coupé (reprise de contrôle)'; + notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`; + notifyWanted = true; + +} else if (acOverSafeLimit && !gridIsOn) { + notifyTitle = '🚨 Charge AC élevée hors réseau'; + notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`; + notifyWanted = true; + +} else if (acOverSafeLimit && gridIsOn) { + notifyTitle = '⚠️ Charge AC élevée'; + notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`; + notifyWanted = true; +} + +const shortReason = reason.join(' | ').slice(0, 255); + +const signature = [ + now.date, + state, + `dg=${desiredGrid}`, + `dl=${desiredLatch}`, + `sb=${socBand}`, + `ac=${acOverSafeLimit ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== lastSignature; + +const result = { + timestamp_paris: now.stamp, + + inputs: { + soc_percent: soc, + grid_state: gridIsOn ? 'on' : 'off', + pv_power_w: pvPowerW, + house_ac_power_w: houseAcPowerW, + grid_power_w: gridPowerW, + evcs_ac_power_w: evcsAcPowerW, + evcs_charging: evcsCharging, + evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null, + hysteresis_enabled: hysteresisEnabled, + notifications_enabled: notificationsEnabled, + grid_latch: latchIsOn, + grid_soc_on: gridSocOn, + grid_soc_off: gridSocOff, + grid_min_on_minutes: gridMinOnMinutes, + grid_cooldown_minutes: gridCooldownMinutes, + ac_loads_max_safe_w: acLoadsMaxSafeW, + last_on_at: lastOnAt || null, + last_off_at: lastOffAt || null, + last_signature: lastSignature || null + }, + + computed: { + desired_grid: desiredGrid, + desired_latch: desiredLatch, + minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null, + minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null, + min_on_elapsed: minOnElapsed, + cooldown_elapsed: cooldownElapsed, + ac_over_safe_limit: acOverSafeLimit, + evcs_seems_active: evcsSeemsActive + }, + + decision: { + state, + grid_action: gridAction, + latch_action: latchAction, + set_last_on_at: setLastOnAt, + set_last_off_at: setLastOffAt, + should_notify: notificationsEnabled && signatureChanged && notifyWanted, + signature, + signature_changed: signatureChanged, + reason, + short_reason: shortReason + }, + + ha_service_calls: { + helper_state: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_state', + value: state + } + }, + + helper_reason: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_reason', + value: shortReason || 'Aucune raison' + } + }, + + helper_signature: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_last_signature', + value: signature + } + }, + + helper_latch: { + domain: 'input_boolean', + service: desiredLatch === 'on' ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_grid_hysteresis_latch' + } + }, + + helper_last_on_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_grid_last_on_at', + datetime: now.stamp + } + }, + + helper_last_off_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_grid_last_off_at', + datetime: now.stamp + } + }, + + grid: { + domain: 'switch', + service: desiredGrid === 'on' ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.edf_general_ok_switch' + } + }, + + logbook: { + domain: 'logbook', + service: 'log', + data: { + name: 'Victron Grid Hysteresis', + message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`, + entity_id: 'switch.edf_general_ok_switch' + } + }, + + mobile_notification: { + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage, + data: { + push: { + "interruption-level": "time-sensitive", + sound: { name: "default" } + }, + ttl: 0, + priority: "high" + } + } + } + } +}; + +return [{ json: result }]; + diff --git a/decoupesurplus/V2/gridhystP1.js b/decoupesurplus/V2/gridhystP1.js new file mode 100644 index 0000000..ba34e83 --- /dev/null +++ b/decoupesurplus/V2/gridhystP1.js @@ -0,0 +1,233 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function getParisNowParts() { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date()) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + }; +} + +function parseLocalStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + return new Date(y, mo - 1, d, h, mi, se).getTime(); +} + +const cfg = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt' +}; + +const raw = $input.first().json; +const input = extractSnapshot(raw); +const now = getParisNowParts(); +const nowMs = parseLocalStampToMs(now.stamp); + +const soc = clamp(num(input.soc_percent, 0), 0, 100); +const gridIsOn = low(input.grid_state || 'off') === 'on'; +const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); + +const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); +const gridPowerW = num(input.grid_power_w, 0); + +const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0)); +const evcsCharging = low(input.evcs_charging || 'off') === 'on'; +const evcsStatusRaw = num(input.evcs_status_raw, NaN); + +const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on'; +const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; +const latchIsOn = low(input.grid_latch || 'off') === 'on'; + +const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100); +const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100); +const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45)); +const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15)); +const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800)); + +const lastOnAt = (input.last_on_at ?? '').toString().trim(); +const lastOffAt = (input.last_off_at ?? '').toString().trim(); +const lastSignature = (input.last_signature ?? '').toString().trim(); + +const lastOnMs = parseLocalStampToMs(lastOnAt); +const lastOffMs = parseLocalStampToMs(lastOffAt); + +const minutesSinceLastOn = + lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null; + +const minutesSinceLastOff = + lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null; + +const minOnElapsed = + minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes; + +const cooldownElapsed = + minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes; + +const socBand = + soc <= gridSocOn ? 'low' : + soc < gridSocOff ? 'mid' : + 'high'; + +const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300; +const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW; + +let state = 'observe_only'; +let reason = []; +let desiredGrid = gridIsOn ? 'on' : 'off'; +let desiredLatch = latchIsOn ? 'on' : 'off'; + +// ====================================================== +// Core hysteresis logic V1.1 +// ====================================================== +if (!hysteresisEnabled) { + state = 'hysteresis_disabled'; + desiredGrid = gridIsOn ? 'on' : 'off'; + desiredLatch = latchIsOn ? 'on' : 'off'; + reason.push('Automatisme hystérésis réseau désactivé'); + +} else if (soc <= gridSocOn) { + // Seuil bas atteint => prise de main immédiate + desiredGrid = 'on'; + desiredLatch = 'on'; + + if (!cooldownElapsed && !gridIsOn) { + state = 'turn_on_low_soc_cooldown_bypassed'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`); + } else if (!gridIsOn) { + state = 'turn_on_low_soc'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`); + } else if (!latchIsOn) { + state = 'adopt_existing_grid_on_low_soc'; + reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`); + } else { + state = 'hold_on_low_soc'; + reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`); + } + +} else if (gridIsOn && !latchIsOn) { + // NOUVEAU CAS V1.1 : + // Le réseau est déjà ON mais ce workflow n'a pas la main. + if (soc < gridSocOff) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'adopt_existing_grid_on'; + reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`); + } else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_stray_grid_on'; + reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`); + } + +} else if (latchIsOn) { + // Tant que le latch est actif, on garde la main + if (!minOnElapsed) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'hold_on_min_runtime'; + reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`); + } else if (soc < gridSocOff) { + desiredGrid = 'on'; + desiredLatch = 'on'; + state = 'hold_on_until_soc_off'; + reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`); + } else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'turn_off_recovered'; + reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`); + } + +} else { + desiredGrid = 'off'; + desiredLatch = 'off'; + state = 'observe_only'; + reason.push('Aucune action : hors zone d’hystérésis'); +} + +// ====================================================== +// Compare desired vs current +// ====================================================== +const gridAction = + desiredGrid === (gridIsOn ? 'on' : 'off') + ? 'none' + : desiredGrid === 'on' + ? 'turn_on' + : 'turn_off'; + +const latchAction = + desiredLatch === (latchIsOn ? 'on' : 'off') + ? 'none' + : desiredLatch === 'on' + ? 'turn_on' + : 'turn_off'; diff --git a/decoupesurplus/V2/gridhystP2.js b/decoupesurplus/V2/gridhystP2.js new file mode 100644 index 0000000..0cd350d --- /dev/null +++ b/decoupesurplus/V2/gridhystP2.js @@ -0,0 +1,230 @@ +// ====================================================== +// Timestamps +// ====================================================== +// On pose un nouveau "last_on_at" si : +// - on allume réellement EDF +// - ou on adopte un EDF déjà ON avec latch OFF +const setLastOnAt = + gridAction === 'turn_on' || + state === 'adopt_existing_grid_on' || + state === 'adopt_existing_grid_on_low_soc'; + +// On pose "last_off_at" si on coupe réellement EDF +const setLastOffAt = + gridAction === 'turn_off'; + +// ====================================================== +// Context / alerts +// ====================================================== +reason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`); +reason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`); +reason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`); +reason.push(`PV ${Math.round(pvPowerW)}W`); +reason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`); +reason.push(`Puissance réseau ${Math.round(gridPowerW)}W`); +reason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`); + +if (minutesSinceLastOn !== null) { + reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`); +} +if (minutesSinceLastOff !== null) { + reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`); +} + +if (acOverSafeLimit) { + reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`); +} + +// ====================================================== +// Notifications +// ====================================================== +let notifyTitle = 'Victron Grid Hysteresis'; +let notifyMessage = ''; +let notifyWanted = false; + +if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') { + notifyTitle = '⚡ EDF activé sur SOC bas'; + notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`; + notifyWanted = true; + +} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') { + notifyTitle = '🔁 EDF ON repris par hystérésis'; + notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`; + notifyWanted = true; + +} else if (state === 'turn_off_recovered') { + notifyTitle = '✅ EDF coupé après recharge'; + notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`; + notifyWanted = true; + +} else if (state === 'turn_off_stray_grid_on') { + notifyTitle = '✅ EDF coupé (reprise de contrôle)'; + notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`; + notifyWanted = true; + +} else if (acOverSafeLimit && !gridIsOn) { + notifyTitle = '🚨 Charge AC élevée hors réseau'; + notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`; + notifyWanted = true; + +} else if (acOverSafeLimit && gridIsOn) { + notifyTitle = '⚠️ Charge AC élevée'; + notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`; + notifyWanted = true; +} + +const shortReason = reason.join(' | ').slice(0, 255); + +const signature = [ + now.date, + state, + `dg=${desiredGrid}`, + `dl=${desiredLatch}`, + `sb=${socBand}`, + `ac=${acOverSafeLimit ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== lastSignature; + +const result = { + timestamp_paris: now.stamp, + + inputs: { + soc_percent: soc, + grid_state: gridIsOn ? 'on' : 'off', + pv_power_w: pvPowerW, + house_ac_power_w: houseAcPowerW, + grid_power_w: gridPowerW, + evcs_ac_power_w: evcsAcPowerW, + evcs_charging: evcsCharging, + evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null, + hysteresis_enabled: hysteresisEnabled, + notifications_enabled: notificationsEnabled, + grid_latch: latchIsOn, + grid_soc_on: gridSocOn, + grid_soc_off: gridSocOff, + grid_min_on_minutes: gridMinOnMinutes, + grid_cooldown_minutes: gridCooldownMinutes, + ac_loads_max_safe_w: acLoadsMaxSafeW, + last_on_at: lastOnAt || null, + last_off_at: lastOffAt || null, + last_signature: lastSignature || null + }, + + computed: { + desired_grid: desiredGrid, + desired_latch: desiredLatch, + minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null, + minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null, + min_on_elapsed: minOnElapsed, + cooldown_elapsed: cooldownElapsed, + ac_over_safe_limit: acOverSafeLimit, + evcs_seems_active: evcsSeemsActive + }, + + decision: { + state, + grid_action: gridAction, + latch_action: latchAction, + set_last_on_at: setLastOnAt, + set_last_off_at: setLastOffAt, + should_notify: notificationsEnabled && signatureChanged && notifyWanted, + signature, + signature_changed: signatureChanged, + reason, + short_reason: shortReason + }, + + ha_service_calls: { + helper_state: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_state', + value: state + } + }, + + helper_reason: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_reason', + value: shortReason || 'Aucune raison' + } + }, + + helper_signature: { + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.victron_grid_control_last_signature', + value: signature + } + }, + + helper_latch: { + domain: 'input_boolean', + service: desiredLatch === 'on' ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.victron_grid_hysteresis_latch' + } + }, + + helper_last_on_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_grid_last_on_at', + datetime: now.stamp + } + }, + + helper_last_off_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_grid_last_off_at', + datetime: now.stamp + } + }, + + grid: { + domain: 'switch', + service: desiredGrid === 'on' ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.edf_general_ok_switch' + } + }, + + logbook: { + domain: 'logbook', + service: 'log', + data: { + name: 'Victron Grid Hysteresis', + message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`, + entity_id: 'switch.edf_general_ok_switch' + } + }, + + mobile_notification: { + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage, + data: { + push: { + "interruption-level": "time-sensitive", + sound: { name: "default" } + }, + ttl: 0, + priority: "high" + } + } + } + } +}; + +return [{ json: result }]; + diff --git a/decoupesurplus/V2/gridhystact.js b/decoupesurplus/V2/gridhystact.js new file mode 100644 index 0000000..d839644 --- /dev/null +++ b/decoupesurplus/V2/gridhystact.js @@ -0,0 +1,86 @@ +const root = $input.first().json; +const actions = []; + +// Toujours mettre à jour état + raison +actions.push({ + json: { + action: 'helper_state', + ...root.ha_service_calls.helper_state + } +}); + +actions.push({ + json: { + action: 'helper_reason', + ...root.ha_service_calls.helper_reason + } +}); + +// Action réseau si nécessaire +if (root.decision.grid_action !== 'none') { + actions.push({ + json: { + action: 'grid', + ...root.ha_service_calls.grid + } + }); +} + +// Action latch si nécessaire +if (root.decision.latch_action !== 'none') { + actions.push({ + json: { + action: 'helper_latch', + ...root.ha_service_calls.helper_latch + } + }); +} + +// Timestamps si nécessaire +if (root.decision.set_last_on_at) { + actions.push({ + json: { + action: 'helper_last_on_at', + ...root.ha_service_calls.helper_last_on_at + } + }); +} + +if (root.decision.set_last_off_at) { + actions.push({ + json: { + action: 'helper_last_off_at', + ...root.ha_service_calls.helper_last_off_at + } + }); +} + +// Signature + logbook si changement +if (root.decision.signature_changed) { + actions.push({ + json: { + action: 'helper_signature', + ...root.ha_service_calls.helper_signature + } + }); + + actions.push({ + json: { + action: 'logbook', + ...root.ha_service_calls.logbook + } + }); +} + +// Notification si utile +if (root.decision.should_notify) { + actions.push({ + json: { + action: 'mobile_notification', + ...root.ha_service_calls.mobile_notification + } + }); +} + +return actions; +