victron-tibber-ess-control

Charging and discharging of the battery on the basis of a dynamic electricity tariff, taking into account conversion losses and the expected solar yield.

Prerequisites

Victron GX device or Raspberry Pi running Venus OS Large!

Following nodes need to be installed:

  • @victronenergy/node-red-contrib-victron (preinstalled)
  • victron-vrm-api
  • node-red-dashboard
  • node-red-contrib-tibber-api
  • node-red-contrib-sunevents
  • node-red-contrib-power-saver
  • node-red-contrib-cron-plus
  • node-red-node-pushbullet (optional)

The “Victron Energy Client” must be activated under Configuration nodes!

Configuration

What needs to be configured?

Configuration nodes

Global settings node

  • VRM site id
  • Battery capacity,
  • Min and max desired SoC
  • Charge hours
  • Desired charging time window
  • charging strategy
  • Price per kwh for solar power
  • Efficiency of the inverter (conversion losses)
  • Efficiency of the charger (conversion losses)
  • Storage costs per kwh (wear and tear on the battery)

If everything is set correctly, the nodes “calculate average price”, “calculate solar excess / energy deficit” and “ESS control logic” should output a status text below. If this is not the case, the corresponding nodes are missing input parameters.

The price and schedule chart can be displayed as follows:: https://venus-os-ip:1881/ui/

Hints

The flow uses Tibber prices to determine the best possible time to charge the battery. In addition, the expected solar surplus, calculated from the predicted solar yield and predicted consumption, is also taken into account.

To charge the battery, the "SOC minimum discharge value" is set to the top or the ESS mode "Keep batteries charged" is set.

Discharging the battery is only permitted if this makes sense, taking into account conversion losses (inverter and charger). The predefined values of 0.90 and 0.92 are valid for Multiplus 48/3000 and 48/5000.

Everyone must decide for themselves whether storage costs should be included or not. The battery is already there anyway and will probably die of old age sooner than of wear and tear.

Support

If you need support or would like to share your suggestions, please use the Akkudoktor forum: https://akkudoktor.net/t/victron-tibber-node-red-flow-fur-automatisiertes-laden-und-entladen-des-akkus-best-price/15637

Changelog

  • v1.3.3 allow to set tibber home number
  • v1.3.2 fix bug in calculation of charge schedule
  • v1.3.1 fix average price calculation
  • v1.3.0 rework to be compatible with 15-minute market time units
  • v1.2.4 add price history, average price calculation fixes
  • v1.2.3 if multiplus is switched off and scheduled charging becomes active, switch multiplus on
  • v1.2.2 take forecast multipliers into account when calculating the solar surplus
  • v1.2.1 add "cover demand" charging strategy
  • v1.2.0 rewrite charge scheduling
  • v1.1.8 adapt for victron-vrm-api 0.2.1
  • v1.1.7 fix logic for "Hold" decision
  • v1.1.6 optimize discharge schedulung, add discharge history
  • v1.1.5 default to "Hold" in ess control logic
  • v1.1.4 rewrite discharge scheduling
  • v1.1.3 treat case of mains power loss
  • v1.1.2 fix edge case on surplus trigger, only notify when decision changes
  • v1.1.1 optimize surplus trigger, fix discharge hour calc when consumption fc data is unavailable
  • v1.1.0 add surplus detection, revise diagram to show actual unloading times
  • v1.0.11 switch Multiplus off overnight to save energy, switches on again automatically when necessary.
  • v1.0.10 determine hours with highest price to discharge the battery
  • v1.0.9 optimize max allowed price calculation
  • v1.0.8 remove paths for self consumption, causes more problems than it solves
  • v1.0.7 adapt for victron-vrm-api 0.0.10, use local time on vrm api nodes
  • v1.0.6 optimize solar excess calculation
  • v1.0.5 adapt for victron-vrm-api 0.0.9, fix missing energy data for transfer from previous day
  • v1.0.4 adapt for victron-vrm-api 0.0.8
  • v1.0.3 optimize average price calculation
  • v1.0.2 Fix problem where deglitch is blocking the whole flow
  • v1.0.1 fix error when solar to battery data does not exist
  • v1.0.0 Initial release
[{"id":"a16570fb695164b3","type":"tab","label":"Tibber Next","disabled":true,"info":"","env":[]},{"id":"bfb01b32332d83b7","type":"template","z":"a16570fb695164b3","name":"Current energy price","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"{\n  viewer {\n    homes {\n      currentSubscription {\n        priceInfo {\n          current {\n            total\n            startsAt\n          }\n          today {\n            total\n            startsAt\n          }\n          tomorrow {\n            total\n            startsAt\n          }\n        }\n      }\n    }\n  }\n}","output":"str","x":820,"y":100,"wires":[["bae75ea10d7f9e73"]]},{"id":"765fa29ce2481b05","type":"victron-output-ess","z":"a16570fb695164b3","service":"com.victronenergy.settings","path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","serviceObj":{"service":"com.victronenergy.settings","name":"Venus settings"},"pathObj":{"path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","type":"integer","name":"Minimum Discharge SOC (%)","mode":"both"},"name":"","onlyChanges":false,"x":3000,"y":460,"wires":[]},{"id":"104ebfa0ebbf06c7","type":"debug","z":"a16570fb695164b3","name":"tibber-query","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1250,"y":40,"wires":[]},{"id":"cf9f972416369b5c","type":"debug","z":"a16570fb695164b3","name":"Price Receiver","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1760,"y":40,"wires":[]},{"id":"249e06e3b1800663","type":"function","z":"a16570fb695164b3","name":"Generate chart data","func":"const slots_charge = msg.payload.schedule_charge.slots;\nconst slots_discharge = msg.payload.schedule_discharge.slots;\nconst slots_battery = msg.payload.history_battery_price.slots;\nconst slots_max_price = msg.payload.history_max_price.slots;\n\nconst series = [\"Charge\", \"Discharge\", \"Tibber Price\", \"Battery Price\", \"Max Price\"];\nconst labels = series;\nconst data = [[], [], [], [], []];\n\nfor (let i = 0; i < slots_charge.length; i++)\n{\n  if (slots_charge[i].onOff)\n  {\n      data[0][i] = { \"x\": slots_charge[i].start, \"y\": slots_charge[i].price};\n  }\n  else\n  {\n      data[0][i] = { \"x\": slots_charge[i].start, \"y\": 0};\n  }\n\n  // Tibber price\n  data[2][i] = { \"x\": slots_charge[i].start, \"y\": slots_charge[i].price};\n}\n\nfor (let i = 0; i < slots_discharge.length; i++)\n{\n  if (!slots_discharge[i].onOff)\n  {\n      data[1][i] = { \"x\": slots_discharge[i].start, \"y\": slots_discharge[i].price};\n  }\n  else\n  {\n      data[1][i] = { \"x\": slots_discharge[i].start, \"y\": 0};\n  }\n}\n\nfor (let i = 0; i < slots_battery.length; i++)\n{\n    data[3][i] = { \"x\": slots_battery[i].start, \"y\": slots_battery[i].price};\n}\n\nfor (let i = 0; i < slots_max_price.length; i++)\n{\n    data[4][i] = { \"x\": slots_max_price[i].start, \"y\": slots_max_price[i].price};\n}\n\nmsg.payload=[{\n    \"series\": series,\n    \"data\": data,\n    \"labels\": labels\n}];\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":3240,"y":140,"wires":[["0f7c6cb87e0a294d"]]},{"id":"965d952a579885dd","type":"function","z":"a16570fb695164b3","name":"max allowed price","func":"var getCurrentMarketTimeSlot = global.get(\"getCurrentMarketTimeSlot\");\n\nconst CHARGER_EFFICIENCY_FACTOR = flow.get('charger_efficiency_factor') || 0.90;\nconst INVERTER_EFFICIENCY_FACTOR = flow.get('inverter_efficiency_factor') || 0.92;\nconst STORAGE_COSTS = flow.get('storage_costs') || 0.00;\nconst NUM_SLOTS = flow.get('num_of_slots') || 96;\n\nlet price_max_allowed = 0.16;\nconst current_slot = getCurrentMarketTimeSlot();\nconst price_data = msg.payload.priceData;\n\nif (price_data.length >= NUM_SLOTS)\n{\n    const price_current = price_data[current_slot].value;\n    flow.set('price_current', price_current);\n\n    let price_low = price_data[current_slot].value;\n    let price_high = price_data[current_slot].value;\n\n    for (let i = current_slot; i < price_data.length; i++) \n    {\n        if (price_data[i].value < price_low)\n        {\n            price_low = price_data[i].value;\n        }\n        \n        if (price_data[i].value > price_high)\n        {\n            price_high = price_data[i].value;\n        }\n    }\n    \n    price_max_allowed = price_high * CHARGER_EFFICIENCY_FACTOR * INVERTER_EFFICIENCY_FACTOR - STORAGE_COSTS;\n    \n    flow.set('price_low', price_low);\n    flow.set('price_high', price_high);\n    flow.set('price_max_allowed', price_max_allowed);\n}\nelse\n{\n    node.warn('Tibber response contains less than ' + NUM_SLOTS + ' slots!');\n}\n\nmsg.payload = Number(price_max_allowed);\nnode.status({text: `${Math.round(msg.payload * 1000) / 1000} €`});\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1770,"y":100,"wires":[["aec84bf9c7901883","61dcee38ed37b2cd"]]},{"id":"811324469fd0c4a9","type":"function","z":"a16570fb695164b3","name":"ESS control logic","func":"const ESS_STATE_KEEP_CHARGED = 9;\nconst ESS_STATE_OPTIMIZED = 10;\n\nconst SWITCH_CHARGER_ONLY = 1;\nconst SWITCH_INVERTER_ONLY = 2;\nconst SWITCH_ON = 3;\nconst SWITCH_OFF = 4;\n\nconst GRID_LOST = 2;\n\nconst SCHEDULED_CHARGE_INACTIVE = 0;\nconst SCHEDULED_CHARGE_ACTIVE = 1;\n\nconst BAT_CAPACITY = flow.get('bat_capacity'); // kWh\nconst SOC_MIN = flow.get('bat_soc_min'); // %\nconst SOC_MAX = flow.get('bat_soc_max'); // %\nconst CHARGE_STRATEGY = flow.get('charge_strategy'); // %\nconst SOLARCHARGER_EFFICIENCY_FACTOR = flow.get('solarcharger_efficiency_factor');\n\nconst BAT_SOC = msg.payload.soc; // %\nconst BAT_CURRENT = msg.payload.battery_current; // A\n\nconst solar_excess = msg.payload.solar_excess * SOLARCHARGER_EFFICIENCY_FACTOR;\n\nlet energy_deficit = msg.payload.energy_deficit;\nif (energy_deficit <= 0)\n{\n    energy_deficit = 0;\n}\n\nconst SOLAR_EXCESS_PERC = Math.round(((solar_excess / BAT_CAPACITY) * 100) * 100) / 100; // %\nconst ENERGY_DEFICIT_PERC = Math.round((((energy_deficit) / BAT_CAPACITY) * 100) * 100) / 100; // %\nconst soc_pv_limit = SOC_MAX - SOLAR_EXCESS_PERC;\n\nlet switch_position = msg.payload.switch_position;\n\nfunction setSwitchPosition(value)\n{\n    if (switch_position != value)\n    {\n        if (-1 <= BAT_CURRENT && BAT_CURRENT <= 1)\n        {\n            switch_position = value;\n        }\n        else\n        {\n            node.warn(\"Not switching mode because of battery current: \" + Math.round(BAT_CURRENT * 100) / 100 + \" A\")\n        }\n    }\n}\n\nconst d = new Date();\nlet hour = d.getHours();\n\nlet is_daytime = flow.get('is_daytime');\nif(typeof is_daytime != 'boolean') is_daytime = (hour >= 8 && hour < 20);\n\n// prices\nconst price_current = flow.get('price_current') || 0; // Eur\nconst price_low = flow.get('price_low') || 0; // Eur\nconst price_discharge_allowed = msg.payload.price_discharge_allowed; // Eur\n\nconst remaining_energy = Math.round((BAT_CAPACITY * BAT_SOC / 100) * 100) / 100; // kWh\nflow.set('remaining_energy', remaining_energy);\n\nconst missing_energy = Math.round((BAT_CAPACITY - remaining_energy) * 100) / 100; // kWh\nflow.set('missing_energy', missing_energy);\n\n// default values\nlet adaptive_soc = msg.payload.adaptive_soc; // %\nlet decision = 'Default';\n\nif (msg.payload.grid_lost_alarm == GRID_LOST)\n{\n    adaptive_soc = SOC_MIN;\n    decision = 'Grid lost';\n    setSwitchPosition(SWITCH_ON);\n}\nelse if (msg.payload.scheduled_charge == SCHEDULED_CHARGE_ACTIVE)\n{\n    adaptive_soc = BAT_SOC;\n    decision = 'Scheduled charge';\n    setSwitchPosition(SWITCH_CHARGER_ONLY);\n}\nelse if (price_current < 0)\n{\n    adaptive_soc = SOC_MAX;\n    decision = 'Charge full';\n    setSwitchPosition(SWITCH_ON);\n}\nelse\n{\n    if (msg.payload.charge)\n    {\n        let soc_target = BAT_SOC;\n\n        if (CHARGE_STRATEGY == 2)\n        {\n            // Cover demand\n            soc_target = BAT_SOC + ENERGY_DEFICIT_PERC;\n            if (soc_target > soc_pv_limit)\n            {\n                soc_target = soc_pv_limit;\n            }\n        }\n        else\n        {\n            // Charge full\n            soc_target = soc_pv_limit;\n        }\n\n        if (BAT_SOC < soc_target)\n        {\n            adaptive_soc = soc_target;\n            decision = 'Charge';\n            setSwitchPosition(SWITCH_ON);\n        }\n        else\n        {\n            adaptive_soc = BAT_SOC;\n            decision = 'Charge (Hold)';\n        }\n    }\n    else\n    {\n        if (msg.payload.discharge && \n            price_current > price_discharge_allowed &&\n            Math.round(BAT_SOC) > SOC_MIN)\n        {\n            adaptive_soc = SOC_MIN;\n            decision = 'Discharge';\n            setSwitchPosition(SWITCH_ON);\n        }\n        else if (msg.payload.surplus == true || \n                 msg.payload.solarcharger_limited == true)\n        {\n            if(adaptive_soc >= BAT_SOC - 5)\n            {\n                adaptive_soc = BAT_SOC - 5;\n            }\n            decision = 'Surplus';\n            setSwitchPosition(SWITCH_ON);\n        }\n        else \n        {\n            adaptive_soc = BAT_SOC;\n            decision = 'Hold';\n            // Try to save some energy\n            if ((!is_daytime) && BAT_SOC >= SOC_MIN)\n            {\n                setSwitchPosition(SWITCH_OFF);\n            }\n        }\n    }\n}\n\n// ensure we never get out of bounds\nif (adaptive_soc < SOC_MIN) { adaptive_soc = SOC_MIN; }\nif (adaptive_soc > SOC_MAX) { adaptive_soc = SOC_MAX; }\n\n// set ess state based on desired soc\nlet ess_state = ESS_STATE_OPTIMIZED;\nif (adaptive_soc >= SOC_MAX)\n{\n    ess_state = ESS_STATE_KEEP_CHARGED;\n}\n\nflow.set('decision', decision);\n\nnode.status({text:decision + \" | \" + Math.round(BAT_SOC * 1000) / 1000 + \"% -> \" + Math.round(adaptive_soc * 1000) / 1000 + \"% (Lim: \" + Math.round(soc_pv_limit * 1000) / 1000 + \"%) | Dis: \" + Math.round(price_discharge_allowed * 1000) / 1000  + \" € | Cur: \" + Math.round(price_current * 1000) / 1000 + \" €\"});\n\nreturn [\n    {'payload': adaptive_soc},\n    {'payload': ess_state},\n    {'payload': switch_position},\n    {'payload': {\n            'adaptive_soc': adaptive_soc,\n            'decision': decision\n        }\n    }\n];","outputs":4,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":2170,"y":600,"wires":[["038d1b6b60a65fa1","a05e2bf4fa496c37"],["a148335726bc47f7","37920e3b413f20a6"],["8c640d6bdcf16e9b"],["14c43ca20b6df735","b4257305ab19175e"]]},{"id":"f2a1aadfb5382d0a","type":"victron-input-system","z":"a16570fb695164b3","service":"com.victronenergy.system/0","path":"/Dc/Battery/Soc","serviceObj":{"service":"com.victronenergy.system/0","name":"Venus system"},"pathObj":{"path":"/Dc/Battery/Soc","type":"float","name":"Battery State of Charge (%)"},"name":"Get SoC","onlyChanges":false,"x":300,"y":860,"wires":[["ba1ee048d8a77fbd"]]},{"id":"ba1ee048d8a77fbd","type":"function","z":"a16570fb695164b3","name":"Verify and store SoC","func":"if (msg.payload > 0)\n{\n    flow.set('victron_soc', msg.payload);\n    node.status({text:\"\" + msg.payload + \" %\"});\n    return msg;\n}\nelse\n{\n    return null;\n}","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":860,"wires":[["afaa39b0c91003f7"]]},{"id":"8e8499c146f49046","type":"change","z":"a16570fb695164b3","name":"topic = prices","rules":[{"t":"set","p":"topic","pt":"msg","to":"prices","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1760,"y":160,"wires":[["c8453dca19fb8ba1","eaa3c79a94a095ce","d08be398b1f9fc5f"]]},{"id":"c8453dca19fb8ba1","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"3","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":2170,"y":100,"wires":[["4092a6dcbd39a896","b9058d0eaa993f17"]]},{"id":"aec84bf9c7901883","type":"change","z":"a16570fb695164b3","name":"topic = max_price","rules":[{"t":"set","p":"topic","pt":"msg","to":"max_price","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1990,"y":100,"wires":[["c8453dca19fb8ba1"]]},{"id":"afaa39b0c91003f7","type":"change","z":"a16570fb695164b3","name":"topic = soc","rules":[{"t":"set","p":"topic","pt":"msg","to":"soc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":790,"y":860,"wires":[["6889369a9ded6701","17b3db566a76813e","4086a5a6e43e0104","eaa3c79a94a095ce","618f8b565f4c4d33"]]},{"id":"cdaa8247246ff7ca","type":"change","z":"a16570fb695164b3","name":"topic = solar_excess","rules":[{"t":"set","p":"topic","pt":"msg","to":"solar_excess","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1660,"y":640,"wires":[["6889369a9ded6701"]]},{"id":"c95d4af87c68c3f8","type":"change","z":"a16570fb695164b3","name":"topic = charge","rules":[{"t":"set","p":"topic","pt":"msg","to":"charge","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2680,"y":140,"wires":[["6889369a9ded6701"]]},{"id":"6889369a9ded6701","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"13","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1970,"y":640,"wires":[["811324469fd0c4a9","132321ea219ecde8"]]},{"id":"8da580bba351db9b","type":"inject","z":"a16570fb695164b3","name":"Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":"10","topic":"","payload":"","payloadType":"date","x":100,"y":160,"wires":[["f356803be03d48de"]]},{"id":"37920e3b413f20a6","type":"victron-output-ess","z":"a16570fb695164b3","service":"com.victronenergy.settings","path":"/Settings/CGwacs/BatteryLife/State","serviceObj":{"service":"com.victronenergy.settings","name":"Venus settings"},"pathObj":{"path":"/Settings/CGwacs/BatteryLife/State","type":"enum","name":"ESS state","enum":{"1":"BatteryLife enabled (GUI controlled)","2":"Optimized Mode /w BatteryLife: self consumption","3":"Optimized Mode /w BatteryLife: self consumption, SoC exceeds 85%","4":"Optimized Mode /w BatteryLife: self consumption, SoC at 100%","5":"Optimized Mode /w BatteryLife: SoC below dynamic SoC limit","6":"Optimized Mode /w BatteryLife: SoC has been below SoC limit for more than 24 hours. Charging the battery (5A)","7":"Optimized Mode /w BatteryLife: Inverter/Charger is in sustain mode","8":"Optimized Mode /w BatteryLife: recharging, SoC dropped by 5% or more below the minimum SoC","9":"'Keep batteries charged' mode is enabled","10":"Optimized mode w/o BatteryLife: self consumption, SoC at or above minimum SoC","11":"Optimized mode w/o BatteryLife: self consumption, SoC is below minimum SoC","12":"Optimized mode w/o BatteryLife: recharging, SoC dropped by 5% or more below minimum SoC"}},"initial":10,"name":"","onlyChanges":false,"x":2660,"y":540,"wires":[]},{"id":"a148335726bc47f7","type":"debug","z":"a16570fb695164b3","name":"ess_state","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":2600,"y":580,"wires":[]},{"id":"038d1b6b60a65fa1","type":"debug","z":"a16570fb695164b3","name":"adaptive_soc","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":2610,"y":500,"wires":[]},{"id":"f356803be03d48de","type":"function","z":"a16570fb695164b3","name":"Global settings","func":"// VRM API\nflow.set(\"siteId\", 123456); // VRM site id\n\n// Location\nflow.set('latitude', 50.313879);\nflow.set('longitude', 10.1747718);\n\n// General\nflow.set(\"home_number\", 0); // Tibber home number, Default = 0\nflow.set(\"pv_mainly_on_ac\", false);  // Set true if the vast majority of PV power is connected to AC.\nflow.set(\"num_of_slots\", 96); // Number of market time slots\n\n// Battery stats\nflow.set(\"bat_capacity\", 28.8);\nflow.set(\"bat_soc_min\", 20);\nflow.set(\"bat_soc_max\", 100);\n\n// Schedule settings\nflow.set(\"avg_charging_power\", 10); // kW\nflow.set(\"charge_hours_offset\", 0.5); // Time for balancing in h\nflow.set(\"max_charge_hours\", 4);\nflow.set(\"charge_after\", 21);\nflow.set(\"charge_before\", 15);\nflow.set(\"charge_strategy\", 2); // 1 = Charge full, 2 = Cover demand\n\n// Price per kwh solar power (Feed-in tariff)\nflow.set(\"price_pv\", 0.00); // €\n\n// Efficiency / conversion losses\nflow.set(\"charger_efficiency_factor\", 0.90);\nflow.set(\"solarcharger_efficiency_factor\", 0.98);\nflow.set(\"inverter_efficiency_factor\", 0.92);\n\n// Storage costs per kwh\nflow.set(\"storage_costs\", 0.00);\n\n// Forecast multipliers\nflow.set('solar_yield_fc_multiplier', 1.00);\nflow.set('consumption_fc_multiplier', 1.00);\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":320,"y":100,"wires":[["82033b2a8ef39e93"]]},{"id":"14c43ca20b6df735","type":"rbe","z":"a16570fb695164b3","name":"","func":"rbei","gap":"","start":"","inout":"out","septopics":true,"property":"payload.decision","topi":"topic","x":2590,"y":820,"wires":[["855b0ad14f6162ab"]]},{"id":"855b0ad14f6162ab","type":"function","z":"a16570fb695164b3","name":"Adaptive SoC message","func":"const adaptive_soc = msg.payload.adaptive_soc;\nconst decision = msg.payload.decision;\n\nconst soc = flow.get('victron_soc') || 0\nconst solar_forecast = flow.get('solar_forecast') || 0;\nconst solar_excess = flow.get('solar_excess') || 0;\nconst energy_deficit = flow.get('energy_deficit') || 0;\nconst remaining_energy = flow.get('remaining_energy') || 0;\nconst missing_energy = flow.get('missing_energy') || 0;\nconst price_current = flow.get('price_current') || 0;\nconst average_price = flow.get('average_price') || 0;\n\nmsg.headers = {};\nmsg.headers['tags'] = 'victron';\nmsg.headers['X-Title'] = 'Tibber';\n\nmsg.payload = \"Remaining: \" + remaining_energy + \" kWh\\n\" +\n              \"Missing: \" + missing_energy + \" kWh\\n\" + \n              \"Forecast: \" + solar_forecast + \" kWh\\n\" +\n              \"Excess: \" + solar_excess + \" kWh\\n\" +\n              \"Energy deficit: \" + energy_deficit + \" kWh\\n\" +\n              \"Current SoC: \" + soc + \" %\\n\" +\n              \"Adaptive SoC: \" + Math.round(adaptive_soc * 1000) / 1000 + \" %\\n\" +\n              \"Price current: \" + Math.round(price_current * 1000) / 1000 + \" Eur\\n\" +\n              \"Average price: \" + Math.round(average_price * 1000) / 1000 + \" Eur\\n\" +\n              \"Decision: \" + decision;\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":2850,"y":820,"wires":[["5928fb581cab5807"]]},{"id":"f56fdad182833a38","type":"function","z":"a16570fb695164b3","name":"Status message","func":"const soc = flow.get('victron_soc') || 0\nconst solar_forecast = flow.get('solar_forecast') || 0;\nconst solar_excess = flow.get('solar_excess') || 0;\nconst energy_deficit = flow.get('energy_deficit') || 0;\nconst charge_hours = flow.get('charge_hours') || 0;\nconst remaining_energy = flow.get('remaining_energy') || 0;\nconst missing_energy = flow.get('missing_energy') || 0;\nconst price_low = flow.get('price_low') || 0;\nconst price_high = flow.get('price_high') || 0;\nconst price_max_allowed = flow.get('price_max_allowed') || 0;\nconst average_price = flow.get('average_price') || 0;\nconst price_discharge_allowed = flow.get('price_discharge_allowed') || 0;\n\nmsg.headers = {};\nmsg.headers['tags'] = 'victron';\nmsg.headers['X-Title'] = 'Tibber';\n\nmsg.payload = \"Remaining: \" + remaining_energy + \" kWh\\n\" +\n              \"Missing: \" + missing_energy + \" kWh\\n\" + \n              \"Forecast: \" + solar_forecast + \" kWh\\n\" +\n              \"Excess: \" + solar_excess + \" kWh\\n\" +\n              \"Energy deficit: \" + energy_deficit + \" kWh\\n\" +\n              \"Charge hours: \" + charge_hours + \" h\\n\" +\n              \"Current SoC: \" + Math.round(soc * 100) / 100 + \" %\\n\" +\n              \"Price low: \" + Math.round(price_low * 1000) / 1000 + \" Eur\\n\" +\n              \"Price high: \" + Math.round(price_high * 1000) / 1000 + \" Eur\\n\" +\n              \"Price max allowed: \" + Math.round(price_max_allowed * 1000) / 1000 + \" Eur\\n\" +\n              \"Average price: \" + Math.round(average_price * 1000) / 1000 + \" Eur\\n\" +\n              \"Price discharge allowed: \" + Math.round(price_discharge_allowed * 1000) / 1000 + \" Eur\";\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":2820,"y":860,"wires":[["5928fb581cab5807"]]},{"id":"a22ddda7bd2a4a7b","type":"victron-input-solarcharger","z":"a16570fb695164b3","service":"com.victronenergy.solarcharger/0","path":"/MppOperationMode","serviceObj":{"service":"com.victronenergy.solarcharger/0","name":"MPPT 450/100 HQ2220WRFXJ"},"pathObj":{"path":"/MppOperationMode","type":"enum","name":"MPP operation mode","enum":{"0":"Off","1":"Voltage or current limited","2":"MPPT Tracker active","255":"Not available"}},"initial":"","name":"","onlyChanges":false,"x":440,"y":1620,"wires":[["41a7b9748af12e6f"]]},{"id":"623a017034897c36","type":"victron-input-solarcharger","z":"a16570fb695164b3","service":"com.victronenergy.solarcharger/279","path":"/MppOperationMode","serviceObj":{"service":"com.victronenergy.solarcharger/279","name":"MPPT 150/35 HQ2151AXHZN"},"pathObj":{"path":"/MppOperationMode","type":"enum","name":"MPP operation mode","enum":{"0":"Off","1":"Voltage or current limited","2":"MPPT Tracker active","255":"Not available"}},"initial":"","name":"","onlyChanges":false,"x":440,"y":1560,"wires":[["3470bcdeb53aaa1a"]]},{"id":"3470bcdeb53aaa1a","type":"change","z":"a16570fb695164b3","name":"topic = solarcharger_1","rules":[{"t":"set","p":"topic","pt":"msg","to":"solarcharger_1","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":790,"y":1560,"wires":[["4086a5a6e43e0104"]]},{"id":"41a7b9748af12e6f","type":"change","z":"a16570fb695164b3","name":"topic = solarcharger_2","rules":[{"t":"set","p":"topic","pt":"msg","to":"solarcharger_2","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":790,"y":1620,"wires":[["4086a5a6e43e0104"]]},{"id":"0a2c8eb1e66241d6","type":"function","z":"a16570fb695164b3","name":"Check Solarcharger limited","func":"if (msg.payload.soc >= 90 \n    && (msg.payload.solarcharger_1 == 1 || msg.payload.solarcharger_2 == 1))\n{\n    msg.payload = true;\n}\nelse\n{\n    msg.payload = false;\n}\n\nnode.status({text:\"Limited: \" + msg.payload});\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1230,"y":1560,"wires":[["9ccfc3d967d2ca9a","27e94ea299dac9f7"]]},{"id":"4086a5a6e43e0104","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"3","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1000,"y":1560,"wires":[["0a2c8eb1e66241d6"]]},{"id":"62031b24b798201f","type":"change","z":"a16570fb695164b3","name":"topic = solarcharger_limited","rules":[{"t":"set","p":"topic","pt":"msg","to":"solarcharger_limited","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1730,"y":1560,"wires":[["6889369a9ded6701","17b3db566a76813e","b5a07d47b8f5094c"]]},{"id":"5f4afc93549f7823","type":"victron-input-ess","z":"a16570fb695164b3","service":"com.victronenergy.settings","path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","serviceObj":{"service":"com.victronenergy.settings","name":"Venus settings"},"pathObj":{"path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","type":"integer","name":"Minimum Discharge SOC (%)"},"name":"","onlyChanges":false,"x":410,"y":1680,"wires":[["168392f8371cf996"]]},{"id":"7489348de4fd71f9","type":"function","z":"a16570fb695164b3","name":"Surplus trigger","func":"if (msg.payload.solarcharger_limited == true && \n    msg.payload.soc - msg.payload.min_discharge_soc < 5)\n{\n    let now = new Date();\n    msg.payload = now.getTime();\n    node.status({text:\"Trigger\"});\n    return msg;\n}\n\nnode.status({text:\"Do nothing\"});\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2160,"y":1680,"wires":[["1c0cb79bfddfed91"]]},{"id":"17b3db566a76813e","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"4","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1960,"y":1680,"wires":[["7489348de4fd71f9","8314ee95672c531e"]]},{"id":"168392f8371cf996","type":"change","z":"a16570fb695164b3","name":"topic = min_discharge_soc","rules":[{"t":"set","p":"topic","pt":"msg","to":"min_discharge_soc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":810,"y":1680,"wires":[["17b3db566a76813e"]]},{"id":"b70839399abcb34b","type":"function","z":"a16570fb695164b3","name":"calculate average price","func":"var getSlotFromDate = global.get(\"getSlotFromDate\");\n\nconst NUM_SLOTS = flow.get('num_of_slots') || 96;\nconst SOC_MIN = flow.get('bat_soc_min'); // %\nconst BAT_CAPACITY = flow.get('bat_capacity'); // kWh\nconst PRICE_PV = flow.get('price_pv'); // Eur\n\nconst CHARGER_EFFICIENCY_FACTOR = flow.get('charger_efficiency_factor') || 0.90;\nconst INVERTER_EFFICIENCY_FACTOR = flow.get('inverter_efficiency_factor') || 0.92;\nconst STORAGE_COSTS = flow.get('storage_costs') || 0.00;\n\nconst PV_MAINLY_ON_AC = flow.get('pv_mainly_on_ac') || false;\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst TOMORROW = new Date(new Date(TODAY).setHours(24));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\n\nconst dom_today = TODAY.getDate();\nconst dom_yesterday = YESTERDAY.getDate();\n\nconst average_price_template = Array(31).fill(0);\nconst average_price_days = flow.get('average_price_days', 'file') || average_price_template;\nconst average_price_initial = average_price_days[dom_yesterday] || 0;\n\nlet average_price = average_price_initial;\nlet kwh = 0;\nlet price_discharge_allowed = 0;\n\nif (\"bs\" in msg.payload.battery_soc.records && msg.payload.battery_soc.records.bs.length > 0)\n{\n    const soc_initial = msg.payload.battery_soc.records.bs[msg.payload.battery_soc.records.bs.length - 1][3] - SOC_MIN;\n\n    if (soc_initial > 0) {\n        kwh = BAT_CAPACITY / 100 * soc_initial;\n    }\n}\n\nconst tibber_slots = Array(NUM_SLOTS).fill(0);\nconst bat_to_consumption_slots = Array(NUM_SLOTS).fill(0);\nconst grid_to_consumption_slots = Array(NUM_SLOTS).fill(0);\nconst bat_to_grid_slots = Array(NUM_SLOTS).fill(0);\nconst grid_to_bat_slots = Array(NUM_SLOTS).fill(0);\nconst pv_to_bat_slots = Array(NUM_SLOTS).fill(0);\n\nfunction parseRecords(slots, records) {\n    for (let i = 0; i < records.length; i++)\n    {\n        const d = new Date(records[i][0]);\n        const slot = getSlotFromDate(d);\n        \n        if (d < TODAY)\n        {\n            if (slot == NUM_SLOTS-1)\n            {\n                // last slot of yesterday\n                slots[0] += records[i][1];\n            }\n        }\n        else\n        {\n            if (slot < NUM_SLOTS-1)\n            {\n                // today\n                slots[slot + 1] += records[i][1];\n            }\n        }\n    }\n}\n\nconst prices = msg.payload.prices;\nfor (let i = 0; i < prices.length; i++)\n{\n    const price = prices[i];\n    const d = new Date(price.start);\n    const slot = getSlotFromDate(d);\n\n    if (d < TODAY)\n    {\n        if (slot == NUM_SLOTS-1)\n        {\n            // last slot of yesterday\n            tibber_slots[0] += price.value;\n        }\n    }\n    else if (d < TOMORROW)\n    {\n        if (slot < NUM_SLOTS-1)\n        {\n            // today\n            tibber_slots[slot + 1] += price.value;\n        }\n    }\n}\n\nif (\"Bc\" in msg.payload.battery_direct_use.records)\n{\n    parseRecords(bat_to_consumption_slots, msg.payload.battery_direct_use.records.Bc);\n}\n\nif (\"Bg\" in msg.payload.battery_to_grid.records)\n{\n    parseRecords(bat_to_grid_slots, msg.payload.battery_to_grid.records.Bg);\n}\n\nif (\"Gc\" in msg.payload.grid_direct_use.records)\n{\n    parseRecords(grid_to_consumption_slots, msg.payload.grid_direct_use.records.Gc);\n}\n\nif (\"Gb\" in msg.payload.grid_to_battery.records)\n{\n    parseRecords(grid_to_bat_slots, msg.payload.grid_to_battery.records.Gb);\n}\n\nif (\"Pb\" in msg.payload.solar_to_battery.records)\n{\n    parseRecords(pv_to_bat_slots, msg.payload.solar_to_battery.records.Pb);\n}\n\n// calculate average price\nfor (let i = 0; i < NUM_SLOTS; i++)\n{\n    if (PV_MAINLY_ON_AC)\n    {\n        // reassign PV->BAT to PV->CONS for slots where GRID->BAT is present\n        if (grid_to_bat_slots[i] > 0.1)\n        {\n            if (grid_to_consumption_slots[i] >= pv_to_bat_slots[i])\n            {\n                grid_to_consumption_slots[i] -= pv_to_bat_slots[i];\n                grid_to_bat_slots[i] += pv_to_bat_slots[i];\n                pv_to_bat_slots[i] = 0;\n            }\n            else\n            {\n                pv_to_bat_slots[i] -= grid_to_consumption_slots[i];\n                grid_to_bat_slots[i] += grid_to_consumption_slots[i];\n                grid_to_consumption_slots[i] = 0;\n            }\n        }\n    }\n\n    // subtract energy discharged from the battery\n    kwh -= (bat_to_consumption_slots[i] + bat_to_grid_slots[i]);\n\n    // update average price\n    const cost = average_price * kwh + tibber_slots[i] * grid_to_bat_slots[i] + PRICE_PV * pv_to_bat_slots[i];\n    kwh += grid_to_bat_slots[i] + pv_to_bat_slots[i];\n    if (kwh > 0)\n    {\n        average_price = cost / kwh;\n    }\n    else\n    {\n        average_price = 0;\n    }\n}\nflow.set('average_price', average_price);\n\n// Store average price\naverage_price_days[dom_today] = average_price;\nflow.set('average_price_days', average_price_days, 'file');\n\n// Calculate the price at which discharging is permitted\nprice_discharge_allowed = average_price / (CHARGER_EFFICIENCY_FACTOR * INVERTER_EFFICIENCY_FACTOR) + STORAGE_COSTS;\nflow.set('price_discharge_allowed', price_discharge_allowed);\n\nmsg.payload = price_discharge_allowed;\n\nnode.status({text:`Avg: ${Math.round(average_price * 1000) / 1000} € (${Math.round(average_price_initial * 1000) / 1000} €) | Dis: ${Math.round(price_discharge_allowed * 1000) / 1000} €`});\n\nreturn [\n    {\n        payload: average_price\n    },\n    {\n        payload: price_discharge_allowed\n    }\n];","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1330,"y":560,"wires":[["969bc34cf984bcb6"],["a84c31ed9858c706"]]},{"id":"7f77310dd9dc401c","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"7","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1110,"y":560,"wires":[["b70839399abcb34b","f093bc328b44f3e1"]]},{"id":"a84c31ed9858c706","type":"change","z":"a16570fb695164b3","name":"topic = price_discharge_allowed","rules":[{"t":"set","p":"topic","pt":"msg","to":"price_discharge_allowed","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1690,"y":560,"wires":[["6889369a9ded6701","eaa3c79a94a095ce"]]},{"id":"57e0bc6187aeb518","type":"comment","z":"a16570fb695164b3","name":"Version: 1.3.3","info":"Last edited: 2025-09-06","x":110,"y":40,"wires":[]},{"id":"37eeb548048087c6","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"solar_yield_forecast","stats_interval":"15mins","show_instance":false,"stats_start":"0","stats_end":"86400","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":400,"y":740,"wires":[["9108789cfdfa7868"]]},{"id":"9108789cfdfa7868","type":"change","z":"a16570fb695164b3","name":"topic = solar_yield_fc","rules":[{"t":"set","p":"topic","pt":"msg","to":"solar_yield_fc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":740,"wires":[["618f8b565f4c4d33"]]},{"id":"618f8b565f4c4d33","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"3","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1110,"y":660,"wires":[["07fb1a4b0976515f","56a1dd46e67ef8bb"]]},{"id":"07fb1a4b0976515f","type":"function","z":"a16570fb695164b3","name":"calculate solar excess / energy deficit","func":"var getSlotFromDate = global.get(\"getSlotFromDate\");\n\nconst NUM_SLOTS = flow.get('num_of_slots') || 96;\nconst BAT_CAPACITY = flow.get('bat_capacity');\nconst SOC_MIN = flow.get('bat_soc_min');\nconst BAT_SOC = msg.payload.soc; // %\n\nconst INVERTER_EFFICIENCY_FACTOR = flow.get('inverter_efficiency_factor') || 0.92;\n\nconst AVG_CHARGING_POWER = flow.get(\"avg_charging_power\");\nconst CHARGE_HOURS_OFFSET = flow.get(\"charge_hours_offset\");\nconst MAX_CHARGE_HOURS = flow.get(\"max_charge_hours\");\n\nconst SOLAR_YIELD_FC_MULTIPLIER = flow.get('solar_yield_fc_multiplier');\nconst CONSUMPTION_FC_MULTIPLIER = flow.get('consumption_fc_multiplier');\n\nconst remaining_energy = (BAT_CAPACITY - (BAT_CAPACITY / 100 * SOC_MIN)) * BAT_SOC / 100; // kWh\n\nconst d = new Date();\nlet day = d.getDay();\n\nlet est_consumption_slots = Array(NUM_SLOTS).fill(0);\nlet solar_slots = Array(NUM_SLOTS).fill(0);\n\nfunction parseRecords(slots, records) {\n    for (let i = 0; i < records.length; i++) \n    {\n        const d = new Date(records[i][0]);\n        const slot = getSlotFromDate(d);\n        slots[slot] += records[i][1] / 1000; // kWh\n    }\n}\n\nparseRecords(est_consumption_slots, msg.payload.consumption_fc.records.vrm_consumption_fc);\nparseRecords(solar_slots, msg.payload.solar_yield_fc.records.solar_yield_forecast);\n\nconst est_consumption_total = Math.round((msg.payload.consumption_fc.totals.vrm_consumption_fc) / 1000 * 100 * CONSUMPTION_FC_MULTIPLIER) / 100; // kWh\nconst solar_total = Math.round((msg.payload.solar_yield_fc.totals.solar_yield_forecast) / 1000 * 100 * SOLAR_YIELD_FC_MULTIPLIER) / 100; // kWh\n\nlet solar_excess = 0;\nfor (let i = 0; i < solar_slots.length; i++)\n{\n    if (solar_slots[i] > est_consumption_slots[i])\n    {\n        solar_excess += (solar_slots[i] * SOLAR_YIELD_FC_MULTIPLIER) - (est_consumption_slots[i] * CONSUMPTION_FC_MULTIPLIER);\n    }\n}\nsolar_excess = Math.round(solar_excess * 100) / 100;\n\nlet uncovered_consumption = 0;\nfor (let i = 0; i < est_consumption_slots.length; i++)\n{\n    if (est_consumption_slots[i] > solar_slots[i])\n    {\n        uncovered_consumption += (est_consumption_slots[i] * CONSUMPTION_FC_MULTIPLIER) - (solar_slots[i] * SOLAR_YIELD_FC_MULTIPLIER);\n    }\n}\n\nlet energy_deficit = (Math.round(((remaining_energy * INVERTER_EFFICIENCY_FACTOR) - uncovered_consumption) * 100) / 100) * -1;\n\nlet charge_hours = 0;\nif (energy_deficit > 0)\n{\n    charge_hours = Math.ceil(energy_deficit / AVG_CHARGING_POWER + CHARGE_HOURS_OFFSET);\n}\n\nif (charge_hours > MAX_CHARGE_HOURS)\n{\n    charge_hours = MAX_CHARGE_HOURS;\n}\n\nflow.set('solar_forecast', solar_total);\nflow.set('solar_excess', solar_excess);\nflow.set('energy_deficit', energy_deficit);\nflow.set('charge_hours', charge_hours);\n\nnode.status({text: `Con: ${est_consumption_total} kWh | Sol: ${solar_total} kWh | Exc/Def: ${solar_excess}/${energy_deficit} kWh`});\n\nreturn [\n    {\n        payload: charge_hours\n    },\n    {\n        payload: solar_excess\n    },\n    {\n        payload: energy_deficit\n    }\n];","outputs":3,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1370,"y":620,"wires":[["6682b7bf7d049f5d"],["cdaa8247246ff7ca"],["4f501b249345225c"]]},{"id":"132321ea219ecde8","type":"debug","z":"a16570fb695164b3","name":"ess control logic input","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":2180,"y":680,"wires":[]},{"id":"4971c00737d70d00","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"vrm_consumption_fc","stats_interval":"15mins","show_instance":false,"stats_start":"0","stats_end":"86400","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":400,"y":800,"wires":[["a11a46a968589bf6"]]},{"id":"a11a46a968589bf6","type":"change","z":"a16570fb695164b3","name":"topic = consumption_fc","rules":[{"t":"set","p":"topic","pt":"msg","to":"consumption_fc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":800,"wires":[["618f8b565f4c4d33"]]},{"id":"708a6547b5832de5","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"Bc","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":340,"y":440,"wires":[["7cbc17fdae2449d4"]]},{"id":"7cbc17fdae2449d4","type":"change","z":"a16570fb695164b3","name":"topic = battery_direct_use","rules":[{"t":"set","p":"topic","pt":"msg","to":"battery_direct_use","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":440,"wires":[["7f77310dd9dc401c"]]},{"id":"4df5cfc986247580","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"Bg","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":340,"y":500,"wires":[["19f9b9135ef4b79d"]]},{"id":"3fcf5002915cea98","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"Pb","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":340,"y":680,"wires":[["f5ae2b831ff55c69"]]},{"id":"19f9b9135ef4b79d","type":"change","z":"a16570fb695164b3","name":"topic = battery_to_grid","rules":[{"t":"set","p":"topic","pt":"msg","to":"battery_to_grid","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":500,"wires":[["7f77310dd9dc401c"]]},{"id":"f5ae2b831ff55c69","type":"change","z":"a16570fb695164b3","name":"topic = solar_to_battery","rules":[{"t":"set","p":"topic","pt":"msg","to":"solar_to_battery","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":680,"wires":[["7f77310dd9dc401c"]]},{"id":"f093bc328b44f3e1","type":"debug","z":"a16570fb695164b3","name":"calculate average price input","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1340,"y":520,"wires":[]},{"id":"82b24738161ceb01","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"bs","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eoy","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":340,"y":380,"wires":[["12f9c4909fce20a0"]]},{"id":"12f9c4909fce20a0","type":"change","z":"a16570fb695164b3","name":"topic = battery_soc","rules":[{"t":"set","p":"topic","pt":"msg","to":"battery_soc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":810,"y":380,"wires":[["7f77310dd9dc401c"]]},{"id":"58d5813f5fa23051","type":"comment","z":"a16570fb695164b3","name":"energy from yesterday","info":"energy carried over from the previous day","x":580,"y":380,"wires":[]},{"id":"cb63dfa838e65653","type":"comment","z":"a16570fb695164b3","name":"Please remove if no Victron Solar Charger is used","info":"","x":420,"y":1520,"wires":[]},{"id":"0fbd92f5ee80c9ad","type":"comment","z":"a16570fb695164b3","name":"Please remove if you do not use ntfy","info":"","x":2680,"y":780,"wires":[]},{"id":"a6f4f0e58b8b6fc4","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"Gb","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":350,"y":620,"wires":[["698a3a58b8e1b73a"]]},{"id":"698a3a58b8e1b73a","type":"change","z":"a16570fb695164b3","name":"topic = grid_to_battery","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_to_battery","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":620,"wires":[["7f77310dd9dc401c"]]},{"id":"56a1dd46e67ef8bb","type":"debug","z":"a16570fb695164b3","name":"solar yield forecast input","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1330,"y":680,"wires":[]},{"id":"f2d8be290d3a14f2","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"vrm_consumption_fc","stats_interval":"15mins","show_instance":false,"stats_start":"0","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":400,"y":260,"wires":[["3e06590676dd12b4"]]},{"id":"3e06590676dd12b4","type":"change","z":"a16570fb695164b3","name":"topic = consumption_fc","rules":[{"t":"set","p":"topic","pt":"msg","to":"consumption_fc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":260,"wires":[["eaa3c79a94a095ce"]]},{"id":"eaa3c79a94a095ce","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"5","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":2170,"y":200,"wires":[["5b5aab71d6240094","73d45d7c643b6049"]]},{"id":"0f104c810887a8a3","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"4","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":3050,"y":140,"wires":[["249e06e3b1800663"]]},{"id":"37db2056d2af0409","type":"change","z":"a16570fb695164b3","name":"topic = schedule_charge","rules":[{"t":"set","p":"topic","pt":"msg","to":"schedule_charge","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2710,"y":100,"wires":[["0f104c810887a8a3"]]},{"id":"8d3f2dc8e050965d","type":"change","z":"a16570fb695164b3","name":"topic = schedule_discharge","rules":[{"t":"set","p":"topic","pt":"msg","to":"schedule_discharge","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2720,"y":200,"wires":[["0f104c810887a8a3"]]},{"id":"b2de53d84465443d","type":"change","z":"a16570fb695164b3","name":"topic = discharge","rules":[{"t":"set","p":"topic","pt":"msg","to":"discharge","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2690,"y":240,"wires":[["6889369a9ded6701"]]},{"id":"ae1404f77b295f89","type":"victron-output-vebus","z":"a16570fb695164b3","service":"com.victronenergy.vebus/276","path":"/Mode","serviceObj":{"service":"com.victronenergy.vebus/276","name":"MultiPlus-II 48/5000/70-50"},"pathObj":{"path":"/Mode","type":"enum","name":"Switch Position","enum":{"1":"Charger Only","2":"Inverter Only","3":"On","4":"Off"},"mode":"both"},"name":"","onlyChanges":false,"x":2990,"y":600,"wires":[]},{"id":"8dc666e04f8768d5","type":"debug","z":"a16570fb695164b3","name":"switch_position","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":2900,"y":640,"wires":[]},{"id":"bfaccf69ef7587dc","type":"victron-input-vebus","z":"a16570fb695164b3","service":"com.victronenergy.vebus/276","path":"/Mode","serviceObj":{"service":"com.victronenergy.vebus/276","name":"MultiPlus-II 48/5000/70-50"},"pathObj":{"path":"/Mode","type":"enum","name":"Switch Position","enum":{"1":"Charger Only","2":"Inverter Only","3":"On","4":"Off"}},"initial":"","name":"","onlyChanges":false,"x":400,"y":1040,"wires":[["aa031772770c3b7d"]]},{"id":"de87d8dc68891998","type":"victron-input-system","z":"a16570fb695164b3","service":"com.victronenergy.system/0","path":"/Ac/Grid/L1/Power","serviceObj":{"service":"com.victronenergy.system/0","name":"Venus system"},"pathObj":{"path":"/Ac/Grid/L1/Power","type":"float","name":"Grid L1 (W)"},"name":"","onlyChanges":false,"x":360,"y":1300,"wires":[["545fa434dad0b086"]]},{"id":"3d3ea61034e4b06d","type":"victron-input-system","z":"a16570fb695164b3","service":"com.victronenergy.system/0","path":"/Ac/Grid/L2/Power","serviceObj":{"service":"com.victronenergy.system/0","name":"Venus system"},"pathObj":{"path":"/Ac/Grid/L2/Power","type":"float","name":"Grid L2 (W)"},"name":"","onlyChanges":false,"x":360,"y":1360,"wires":[["4c179d21708a2f8a"]]},{"id":"6eb656698d72a259","type":"victron-input-system","z":"a16570fb695164b3","service":"com.victronenergy.system/0","path":"/Ac/Grid/L3/Power","serviceObj":{"service":"com.victronenergy.system/0","name":"Venus system"},"pathObj":{"path":"/Ac/Grid/L3/Power","type":"float","name":"Grid L3 (W)"},"name":"","onlyChanges":false,"x":360,"y":1420,"wires":[["0fc20d51fc25960e"]]},{"id":"f58c4cd2f02bc4f5","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1740,"y":1360,"wires":[["fe5f76a39e5392b9"]]},{"id":"aa031772770c3b7d","type":"change","z":"a16570fb695164b3","name":"topic = switch_position","rules":[{"t":"set","p":"topic","pt":"msg","to":"switch_position","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":1040,"wires":[["f58c4cd2f02bc4f5","6889369a9ded6701","75bf346925fb998b","17b3db566a76813e"]]},{"id":"545fa434dad0b086","type":"change","z":"a16570fb695164b3","name":"topic = grid_l1","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_l1","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":690,"y":1300,"wires":[["4008d3afc10b7723"]]},{"id":"4c179d21708a2f8a","type":"change","z":"a16570fb695164b3","name":"topic = grid_l2","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_l2","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":690,"y":1360,"wires":[["4008d3afc10b7723"]]},{"id":"0fc20d51fc25960e","type":"change","z":"a16570fb695164b3","name":"topic = grid_l3","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_l3","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":690,"y":1420,"wires":[["4008d3afc10b7723"]]},{"id":"fe5f76a39e5392b9","type":"function","z":"a16570fb695164b3","name":"Surplus trigger","func":"const SWITCH_ON = 3;\n\nif (msg.payload.switch_position != SWITCH_ON\n    && msg.payload.surplus ==  true)\n{\n    let now = new Date();\n    msg.payload = now.getTime();\n    node.status({text:\"Trigger\"});\n    return msg;\n}\nelse\n{\n    node.status({text:\"Do nothing\"});\n}\nreturn null;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1930,"y":1360,"wires":[["ff572298b0736bba"]]},{"id":"4008d3afc10b7723","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"3","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":920,"y":1360,"wires":[["d9b12fc6217ee914"]]},{"id":"d9b12fc6217ee914","type":"function","z":"a16570fb695164b3","name":"Detect grid feed-in","func":"let grid_total = Math.round(msg.payload.grid_l1 + msg.payload.grid_l2 + msg.payload.grid_l3);\nlet feed_in = false;\n\nif (grid_total <= -100)\n{\n    feed_in = true;\n}\n\nnode.status({text:\"Grid: \" + grid_total + \" W | Feed-In: \" + feed_in});\n\nreturn [\n    {\n        payload: feed_in\n    }\n];","outputs":2,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":1120,"y":1360,"wires":[["4581c7c02253953f","9b15697144dfcbe0"],[]]},{"id":"79c9d77f6a4c367c","type":"change","z":"a16570fb695164b3","name":"topic = surplus","rules":[{"t":"set","p":"topic","pt":"msg","to":"surplus","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1550,"y":1360,"wires":[["6889369a9ded6701","f58c4cd2f02bc4f5","193190c9cd5e97d8"]]},{"id":"b0a759cb094ee6b8","type":"change","z":"a16570fb695164b3","name":"topic = battery_current","rules":[{"t":"set","p":"topic","pt":"msg","to":"battery_current","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":1100,"wires":[["6889369a9ded6701"]]},{"id":"f3e66f8ae6cc989f","type":"victron-input-vebus","z":"a16570fb695164b3","service":"com.victronenergy.vebus/276","path":"/Dc/0/Current","serviceObj":{"service":"com.victronenergy.vebus/276","name":"MultiPlus-II 48/5000/70-50"},"pathObj":{"path":"/Dc/0/Current","type":"float","name":"Battery current (A)"},"initial":"","name":"","onlyChanges":false,"x":410,"y":1100,"wires":[["b0a759cb094ee6b8"]]},{"id":"7c8f8325514784e0","type":"comment","z":"a16570fb695164b3","name":"Surplus detection","info":"","x":320,"y":1260,"wires":[]},{"id":"8c640d6bdcf16e9b","type":"delay","z":"a16570fb695164b3","name":"","pauseType":"delay","timeout":"10","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":2600,"y":620,"wires":[["8dc666e04f8768d5","ae1404f77b295f89"]]},{"id":"a05e2bf4fa496c37","type":"rbe","z":"a16570fb695164b3","name":"","func":"rbe","gap":"","start":"","inout":"out","septopics":true,"property":"payload","topi":"topic","x":2590,"y":460,"wires":[["765fa29ce2481b05"]]},{"id":"4581c7c02253953f","type":"trigger","z":"a16570fb695164b3","name":"","op1":"","op2":"","op1type":"nul","op2type":"pay","duration":"30","extend":false,"overrideDelay":false,"units":"s","reset":"false","bytopic":"all","topic":"topic","outputs":1,"x":1340,"y":1320,"wires":[["79c9d77f6a4c367c"]]},{"id":"9ccfc3d967d2ca9a","type":"trigger","z":"a16570fb695164b3","name":"","op1":"","op2":"","op1type":"nul","op2type":"pay","duration":"30","extend":false,"overrideDelay":false,"units":"s","reset":"false","bytopic":"all","topic":"topic","outputs":1,"x":1480,"y":1520,"wires":[["62031b24b798201f"]]},{"id":"193190c9cd5e97d8","type":"debug","z":"a16570fb695164b3","name":"surplus","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":1750,"y":1400,"wires":[]},{"id":"b5a07d47b8f5094c","type":"debug","z":"a16570fb695164b3","name":"limited","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":1960,"y":1560,"wires":[]},{"id":"9b15697144dfcbe0","type":"function","z":"a16570fb695164b3","name":"filter true","func":"if (msg.payload == false)\n{\n    return msg;\n}\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1330,"y":1400,"wires":[["79c9d77f6a4c367c"]]},{"id":"27e94ea299dac9f7","type":"function","z":"a16570fb695164b3","name":"filter true","func":"if (msg.payload == false)\n{\n    return msg;\n}\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1470,"y":1580,"wires":[["62031b24b798201f"]]},{"id":"54febb9c9ea2ac0d","type":"victron-input-vebus","z":"a16570fb695164b3","service":"com.victronenergy.vebus/276","path":"/Alarms/GridLost","serviceObj":{"service":"com.victronenergy.vebus/276","name":"MultiPlus-II 48/5000/70-50"},"pathObj":{"path":"/Alarms/GridLost","type":"enum","name":"Grid lost alarm","enum":{"0":"Ok","2":"Alarm"}},"initial":"","name":"","onlyChanges":false,"x":400,"y":980,"wires":[["48219a8a711ce97e"]]},{"id":"48219a8a711ce97e","type":"change","z":"a16570fb695164b3","name":"topic = grid_lost_alarm","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_lost_alarm","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":980,"wires":[["75bf346925fb998b","6889369a9ded6701"]]},{"id":"75bf346925fb998b","type":"join","z":"a16570fb695164b3","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":1890,"y":1020,"wires":[["45b7ca0e2a79a12b"]]},{"id":"45b7ca0e2a79a12b","type":"function","z":"a16570fb695164b3","name":"Check if grid lost","func":"const GRID_LOST = 2;\nconst SWITCH_ON = 3;\n\nif (msg.payload.grid_lost_alarm == GRID_LOST &&\n    msg.payload.switch_position != SWITCH_ON)\n{\n    node.status({text:\"Turn Multiplus on\"});\n    msg.payload = SWITCH_ON;\n    return msg;\n}\nelse\n{\n    node.status({text:\"Do nothing\"});\n}\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2090,"y":1020,"wires":[["8c640d6bdcf16e9b"]]},{"id":"07116d8142388eb3","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"solar_yield_forecast","stats_interval":"15mins","show_instance":false,"stats_start":"0","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":400,"y":320,"wires":[["c76702b2dccd9c96"]]},{"id":"c76702b2dccd9c96","type":"change","z":"a16570fb695164b3","name":"topic = solar_yield_fc","rules":[{"t":"set","p":"topic","pt":"msg","to":"solar_yield_fc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":320,"wires":[["eaa3c79a94a095ce"]]},{"id":"5b5aab71d6240094","type":"function","z":"a16570fb695164b3","name":"calculate discharge schedule","func":"var getSlotFromDate = global.get(\"getSlotFromDate\");\nvar slotToIso = global.get(\"slotToIso\");\n\nconst CONTEXT = 'file';\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\nconst TOMORROW = new Date(new Date(TODAY).setHours(24));\n\nconst NUM_SLOTS = flow.get('num_of_slots') || 96;\nconst BAT_CAPACITY = flow.get('bat_capacity'); // kWh\nconst SOC_MIN = flow.get('bat_soc_min'); // %\nconst SOC_MAX = flow.get('bat_soc_max'); // %\nconst INVERTER_EFFICIENCY_FACTOR = flow.get('inverter_efficiency_factor');\nconst CHARGER_EFFICIENCY_FACTOR = flow.get('charger_efficiency_factor');\nconst SOLARCHARGER_EFFICIENCY_FACTOR = flow.get('solarcharger_efficiency_factor');\nconst PV_MAINLY_ON_AC = flow.get('pv_mainly_on_ac') || false;\nconst BAT_USABLE_CAPACITY = BAT_CAPACITY / 100 * (SOC_MAX - SOC_MIN);\nconst SOLAR_YIELD_FC_MULTIPLIER = flow.get('solar_yield_fc_multiplier');\nconst CONSUMPTION_FC_MULTIPLIER = flow.get('consumption_fc_multiplier');\nlet history = context.get('history', CONTEXT) || [];\n\nconst soc_bat = msg.payload.soc; // %\nconst price_data = msg.payload.prices.priceData;\nconst price_discharge_allowed = msg.payload.price_discharge_allowed;\n\nlet bat_current_capacity = 0;\nif(soc_bat > SOC_MIN)\n{\n    bat_current_capacity = BAT_CAPACITY / 100 * (soc_bat - SOC_MIN);\n}\n\nif(!('vrm_consumption_fc' in msg.payload.consumption_fc.records) ||\n   !('solar_yield_forecast' in msg.payload.solar_yield_fc.records))\n{\n    node.status({text: `Discharge: true | FC NOT AVAILABLE`});\n    node.warn('Victron forecasts are not available');\n    \n    return [\n        {\n            payload: {\n                slots: []\n            }\n        },\n        {\n            payload: true\n        }\n    ];    \n}\n\nconst consumption_fc = msg.payload.consumption_fc.records.vrm_consumption_fc;\nconst solar_yield_fc = msg.payload.solar_yield_fc.records.solar_yield_forecast;\n\nconst effective_consumption = [];\n\nfor (let i = 0; i < Math.min(consumption_fc.length, solar_yield_fc.length); i++)\n{\n    const date = new Date(consumption_fc[i][0]);\n    const slot = getSlotFromDate(date);\n\n    const start = slotToIso(date, slot);\n    const price = price_data[slot].value;\n\n    const consumption = consumption_fc[i][1] * CONSUMPTION_FC_MULTIPLIER;\n    const solar_yield = solar_yield_fc[i][1] * SOLAR_YIELD_FC_MULTIPLIER;\n    let value = (consumption - solar_yield) / 1000; // kWh\n    \n    if(value < 0)\n    {\n        if(PV_MAINLY_ON_AC)\n        {\n            value *= CHARGER_EFFICIENCY_FACTOR;\n        }\n        else\n        {\n            value *= SOLARCHARGER_EFFICIENCY_FACTOR;\n        }\n    }\n    else\n    {\n        value /= INVERTER_EFFICIENCY_FACTOR;\n    }\n\n    effective_consumption.push(\n        {\n            start,\n            value,\n            price,\n            onOff: true // no discharge\n        }\n    );\n}\n\nif(bat_current_capacity > BAT_USABLE_CAPACITY)\n{\n    bat_current_capacity = BAT_USABLE_CAPACITY;\n}\n\nfunction check_feasibility()\n{\n    let bat_cap_est = bat_current_capacity;\n\n    for(const entry of effective_consumption)\n    {\n        if((entry.value < 0) || (entry.onOff == false))\n        {\n            bat_cap_est -= entry.value;\n        }\n        if(bat_cap_est > BAT_USABLE_CAPACITY)\n        {\n            bat_cap_est = BAT_USABLE_CAPACITY;\n        }\n        if(bat_cap_est < 0)\n        {\n            return false;\n        }\n    }\n    \n    return true;\n}\n\nlet discharge = false;\n\nconst filtered_sorted_effective_consumption = Array.from(effective_consumption).sort((a, b) => {\n    return b.price - a.price;\n}).filter((entry) => {\n    return entry.price >= price_discharge_allowed;\n});\n\nfor(const entry of filtered_sorted_effective_consumption)\n{\n    entry.onOff = false; // discharge\n    \n    if(check_feasibility() == true)\n    {\n        const current_slot = getSlotFromDate(new Date());\n        if (current_slot == getSlotFromDate(entry.start)) {\n            discharge = true;\n        }\n    }\n    else\n    {\n        entry.onOff = true; // no discharge\n        break;\n    }\n}\n\n// add end of today entry so the schedule looks nicer\neffective_consumption.push(\n    {\n        start: TOMORROW.toISOString(),\n        value: 0,\n        price: (NUM_SLOTS in price_data) ? price_data[NUM_SLOTS].value : price_data[NUM_SLOTS-1].value,\n        onOff: true // no discharge\n    }\n);\n\n// remove entries older then yesterday from the history\nhistory = history.filter((entry) => {\n    return new Date(entry.start) >= YESTERDAY;\n});\n\n// add/update current/future hours in history\neffective_consumption.forEach((entry) => {\n    const i = history.findIndex((e) => e.start == entry.start);\n    if(i >= 0)\n    {\n        // update\n        history[i] = entry;\n    }\n    else\n    {\n        // add\n        history.push(entry);\n        \n    }\n});\n\ncontext.set('history', history, CONTEXT);\n\nnode.status({text: `Discharge: ${discharge}`});\n\nreturn [\n    {\n        payload: {\n            slots: history\n        }\n    },\n    {\n        payload: discharge\n    }\n];","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2380,"y":220,"wires":[["8d3f2dc8e050965d"],["b2de53d84465443d"]]},{"id":"73d45d7c643b6049","type":"debug","z":"a16570fb695164b3","name":"calculate discharge schedule input","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":2400,"y":180,"wires":[]},{"id":"3280eb28a72ee4c9","type":"link in","z":"a16570fb695164b3","name":"trigger","links":["ff572298b0736bba","1c0cb79bfddfed91"],"x":145,"y":220,"wires":[["f356803be03d48de"]]},{"id":"ff572298b0736bba","type":"link out","z":"a16570fb695164b3","name":"link out 1","mode":"link","links":["3280eb28a72ee4c9"],"x":2085,"y":1360,"wires":[]},{"id":"1c0cb79bfddfed91","type":"link out","z":"a16570fb695164b3","name":"link out 2","mode":"link","links":["3280eb28a72ee4c9"],"x":2305,"y":1680,"wires":[]},{"id":"0710c531c8af5b5b","type":"victron-input-ess","z":"a16570fb695164b3","service":"com.victronenergy.settings","path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","serviceObj":{"service":"com.victronenergy.settings","name":"Venus settings"},"pathObj":{"path":"/Settings/CGwacs/BatteryLife/MinimumSocLimit","type":"integer","name":"Minimum Discharge SOC (%)"},"name":"","onlyChanges":false,"x":410,"y":920,"wires":[["344175c0806a2f1c"]]},{"id":"344175c0806a2f1c","type":"change","z":"a16570fb695164b3","name":"topic = adaptive_soc","rules":[{"t":"set","p":"topic","pt":"msg","to":"adaptive_soc","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":920,"wires":[["6889369a9ded6701"]]},{"id":"4092a6dcbd39a896","type":"function","z":"a16570fb695164b3","name":"calculate charge schedule","func":"function toSlot(hours, minutes) {\n    return hours * 4 + Math.floor(minutes / 15);\n}\n\nvar getCurrentMarketTimeSlot = global.get(\"getCurrentMarketTimeSlot\");\nvar getSlotFromDate = global.get(\"getSlotFromDate\");\nvar slotToDate = global.get(\"slotToDate\");\n\nconst CONTEXT = 'file';\n\nconst CHARGE_AFTER_SLOT = toSlot(flow.get(\"charge_after\"), 0);\nconst CHARGE_BEFORE_SLOT = toSlot(flow.get(\"charge_before\"), 0);\n\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\nconst TOMORROW = new Date(new Date(TODAY).setHours(24));\nconst current_slot = getCurrentMarketTimeSlot();\n\nlet history = context.get('history', CONTEXT) || [];\n\nconst price_data = msg.payload.prices.priceData;\nconst max_price = msg.payload.max_price;\nconst charge_slots = msg.payload.charge_hours * 4;\n\n// remove entries older then yesterday from the history\nhistory = history.filter((entry) => {\n    return new Date(entry.start) >= YESTERDAY;\n});\n\n// add/update current/future prices in history\nprice_data.forEach((entry) => {\n    // don't touch hours that are in the past\n    if(Date.parse(entry.start) < new Date().setMinutes(0, 0, 0))\n    {\n        return;\n    }\n\n    const i = history.findIndex((e) => Date.parse(e.start) == Date.parse(entry.start));\n    if (i >= 0)\n    {\n        // update\n        history[i].price = entry.value;\n        history[i].onOff = false;\n    }\n    else\n    {\n        // add\n        history.push({\n            start: entry.start,\n            price: entry.value,\n            onOff: false\n        });\n    }\n});\n\nlet charge_after_day;\nlet charge_before_day;\n\nif (CHARGE_AFTER_SLOT < CHARGE_BEFORE_SLOT) // charge time does not cross day boundary\n{\n    if (current_slot > CHARGE_AFTER_SLOT && current_slot >= CHARGE_BEFORE_SLOT)\n    {\n        // charge time has already passed for today\n        charge_after_day = TOMORROW;\n        charge_before_day = TOMORROW;\n    }\n    else\n    {\n        // charge time is sometime today or we are inside it\n        charge_after_day = TODAY;\n        charge_before_day = TODAY;\n    }\n}\nelse // charge time crosses day boundary\n{\n    if (current_slot >= CHARGE_AFTER_SLOT)\n    {\n        // we are inside the charge time before midnight\n        charge_after_day = TODAY;\n        charge_before_day = TOMORROW;\n    }\n    else if (current_slot < CHARGE_BEFORE_SLOT)\n    {\n        // we are inside the charge time after midnight\n        charge_after_day = YESTERDAY;\n        charge_before_day = TODAY;\n    }\n    else\n    {\n        // next charge time is later today\n        charge_after_day = TODAY;\n        charge_before_day = TOMORROW;\n    }\n}\n\nconst charge_after_date = slotToDate(charge_after_day, CHARGE_AFTER_SLOT);\nconst charge_before_date = slotToDate(charge_before_day, CHARGE_BEFORE_SLOT);\n\nconst charge_after_idx = history.findIndex((e) => Date.parse(e.start) == charge_after_date.getTime());\nconst charge_before_idx = history.findIndex((e) => Date.parse(e.start) == charge_before_date.getTime());\n\nlet charge = false;\n\nif ((charge_after_idx >= 0) && (charge_before_idx >= 0))\n{\n    let best_start_idx = -1;\n    let duration = 0;\n    let best_price_sum = 1000000;\n    for (let consecutive_slots = charge_slots; consecutive_slots > 0 && duration == 0; consecutive_slots--)\n    {\n        for (let start_idx = charge_after_idx; start_idx <= (charge_before_idx - consecutive_slots); start_idx++)\n        {\n            let charge_allowed = true;\n            let price_sum = 0;\n            for (let i = start_idx; i < start_idx + consecutive_slots; i++)\n            {\n                const price = history[i].price;\n                price_sum += price;\n                if(price >= max_price)\n                {\n                    charge_allowed = false;\n                    break;\n                }\n            }\n            if (charge_allowed && price_sum < best_price_sum)\n            {\n                best_price_sum = price_sum;\n                duration = consecutive_slots;\n                best_start_idx = start_idx;\n            }\n        }\n    }\n    \n    if (best_start_idx >= 0)\n    {\n        for (let i = best_start_idx; i < best_start_idx + duration; i++)\n        {\n            // don't touch hours that are in the past\n            if(new Date(history[i].start) < slotToDate(TODAY, current_slot))\n            {\n                continue;\n            }\n            \n            history[i].onOff = true;\n\n            if (getSlotFromDate(history[i].start) == current_slot) {\n                charge = true;\n            }\n        }\n    }\n}\n\ncontext.set('history', history, CONTEXT);\n\nnode.status({text: `Charge: ${charge} | Charge slots: ${charge_slots}`});\n\nreturn [\n    {\n        payload: {\n            slots: history\n        }\n    },\n    {\n        payload: charge\n    }\n];","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2370,"y":120,"wires":[["37db2056d2af0409"],["c95d4af87c68c3f8"]]},{"id":"b9058d0eaa993f17","type":"debug","z":"a16570fb695164b3","name":"calculate charge schedule input","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":2390,"y":80,"wires":[]},{"id":"6682b7bf7d049f5d","type":"change","z":"a16570fb695164b3","name":"topic = charge_hours","rules":[{"t":"set","p":"topic","pt":"msg","to":"charge_hours","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1660,"y":600,"wires":[["c8453dca19fb8ba1"]]},{"id":"4f501b249345225c","type":"change","z":"a16570fb695164b3","name":"topic = energy_deficit","rules":[{"t":"set","p":"topic","pt":"msg","to":"energy_deficit","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1660,"y":680,"wires":[["6889369a9ded6701"]]},{"id":"f6b7c2696efe6025","type":"mqtt out","z":"a16570fb695164b3","name":"electricity_price","topic":"custom/electricity_price","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"eb7dbabef2e64fb0","x":2880,"y":1000,"wires":[]},{"id":"b4257305ab19175e","type":"function","z":"a16570fb695164b3","name":"publish electricity price","func":"const decision = flow.get('decision');\nconst soc = flow.get('victron_soc') | 0;\n\nlet price = 0;\nif (decision == \"Discharge\" && soc > 20)\n{\n    price = flow.get('average_price');\n} \nelse\n{\n    price = flow.get('price_current');\n}\n\nprice = Math.round(price * 100) / 100;\n\nnode.status({text: `Decision: ${decision} | Price: ${price} €`});\n\nmsg.payload = '{\"value\": \"' + price + '\"}';\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2640,"y":1000,"wires":[["f6b7c2696efe6025"]]},{"id":"969bc34cf984bcb6","type":"function","z":"a16570fb695164b3","name":"track battery price","func":"var getCurrentMarketTimeSlot = global.get(\"getCurrentMarketTimeSlot\");\nvar getSlotFromDate = global.get(\"getSlotFromDate\");\nvar slotToIso = global.get(\"slotToIso\");\n\nconst CONTEXT = 'file';\nconst HOUR = new Date(new Date().setMinutes(0, 0, 0));\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\nconst SLOT = getCurrentMarketTimeSlot();\n\nlet history = context.get('history', CONTEXT) || [];\n\n// remove entries older then yesterday from the history\nhistory = history.filter((entry) => {\n    return new Date(entry.start) >= YESTERDAY;\n});\n\n// add/update current hour in history\nconst entry = {\n    start: slotToIso(TODAY, SLOT),\n    price: msg.payload\n};\nconst next_entry = {\n    start: slotToIso(TODAY, SLOT + 1),\n    price: msg.payload\n};\n\n// remove current and future hours from history\nconst i = history.findIndex((e) => e.start == slotToIso(TODAY, SLOT));\nif(i >= 0)\n{\n    while(history.length > i)\n    {\n        history.pop();\n    }\n}\n\n// add current hour to history\nhistory.push(entry);\n// add next hour to history so we see a line in the dashboard and not just a dot\nhistory.push(next_entry);\n\ncontext.set('history', history, CONTEXT);\n\nreturn {\n    payload: {\n        slots: history\n    }\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1990,"y":340,"wires":[["fd8a9e291aa13b68"]]},{"id":"fd8a9e291aa13b68","type":"change","z":"a16570fb695164b3","name":"topic = history_battery_price","rules":[{"t":"set","p":"topic","pt":"msg","to":"history_battery_price","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2720,"y":340,"wires":[["0f104c810887a8a3"]]},{"id":"11033d3afb3390a2","type":"vrm-api","z":"a16570fb695164b3","vrm":"ce0b2bd85123a79a","name":"","api_type":"installations","idUser":"","idSite":"{{flow.siteId}}","installations":"stats","attribute":"Gc","stats_interval":"15mins","show_instance":false,"stats_start":"boy","stats_end":"eod","use_utc":false,"gps_start":"","gps_end":"","instance":"","vrm_id":"","b_max":"","tb_max":"","fb_max":"","tg_max":"","fg_max":"","b_cycle_cost":"","buy_price_formula":"","sell_price_formula":"","green_mode_on":true,"feed_in_possible":true,"feed_in_control_on":true,"b_goal_hour":"","b_goal_SOC":"","store_in_global_context":false,"verbose":false,"x":340,"y":560,"wires":[["0e6b780e746545dc"]]},{"id":"0e6b780e746545dc","type":"change","z":"a16570fb695164b3","name":"topic = grid_direct_use","rules":[{"t":"set","p":"topic","pt":"msg","to":"grid_direct_use","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":820,"y":560,"wires":[["7f77310dd9dc401c"]]},{"id":"862e87d4bb507a6a","type":"function","z":"a16570fb695164b3","name":"location","func":"return {\n    payload: {\n        latitude: flow.get('latitude'),\n        longitude: flow.get('longitude')\n    }\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":540,"y":40,"wires":[["ae6d84dc44505fc6"]]},{"id":"bce6a3062e21cc1f","type":"function","z":"a16570fb695164b3","name":"is_daytime","func":"let last_event;\n\nif (msg.payload.sunevent)\n{\n    last_event = msg.payload.sunevent;\n}\nelse\n{\n    last_event = msg.payload.sunevents[0].event_name;\n}\n\nconst is_daytime = last_event == 'sunriseEnd' ||\n                   last_event == 'goldenHourEnd' ||\n                   last_event == 'solarNoon' ||\n                   last_event == 'goldenHour';\n\nflow.set('is_daytime', is_daytime);\n\nnode.status({text: is_daytime});\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":990,"y":40,"wires":[[]]},{"id":"95d4de734f6977e3","type":"victron-input-custom","z":"a16570fb695164b3","service":"com.victronenergy.system/0","path":"/Control/ScheduledCharge","serviceObj":{"service":"com.victronenergy.system/0","name":"com.victronenergy.system (0)"},"pathObj":{"path":"/Control/ScheduledCharge","name":"/Control/ScheduledCharge","type":"number","value":1},"name":"","onlyChanges":false,"x":450,"y":1160,"wires":[["f98fcc308cd467a1"]]},{"id":"f98fcc308cd467a1","type":"change","z":"a16570fb695164b3","name":"topic = scheduled_charge","rules":[{"t":"set","p":"topic","pt":"msg","to":"scheduled_charge","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":830,"y":1160,"wires":[["6889369a9ded6701"]]},{"id":"8314ee95672c531e","type":"function","z":"a16570fb695164b3","name":"Check switch position","func":"const SWITCH_ON = 3;\n\nif (msg.payload.solarcharger_limited == true &&\n    msg.payload.switch_position != SWITCH_ON)\n{\n    node.status({text:\"Turn Multiplus on\"});\n    msg.payload = SWITCH_ON;\n    return msg;\n}\nelse\n{\n    node.status({text:\"Do nothing\"});\n}\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":2180,"y":1620,"wires":[["8c640d6bdcf16e9b"]]},{"id":"71cd20f3bb513b77","type":"comment","z":"a16570fb695164b3","name":"Reduce from 13 to 12 if Solar Charger removed","info":"","x":2100,"y":720,"wires":[]},{"id":"d08be398b1f9fc5f","type":"function","z":"a16570fb695164b3","name":"track price","func":"const CONTEXT = 'file';\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\n\nlet history = context.get('history', CONTEXT) || [];\n\nconst price_data = msg.payload.priceData;\n\n// filter out everything except yesterday\nhistory = history.filter((entry) => {\n    const d = new Date(entry.start);\n    return d >= YESTERDAY && d < TODAY;\n});\n\n// add today/tomorrow to history\nhistory.push(...price_data)\n\ncontext.set('history', history, CONTEXT);\n\nreturn {\n    payload: history,\n    topic: msg.topic\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1290,"y":400,"wires":[["7f77310dd9dc401c"]]},{"id":"61dcee38ed37b2cd","type":"function","z":"a16570fb695164b3","name":"track max price","func":"var getCurrentMarketTimeSlot = global.get(\"getCurrentMarketTimeSlot\");\nvar getSlotFromDate = global.get(\"getSlotFromDate\");\nvar slotToIso = global.get(\"slotToIso\");\n\nconst CONTEXT = 'file';\n\nconst HOUR = new Date(new Date().setMinutes(0, 0, 0));\nconst TODAY = new Date(new Date().setHours(0, 0, 0, 0));\nconst YESTERDAY = new Date(new Date(TODAY).setHours(-24));\nconst SLOT = getCurrentMarketTimeSlot();\n\nlet history = context.get('history', CONTEXT) || [];\n\n// remove entries older then yesterday from the history\nhistory = history.filter((entry) => {\n    return new Date(entry.start) >= YESTERDAY;\n});\n\n// add/update current hour in history\nconst entry = {\n    start: slotToIso(TODAY, SLOT),\n    price: msg.payload\n};\nconst next_entry = {\n    start: slotToIso(TODAY, SLOT + 1),\n    price: msg.payload\n};\n\n// remove current and future hours from history\nconst i = history.findIndex((e) => e.start == slotToIso(TODAY, SLOT));\nif(i >= 0)\n{\n    while(history.length > i)\n    {\n        history.pop();\n    }\n}\n\n// add current hour to history\nhistory.push(entry);\n// add next hour to history so we see a line in the dashboard and not just a dot\nhistory.push(next_entry);\n\ncontext.set('history', history, CONTEXT);\n\nreturn {\n    payload: {\n        slots: history\n    }\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1980,"y":300,"wires":[["9ecde3ed3de444d8"]]},{"id":"9ecde3ed3de444d8","type":"change","z":"a16570fb695164b3","name":"topic = history_max_price","rules":[{"t":"set","p":"topic","pt":"msg","to":"history_max_price","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2710,"y":300,"wires":[["0f104c810887a8a3"]]},{"id":"5928fb581cab5807","type":"http request","z":"a16570fb695164b3","name":"ntfy.sh","method":"POST","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[{"keyType":"other","keyValue":"Authorization","valueType":"other","valueValue":"Bearer "}],"x":3070,"y":860,"wires":[[]]},{"id":"78686ed1c4293fa2","type":"cronplus","z":"a16570fb695164b3","name":"Every quarter hour","outputField":"payload","timeZone":"","storeName":"","commandResponseMsgOutput":"output1","defaultLocation":"50.31687238278184 10.178041756153105","defaultLocationType":"fixed","outputs":1,"options":[{"name":"schedule1","topic":"topic1","payloadType":"default","payload":"","expressionType":"cron","expression":"10 */15 * * * * *","location":"","offset":"0","solarType":"all","solarEvents":"sunrise,sunset"}],"x":130,"y":100,"wires":[["f356803be03d48de"]]},{"id":"8093def21ac202f8","type":"cronplus","z":"a16570fb695164b3","name":"7:05 / 20:05","outputField":"payload","timeZone":"","storeName":"","commandResponseMsgOutput":"output1","defaultLocation":"50.316866217047334 10.178051143884659","defaultLocationType":"fixed","outputs":1,"options":[{"name":"schedule1","topic":"topic1","payloadType":"default","payload":"","expressionType":"cron","expression":"0 5 7,20 * * * *","location":"","offset":"0","solarType":"selected","solarEvents":"sunrise,sunset"}],"x":2610,"y":860,"wires":[["f56fdad182833a38"]]},{"id":"ae6d84dc44505fc6","type":"sun events","z":"a16570fb695164b3","testmode":"N","verbose":"N","topic":"","name":"","x":790,"y":40,"wires":[["bce6a3062e21cc1f"],["bce6a3062e21cc1f"]]},{"id":"bae75ea10d7f9e73","type":"tibber-query","z":"a16570fb695164b3","name":"","active":true,"apiEndpointRef":"073c94010f8c84e0","x":1030,"y":100,"wires":[["104ebfa0ebbf06c7","9be7bca9b5000ece"]]},{"id":"e188fef69e738b85","type":"ps-receive-price","z":"a16570fb695164b3","name":"Price Receiver","x":1560,"y":100,"wires":[["cf9f972416369b5c","8e8499c146f49046","965d952a579885dd"]]},{"id":"0f7c6cb87e0a294d","type":"ui_chart","z":"a16570fb695164b3","name":"","group":"ae94fc0e9881e8c0","order":7,"width":0,"height":0,"label":"Price & Schedule Chart","chartType":"line","legend":"true","xformat":"auto","interpolate":"step","nodata":"","dot":false,"ymin":"","ymax":"","removeOlder":"6","removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#ff0000","#0080ff","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"className":"","x":3490,"y":140,"wires":[[]]},{"id":"82033b2a8ef39e93","type":"function","z":"a16570fb695164b3","name":"Global functions","func":"function getCurrentMarketTimeSlot() {\n  const now = new Date();\n  const block = now.getHours() * 4 + Math.floor(now.getMinutes() / 15);\n  return block;\n}\nglobal.set(\"getCurrentMarketTimeSlot\", getCurrentMarketTimeSlot);\n\n\nfunction getSlotFromDate(d) {\n    const date = new Date(d);\n    return date.getHours() * 4 + Math.floor(date.getMinutes() / 15);\n}\nglobal.set(\"getSlotFromDate\", getSlotFromDate);\n\n\nfunction slotToDate(day, slot) {\n    const d = new Date(day);\n    const hours = Math.floor(slot / 4);\n    const minutes = (slot % 4) * 15;\n    d.setHours(hours, minutes, 0, 0);\n    return d;\n}\nglobal.set(\"slotToDate\", slotToDate);\n\n\nfunction slotToIso(baseDay, slot) {\n    const d = new Date(baseDay);\n    d.setHours(0, 0, 0, 0); // midnight\n    const minutes = slot * 15;\n    d.setMinutes(minutes);\n    return d.toISOString();\n}\nglobal.set(\"slotToIso\", slotToIso);\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":100,"wires":[["bfb01b32332d83b7","862e87d4bb507a6a","f2d8be290d3a14f2","07116d8142388eb3","82b24738161ceb01","708a6547b5832de5","4df5cfc986247580","11033d3afb3390a2","a6f4f0e58b8b6fc4","3fcf5002915cea98","37eeb548048087c6","4971c00737d70d00"]]},{"id":"9be7bca9b5000ece","type":"function","z":"a16570fb695164b3","name":"Normalize to quarter hours","func":"const HOME_NUMBER = flow.get('home_number') || 0;\n\nfunction normalizeToQuarterHours(priceData) {\n    if (!priceData || priceData.length === 0) {\n        return [];\n    }\n\n    // Detect if data is already 15-minute slots\n    const first = new Date(priceData[0].startsAt);\n    const second = new Date(priceData[1].startsAt);\n    const diffMinutes = (second.getTime() - first.getTime()) / (60 * 1000);\n\n    if (diffMinutes === 15) {\n        // Already in 15-min slots\n        return priceData;\n    }\n\n    if (diffMinutes === 60) {\n        // Expand hourly → 15-min\n        const slots = [];\n        priceData.forEach(entry => {\n            const baseDate = new Date(entry.startsAt);\n            for (let i = 0; i < 4; i++) {\n                const slotDate = new Date(baseDate.getTime() + i * 15 * 60 * 1000);\n                slots.push({\n                    total: entry.total,\n                    startsAt: slotDate.toISOString()\n                });\n            }\n        });\n        return slots;\n    }\n\n    // Unexpected resolution, just return as-is\n    node && node.warn && node.warn(`Unexpected price data interval: ${diffMinutes} minutes`);\n    return priceData;\n}\n\nif (msg.payload?.viewer?.homes?.[HOME_NUMBER]?.currentSubscription?.priceInfo?.today) {\n    const tibberToday = msg.payload.viewer.homes[HOME_NUMBER].currentSubscription.priceInfo.today;\n    const quarterHourSlotsToday = normalizeToQuarterHours(tibberToday);\n    msg.payload.viewer.homes[HOME_NUMBER].currentSubscription.priceInfo.today = quarterHourSlotsToday;\n}\n\nif (msg.payload?.viewer?.homes?.[HOME_NUMBER]?.currentSubscription?.priceInfo?.tomorrow) {\n    const tibberTomorrow = msg.payload.viewer.homes[HOME_NUMBER].currentSubscription.priceInfo.tomorrow;\n    const quarterHourSlotsTomorrow = normalizeToQuarterHours(tibberTomorrow);\n    msg.payload.viewer.homes[HOME_NUMBER].currentSubscription.priceInfo.tomorrow = quarterHourSlotsTomorrow;\n}\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1300,"y":100,"wires":[["e188fef69e738b85","84b340a240fef104"]]},{"id":"84b340a240fef104","type":"debug","z":"a16570fb695164b3","name":"tibber-query expanded","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1440,"y":160,"wires":[]},{"id":"ce0b2bd85123a79a","type":"config-vrm-api","name":"VRM"},{"id":"eb7dbabef2e64fb0","type":"mqtt-broker","name":"Cerbo GX","broker":"192.168.0.18","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"073c94010f8c84e0","type":"tibber-api-endpoint","queryUrl":"https://api.tibber.com/v1-beta/gql","feedConnectionTimeout":"30","feedTimeout":"60","queryRequestTimeout":"30","name":"Tibber"},{"id":"ae94fc0e9881e8c0","type":"ui_group","name":"Visualisations","tab":"9e3dc13a1610987c","order":2,"disp":true,"width":"24","collapse":false,"className":""},{"id":"9e3dc13a1610987c","type":"ui_tab","name":"Tibber","icon":"dashboard","disabled":false,"hidden":false},{"id":"c6d63ed90a6c66d2","type":"global-config","env":[],"modules":{"@victronenergy/node-red-contrib-victron":"1.6.39","victron-vrm-api":"0.2.15","node-red-contrib-cron-plus":"2.2.1","node-red-contrib-sunevents":"3.1.1","node-red-contrib-tibber-api":"6.3.2","node-red-contrib-power-saver":"4.2.5","node-red-dashboard":"3.6.6"}}]

Flow Info

Created 1 year, 10 months ago
Updated 1 day ago
Rating: not yet rated

Owner

Actions

Rate:

Node Types

Core
  • change (x36)
  • comment (x6)
  • debug (x13)
  • delay (x1)
  • function (x27)
  • http request (x1)
  • inject (x1)
  • join (x11)
  • link in (x1)
  • link out (x2)
  • mqtt out (x1)
  • mqtt-broker (x1)
  • rbe (x2)
  • template (x1)
  • trigger (x2)
Other

Tags

  • victron
  • tibber
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option