[script] Show Current Sun/Moon & Wind direction and Cast shadow of your house

Hello everyone,

Since the original script by @pmpkk hasn’t been updated in a long time and I personaly used it for years and did some modifications to it… now I decided to “fork it”,
I’ve made several tweaks over the years to suit my needs better, so let’s share. Most recently, I’ve updated it to work with the latest version of Astral without requiring any specific old version.

To improve visibility and maintainability, I decided to create a separate topic for this version. Feel free to modify, enhance, or correct my code as needed! Feedback is always appreciated.

What Does It Do?

The script generates an SVG file in the openhab/html directory by default, making it easy to integrate into your OpenHAB UI. Every time the sun’s azimuth changes, the script regenerates the SVG, ensuring your panel always displays the correct sun position, shadow, moon, and wind direction.

Example (Dark Basic UI):


blue line → wind direction
yellow dot → sun :slight_smile:
gray half dot → moon phase & position
green half circle → daytime (half is noon) half on the other end is midnight
everything is rotating and changing in relation to your house.

The raw SVG is transparent

You can easily customize the colors in the file to match your preferred style.

How to Use It?

Simply adjust your location details, define your house shape, and tweak colors if needed. Other than that, the script runs independently and doesn’t require anything beyond a wind angle.

If your home setup doesn’t provide a wind direction variable, you can either remove that part of the script or call it with 0, as shown in the example below.

Prerequisities

apt update
apt dist-upgrade
apt install python3-pip -y
pip install astral pytz pylunar

The script: shadow.py

note: adjust Your Location Info to yours values!

ver: 02/2025

from datetime import datetime, timedelta, date, time
from astral import LocationInfo, sun
import math, sys, pytz, pylunar

####
#$ CONFIGURE YOUR LOCATION
####

LATITUDE = 50.08239089640888
LONGITUDE = 14.426095753880265
ALTITUDE = 474.0
TIMEZONE = 'Europe/Prague'
TOWN = 'Prague'
COUNTRY = 'Czech Republic'

####
# CONFIGURE YOUR HOUSE SHAPE (100 by 100 units square)
####

SHAPE = [
    {'x': 16.37, 'y': 62.07}, {'x': 28.32, 'y': 40.16}, {'x': 29.57, 'y': 39.87},
    {'x': 36.16, 'y': 28.41}, {'x': 35.51, 'y': 27.74}, {'x': 35.63, 'y': 15.48},
    {'x': 46.14, 'y':  9.63}, {'x': 56.50, 'y': 16.13}, {'x': 56.63, 'y': 22.21},
    {'x': 69.77, 'y': 28.99}, {'x': 69.99, 'y': 28.78}, {'x': 85.67, 'y': 37.63},
    {'x': 67.10, 'y': 70.33}, {'x': 66.09, 'y': 70.21}, {'x': 58.27, 'y': 83.97},
    {'x': 39.55, 'y': 73.58}, {'x': 38.68, 'y': 74.68}
]

####
# General setting, colors and fileoutput path
####

HOURS = 1
WIDTH = 100
HEIGHT = 100
PRIMARY_COLOR = '#1b3024'
LIGHT_COLOR = '#26bf75'
BG_COLOR = '#1a1919'
SUN_COLOR = '#ffff66'
WIND_COLOR = '#52b4bf'
SUN_RADIUS = 5

MOON_COLOR = '#999999'
MOON_RADIUS = 3
STROKE_WIDTH = '1'
FILENAME = '/etc/openhab/html/shadow.svg'
DEGS = []


#######################################################################
#######################################################################

##### not really needed to edit anything below

#######################################################################
#######################################################################

class Shadow:
    def __init__(self):
        self.debug = False
        self.location = LocationInfo(TOWN, COUNTRY, TIMEZONE, LATITUDE, LONGITUDE)
        
        # Get the current time in the specified timezone
        timezone = pytz.timezone(TIMEZONE)
        self.now = timezone.localize(datetime.now())
        self.nowUTC = datetime.utcnow()
        
        # Get the sun data using the observer and current date
        self.sun_azimuth = self.calculate_sun_azimuth()
        self.sun_elevation = self.calculate_sun_elevation()
        if self.debug: 
            print(f'Sun azimuth: {self.sun_azimuth}')
            print(f'Sun elevation: {self.sun_elevation}')
        
        # Loop through the hours and calculate azimuth at each hour
        for i in range(0, 24, HOURS):
            hour_time = timezone.localize(datetime.combine(date.today(), time(i)))
            azimuth = self.calculate_sun_azimuth(hour_time)
            DEGS.append(azimuth if azimuth is not None else 0)
        
        # Get the moon info
        self.moon_info = pylunar.MoonInfo(self.decdeg2dms(LATITUDE), self.decdeg2dms(LONGITUDE))
        self.moon_info.update(self.nowUTC)
        self.moon_azimuth = self.moon_info.azimuth()
        self.moon_elevation = self.moon_info.altitude()
        
        # Calculate the moon phase using pylunar
        self.phase = self.calculate_moon_phase()
        if self.debug:        
            print(f'Moon Age: {self.phase}')
            print(f'Moon azimuth: {self.moon_azimuth}')
            print(f'Moon elevation: {self.moon_elevation}')
        
        # Use the sun elevation or moon elevation as the final elevation
        self.elevation = self.sun_elevation if self.sun_elevation > 0 else self.moon_elevation
        
        # Calculate the sun azimuth at sunrise and sunset
        self.sunrise_azimuth = self.calculate_sunrise_sunset('sunrise')
        self.sunset_azimuth = self.calculate_sunrise_sunset('sunset')
        
    def calculate_moon_phase(self):
        # Calculate the moon phase using pylunar
        phase = self.moon_info.age()
        return phase
    
    def calculate_sun_azimuth(self, moment=None):
        if moment is None:
            moment = self.now
        return sun.azimuth(self.location.observer, moment)

    def calculate_sun_elevation(self, moment=None):
        if moment is None:
            moment = self.now
        return sun.elevation(self.location.observer, moment)
    
    def calculate_sunrise_sunset(self, event_type):
        # Get sunrise or sunset time
        if event_type == 'sunrise':
            event_time = sun.sunrise(self.location.observer, self.now.date())
        elif event_type == 'sunset':
            event_time = sun.sunset(self.location.observer, self.now.date())
        
        # Convert event time to correct timezone (if necessary)
        timezone = pytz.timezone(TIMEZONE)
        event_time = event_time.astimezone(timezone)
        
        # Calculate the azimuth for sunrise or sunset time
        return self.calculate_sun_azimuth(event_time) 

    def decdeg2dms(self, deg):
        d = int(deg)
        m = int((deg - d) * 60)
        s = (deg - d - m / 60) * 3600.0
        return (d, m, s)

    def generatePath(self,stroke,fill,points,attrs=None,width=None):

        swith = STROKE_WIDTH
        if (width != None): swith = width

        p = ''
        p = p + '<path stroke="' + stroke + '" stroke-width="' + swith + '" fill="' + fill + '" '
        if (attrs != None): p = p + ' ' + attrs + ' '
        p = p + ' d="'
        for point in points:
            if (points.index(point) == 0):
                p = p + 'M' + str(point['x']) + ' ' + str(point['y'])
            else:
                p = p + ' L' + str(point['x']) + ' ' + str(point['y'])
        p = p + '" />'

        return p

    def generateArc(self,dist,stroke,fill,orig_start,orig_end,attrs=None):
        
        if(LATITUDE<0):
            start = orig_end
            end = orig_start
        else:
            start = orig_start
            end = orig_end

        p = ''
        try:
            angle = end-start
            if (angle<0):
                angle = 360 + angle

            p = p + '<path d="M' + str(self.degreesToPoint(start,dist)['x']) + ' ' + str(self.degreesToPoint(start,dist)['y']) + ' '
            p = p + 'A' + str(dist) + ' ' + str(dist) + ' 0 '
            if (angle<180):
                p = p + '0 1 '
            else:
                p = p + '1 1 '
            p = p + str(self.degreesToPoint(end,dist)['x']) + ' ' + str(self.degreesToPoint(end,dist)['y']) + '"'
            p = p + ' stroke="' + stroke + '"'
            if (fill != None): 
                p = p + ' fill="' + fill + '" '
            else:
                p = p + ' fill="none" '
            if (attrs != None): 
                p = p + ' ' + attrs + ' '
            else:
                p = p + ' stroke-width="' + STROKE_WIDTH + '"'
            p = p + ' />'
        except:
            p = ''

        return p	

    def degreesToPoint(self,d,r):

        coordinates = {'x': 0, 'y': 0}
        cx = WIDTH / 2
        cy = HEIGHT / 2 
        d2 = 180 - d
        coordinates['x'] = cx + math.sin(math.radians(d2))*r
        coordinates['y'] = cy + math.cos(math.radians(d2))*r

        return coordinates

    def generateSVG(self, wind_azimuth=0):

        wind_azimuth = float(wind_azimuth)

        realSun_pos = self.degreesToPoint(self.sun_azimuth, 10000)
        realMoon_pos = self.degreesToPoint(self.moon_azimuth, 10000)
        if self.debug:
            print("")
            print("real sun position: " + str(realSun_pos))

        
        sun_pos = self.degreesToPoint(self.sun_azimuth, WIDTH / 2)
        moon_pos = self.degreesToPoint(self.moon_azimuth, WIDTH / 2)
        wind_pos = self.degreesToPoint(wind_azimuth, WIDTH / 2)

        minPoint = -1
        maxPoint = -1

        i = 0

        minAngle = 999
        maxAngle = -999
        if(self.sun_elevation>0):
            angle_pos = sun_pos
            real_pos = realSun_pos
        else:
            angle_pos = moon_pos
            real_pos = realMoon_pos

        if self.debug:
            print("")
            print("House:")

        for point in SHAPE:
            #Angle of close light source
            angle = -math.degrees(math.atan2(point['y']-angle_pos['y'],point['x']-angle_pos['x']))
            #Angle of distant light source (e.g. sun_pos)
            angle = -math.degrees(math.atan2(point['y']-real_pos['y'],point['x']-real_pos['x']))
            distance = math.sqrt(math.pow(angle_pos['y']-point['y'],2) + math.pow(angle_pos['x']-point['x'],2))

            if (angle<minAngle): 
                minAngle = angle
                minPoint = i
            if (angle>maxAngle): 
                maxAngle = angle
                maxPoint = i
            point['angle'] = angle
            point['distance'] = distance
            if self.debug:
                print(str(i).ljust(10),":", str(point['x']).ljust(10), str(point['y']).ljust(10), "angle: ", str(round(angle,7)).ljust(10), "dist: ", str(round(distance)).ljust(10))
            i = i + 1

        if self.debug: 
            print("")
            print("Min Point = ",minPoint)
            print("Max Point = ",maxPoint)
            print("")

        i = minPoint
        k = 0
        side1Distance = 0
        side2Distance = 0
        side1Done = False
        side2Done = False
        side1 = []
        side2 = []
        while True:
            if (side1Done == False):
                side1Distance = side1Distance + SHAPE[i]['distance']
                if(i != minPoint and i != maxPoint): SHAPE[i]['side'] = 1
                if (i == maxPoint): side1Done = True
                side1.append( { 'x': SHAPE[i]['x'], 'y': SHAPE[i]['y'] } )
            if (side1Done == True):
                side2Distance = side2Distance + SHAPE[i]['distance']
                if(i != minPoint and i != maxPoint): SHAPE[i]['side'] = 2
                if (i == minPoint): side2Done = True
                side2.append( { 'x': SHAPE[i]['x'], 'y': SHAPE[i]['y'] } )

            i = i + 1
            if( i > len(SHAPE)-1): i = 0

            if (side1Done and side2Done): break

            k = k + 1
            if (k == 20): break

        svg = '<?xml version="1.0" encoding="utf-8"?>'
        svg = svg + '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
        svg = svg + '<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="-10 -10 120 120" xml:space="preserve" width="150px" height="150px">'

        # background
        svg = svg + '<circle cx="' + str(WIDTH/2) + '" cy="' + str(HEIGHT/2) + '" r="' + str(WIDTH/2-1) + '" fill="' + BG_COLOR + '"/>'

        minPointShadowX = SHAPE[minPoint]['x'] + WIDTH * math.cos(math.radians(minAngle))
        minPointShadowY = SHAPE[minPoint]['y'] - HEIGHT * math.sin(math.radians(minAngle))
        maxPointShadowX = SHAPE[maxPoint]['x'] + WIDTH * math.cos(math.radians(maxAngle))
        maxPointShadowY = SHAPE[maxPoint]['y'] - HEIGHT * math.sin(math.radians(maxAngle))

        shadow = [ {'x': maxPointShadowX, 'y': maxPointShadowY } ] + \
                side2 + \
                [ {'x': minPointShadowX, 'y': minPointShadowY } ]
        
        svg = svg + '<defs><mask id="shadowMask">'
        svg = svg + '	  <rect width="100%" height="100%" fill="black"/>'
        svg = svg + '	  <circle cx="' + str(WIDTH/2) + '" cy="' + str(HEIGHT/2) + '" r="' + str(WIDTH/2-1) + '" fill="white"/>'
        svg = svg + '</mask></defs>'

        svg = svg + self.generatePath('none',PRIMARY_COLOR,SHAPE)

        shadow_svg = self.generatePath('none','black',shadow,'mask="url(#shadowMask)" fill-opacity="0.5"')

        if (self.elevation>0): 
            svg = svg + self.generatePath(LIGHT_COLOR,'none',side1)
        else:
            svg = svg + self.generatePath(PRIMARY_COLOR,'none',side2)

        if (self.elevation>0): svg = svg + shadow_svg

        svg = svg + self.generateArc(WIDTH/2,PRIMARY_COLOR,'none',self.sunset_azimuth,self.sunrise_azimuth)
        svg = svg + self.generateArc(WIDTH/2,LIGHT_COLOR,'none',self.sunrise_azimuth,self.sunset_azimuth)

        svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(self.sunrise_azimuth,WIDTH//2-2), self.degreesToPoint(self.sunrise_azimuth,WIDTH//2+2)])
        svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(self.sunset_azimuth,WIDTH//2-2), self.degreesToPoint(self.sunset_azimuth,WIDTH//2+2)])

        for i in range(0,len(DEGS)):
            if (i == len(DEGS)-1):
                j = 0
            else:
                j = i + 1
            if (i % 2 == 0):
                svg = svg + self.generateArc(WIDTH/2+8,PRIMARY_COLOR,'none',DEGS[i],DEGS[j],'stroke-width="3" stroke-opacity="0.2"')	
            else:
                svg = svg + self.generateArc(WIDTH/2+8,PRIMARY_COLOR,'none',DEGS[i],DEGS[j],'stroke-width="3"')

            if self.debug:
                print(DEGS[i])


        svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(DEGS[0],WIDTH//2+5), self.degreesToPoint(DEGS[0],WIDTH//2+11)])
        svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(DEGS[(len(DEGS))//2],WIDTH//2+5), self.degreesToPoint(DEGS[(len(DEGS))//2],WIDTH//2+11)])

        # moon drawing: compute left and right arcs
        if self.debug:
            print('phase: ' + str(phase))
        left_radius=MOON_RADIUS
        left_sweep=0
        right_radius=MOON_RADIUS
        right_sweep=0
        if (self.phase > 14):
            right_radius = MOON_RADIUS - (2.0*MOON_RADIUS* (1.0 - ((self.phase%14)*0.99 / 14.0)))
            if (right_radius < 0):
                right_radius = right_radius * -1.0
                right_sweep = 0
            else:
                right_sweep = 1
        
        if (self.phase < 14):
            left_radius = MOON_RADIUS - (2.0*MOON_RADIUS* (1.0 - ((self.phase%14)*0.99 / 14.0)))
            if (left_radius < 0):
                left_radius = left_radius * -1.0
                left_sweep = 1
                
        if (self.moon_elevation>0): 
            svg = svg + '<path stroke="none" stroke-width="0" fill="' + MOON_COLOR \
            + '" d="M ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']-MOON_RADIUS) \
            + ' A ' + str(left_radius) + ' ' + str(MOON_RADIUS) + ' 0 0 ' + str(left_sweep) + ' ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']+MOON_RADIUS) \
            + '   ' + str(right_radius) + ' ' + str(MOON_RADIUS) + ' 0 0 ' + str(right_sweep) + ' ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']-MOON_RADIUS) + ' z" />'

        # sun drawing
        if (self.sun_elevation>0): 
            svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS) + '" stroke="none" stroke-width="0" fill="' + SUN_COLOR + '55" />'
            svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS -1) + '" stroke="none" stroke-width="0" fill="' + SUN_COLOR + '99" />'
            svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS -2) + '" stroke="' + SUN_COLOR + '" stroke-width="0" fill="' + SUN_COLOR + '" />'

        #wind
        svg = svg + self.generatePath(WIND_COLOR,'none',[self.degreesToPoint(wind_azimuth,WIDTH//2+5), self.degreesToPoint(wind_azimuth,WIDTH//2+20)],"","2")


        svg = svg + '</svg>'

        #if self.debug:
        #	print(svg)

        f = open(FILENAME, 'w')
        f.write(svg)
        f.close()


def main():

    t1 = datetime.now()

    s = Shadow()

    args = sys.argv
       
    if(len(args) == 1):
        dummy = 0
    else:
        if(args[1] == "update"):
            s.generateSVG(args[2])

    t2 = datetime.now()
    print("Done in " + str(t2-t1) + " seconds")

if __name__ == '__main__':
    main()

to call the script from OH on SunAziumuth and WindAngle change:

rule "Updater: python: Shadow SVG"
when
    Item Sun_Azimuth received update or
    Item WS_WindAngle received update or
    System started
then
    executeCommandLine("python3", "/etc/openhab/scripts/shadow.py", "update", (WS_WindAngle.state as Number).floatValue.toString)
end

if you don’t have WindAngle in your home setup, either remove respective parts from the python script direcly or call the script with 0 (which will draw line, but it is not tha much visible :wink: )

executeCommandLine("python3", "/etc/openhab/scripts/shadow.py", "update", 0)

to display it on BasicUI

Image url="https://<OHIP>/static/shadow.svg" refresh=30000
4 Likes

That’s great, thanks!

One thing, I’m not very good in calculating the coordinates, is there a tool out there, where I can draw lines and they’ll get translated in the correct shapes already?

I created mine using GIMP, which allows you to convert a drawn image into an SVG shape. From there, you just copy the coordinates and adjust them to match the format used in the Python script.

That was the easiest method I could find.

The only tricky part was rotating the house correctly to match the WNES (West, North, East, South) orientation. It needs to be precise; otherwise, the sun’s position relative to the house will be completely off.

Google Maps was a great help for this—I took a screenshot of my house, added a new layer on top, and traced it to align everything properly. Some minor tweaking was needed to determine where to place the first coordinate in the SVG format, but once that was sorted, the rest was straightforward.

1 Like

I have done this as follows:

  1. Copy your home from Google Maps

  2. Scale the image to 100 x 100 px with XnView
    image

  3. Check the coordinates with
    Mobilefish.com - Record XY mouse coordinates on an uploaded image

  4. Move the image of your house by adding an offset to all x and y coordinates (in shadow.py) so that your house is in the center of shadow.svg. You just have to try it

2 Likes

Thank you for the online link. Can you please post your SHAPE-hashmap?
Perhaps I’m just too stupid, but my coordinates are quite simple and look something like:

so, my SHAPE looks like this:

SHAPE = [
    {'x': 21.00, 'y': 87.00}, {'x': 92.00, 'y': 75.00}, {'x': 81.00, 'y': 9.00}, {'x': 11.00, 'y': 19.00}
]

but the SVG looks not really good :wink: :

From what I think, the light_color shouldn’t be on the north-west part of the house…?

Here are my coordinates, but beware they are not exactly the same as in the example, I just did that today.

SHAPE = [{'x':14, 'y': 32}, \
		{'x': 58, 'y': 21}, \
		{'x': 64, 'y': 45}, \
		{'x': 66, 'y': 52}, \
		{'x': 80, 'y': 47}, \
        {'x': 89, 'y': 70}, \
		{'x': 78, 'y': 74}, \
		{'x': 71, 'y': 77}, \
		{'x': 30, 'y': 85}, \
		{'x': 22, 'y': 57}]

yes, you have inverted it - this was my initial issue as well,
that’s where gimp was handy - mirror it by the X and Y as it is now showing shadow otherway round than it should be

This topic was automatically closed 41 days after the last reply. New replies are no longer allowed.