Automatic blinds / sunscreen control based on sun platform

Based on feedback on my posts here and here I have made a template sensor that can automatically control your blinds or sunscreen based on where the sun is in the sky.

Thanks to @basbrus you can now import the blueprint without having to setup anything yourself by clicking this button:

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

The control method uses the solar elevation and azimuth angles calculated by the HA sun platform as inputs to calculate the optimal shade height h.

Please note currently only vertical sunscreens will work with this method. I am looking at making it also work with sunscreens at any tilt angle, but it will require some modifications to the formula.

Method

The method is based on the shading control strategy III (SC-III) explained in [1] developed by Prof. Athanasios Tzempelikos. The general idea is to avoid sun glare, which is when the sun directly hits your eyes which causes discomfort, while still maximizing the amount of natural light.

image

[1] Comparative control strategies for roller shades with respect to daylighting and energy performance

The formula is pretty simple:

h = (d / cos(γ)) * tan(α)

Where:

  • h: the height of the open part of the window in meters
  • d: distance between the window and the working area (the area you want shaded) in meters
  • γ: the surface solar azimuth in degrees. This is the difference in degrees between the direction of the window and where the sun is in the sky. (or: γ = window_azimuth - sun_azimuth). We get sun_azimuth from HA sun platform.
  • α: the solar elevation angle in degrees. We get this from HA sun platform.

For illustration of sun azimuth and elevation:

Finding the window azimuth (what direction your window points)

You can follow the steps by forecast.solar, it works quite well. I will also put the steps here for your convenience:

  • Go to Open Street Map Compass
  • Click Draw single leg route
  • Find your location and zoom in as far as possible
  • Click Show compass
  • Move and rotate the compass in place, north is always at the top of the map
  • Get your azimuth (upper right corner), for me abt. 172°

Template code

Template code can be found below:

- platform: template
  sensors:
    blinds_height_perc:
      friendly_name: Solar blinds height percentage
      unit_of_measurement: '%'
      value_template: >
       {% set deg2rad = pi/180 %}

        {%- macro norm(x, min, max) %}
        	{{ (x - min) / (max - min) }}
        {%- endmacro %}

        {%- macro h2perc(x) %}
        	{{ 100 * float(norm(x, h_min, h_max)) }}
        {%- endmacro %}

        {%- macro clipv(x, x_min, x_max) %}
        	{{ max(min(x, x_max), x_min) }}
        {%- endmacro %}

        {% set win_azi = 136 %}
        {% set d = 0.5 %}
        {% set h_max = 1.96 %}
        {% set h_min = 0 %}

        {% set fov = deg2rad * 90 %}

        {% set sun_azi = state_attr('sun.sun', 'azimuth') %}
        {% set sun_ele = state_attr('sun.sun', 'elevation') %}

        {% set def_h = 0.6 * h_max %}
        
        {% set alpha = deg2rad * sun_ele %}
        {% set gamma = deg2rad * (win_azi - sun_azi) %}    

        {% set h = (d / cos(gamma)) * tan(alpha) %}

        {% if (alpha > 0) and (gamma | abs < fov) %}
            {{ clipv(h2perc(h) | round(0) | int , 0, 100) }}
        {% else %}  
            {{ clipv(h2perc(def_h) | round(0) | int , 0, 100) }}
        {% endif %}

First, we define two macros. One is used to normalize a number in range [0,1] and the second converts the blind height h to the cover position [0,100].

We then define some constants:

  • win_azi: can be found by the method explained in the above section.
  • d: is the distance from the window you want the beginning of the shadow to fall. I set it to 0.5 meters but you should probably play around a bit with this since it depends on your situation.
  • h_max: is the height of your window in meters (or actually the maximum height of your blinds, but for most people this will be the same number ofcourse)
  • h_min: is the minimum height in meters when the blinds are fully open. This will be 0 in most cases.
  • fov: field of view you want to enable control. This is relative to the window. A fov of 90 degrees means control is enabled anytime the sun is in front of the window (90 degrees to the right, 90 degrees to the left = 180 degrees total or half a circle). You can decrease this number if you have for example a lot of trees/houses to your left and right.

Then finally we calculate the optimal height h, normalize it, and calculate the percentage the blind should open/close.

Control automation

The automation is quite simple. It’s actually the same one as in the HA Solar Tracker post.

alias: control_blind
description: ""
trigger:
  - platform: state
    entity_id:
      - sensor.blinds_height_perc
condition: []
action:
  - service: cover.set_cover_position      
    target:
      entity_id: cover.sun_screen_control
    data:
      position: "{{ states('sensor.blinds_height_perc') | int(0) }}"
mode: single

There are definitely some improvements possible here. For example, on a very dark day with little daylight we might want to open the blinds completely to maximize the amount of natural light. This can easily be added in the automation but I currently have no way of testing it myself.

Based on the paper [1] it seems that a minimum value of 500 lux should always be followed since otherwise it becomes too dark. So perhaps someone with automatic blinds and a light sensor can add this?

PVLib simulation

I made a small PVLib simulation to see what the controller would do during a short winter and a long summer day:

21 Likes

Bas from Dutch Domotics discord turned the template into a nice macro so it can easily be reused for different windows:

{%- macro cover_height_sun(azimuth, distance, height, min_height) -%}
  {%- set deg2rad = pi/180 -%}

  {# normalize in range [0,1] #}
  {%- macro norm(x, min, max) %}
    {{ (x - min) / (max - min) }}
  {%- endmacro %}

  {# convert blind height h to percentage [0,100] #}
  {%- macro h2perc(x) %}
    {{ 100 * float(norm(x, h_min, h_max)) }}
  {%- endmacro %}

  {# clip value between [min, max] #}
  {%- macro clipv(x, x_min, x_max) %}
    {{ max(min(x, x_max), x_min) }}
  {%- endmacro %}

  {# constants #}
  {%- set win_azi = azimuth -%}
  {%- set d = distance | default(0.5) -%}
  {%- set h_max = height | default(2.10) -%}
  {%- set h_min = min_height | default(0) -%}

  {# field of view you want to enable control #}
  {# !!! MUST be equal or lower than 90 degrees !!! #}
  {# > 90 degrees is behind the window and then calculation is invalid! #}
  {%- set fov = deg2rad * 90 -%}

  {# get sun elevation / azimuth from sun.sun #}
  {%- set sun_azi = state_attr('sun.sun', 'azimuth') -%}
  {%- set sun_ele = state_attr('sun.sun', 'elevation') -%}

  {# default height, when automatic control is off. #}
  {%- set def_h = 0.6 * h_max -%}

  {%- set alpha = deg2rad * sun_ele -%}
  {%- set gamma = deg2rad * (win_azi - sun_azi) -%}    

  {%- set h = (d / cos(gamma)) * tan(alpha) -%}

  {%- if (alpha > 0) and (gamma | abs < fov) -%}
      {{- clipv(h2perc(h) | round(0) | int , 0, 100) -}}
  {%- else -%}  
      {{- clipv(h2perc(def_h) | round(0) | int , 0, 100) -}}
  {%- endif -%}
{%- endmacro-%}

ps. you can store the template under config/custom_templates/blind_control.jinja, or any other file name you like. See HA docs.

4 Likes

First impression is it looks like a nice project, although I don’t know if I will use it.
I am testing the sensor, but unfortunately it is not working.
The template adapted by Bas from Dutch Domotics does not work either.
No idea why it’s going wrong, but I’m investigating that now.

Bas from DD discord pointed me to a mistake. I thought setting the cover to 100% would fully close it but 100% is actually fully open. Oops. The templates are updated and it should work now.

Edit: I fixed another mistake. Inline comments weren’t processed properly by HA so the template didn’t work. It should finally work now…

To create easily multiple automations for all sides of your home I have build a Blueprint.
The Blueprint uses the sensor you have made based on the template code by @langestefan.

You can configure a time-out, minimum percentage change before adjusting the cover and add multiple actions and conditions to it.

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

---
#version 1.0.5
blueprint:
  name: Cover Height
  description: "`version 1.0.5` \n
    Set cover position based on direct sunlight exposed to window \n\n
    Calculations are done internally in the blueprint removing the need to use a sensor to input a value
    \n for in depth information on the calculations and variables, check this forum [post](https://community.home-assistant.io/t/automatic-blinds-sunscreen-control-based-on-sun-platform/573818)
    \n **Code Owner:** [`@langestefan`](https://community.home-assistant.io/u/langestefan/)
    \n **Blueprint by:** [`@basbruss`](https://community.home-assistant.io/u/basbruss/)"
  domain: automation
  input:
    cover_entity:
      name: Cover
      description: "Cover(s) to change position based on sun. \n only select entities. Devices will not work!"
      selector:
        target:
          entity:
            domain: cover
    azimuth:
      name: Azimuth
      description: The azimuth of the window/cover [**?**](https://community-assets.home-assistant.io/original/4X/5/2/7/527029c7c138eb6146aac68d34e92376b4560fb6.png)
      default: 180
      selector:
        number:
          min: 0
          max: 359
          mode: slider
          step: 1
    distance:
      name: Distance
      description: Distance from the cover to shaded area ![**?**](https://community-assets.home-assistant.io/original/4X/f/2/6/f26221689c32f55b731c5931de5a52791b760e90.jpeg).
      default: 0.5
      selector:
        number:
          min: 0
          max: 3
          mode: slider
          step: 0.1
          unit_of_measurement: "M"
    max_height:
      name: Cover Height
      description: Max height of the cover in Meters.
      default: 2.1
      selector:
        number:
          min: 0
          max: 4
          step: 0.1
          unit_of_measurement: "M"
    min_height:
      name: Minimun Height
      description: The minimum height in meters when the blinds are fully open. This will be 0 in most cases.
      default: 0
      selector:
        number:
          min: 0
          max: 4
          step: 0.1
          unit_of_measurement: "M"
    default_height:
      name: Default Cover Height
      description: The default height of the cover when the sun is not within the range of the window/cover
      default: 60
      selector:
        number:
          min: 0
          max: 100
          mode: slider
          step: 1
          unit_of_measurement: "%"
    default_template:
      name: Default height template
      description: Overrules set value in **Default Cover Height**
      default: ""
      selector:
        template:
    minimum_position:
      name: Minimum position cover
      description: The lowest position allowed to change the cover to.
      default: 0
      selector:
        number:
          min: 0
          max: 100
          mode: slider
          step: 1
          unit_of_measurement: "%"
    degrees:
      name: Field of view
      description: Amount of degrees relative to the sun' azimuth; (90 degrees equals 180 fov)
      default: 90
      selector:
        number:
          min: 0
          max: 90
          mode: slider
          step: 1
          unit_of_measurement: "°"
    azimuth_left:
      name: "Field of view Left"
      description: "**Only change when left and right angles are different**
        \n Amount of degrees from left side of the window \n
        Only use when left and right angles are not equal"
      default: 90
      selector:
        number:
          min: 0
          max: 90
          mode: slider
          step: 1
          unit_of_measurement: "°"
    azimuth_right:
      name: "Field of view Right"
      description: "**Only change when left and right angles are different**
        \n Amount of degrees from left side of the window \n
        Only use when left and right angles are not equal"
      default: 90
      selector:
        number:
          min: 0
          max: 90
          mode: slider
          step: 1
          unit_of_measurement: "°"
    max_elevation:
      name: Maximum Elevation
      description: Maximum angle of elevation
      default: 90
      selector:
        number:
          min: 0
          max: 90
          mode: slider
          step: 1
          unit_of_measurement: "°"
    min_elevation:
      name: Minimum Elevation
      description: Minimum angle of elevation
      default: 0
      selector:
        number:
          min: 0
          max: 90
          mode: slider
          step: 1
          unit_of_measurement: "°"
    change_threshold:
      name: Minimun percentage change
      description: The minimum percentage change to current position of the cover(s) to change position (to save battery)
      default: 0
      selector:
        number:
          min: 0
          max: 100
          mode: slider
          step: 1
          unit_of_measurement: "%"
    time_out:
      name: Time-out
      description: Minimum time between updates (to save battery)
      default: 1
      selector:
        number:
          min: 0
          max: 60
          mode: slider
          step: 1
          unit_of_measurement: minutes
    condition_mode:
      name: Condition mode
      description: Set mode of above conditions to AND or OR
      default: and
      selector:
        select:
          options:
            - label: AND
              value: and
            - label: OR
              value: or
    condition:
      name: Extra Conditions
      description: Extra conditions for the automation
      default: []
      selector:
        action: {}
    action:
      name: Extra Actions
      description: Extra actions to run before intial service
      default: []
      selector:
        action: {}
variables:
  cover_entity: !input cover_entity
  azimuth: !input azimuth
  distance: !input distance
  max_height: !input max_height
  min_height: !input min_height
  default_height: !input default_height
  min_position: !input minimum_position
  degrees: !input degrees
  default_template: !input default_template
  azimuth_left: !input azimuth_left
  azimuth_right: !input azimuth_right
  max_elevation: !input max_elevation
  min_elevation: !input min_elevation
  cover_height: >
    {%- set deg2rad = pi/180 -%}
    {# normalize in range [0,1] #}
    {%- macro norm(x, min, max) %}
      {{ (x - min) / (max - min) }}
    {%- endmacro %}
    {# convert blind height h to percentage [0,100] #}
    {%- macro h2perc(x) %}
      {{ 100 * float(norm(x, h_min, h_max)) }}
    {%- endmacro %}
    {# clip value between [min, max] #}
    {%- macro clipv(x, x_min, x_max) %}
      {{ max(min(x, x_max), x_min) }}
    {%- endmacro %}
    {# constants #}
    {%- set win_azi = azimuth -%}
    {%- set left_azi = azimuth_left | default(90) -%}
    {%- set right_azi = azimuth_right | default(90) -%}
    {%- set elev_high = deg2rad * (max_elevation | default(90)) -%} {# Maximum: 90 #}
    {%- set elev_low = deg2rad * (min_elevation| default(0)) -%} {# Minimum: 0 #}
    {%- set d = distance | default(0.5) -%}
    {%- set h_max = max_height | default(2.10) -%}
    {%- set h_min = min_height | default(0) -%}
    {%- set deg = degrees | default(90) -%}
    {%- set def = default_height | default(60) -%}
    {%- set min_pos = min_position | default(0) -%}
    {%- set def_temp = default_template | default('') -%}
    {% if def_temp | int(-1) >= 0 %}
      {% set def = def_temp %}
    {%endif%}

    {# FOV #}
    {%- if left_azi != right_azi-%}
      {%- set azi_left = deg2rad * -left_azi -%} {# Minimum: -90 #}
      {%- set azi_right = deg2rad * right_azi -%} {# Maximum: 90 #}
    {%-else-%}
      {%- set azi_left = deg2rad * -deg -%} {# Minimum: -90 #}
      {%- set azi_right = deg2rad * deg -%} {# Maximum: 90 #}
    {%-endif-%}
    {%- set fov = deg2rad * deg -%}
    {# get sun elevation / azimuth from sun.sun #}
    {%- set sun_azi = state_attr('sun.sun', 'azimuth') -%}
    {%- set sun_ele = state_attr('sun.sun', 'elevation') -%}
    {# default height, when automatic control is off. #}
    {%- set def_h = def / 100 * h_max -%}
    {%- set alpha = deg2rad * sun_ele -%}
    {%- set gamma = deg2rad * (win_azi - sun_azi) -%}    
    {%- set h = (d / cos(gamma)) * tan(alpha) -%}
    {# gamma is outside of FOV #}
    {%- if gamma < azi_left or gamma > azi_right or alpha < elev_low or alpha > elev_high -%}
      {{ clipv(h2perc(def_h) | round(0) | int , 0, 100) }}
    {# gamma is inside of FOV #}
    {%- else -%}  
      {{ clipv(h2perc(h) | round(0) | int , min_pos, 100) }}
    {%- endif -%}
  change_threshold: !input change_threshold
  time_out: !input time_out
  condition_mode: !input condition_mode
trigger:
  - platform: state
    entity_id:
      - sun.sun
condition: !input condition
action:
  - choose:
      - conditions:
          - condition: template
            value_template: "{{condition_mode == 'and'}}"
        sequence:
          - condition: and
            conditions:
              - condition: template
                value_template: >
                  {%set position = state_attr(cover_entity.entity_id,'current_position')%}
                  {{ ((position | float - cover_height | float) | abs > change_threshold)
                  or (cover_height in [default_height, default_template] and position not in [default_height, default_template])}}
              - condition: template
                value_template: >
                  {{now() - timedelta(minutes=time_out) >= states[cover_entity.entity_id].last_updated }}
          - choose: []
            default: !input action
          - service: cover.set_cover_position
            data:
              position: "{{ cover_height | int(0) }}"
            target: !input cover_entity
      - conditions:
          - condition: template
            value_template: "{{condition_mode == 'or'}}"
        sequence:
          - condition: or
            conditions:
              - condition: template
                value_template: >
                  {%set position = state_attr(cover_entity.entity_id,'current_position')%}
                  {{ ((position | float - cover_height | float) | abs > change_threshold)
                  or (cover_height in [default_height, default_template] and position not in [default_height, default_template])}}
              - condition: template
                value_template: >
                  {{now() - timedelta(minutes=time_out) >= states[cover_entity.entity_id].last_updated }}
          - choose: []
            default: !input action
          - service: cover.set_cover_position
            data:
              position: "{{ cover_height | int(0) }}"
            target: !input cover_entity
mode: single

3 Likes

Hi mate,

Thanks for the good work.

I started to implement and, even if it works, I am trying to fine tune my templates.
I have some few questions for you:

  1. I am not sure I understand h_max and h_min. Are those heights mesured from the ground?
    For example
  • If I have a sliding window (which is 2.1m high) on my ground floor,
    h_min=0
    h_max=2.1

  • If I have a regular window (which would be 1.4m high) at the first floor, then
    h_min= 3.5 (the first floor is roughly 2.5m high then the window sits 1m above that)
    h_max=3.5+1.4=4.9 ?

  • Or maybe the height is measured from the sea level?

  1. I have those kinds of blinds:
    image
    When I set the position to 0, they are completely closed and let no light in. Usually, ona very sunny day, I set the position to 15% because then, I will still see a bit of light through the small holes of the blind which is much more comfortable (This blocks the direct sun light but let a bit of light in)
    My question is, how can I set the minimum position to 15% instead of 0%?

Thanks a lot!

h_max is the height of the window. h_min you can just put to 0. I also tried to explain it in the post:

My question is, how can I set the minimum position to 15% instead of 0%?

You can change this line:

{{- clipv(h2perc(h) | round(0) | int, 0, 100) -}}

To clip at 15%:

{{- clipv(h2perc(h) | round(0) | int, 15, 100) -}}

Thanks for the reply. Very helpful.
Sorry to be fussy but with that definition of h_min and h_max it means that the 2 same windows with the same azimuth, 1 being at the ground floor and the orther one being at the first or even 2nd floor will have the same result?

I might be wrong but I was expecing them to have a different result

The height does matter slightly, but I cannot find any resource that describes how to take it into account so the effect must be neglible.

I did find this resource: Ask Tom: Do times for sunrise and sunset not take altitude into account?

So sunrise is about 1 minute earlier for every 1500 meters altitude above sea level. So I guess we can neglect it.

Perhaps another way to look at it is that the sun’s rays hit the earth at the same angle no matter what height you are:

image

1 Like

Very clear and interesting.
Thanks for taking the time to answer.

So, I am using your template and it’s working beautifuly.
I use the output of the template in Nodered to automate my blinds.

Cheers

1 Like

I don’t have any blinds to use this for but I’m interested in seeing the numbers. I’m struggling with the window azimuth part though. I don’t understand what part of the compass needs to be where :confused:

Can anyone explain that part for dummies please?

You want to measure the azimuth angle of your window in degrees, clockwise, from north (0°). For example if your window faces exactly south it’s at an azimuth angle of 180°. If it faces exactly east it’s at an angle of 90°, if it faces exactly west 270° etc. So basically align the compass such that it points in exactly the same direction as your window is facing.

image

I think this is awesome project :slight_smile:
Do you think it will be possible to add this to blueprint? Not sure if it is possible in full scale, but partially at least?

Not entirely sure what you mean? There is a blueprint here above shared by @basbrus. I will also put it in the main post

I Like this Project, more flexible and looks very good.
After testing something goes wrong on my part und i do not understand the reason. My Sensor looks like:

- sensor:
    name: Solar blinds height percentage Osten
    unique_id: blinds_height_perc_east
    unit_of_measurement: '%'
    state: >
       {% set deg2rad = pi/180 %}

        {%- macro norm(x, min, max) %}
        	{{ (x - min) / (max - min) }}
        {%- endmacro %}
        
        {# convert blind height h to percentage [0,100] #}
        {%- macro h2perc(x) %}
        	{{ 100 * float(norm(x, h_min, h_max)) }}
        {%- endmacro %}

        {%- macro clipv(x, x_min, x_max) %}
        	{{ max(min(x, x_max), x_min) }}
        {%- endmacro %}

        {% set win_azi = 108.25 %}
        {% set d = 0.5 %}
        {% set h_max = 2.15 %}
        {% set h_min = 0 %}
        
        {# field of view you want to enable control #}
        {# !!! MUST be equal or lower than 90 degrees !!! #}
        {# > 90 degrees is behind the window and then calculation is invalid! #}
        {% set fov = deg2rad * 60 %}
        
        {# get sun elevation / azimuth from sun.sun #}
        {% set sun_azi = state_attr('sun.sun', 'azimuth') %}
        {% set sun_ele = state_attr('sun.sun', 'elevation') %}
        
        {% set def_h = 0.6 * h_max %}
        
        {% set alpha = deg2rad * sun_ele %}
        {% set gamma = deg2rad * (win_azi - sun_azi) %}    

        {% set h = (d / cos(gamma)) * tan(alpha) %}

        {% if (alpha > 0) and (gamma | abs < fov) %}
            {{ clipv(h2perc(h) | round(0) | int , 50, 100) }}
        {% else %}  
            {{ clipv(h2perc(def_h) | round(0) | int , 50, 100) }}
        {% endif %}

The calculation itself works but in time where the blinds have to be set to 100% it jumps to 60%
image

Hopefully someone have an idear whats going wrong

Thanks in Advanced

Did you set the azimuth angle correctly? It jumps to 60% (the standard value def_h = 0.6 * h_max) when either the sun moves behind the window or the sun moves below the horizon. Since it happened


I am Living in the building 55 next to the border of the compass

So far as i know its right. What do i have to do to set the default to 100% (full open) after the sun moves around the corner

Yes if your window faces south east then that’s fine. To change the default value you can change this line:

{% set def_h = 0.6 * h_max %}

To:

{% set def_h = h_max %}

This will set the default height to 100% so the window will be fully open.

PS. I think it will be easier for you if you import the blueprint. The import button is at the top of this post

1 Like

i am still using your blueprint. it is perfect. Many thanks for your work. But i need this sensor for working or i am wrong?

edit: I have put a new blueprint at the top of the page which does not need a template sensor anymore!

You can also find it here: :sun_with_face: Automatic blinds / sunscreen control based on sun platform

1 Like