Dynamiczny Skrypt Ograniczania Obciążenia dla Zarządzania Energią

Ten zaawansowany skrypt load-shedding ma na celu utrzymanie zużycia energii w określonych progach minimalnych i maksymalnych poprzez dynamiczne sterowanie urządzeniami. Skrypt nadaje priorytet urządzeniom na podstawie zdefiniowanych harmonogramów, minimalizując zużycie energii przy jednoczesnym zapewnieniu działania urządzeń kluczowych. Oferuje kilka strategii optymalizacji wydajności, takich jak ustawienie odpowiednio dużego odstępu między minimalnym i maksymalnym limitem mocy oraz dostosowanie czasu odpytywania, aby ograniczyć niepotrzebne przełączanie. Skrypt obsługuje zarówno Shelly urządzenia, jak i urządzenia zewnętrzne za pośrednictwem webhooków. Można skonfigurować powiadomienia, aby alertować użytkowników, gdy urządzenia są przełączane. To rozwiązanie sprawdza się w środowiskach, w których zarządzanie energią ma kluczowe znaczenie, takich jak systemy fotowoltaiczne lub zarządzanie szczytowym poborem mocy.

Skrypt load-shedding utrzymuje zmierzone zużycie między niską (min) i wysoką (max) łączną mocą (watts), sterując zasilaniem innych urządzeń.

Najważniejsze kwestie:

1. Upewnij się, że wartość ustawiona dla max jest większa niż wartość ustawiona dla min (10% należy uznać za najmniejszy dopuszczalny odstęp, 20% to lepsze minimalne minimum)
2. Im większa odległość między min a max, tym mniejsze będzie „przełączanie”. Ustawienie odpowiednio dużego odstępu między tymi wartościami sprawi, że odciążanie będzie bardziej efektywne.
3. Najmniejsza wartość dla poll_time powinna wynosić 60 — podczas cykli „włączania” należy dać wystarczająco dużo czasu, aby skoki prądu rozruchowego się ustabilizowały.
4. Priorytet jest ustawiany od najważniejszych (w razie możliwości pozostaw włączone) do najmniej ważnych.
5. Każde urządzenie, którego w ogóle nie ma na liście lub które zostało pominięte we wszystkich harmonogramach, będzie niezarządzane — nigdy nie będzie podlegać load shedding.
6. Dobrą praktyką jest wpisanie wszystkich uwzględnionych urządzeń w każdym harmonogramie, w jednym z zestawów „priority”, „on” lub „off”.

poll_time: minimalny odstęp czasu między zastosowaniem zwykłych kroków włączania/wyłączania
short_poll: podczas dodawania urządzeń urządzenia o najwyższym priorytecie są włączane, nawet jeśli zakłada się, że już są włączone; ten krótszy czas przyspiesza proces

        javascript
// Schemat JSON dla ustawień wejściowych:
// devices = [ { "name":,"descr":,"addr":,"gen":,"type":,"id":,"notify":}, # urządzenie shelly. descr i notify są opcjonalne, notify domyślnie przyjmuje false
//             { "name":,"descr":,"on_url":,"off_url":,"notify":},         # przykład webhooka.
//             ...
//           ]
// notify = [ { "name":, "descr":, "url": },                               # każda nazwana notyfikacja może być użyta w harmonogramie
//            ...                                                          # wystąpienia {device}, {state} i {wattage} zostaną zastąpione w miejscu
//          ]
// schedules = [
//               { "name":,"enable":,"start":,"days":,                     # enable jest opcjonalne, domyślnie true, days opcjonalne, domyślnie SMTWTFS
//                 "descr":,  
//                 "priority":[],                                          # priorytetyzowana lista urządzeń. pierwsze pozostaje włączone najdłużej, ostatnie na liście jest zdejmowane jako pierwsze
//                 "on":[],                                                # urządzenia, które mają pozostać włączone. może zawierać pojedynczy wpis "ALL" albo listę urządzeń
//                 "off":[],                                               # urządzenia, które mają pozostać wyłączone. harmonogram musi mieć co najmniej jedną z opcji priority, on, off       
//                 "min":,"max":,"poll_time":,"short_poll":,               # jeśli określono priority, wszystkie te opcje są wymagane
//                 "notify_on":,"notify_off"                               # powiadomienia są opcjonalne
//               },
//               ...
//             ]



/************************   ustawienia  ************************/

Pro4PM_channels = [ 0, 1, 2, 3 ];      // domyślnie suma wszystkich kanałów dla 4PM 
Pro3EM_channels = [ 'a', 'b', 'c' ];   // podobnie, jeśli urządzenie to 3EM

max_ = 1200;                 // globalne maksimum, używane tylko jeśli nigdy nie zostało zdefiniowane w harmonogramach
min_ = 900;                  //                    "                       "
poll_time = 300;             // jeśli nie zostanie nadpisane w harmonogramie, określa czas między zdejmowaniem i dodawaniem obciążenia
short_poll = 10;             // szybszy cykl podczas sprawdzania, czy urządzenie oznaczone jako "on" nadal jest włączone
logging = false;
kvs_status = false;          // zapisuj status w key-value-store
simulation_power = 0;        // ustaw to, aby ręcznie testować w konsoli
simulation_hhmm = "";        // pozostaw "" dla normalnego działania, ustaw na godzinę, np. "03:00", aby testować
simulation_day = -1;         // -1 dla normalnego działania, do testów: 0=niedziela, 1=poniedziałek...

devices = [ { "name":"Podgrzewacz wody", "descr": "Shelly Pro 3EM", "addr":"192.168.1.105","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Ładowarka EV", "descr": "Shelly Pro 3EM", "addr":"192.168.1.106","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Pompa basenowa", "descr": "Shelly Pro 3EM", "addr":"192.168.1.107","gen":2, "type":"Switch", "id":100, "notify" : false },
            { "name":"Piekarnik", "descr": "Shelly Pro 3EM", "addr":"192.168.1.108","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Klimatyzator", "descr": "Shelly Pro 3EM", "addr":"192.168.1.109","gen":2, "type":"Switch", "id":100, "notify" : false },
            { "name":"Wentylator sufitowy",  "descr": "Shelly 1PM", "addr":"192.168.1.110","gen":1, "type":"relay", "id":0, "notify" : false },
            { "name":"Jacuzzi", "descr": "Punkt końcowy NodeRed do sterowania urządzeniami innymi niż shelly", 
                     "on_url":"http://192.168.2.1:1880/endpoint/hot_tub?state=ON",
                     "off_url":"http://192.168.2.1:1880/endpoint/hot_tub?state=ON",
                     "notify" : false },
            { "name":"Centrum rozrywki",  "descr": "Shelly Plus 2PM relay single channel",
                     "addr":"192.168.1.114","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"HVAC",  "descr": "Shelly Plus 1PM z stycznikiem",
                     "addr":"192.168.1.111","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"Zmywarka",  "descr": "Shelly Plus 2PM relay first channel",
                     "addr":"192.168.1.112","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"Kuchenka mikrofalowa", "descr": "Shelly Plus 2PM relay second channel",
                     "addr":"192.168.1.112","gen":2,"type":"relay","id":1, "notify" : true },
          ]

notify = [ { "name": "powiadom o wyłączeniu", "descr": "Webhook IFTTT uruchamiany, gdy urządzenie zostanie wyłączone",
              "url":"https://maker.ifttt.com/trigger/send_email/with/key/crLBieQXeiUi1SwQUmYMLn&value1=knobs" },
            { "name": "powiadom o włączeniu",
              "url":"https://maker.ifttt.com/trigger/send_email/with/key/crLBieQXeiUi1SwQUmYMLn&value1=sally" },
         ]

schedules = [ { "name":"Dzienne słońce", "enable": true, "start":"07:00", "days":"SMTWTFS",
                "descr":"Dni robocze, start o 7:00, gdy produkcja z instalacji fotowoltaicznej rośnie",
                "priority":["HVAC","Piekarnik","Kuchenka mikrofalowa","Centrum rozrywki","Podgrzewacz wody","Ładowarka EV","Zmywarka","Wentylator sufitowy","Pompa basenowa"],
                "min":2500, "max":3500, "poll_time":300, short_poll:10 },
             { "name":"Wieczorny szczyt taryfy czasowej", "enable": true, "start":"17:00", "days":".MTWTF.",
                "descr":"Dni robocze, start o 17:00, powrót do sieci, obowiązują stawki taryfy czasowej/szczytowego zapotrzebowania z karą",
                "priority":["HVAC","Wentylator sufitowy","Centrum rozrywki","Kuchenka mikrofalowa","Zmywarka"],
                "off" : ["Pompa basenowa"],
                "min":2000, "max":3000, "poll_time":300, short_poll:10,
                "notify_on" : "powiadom o włączeniu", "notify_off" : "powiadom o wyłączeniu" },
             { "name":"Weekendowe noce z sieci", "enable": true, "start":"17:00", "days":"S.....S",
                "descr":"Sobota/niedziela po słońcu, bez stawek taryfy czasowej",
                "on" : ["ALL"] },
             { "name":"Wszystkie noce z sieci", "enable": true, "start":"20:00", "days":"SMTWTFS",
                "descr":"Każdego dnia, start o 20:00, powrót do sieci, stawki taryfy czasowej/szczytowego zapotrzebowania już nie obowiązują",
                "on" : ["ALL"] },
           ]

/***************   zmienne programu, nie zmieniać  ***************/

ts = 0;
idx_next_to_toggle = -1;
last_cycle_time = 0;
channel_power = { };
verifying = false;
days = "SMTWTFS";
last_schedule = -1;
schedule = -1;
device_map = {};
schedule_map = {};
notify_map = {};
priority = [];
queue = []
in_flight = 0;
kvs = { device_states : { }, power : 0, schedule : "none", direction : "coasting" };
last_kv = "";
notify_on = "";
notify_off = "";

function total_power( ) {
    if ( simulation_power ) return simulation_power;
    let power = 0;
    for( let k in channel_power )
       power += channel_power[ k ];
    return power;
}

function callback( result, error_code, error_message, user_data ) {
    in_flight--;
    if ( error_code != 0 ) {
        print( "niepowodzenie " + user_data );
        // TBD: obecnie nie mamy żadnej logiki ponawiania
    } else {
        if ( logging ) print( "sukces" );
    }
}

function turn( pdevice, dir, notify, wattage ) {
    let device = devices[ device_map[ pdevice ] ];
    let cmd = "";
    if ( dir == "on" && device.presumed_state == "on" )
        verifying = true;
    else
        verifying = false;
    if ( dir != device.presumed_state )
        kvs.device_states[ device.name ] = dir;

    device.presumed_state = dir;
    let on = dir == "on" ? "true" : "false";
    print( "Przełącz " + device.name + " " + dir );

    if ( simulation_hhmm || simulation_power || simulation_day > -1 ) return;
    if ( def( device.notify ) && device.notify ) {
        if ( dir == "on" && notify_on != "" )
            cmd = notify[ notify_map[ notify_on ] ];
        if ( dir == "off" && notify_off != "" )
            cmd = notify[ notify_map[ notify_off ] ];
        if ( cmd != "" ) {
            cmd = cmd.replace( "{device}", device.name );
            cmd = cmd.replace( "{state}", dir );
            cmd = cmd.replace( "{wattage}", wattage );
            Shelly.call( "HTTP.GET", { url: cmd }, callback, device.name );
            in_flight++;
        }
    }

    if ( def( device.gen ) ) {
        if ( device.gen == 1 )
            cmd = device.type+"/"+device.id.toString()+"?turn="+dir
        else
            cmd = "rpc/"+device.type+".Set?id="+device.id.toString()+"&on="+on
        Shelly.call( "HTTP.GET", { url: "http://"+device.addr+"/"+cmd }, callback, device.name );
        in_flight++;
    }
    if ( def( device.on_url ) && dir == "on" ) {
        Shelly.call( "HTTP.GET", { url: device.on_url }, callback, device.name );
        in_flight++;
    }
    if ( def( device.off_url ) && dir == "off" ) {
        Shelly.call( "HTTP.GET", { url: device.off_url }, callback, device.name );
        in_flight++;
    }
}

function qturn( device, dir, notify, wattage ) {
    if (!def(device)) {
        print("niezdefiniowane w qturn");
        return;
    }
    queue.push( { "device": device, "dir": dir, "notify": notify, "wattage": wattage } )
}

function pad0( s, n ) {
    s = s.toString();
    if ( s.length < n )
        return '0' + s;
    return s;
}

function find_active_schedule( ) {
    let sched = -1;
    let sched_time = '00:00';
    let last_sched = 0;
    let last_time = '00:00';
    let now = new Date();
    let hour = now.getHours();
    let minute = now.getMinutes();
    let start_time = schedules[ 0 ].start;
    let day = now.getDay();
    let hhmm = pad0(hour,2) + ':' + pad0(minute,2);
    if ( simulation_day > -1 )
        day = simulation_day;
    if ( simulation_hhmm != "" )
        hhmm = simulation_hhmm;
    for ( n in schedules ) {
        let s = schedules[ n ];
        if ( def( s.enable ) && ! s.enable ) continue;
        if ( s.start > last_time ) {
            last_time = s.start;
            last_sched = n;
        }
        if ( ! def( s.days ) || s.days[ day ] == days[ day ] ) {
            if ( hhmm >= s.start && s.start >= sched_time && s.start > sched_time ) {
                sched_time = s.start;
                sched = n;
            }
        }
    }
    if ( sched == -1 ) sched = last_sched;
    return sched;
}

function toggle_all( dir, notify, wattage ) {
    for ( let d in devices ) {
        qturn( devices[ d ].name, dir, notify, wattage );
    }
}

function check_queue( ) {
    if ( queue.length > 0 && in_flight < 2 ) {
        let t = queue[0];
        queue = queue.slice(1);
        turn( t.device, t.dir, t.notify, t.wattage );
    }
}

function process_kv( result, error_code, error_message ) {
    if ( last_kv != result.value ) {
        last_kv = result.value;
        let j = JSON.parse( result.value );
        if ( def( j.settings ) ) {
            for ( s in j.settings ) {
                 let setting = j.settings[ s ];
                 if ( def( setting.schedule ) &&
                      def( setting.kvs ) &&
                      setting.schedule in schedule_map  )
                     for ( k in setting.kvs ) {
                         kv = setting.kvs[ k ];
                         if ( def( kv.key ) &&
                              def( kv.value )
                         ) schedules[ schedule_map[ setting.schedule ] ][ kv.key ] = kv.value;
                     }
            }
        }
    }
}

function check_power( msg ) {
    if (!def(msg)) return;
    check_queue();
    let now = Date.now() / 1000;
    let poll_now = false;
    if ( def( msg.delta ) ) {
        if ( def( msg.delta.apower ) && msg.id in Pro4PM_channels )
            channel_power[ msg.id ] = msg.delta.apower;
        if ( def( msg.delta.a_act_power ) )
            for ( let k in Pro3EM_channels )
                channel_power[ Pro3EM_channels[k] ] = msg.delta[ Pro3EM_channels[k] + '_act_power' ];
    }
    kvs.power = total_power( );

    let schedule = find_active_schedule( ); 
    if ( schedule != last_schedule ) {
        kvs.schedule = schedules[ schedule ].name;
        print( "aktywowany " + kvs.schedule );
        let s = schedules[ schedule ]
        if ( def( s.priority ) )
            priority = s.priority;
        else
            priority = [];
        kvs.direction = "loading";
        idx_next_to_toggle = 0;
        if ( def( s.min ) ) min_ = s.min;
        if ( def( s.max ) ) max_ = s.max;
        if ( def( s.poll_time ) ) poll_time = s.poll_time;
        if ( def( s.short_poll ) ) short_poll = s.short_poll;
        if ( def( s.notify_on ) ) notify_on = s.notify_on;
        if ( def( s.notify_off ) ) notify_off = s.notify_off;
        if ( def( s.off ) ) for  (let  d in s.off ) if ( s.off[d] == "ALL" ) toggle_all( "off", notify, kvs.power ) else qturn( s.off[d], "off", notify, kvs.power );
        if ( def( s.on ) ) for ( let d in s.on ) if ( s.on[d] == "ALL" ) toggle_all( "on", notify, kvs.power ) else qturn( s.on[d], "on", notify, kvs.power );
    } 

    if ( now > last_cycle_time + poll_time || verifying && now > last_cycle_time + short_poll ) {
        last_cycle_time = now;
        poll_now = true;
    }
    if ( priority.length ) {
        if ( kvs.power > max_ ) {
            if ( kvs.direction !== "shedding" ) {
                kvs.direction = "shedding";
                idx_next_to_toggle = priority.length -1;
            }
        } else if ( kvs.power < min_ ) {
            if ( kvs.direction !== "loading" ) {
                kvs.direction = "loading";
                idx_next_to_toggle = 0;
            }
        } else if ( kvs.direction !== "coasting" ) {
            kvs.direction = "coasting";
        }

        if ( def( msg.delta ) || schedule != last_schedule ) {
            if ( poll_now ) {
                if ( kvs.direction === "loading" ) {
                    qturn( priority[ idx_next_to_toggle ], "on", notify, kvs.power );
                    if ( idx_next_to_toggle < priority.length -1 ) idx_next_to_toggle += 1;
                }
                if ( kvs.direction === "shedding" ) {
                    qturn( priority[ idx_next_to_toggle ], "off", notify, kvs.power );
                    if ( idx_next_to_toggle > 0 ) idx_next_to_toggle -= 1;
                }
            }
        }
    } else if ( poll_now )
        Shelly.call( "KVS.get", {key:"load-shed-setting"}, process_kv )

    last_schedule = schedule;
    check_queue();
    if ( poll_now && kvs_status )
        Shelly.call( "KVS.set", { key : "load-shed-status", value : JSON.stringify( kvs ) } )
}

function def( o ) {
    return typeof o !== "undefined";
}

function check_devices( l, t, s ) {
    for ( let d in l )
        if ( ! ( l[d] in device_map ) && l[d] != "ALL" )
            print( "Niezdefiniowane urządzenie " + l[d] + " na liście '" + t + "' harmonogramu " + s.name );
}

function init( ) {
    for ( let d in devices ) {
        device_map[ devices[d].name ] = d;
        d.presumed_state = "unknown";
    }
    for ( let sched in schedules ) {
        schedule_map[ schedules[ sched ].name ] = sched;
        let s = schedules[sched];
        s.start = pad0( s.start, 5);
        if ( def(s.off) ) check_devices(s.off, "off", s );
        if ( def(s.on) ) check_devices(s.on, "on", s );
        if ( def(s.priority) ) check_devices(s.priority, "priority", s );
    }
    for ( let n in notify )
        notify_map[ notify[ n ].name ] = n;
}

init();

Shelly.addStatusHandler( check_power );