Dynamic Load Shedding Script per la Gestione dell'Energia

Questo sofisticato script di load-shedding mira a mantenere il consumo energetico entro soglie minime e massime specificate controllando dinamicamente i dispositivi. Lo script dà priorità ai dispositivi in base a orari predefiniti, minimizzando l'uso di energia pur garantendo che i dispositivi essenziali rimangano operativi. Offre diverse strategie per ottimizzare le prestazioni, come impostare una differenza significativa tra i limiti minimi e massimi di potenza e regolare i tempi di polling per ridurre cambiamenti inutili. Lo script supporta sia dispositivi Shelly che dispositivi esterni tramite webhook. Le notifiche possono essere configurate per avvisare gli utenti quando i dispositivi vengono attivati o disattivati. Questa soluzione è adatta per ambienti in cui la gestione dell'energia è cruciale, come sistemi di energia solare o gestione della domanda di picco.

Lo script di load-shedding manterrà l'uso misurato tra un valore basso (min) e alto (max) di potenza totale (watt), controllando l'alimentazione degli altri dispositivi.

Considerazioni chiave:

1. Assicurarsi che il valore impostato per max sia maggiore del valore impostato per min (il 10% dovrebbe essere la differenza minima, il 20% è una differenza minima migliore)
2. Maggiore è la distanza tra min e max, minore sarà il "churn". Impostare uno spazio ampio tra questi valori renderà il load shedding più efficiente.
3. Il valore minimo per poll_time dovrebbe essere 60 - durante i cicli di "accensione", si dovrebbe lasciare abbastanza tempo per far stabilizzare i picchi di spunto.
4. La priorità è in ordine dal più importante (da mantenere acceso se possibile) al meno importante.
5. Qualsiasi dispositivo non elencato o escluso da tutti gli orari sarà non gestito—mai soggetto a load shedding.
6. La migliore pratica è nominare tutti i dispositivi inclusi in ogni orario, in uno dei set "priority", "on" o "off".

poll_time: intervallo minimo tra l'applicazione dei normali passaggi di accensione/spegnimento
short_poll: quando si aggiungono dispositivi, i dispositivi con priorità più alta vengono accesi, anche se si presume siano già accesi, questo tempo più breve accelera il processo

        // JSON schema for input settings:
// devices = [ { "name":,"descr":,"addr":,"gen":,"type":,"id":,"notify":}, # dispositivo shelly. descr e notify sono opzionali, notify di default è false
//             { "name":,"descr":,"on_url":,"off_url":,"notify":},         # esempio webhook.
//             ...
//           ]
// notify = [ { "name":, "descr":, "url": },                               # ogni notifica nominata può essere usata in uno schedule
//            ...                                                          # le occorrenze di {device}, {state}, e {wattage} saranno sostituite inline
//          ]
// schedules = [
//               { "name":,"enable":,"start":,"days":,                     # enable è opzionale, di default true, days opzionale, di default SMTWTFS
//                 "descr":,  
//                 "priority":[],                                          # lista dispositivi prioritaria. il primo rimane acceso di più, l'ultimo è il primo da spegnere
//                 "on":[],                                                # dispositivi da mantenere accesi. può contenere la singola voce "ALL", o una lista di dispositivi
//                 "off":[],                                               # dispositivi da mantenere spenti. uno schedule deve avere almeno uno tra priority, on, off       
//                 "min":,"max":,"poll_time":,"short_poll":,               # se priority è specificato, tutte queste opzioni sono richieste
//                 "notify_on":,"notify_off"                               # notifiche sono opzionali
//               },
//               ...
//             ]



/************************   impostazioni  ************************/

Pro4PM_channels = [ 0, 1, 2, 3 ];      // default alla somma di tutti i canali per 4PM 
Pro3EM_channels = [ 'a', 'b', 'c' ];   // simile se il dispositivo è 3EM

max_ = 1200;                 // massimo globale, usato solo se mai definito negli schedule
min_ = 900;                  //                    "                       "
poll_time = 300;             // a meno che non sia sovrascritto in uno schedule, definisce il tempo tra lo shedding o l'aggiunta di carico
short_poll = 10;             // tempo di ciclo più veloce quando si verifica che un dispositivo "on" sia ancora acceso
logging = false;
kvs_status = false;          // memorizza lo stato nel key-value-store
simulation_power = 0;        // impostare questo per test manuale in console
simulation_hhmm = "";        // lasciare "" per funzionamento normale, impostare a orario tipo "03:00" per test
simulation_day = -1;         // -1 per funzionamento normale, per testare, 0=Domenica, 1=Lunedì...

devices = [ { "name":"Scaldabagno", "descr": "Shelly Pro 3EM", "addr":"192.168.1.105","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Caricatore EV", "descr": "Shelly Pro 3EM", "addr":"192.168.1.106","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Pompa Piscina", "descr": "Shelly Pro 3EM", "addr":"192.168.1.107","gen":2, "type":"Switch", "id":100, "notify" : false },
            { "name":"Forno", "descr": "Shelly Pro 3EM", "addr":"192.168.1.108","gen":2, "type":"Switch", "id":100, "notify" : true },
            { "name":"Condizionatore", "descr": "Shelly Pro 3EM", "addr":"192.168.1.109","gen":2, "type":"Switch", "id":100, "notify" : false },
            { "name":"Ventilatore a soffitto",  "descr": "Shelly 1PM", "addr":"192.168.1.110","gen":1, "type":"relay", "id":0, "notify" : false },
            { "name":"Vasca idromassaggio", "descr": "Endpoint NodeRed per controllare dispositivi non 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":"Centro intrattenimento",  "descr": "Shelly Plus 2PM relay canale singolo",
                     "addr":"192.168.1.114","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"HVAC",  "descr": "Shelly Plus 1PM con contattore",
                     "addr":"192.168.1.111","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"Lavastoviglie",  "descr": "Shelly Plus 2PM relay primo canale",
                     "addr":"192.168.1.112","gen":2,"type":"relay","id":0, "notify" : true },
            { "name":"Microonde", "descr": "Shelly Plus 2PM relay secondo canale",
                     "addr":"192.168.1.112","gen":2,"type":"relay","id":1, "notify" : true },
          ]

notify = [ { "name": "notifica off", "descr": "Webhook IFTTT da attivare quando un dispositivo è disabilitato",
              "url":"https://maker.ifttt.com/trigger/send_email/with/key/crLBieQXeiUi1SwQUmYMLn&value1=knobs" },
            { "name": "notifica on",
              "url":"https://maker.ifttt.com/trigger/send_email/with/key/crLBieQXeiUi1SwQUmYMLn&value1=sally" },
         ]

schedules = [ { "name":"Solare diurno", "enable": true, "start":"07:00", "days":"SMTWTFS",
                "descr":"Giorni feriali, inizio 7AM, quando la produzione solare aumenta",
                "priority":["HVAC","Forno","Microonde","Centro intrattenimento","Scaldabagno","Caricatore EV","Lavastoviglie","Ventilatore a soffitto","Pompa Piscina"],
                "min":2500, "max":3500, "poll_time":300, short_poll:10 },
             { "name":"Fascia serale Uso-Picco Domanda", "enable": true, "start":"17:00", "days":".MTWTF.",
                "descr":"Giorni feriali, inizio 5PM, ritorno alla rete, tariffe a tempo di uso/picco con penalità",
                "priority":["HVAC","Ventilatore a soffitto","Centro intrattenimento","Microonde","Lavastoviglie"],
                "off" : ["Pompa Piscina"],
                "min":2000, "max":3000, "poll_time":300, short_poll:10,
                "notify_on" : "notifica on", "notify_off" : "notifica off" },
             { "name":"Notti weekend rete", "enable": true, "start":"17:00", "days":"S.....S",
                "descr":"Sabato/Domenica dopo il solare, nessuna tariffa a tempo di uso",
                "on" : ["ALL"] },
             { "name":"Tutte le notti rete", "enable": true, "start":"20:00", "days":"SMTWTFS",
                "descr":"Ogni giorno, inizio 8PM, ritorno alla rete, le tariffe a tempo di uso/picco sono terminate",
                "on" : ["ALL"] },
           ]

/***************   variabili di programma, non modificare  ***************/

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( "fail " + user_data );
        // TBD: attualmente non abbiamo logica di retry
    } else {
        if ( logging ) print( "successo" );
    }
}

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( "Accendi " + 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("undef in 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( "attivato " + 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( "Dispositivo indefinito " + l[d] + " nella lista '" + t + "' dello schedule " + 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 );