Circular Mean (averaging angles in a circle)

Hello,

An interesting math problem. I’d like to have a template sensor which averages the result of 3 incoming angle sensors, all reporting in degrees from 0-359.9. The circular mean is a special case since of course the correct answer for the average of a 350 degree reading and a 10 degree reading and a 0 degree reading is 0, but (350+10+0)/3 = 120… wrong.

There’s a formula described here on Wikipedia, but I’m not adept enough with template sensors to implement this. (average the sine, average the cosine, take the arctan of sin-avg/cos-avg) Anyone have an idea?

https://wikimedia.org/api/rest_v1/media/math/render/svg/9508e363cb63a0fb40bb1dbe85409588843e2465

Assuming all your angles are positive numbers:

template:
  - sensor:
      - name: "Average Angle"
        unit_of_measurement: "°"
        state_class: 'measurement'
        state: >
          {% set a1 = states('sensor.your_1st_sensor_here')|float(-1) %}
          {% set a2 = states('sensor.your_2nd_sensor_here')|float(-1) %}
          {% set a3 = states('sensor.your_3rd_sensor_here')|float(-1) %}

          {% set s = ( sin(a1,0) + sin(a2,0) + sin(a3,0) ) / 3 %}
          {% set c = ( cos(a1,1) + cos(a2,1) + cos(a3,1) ) / 3 %}

          {% set r = atan(s/c,-1) %}

          {% if s > 0 and c > 0 %}
            {{ r }}
          {% elif c < 0 %}
            {{ r + 180 }}
          {% elif s < 0 and c > 0 %}
            {{ r + 360 }}
          {% else %}
            unknown
          {% endif %}

        availability: "{{ r >= 0 and a1 >= 0 and a2 >= 0 and a3 >= 0 }}"

If you have negative angles in your sensors I will have to adjust the defaults and availability.

1 Like

Wow, great, thanks. I wouldn’t have caught the ifs at the end, I think. Yes, always positive readings.

Actually my availability template has an issue, it is possible that r < 0 could still be valid for positive angles. This should work for all angles, negative and positive:

template:
  - sensor:
      - name: "Average Angle"
        unit_of_measurement: "°"
        state_class: "measurement"
        state: >
          {% set a1 = states('sensor.your_1st_sensor_here')|float('unknown') %}
          {% set a2 = states('sensor.your_2nd_sensor_here')|float('unknown') %}
          {% set a3 = states('sensor.your_3rd_sensor_here')|float('unknown') %}
          {% if 'unknown' not in [a1,a2,a3] %}
            {% set s = ( sin(a1,'unknown') + sin(a2,'unknown') + sin(a3,'unknown') ) / 3 %}
            {% set c = ( cos(a1,'unknown') + cos(a2,'unknown') + cos(a3,'unknown') ) / 3 %}

            {% set r = atan(s/c,'unknown') %}

            {% if 'unknown' in [c,s,r] %}
              unknown
            {% elif s > 0 and c > 0 %}
              {{ r }}
            {% elif c < 0 %}
              {{ r + 180 }}
            {% elif s < 0 and c > 0 %}
              {{ r + 360 }}
            {% else %}
              unknown
            {% endif %}
          {% else %}
            unknown
          {% endif %}

        availability: "{{ 'unknown' not in [a1,a2,a3,c,s,r] }}"
1 Like

A small but important correction, the formula only works in radians. So each variable has to be converted to radian, then the answer back to degrees. Rounded to whole digit degree answer.

    - name: Circular Mean 3 sensors
      unique_id: circular_mean_of_3_sensors
      unit_of_measurement: '°'
      icon: mdi:compass-rose
      state_class: "measurement"
      state: >-
        {% set pi = 3.14159265359 %}
        {% set a1 = (states('sensor.direction1')|float('Unknown')*pi/180) %}
        {% set a2 = (states('sensor.direction2')|float('Unknown')*pi/180) %}
        {% set a3 = (states('sensor.direction3')|float('Unknown')*pi/180) %}
        {% if 'Unknown' not in [a1,a2,a3] %}
          {% set s = ( sin(a1,'Unknown') + sin(a2,'Unknown') + sin(a3,'Unknown') ) / 3 %}
          {% set c = ( cos(a1,'Unknown') + cos(a2,'Unknown') + cos(a3,'Unknown') ) / 3 %}
           {% set r = (atan(s/c,'Unknown')*180/pi)|round(0, default='Unknown') %}
           {% if 'Unknown' in [c,s,r] %}
            Unknown
          {% elif s > 0 and c > 0 %}
            {{ r }}
          {% elif c < 0 %}
            {{ r + 180 }}
          {% elif s < 0 and c > 0 %}
            {{ r + 360 }}
          {% else %}
            Unknown
          {% endif %}
        {% else %}
          Unknown
        {% endif %}
      availability: "{{ 'Unknown' not in [a1,a2,a3,c,s,r] }}"

1 Like