Created
January 7, 2026 11:14
-
-
Save SunEnergyXT/e5487ff1669ed17c7a14e00ca2390f75 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| blueprint: | |
| name: Zero Feed-in Controller (Hybrid Deye/APS) | |
| description: | | |
| A automation designed for precise zero feed-in control and battery standby management. This system utilizes an incremental PI algorithm to regulate grid exchange power, supporting a hybrid configuration of up to 4 microinverters. | |
| domain: automation | |
| input: | |
| shelly_power_sensor: | |
| name: Shelly total active power sensor (Required) | |
| selector: { entity: { domain: sensor, device_class: power } } | |
| avg_battery_soc: | |
| name: Battery remaining soc (Required) | |
| selector: { entity: { domain: sensor, device_class: battery } } | |
| timeout_no_io_entity: | |
| name: Battery no-input/no-output auto-shutdown timeout (Required) | |
| selector: { entity: { domain: number } } | |
| timeout_dod_entity: | |
| name: Battery DOD-limit auto-shutdown timeout (Required) | |
| selector: { entity: { domain: number } } | |
| discharge_limit_a: | |
| name: Minimum SOC allowed for discharge (Required) | |
| selector: { entity: { domain: number } } | |
| discharge_limit_b: | |
| name: Battery BMS hardware discharge-limit SOC (Required) | |
| selector: { entity: { domain: sensor, device_class: battery } } | |
| integral_store_entity: | |
| name: PI cumulative points storage entity (Required) | |
| selector: { entity: { domain: input_number } } | |
| # Deye Inverters (Percentage Based) | |
| deye_1_entity: | |
| name: Deye inverter 1 - active power regulation entity (Optional) | |
| default: [] | |
| selector: { entity: { domain: number } } | |
| deye_1_rated: | |
| name: Deye inverter 1 - Rated Power (W) | |
| default: 0 | |
| selector: { number: { min: 0, max: 2500, mode: box, unit_of_measurement: W } } | |
| deye_2_entity: | |
| name: Deye inverter 2 - active power regulation entity (Optional) | |
| default: [] | |
| selector: { entity: { domain: number } } | |
| deye_2_rated: | |
| name: Deye inverter 2 - Rated Power (W) | |
| default: 0 | |
| selector: { number: { min: 0, max: 2500, mode: box, unit_of_measurement: W } } | |
| # APS Inverters (Wattage Based) | |
| aps_1_entity: | |
| name: APS inverter 1 - Max Output Limit Entity (Optional) | |
| default: [] | |
| selector: { entity: { domain: number } } | |
| aps_1_power_sensor: | |
| name: APS inverter 1 - Actual Output Power Sensor | |
| default: [] | |
| selector: { entity: { domain: sensor, device_class: power } } | |
| aps_1_rated: | |
| name: APS inverter 1 - Rated Power (W) | |
| default: 0 | |
| selector: { number: { min: 0, max: 800, mode: box, unit_of_measurement: W } } | |
| aps_2_entity: | |
| name: APS inverter 2 - Max Output Limit Entity (Optional) | |
| default: [] | |
| selector: { entity: { domain: number } } | |
| aps_2_power_sensor: | |
| name: APS inverter 2 - Actual Output Power Sensor | |
| default: [] | |
| selector: { entity: { domain: sensor, device_class: power } } | |
| aps_2_rated: | |
| name: APS inverter 2 - Rated Power (W) | |
| default: 0 | |
| selector: { number: { min: 0, max: 800, mode: box, unit_of_measurement: W } } | |
| # Single mode to prevent race conditions | |
| mode: single | |
| max_exceeded: silent | |
| variables: | |
| # --- Input Mapping --- | |
| grid_sensor: !input shelly_power_sensor | |
| avg_soc_sensor: !input avg_battery_soc | |
| integral_entity: !input integral_store_entity | |
| # Protection | |
| timeout_no_io: !input timeout_no_io_entity | |
| timeout_dod: !input timeout_dod_entity | |
| limit_a_ent: !input discharge_limit_a | |
| limit_b_ent: !input discharge_limit_b | |
| # Devices Configuration | |
| d1_ent: !input deye_1_entity | |
| d1_rated: !input deye_1_rated | |
| d2_ent: !input deye_2_entity | |
| d2_rated: !input deye_2_rated | |
| a1_ent: !input aps_1_entity | |
| a1_p_ent: !input aps_1_power_sensor | |
| a1_rated: !input aps_1_rated | |
| a2_ent: !input aps_2_entity | |
| a2_p_ent: !input aps_2_power_sensor | |
| a2_rated: !input aps_2_rated | |
| # Existence Checks | |
| d1_on: "{{ d1_ent != [] and d1_ent != None and d1_rated > 0 }}" | |
| d2_on: "{{ d2_ent != [] and d2_ent != None and d2_rated > 0 }}" | |
| a1_on: "{{ a1_ent != [] and a1_ent != None and a1_rated > 0 }}" | |
| a2_on: "{{ a2_ent != [] and a2_ent != None and a2_rated > 0 }}" | |
| # Global Check: Is ANY inverter configured? | |
| any_inv_on: "{{ d1_on or d2_on or a1_on or a2_on }}" | |
| # Calculate Total System Power | |
| p_sum: > | |
| {{ (d1_rated if d1_on else 0) + | |
| (d2_rated if d2_on else 0) + | |
| (a1_rated if a1_on else 0) + | |
| (a2_rated if a2_on else 0) }} | |
| # Count Active APS Units | |
| aps_count: > | |
| {{ (1 if a1_on else 0) + (1 if a2_on else 0) }} | |
| # PI Parameters | |
| kp: 0.7 | |
| ki: 0.05 | |
| # Protection Constants | |
| c_soc_threshold: 10 | |
| c_timeout_long: 1440 | |
| c_timeout_def_no_io: 15 | |
| c_timeout_def_dod: 5 | |
| c_buffer_soc: 3 | |
| # APS Hardware Constraints | |
| c_aps_min_w: 30 | |
| # Deadband Configuration | |
| # 0 APS: -30W | 1 APS: -50W | 2 APS: -100W | |
| c_deadband_max: 30 | |
| c_deadband_min: > | |
| {% if aps_count == 0 %} -30 | |
| {% elif aps_count == 1 %} -50 | |
| {% else %} -100 | |
| {% endif %} | |
| # Output Hysteresis (W) | |
| c_output_hysteresis_w: 5 | |
| c_integral_limit_pct: 0.3 | |
| trigger: | |
| - platform: state | |
| entity_id: !input shelly_power_sensor | |
| action: | |
| # 1. State Retrieval | |
| - variables: | |
| avg_soc: "{{ states(avg_soc_sensor) | float(0) }}" | |
| p_grid: "{{ states(grid_sensor) | float(0) }}" | |
| limit_a: "{{ states(limit_a_ent) | float(0) }}" | |
| limit_b: "{{ states(limit_b_ent) | float(0) }}" | |
| real_min_soc: "{{ [limit_a, limit_b] | max }}" | |
| curr_timeout_no_io: "{{ states(timeout_no_io) | float(0) }}" | |
| curr_timeout_dod: "{{ states(timeout_dod) | float(0) }}" | |
| # 2. Timeout Management (Executed regardless of inverter presence) | |
| - choose: | |
| - conditions: | |
| - condition: template | |
| value_template: "{{ avg_soc > c_soc_threshold }}" | |
| sequence: | |
| - if: "{{ curr_timeout_no_io == c_timeout_def_no_io }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ timeout_no_io }}" } | |
| data: { value: "{{ c_timeout_long }}" } | |
| - if: "{{ curr_timeout_dod == c_timeout_def_dod }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ timeout_dod }}" } | |
| data: { value: "{{ c_timeout_long }}" } | |
| - conditions: | |
| - condition: template | |
| value_template: "{{ avg_soc <= c_soc_threshold }}" | |
| sequence: | |
| - if: "{{ curr_timeout_no_io != c_timeout_def_no_io }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ timeout_no_io }}" } | |
| data: { value: "{{ c_timeout_def_no_io }}" } | |
| - if: "{{ curr_timeout_dod != c_timeout_def_dod }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ timeout_dod }}" } | |
| data: { value: "{{ c_timeout_def_dod }}" } | |
| # 3. NO-INVERTER CHECK | |
| - if: | |
| - condition: template | |
| value_template: "{{ not any_inv_on }}" | |
| then: | |
| - stop: "No inverters configured. Timeout check complete. Stopping execution." | |
| # 4. Discharge Protection (Only runs if inverters exist) | |
| - choose: | |
| - conditions: | |
| - condition: template | |
| value_template: "{{ avg_soc <= (real_min_soc + c_buffer_soc) }}" | |
| sequence: | |
| # Shut down Deye (0%) | |
| - if: "{{ d1_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ d1_ent }}" } | |
| data: { value: 0 } | |
| - if: "{{ d2_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ d2_ent }}" } | |
| data: { value: 0 } | |
| # Min limit APS (30W) | |
| - if: "{{ a1_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ a1_ent }}" } | |
| data: { value: "{{ c_aps_min_w }}" } | |
| - if: "{{ a2_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ a2_ent }}" } | |
| data: { value: "{{ c_aps_min_w }}" } | |
| # Reset Integral | |
| - service: input_number.set_value | |
| target: { entity_id: "{{ integral_entity }}" } | |
| data: { value: 0 } | |
| - stop: "Protection Active. Output forced to Min." | |
| # 5. Deadband Check (Dynamic) | |
| - if: | |
| - condition: template | |
| value_template: "{{ p_grid > c_deadband_min and p_grid < c_deadband_max }}" | |
| then: | |
| - stop: "Grid within dynamic deadband ({{ c_deadband_min }}W to {{ c_deadband_max }}W). Skipping." | |
| # 6. Core PI Calculation (Watts Domain) | |
| - variables: | |
| # a. Calculate Time Delta (dt) | |
| last_ts_list: > | |
| {% set times = [] %} | |
| {% if d1_on and states[d1_ent].last_changed %}{% set times = times + [as_timestamp(states[d1_ent].last_changed)] %}{% endif %} | |
| {% if d2_on and states[d2_ent].last_changed %}{% set times = times + [as_timestamp(states[d2_ent].last_changed)] %}{% endif %} | |
| {% if a1_on and states[a1_ent].last_changed %}{% set times = times + [as_timestamp(states[a1_ent].last_changed)] %}{% endif %} | |
| {% if a2_on and states[a2_ent].last_changed %}{% set times = times + [as_timestamp(states[a2_ent].last_changed)] %}{% endif %} | |
| {{ times | max if times else 0 }} | |
| current_ts: "{{ now().timestamp() }}" | |
| raw_diff: "{{ 999 if last_ts_list == 0 else (current_ts - last_ts_list) }}" | |
| dt: "{{ 15 if raw_diff > 60 else raw_diff | round(3) }}" | |
| # b. Calculate Total Current Output (Watts) | |
| # Deye: Calculate from percentage setting (Assuming Deye follows setting well) | |
| w_d1: "{{ (states(d1_ent)|float(0) / 100 * d1_rated) if d1_on else 0 }}" | |
| w_d2: "{{ (states(d2_ent)|float(0) / 100 * d2_rated) if d2_on else 0 }}" | |
| # APS: Use Actual Power Sensor if available, otherwise fallback to Setting | |
| w_a1: > | |
| {% if a1_on %} | |
| {% if a1_p_ent != [] and states(a1_p_ent) not in ['unknown', 'unavailable'] %} | |
| {{ states(a1_p_ent)|float(0) }} | |
| {% else %} | |
| {{ states(a1_ent)|float(0) }} | |
| {% endif %} | |
| {% else %} 0 {% endif %} | |
| w_a2: > | |
| {% if a2_on %} | |
| {% if a2_p_ent != [] and states(a2_p_ent) not in ['unknown', 'unavailable'] %} | |
| {{ states(a2_p_ent)|float(0) }} | |
| {% else %} | |
| {{ states(a2_ent)|float(0) }} | |
| {% endif %} | |
| {% else %} 0 {% endif %} | |
| total_curr_w: "{{ w_d1 + w_d2 + w_a1 + w_a2 }}" | |
| # c. PI Algo | |
| error: "{{ -p_grid }}" | |
| last_integral: "{{ states(integral_entity) | float(0) }}" | |
| integ_limit_w: "{{ p_sum * c_integral_limit_pct }}" | |
| raw_new_integ: "{{ last_integral + (ki * error * dt) }}" | |
| # Anti-windup | |
| new_integral: > | |
| {% if raw_new_integ > integ_limit_w %} {{ integ_limit_w }} | |
| {% elif raw_new_integ < -integ_limit_w %} {{ -integ_limit_w }} | |
| {% else %} {{ raw_new_integ }} | |
| {% endif %} | |
| power_offset: "{{ (kp * error) + new_integral }}" | |
| # Corrected logic: Target = Current - Offset | |
| raw_target_total: "{{ total_curr_w - power_offset }}" | |
| # Clamp Total Target | |
| target_total_w: > | |
| {% if raw_target_total > p_sum %} {{ p_sum }} | |
| {% elif raw_target_total < 0 %} 0 | |
| {% else %} {{ raw_target_total }} | |
| {% endif %} | |
| # 7. Distribute & Execute | |
| - variables: | |
| # Distribution Ratios (Safe because p_sum > 0 if we reached here) | |
| r_d1: "{{ d1_rated / p_sum if d1_on else 0 }}" | |
| r_d2: "{{ d2_rated / p_sum if d2_on else 0 }}" | |
| r_a1: "{{ a1_rated / p_sum if a1_on else 0 }}" | |
| r_a2: "{{ a2_rated / p_sum if a2_on else 0 }}" | |
| # Calculate Targets | |
| t_d1: "{{ target_total_w * r_d1 }}" | |
| t_d2: "{{ target_total_w * r_d2 }}" | |
| t_a1: "{{ target_total_w * r_a1 }}" | |
| t_a2: "{{ target_total_w * r_a2 }}" | |
| # Format Values | |
| val_d1: "{{ (t_d1 / d1_rated * 100) | round(1) if d1_on else 0 }}" | |
| val_d2: "{{ (t_d2 / d2_rated * 100) | round(1) if d2_on else 0 }}" | |
| # APS: Clamp to Min 30W | |
| val_a1: "{{ [t_a1 | int, c_aps_min_w] | max if a1_on else 0 }}" | |
| val_a2: "{{ [t_a2 | int, c_aps_min_w] | max if a2_on else 0 }}" | |
| # 8. Apply Changes | |
| - if: | |
| - condition: template | |
| value_template: "{{ (target_total_w - total_curr_w) | abs > c_output_hysteresis_w }}" | |
| then: | |
| - service: input_number.set_value | |
| target: { entity_id: "{{ integral_entity }}" } | |
| data: { value: "{{ new_integral | round(2) }}" } | |
| - if: "{{ d1_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ d1_ent }}" } | |
| data: { value: "{{ val_d1 }}" } | |
| - if: "{{ d2_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ d2_ent }}" } | |
| data: { value: "{{ val_d2 }}" } | |
| - if: "{{ a1_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ a1_ent }}" } | |
| data: { value: "{{ val_a1 }}" } | |
| - if: "{{ a2_on }}" | |
| then: | |
| - service: number.set_value | |
| target: { entity_id: "{{ a2_ent }}" } | |
| data: { value: "{{ val_a2 }}" } | |
| - service: system_log.write | |
| data: | |
| level: info | |
| message: > | |
| PI-Univ: Grid={{ p_grid }}W, Target={{ target_total_w | int }}W. | |
| Cur: D1/2={{ w_d1 }}/{{ w_d2 }}W, A1/2={{ w_a1 }}/{{ w_a2 }}W. | |
| Set: D1={{ val_d1 }}%, D2={{ val_d2 }}%, A1={{ val_a1 }}W, A2={{ val_a2 }}W. |
Hi,
I'm using the Blueprint with two Deye 2000W micro-inverters, but unfortunately the control isn't working. It works well initially, but becomes inaccurate over time until it can no longer keep up.
The inverters then always output full power, without taking into account the required house power.
Do you have a solution for this?
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@SunEnergyXT if you would add
states(d1_ent/d2_ent/a1_ent/a2_ent) not in ["unavailable", "unknown"] }}'to lines https://gist.github.com/SunEnergyXT/e5487ff1669ed17c7a14e00ca2390f75#file-zero-feed-in-controller-yaml-L113-L116 the blueprint would avoid spamming the logs with