# Sun indicator

Displays a pretty, dynamically generated indicator of what point in the day or night you are

## `sun.1m.py`

``````#!/usr/bin/env python3

# Copyright (c) 2024 Kipras Melnikovas
# https://github.com/kiprasmel/xbar-plugins

# <xbar.title>Sun indicator</xbar.title>
# <xbar.version>v0.1</xbar.version>
# <xbar.author>Kipras Melnikovas</xbar.author>
# <xbar.author.github>kiprasmel</xbar.author.github>
# <xbar.desc>Displays a pretty, dynamically generated indicator of what point in the day or night you are</xbar.desc>
# <xbar.image>https://raw.githubusercontent.com/kiprasmel/xbar-plugins/main/Weather/sunrise-sunset/sun-indicator.png</xbar.image>
# <xbar.dependencies>python,suntime,pillow,ansicolors,scipy</xbar.dependencies>

# <xbar.var>string(VAR_LATITUDE=0.00000): Your latitude to calculate the sun position.</xbar.var>
# <xbar.var>string(VAR_LONGITUDE=0.00000): Your longitude to calculate the sun position.</xbar.var>
# <xbar.var>number(VAR_WIDTH_COMPRESSION_FACTOR=20): The bigger the compression, the smaller the width. Explanation: To accuratelly represent all 1440 minutes in a day, you'd need 1440px of width. Instead, choose how much to compress the width. A compression factor of 20 means that you'll see the visual indicator move slightly every 20 minutes. min = 2, max = 60 (1440/60 = 24 = 1 per hour).</xbar.var>
# <xbar.var>number(VAR_HEIGHT=10): How tall do you want the indicator to be?</xbar.var>
# <xbar.var>number(VAR_BORDER_WIDTH=1): How big should the borders be?</xbar.var>
# <xbar.var>number(VAR_HOURS_OFFSET=0): Debug: how many hours to offset?</xbar.var>
# <xbar.var>boolean(VAR_DRAW_TIME_UNTIL_NEXT_PHASE=false): Display the "time until next phase" indicator?</xbar.var>

import os
import io
import json
import base64
from math import ceil, floor
from datetime import datetime, timezone, timedelta

# deps
from suntime import Sun
from PIL import Image, ImageDraw, ImageFont
from colors import color as ansicolor
# scipy required conditionally below.

def main():
HOURS = 24
MINUTES = HOURS * 60

# ------------------ #
# begin customizable #
# ------------------ #

LATITUDE  = env("VAR_LATITUDE",  "0.00000", float)
LONGITUDE = env("VAR_LONGITUDE", "0.00000", float)

WIDTH_COMPRESSION_FACTOR = env("VAR_WIDTH_COMPRESSION_FACTOR", "20", int)
HOURS_OFFSET             = env("VAR_HOURS_OFFSET",              "0", int)

height       = env("VAR_HEIGHT",      "10", int)
border_width = env("VAR_BORDER_WIDTH", "1", int)

DRAW_TIME_UNTIL_NEXT_PHASE = env("VAR_DRAW_TIME_UNTIL_NEXT_PHASE", "true", boolean)

# ---------------- #
# end customizable #
# ---------------- #

# RGB
# TODO customizable
border_color  = (  0,   0,   0)
chevron_color = (  0,   0,   0)
COLOR_DAY     = (255, 255,   0)
COLOR_NIGHT   = ( 40, 180, 255)
colors = {
"day": COLOR_DAY,
"night": COLOR_NIGHT,
}
YEAR_COLOR_DAY   = (255, 255,   0) # TODO
YEAR_COLOR_NIGHT = ( 40, 180, 255) # TODO
colors_year = {
"day": YEAR_COLOR_DAY,
"night": YEAR_COLOR_NIGHT,
}

UNITS = MINUTES // WIDTH_COMPRESSION_FACTOR

log("vars:")
log("MINUTES %d" % MINUTES)
log("UNITS %d" % UNITS)
log("WIDTH_COMPRESSION_FACTOR %d" % WIDTH_COMPRESSION_FACTOR)
log("HOURS_OFFSET %d" % HOURS_OFFSET)
log("LATITUDE %d" % LATITUDE)
log("LONGITUDE %d" % LONGITUDE)
log("height %d" % height)
log("border_width %d" % border_width)
log("")

assert(-90  < LATITUDE  <  90)
assert(-180 < LONGITUDE < 180)
assert(1 < WIDTH_COMPRESSION_FACTOR <= 60)
assert(height >= 0)
assert(border_width >= 0)

width = UNITS
border_offset = 0.5 if border_width == 1 else border_width // 2

log("width %d height %d" % (width, height))
log("")

# +1 because otherwise missing 1 px
image_width  = width  + 2 * border_width + 1
image_height = height + 2 * border_width + 1

image = Image.new("RGB", (image_width, image_height), "white")
draw = ImageDraw.Draw(image)

draw_border(draw, width, height, border_width, border_color)

# local time
now = (datetime.now(timezone.utc) + timedelta(hours=HOURS_OFFSET)).astimezone()
# now = datetime(2024, 2, 17, 22, 10, 0).astimezone() # night showcase
# now = datetime(2024, 2, 16, 12, 0, 0).astimezone() # day showcase

(now_minute,
sunrise_minute,
sunset_minute,
sunrise,
sunset) = get_time_info_for_latlon(now, LATITUDE, LONGITUDE)

(colors_by_unit,
sun_count_by_minute,
night_count_by_minute) = classify_units_of_time(now_minute,
sunrise_minute,
sunset_minute,
MINUTES,
UNITS,
WIDTH_COMPRESSION_FACTOR)

log("sun_count_by_minute %d night_count_by_minute %d" % (sun_count_by_minute, night_count_by_minute))

draw_day_night_indicator(draw, colors_by_unit, colors, height, border_width)
draw_chevron(draw, image_width, image_height, border_width, chevron_color)

# colors
colw = lambda x, *y: ansicolor(x, fg="#dddddc", *y) # xbar reverts to black all equal
cold = lambda x, *y: ansicolor(x, COLOR_DAY, *y)
coln = lambda x, *y: ansicolor(x, COLOR_NIGHT, *y)

set_rise_info, percent_done_info, sunrise_in, sunset_in  = get_sunset_sunrise_info(now_minute, sunrise_minute, sunset_minute, HOURS, MINUTES, cold, coln)

# TODO: some other bug?
if DRAW_TIME_UNTIL_NEXT_PHASE:
draw_time_until_next_phase(draw, sunrise_in, sunset_in, width, height)

image_base64 = enc_image_base64(image)
# main indicator done

sun_percent, night_percent = get_percents(sun_count_by_minute, night_count_by_minute)

rise_at        = cold(format_hours_mins_colon(sunrise))
set_at         = coln(format_hours_mins_colon(sunset))
sun_up_for     = cold(format_hours_mins_short(timedelta(minutes=sun_count_by_minute),   True))
night_down_for = coln(format_hours_mins_short(timedelta(minutes=night_count_by_minute), True))
up_percent     = cold(sun_percent)
down_percent   = coln(night_percent)

day_night_ratio_full = colw("rise ") + rise_at + colw(" for ") + sun_up_for + "\n" + \
colw("set  ") + set_at  + colw(" for ") + night_down_for

image2 = Image.new("RGB", (width + 1, height + 1), "white")
draw2 = ImageDraw.Draw(image2)
# darken 10% (HSL saturation -10%)
colors2 = {
"day":   "#f2f20d",
"night": "#33b1f4",
}
draw_day_night_indicator(draw2, sorted(colors_by_unit), colors2, height, 0)
day_night_ratio_absolute = enc_image_base64(image2)

img_b64_sun_percent_thru_year = draw_sun_percent_thru_year(now, LATITUDE, LONGITUDE, MINUTES, colors_year)
img_sun_percent_thru_year = f"| image={img_b64_sun_percent_thru_year}"
# whole year done

# equal_spacing_font_arg = "font='Monaco' size=13"
equal_spacing_font_arg = ""

xbar_output = f"""
| image={image_base64}
---
{set_rise_info[0]} {colw("(")}{percent_done_info}{colw(")")} | {equal_spacing_font_arg}
{set_rise_info[1]}                                           | {equal_spacing_font_arg}

{day_night_ratio_full}

{up_percent}{colw(":")}{down_percent}
| image={day_night_ratio_absolute}
-- {colw("sunrise")} {cold(sunrise)}
-- {colw("sunset")}  {coln(sunset)}
--
-- whole year
-- {img_sun_percent_thru_year}
"""

xbar_output = striplines(xbar_output)

log("")
print(xbar_output)
# done

# utils

def env(x, default, transform = lambda y: y):
return transform(os.environ.get(x, default))

boolean = lambda x: x == "true"

DEBUG = int(os.environ.get("DEBUG", 0))
def log(*x, lvl=1):
if DEBUG >= lvl:
print(*x)

def get_time_info_for_latlon(now, LATITUDE, LONGITUDE, local=True, lvl=1):
sun = Sun(LATITUDE, LONGITUDE)
sunrise = sun.get_sunrise_time(now)
sunset  = sun.get_sunset_time(now)
if local:
sunrise = sunrise.astimezone()
sunset = sunset.astimezone()

log("now     %s" % now, lvl=lvl)
log("sunrise %s" % sunrise, lvl=lvl)
log("sunset  %s" % sunset, lvl=lvl)
log("", lvl=lvl)

now_minute     = now.hour     * 60 + now.minute
sunrise_minute = sunrise.hour * 60 + sunrise.minute
sunset_minute  = sunset.hour  * 60 + sunset.minute

log("now     minute %s" %     now_minute, lvl=lvl)
log("sunrise minute %s" % sunrise_minute, lvl=lvl)
log("sunset  minute %s" % sunset_minute, lvl=lvl)
log("", lvl=lvl)
return now_minute, sunrise_minute, sunset_minute, sunrise, sunset

def classify_units_of_time(now_minute, sunrise_minute, sunset_minute, MINUTES, UNITS, WIDTH_COMPRESSION_FACTOR):
# calculate uncompressed to get proper accuracy
sun_count_by_minute   = 0
night_count_by_minute = 0
for minute in range(MINUTES):
is_day = sunrise_minute <= minute <= sunset_minute
if is_day:
sun_count_by_minute   += 1
else:
night_count_by_minute += 1

# calculate compressed
colors_by_unit = [None] * UNITS

sunrise_unit = sunrise_minute // WIDTH_COMPRESSION_FACTOR
sunset_unit  = sunset_minute  // WIDTH_COMPRESSION_FACTOR
for unit in range(UNITS):
is_day = sunrise_unit <= unit <= sunset_unit
if is_day:
color = "day"
else:
color = "night"
colors_by_unit[unit] = color

# calculate uncompressed to get proper accuracy
center_minute_abs = MINUTES / 2
center_minute_rel = now_minute - center_minute_abs
rotate_index = norm_mod(center_minute_rel, MINUTES) / WIDTH_COMPRESSION_FACTOR
rotate_index = ceil(rotate_index)
# why ceil?
# focus at the time when sunset will be in exactly 12 hours.  this means
# that all the "day" colors are to the left, and all the night colors to the
# right (because 12 hours is half of 24 hours, and we're 12 hours away from
# the center, so nothing from the "day" colors should show up on the right
# just yet).
#
# now, as soon as 1 more second passes, we're < 12 hours away from sunrise,
# so we should show at least 1 "day" on the right.  we don't have "seconds",
# but we have "minutes", hence, once at least 1 minute passes, we show at
# least one "day" color on the right.  hence, `ceil`.

# rotate the array to center everything around the current position in time
colors_by_unit = colors_by_unit[rotate_index:] + colors_by_unit[:rotate_index]

return colors_by_unit, sun_count_by_minute, night_count_by_minute

def norm_mod(x, mod):
return ((x % mod) + mod) % mod

# guarantees percents add up to 100, and are properly split up
def get_percents(*counts, total_count=None):
N = len(counts)

counts_sum = sum(counts)
has_custom_count = False

if total_count == None:
total_count = counts_sum
elif total_count != counts_sum:
has_custom_count = True

sum_percents = 0
data = [None] * N
for i, count in enumerate(counts):
data[i] = {}
data[i]["i"] = i
data[i]["count"] = count
data[i]["frac"] = count / total_count
data[i]["percent"] = round(data[i]["frac"] * 100)
data[i]["rem"] = data[i]["frac"] * (100 * 10) % 10 / 10
sum_percents += data[i]["percent"]

if sum_percents < 100 and not has_custom_count:
# too little, need to add to those who barely didn't round up.
# too much is impossible, because round() goes up iff > 0.5

sorted(data, key=lambda x: x["rem"])

mid = 0
while data[mid]["frac"] > 0.5 and mid < N:
mid += 1

while sum_percents != 100:
data[mid]["percent"] += 1
sum_percents += 1
mid += 1

# sort by orig idx
sorted(data, key=lambda x: x["i"])

log(data)

return [x["percent"] for x in data]

def draw_border(draw, width, height, border_width, border_color):
bx1 = 0
by1 = 0
bx2 = width  + border_width * 2
by2 = height + border_width * 2
draw.rectangle([bx1, by1, bx2, by2], outline=border_color, width=border_width)

def draw_day_night_indicator(draw, colors_by_unit, colors, height, border_width):
for i, color in enumerate(colors_by_unit):
x1 = (i	)    + border_width
x2 = (i + 1) + border_width
y1 =		   border_width
y2 = height +  border_width

draw.rectangle([x1, y1, x2, y2], fill=colors[color])
log("i %d coords: (%d %d) (%d %d) color %s" % (i, x1, y1, x2, y2, color), lvl=2)

def draw_chevron(draw, image_width, image_height, border_width, chevron_color):
# TODO: adjust properly when between day & night (now incorrect placement)
chevron_height = 6
chevron_width  = 8
chevron_x      = (image_width - chevron_width) // 2
chevron_bottom = image_height - border_width
chevron_top    = chevron_bottom - chevron_height + 2 # TODO AUTO
chevron_middle = chevron_bottom - chevron_height // 2
BL = (chevron_x, chevron_bottom)
MT = (chevron_x + chevron_width // 2, chevron_top)
BR = (chevron_x + chevron_width, chevron_bottom)
chevron_points = [BL, MT, BR]
draw.polygon(chevron_points, fill=chevron_color)
log(f"chevron polygon: {chevron_points}", lvl=2)

def draw_time_until_next_phase(draw, sunrise_in, sunset_in, width, height):
# try:
# except:
CHAR_W = 6
CHAR_H = 8

if sunrise_in < sunset_in:
delta = sunrise_in
else:
delta = sunset_in

next_phase_in = timedelta(minutes=delta)
(next_ph_h, next_ph_m) = parse_hours_mins(next_phase_in)

will_display_minute_indicator = False

if next_ph_h > 0:
if next_ph_h < 10:
will_display_minute_indicator = True

next_ph_h = str(next_ph_h)
else:
next_ph_h = str(next_ph_m) + "m"

next_ph_h      = "-" + next_ph_h
char_count     = len(str(next_ph_h))
RIGHT_OFFSET   = width - char_count * CHAR_W + 1
RIGHT_OFFSET  += -4 if will_display_minute_indicator else 0
next_ph_height = (height - CHAR_H) // 2

draw.text((RIGHT_OFFSET, next_ph_height), "%s" % next_ph_h, font=font, fill=(0, 0, 0))

if will_display_minute_indicator:
min_left  =      next_ph_m // 10
min_left += 1 if next_ph_m %  10 >= 5 else 0

# lower part
dotx1 = width - 2
doty1 = next_ph_height + 6 + 1 + 1

# upper part
dotx2 = dotx1 + 1
doty2 = doty1 - 1 + 1 - min_left + 0

draw.rectangle([dotx1, doty1, dotx2, doty2], fill=(0, 0, 0))

def draw_sun_percent_thru_year(now, LATITUDE, LONGITUDE, MINUTES, colors):
YEAR_DAYS            = 365
OUR_WIDTH_COMPR_FACT = 12 # factors(1440)
OUR_UNITS            = MINUTES // OUR_WIDTH_COMPR_FACT

day = now.replace() # clone
sun_counts_in_year   = []
night_counts_in_year = []

for day_of_year in range(YEAR_DAYS):
day += timedelta(days=1)

# local=False to avoid a daylight savings time jump,
# i.e. would look v ugly
(now_minute, sunrise_minute, sunset_minute, *_rest) = get_time_info_for_latlon(day, LATITUDE, LONGITUDE, local=False, lvl=2)

(colors_by_unit, sun_minutes, night_minutes) = classify_units_of_time(now_minute, sunrise_minute, sunset_minute,
MINUTES, OUR_UNITS, OUR_WIDTH_COMPR_FACT)

sun_count   = sum([1 for x in colors_by_unit if x == "day" ])
night_count = len(colors_by_unit) - sun_count
sun_counts_in_year.append(sun_count)
night_counts_in_year.append(night_count)

# might not have this dep installed;
# that's ok, image just won't look as smooth.
try:
from scipy.ndimage import uniform_filter1d

window_size = 30     # days
mode        = "wrap" # wrap because, well, the year wraps & we start at the same again.
sun_counts_in_year   = uniform_filter1d(sun_counts_in_year,  window_size, mode=mode)
night_counts_in_year = uniform_filter1d(night_counts_in_year, window_size, mode=mode)
except ImportError as e:
pass

img_height = MINUTES // OUR_WIDTH_COMPR_FACT
image      = Image.new("RGB", (YEAR_DAYS , img_height))
draw       = ImageDraw.Draw(image)

for day_of_year in range(YEAR_DAYS):
sun_count = sun_counts_in_year[day_of_year]
night_count = night_counts_in_year[day_of_year]

x1 = day_of_year
x2 = day_of_year
y1 = 0
y2 = sun_count
color = "day"

draw.rectangle([x1, y1, x2, y2], fill=colors[color])

y1 = y2
y2 = sun_count + night_count
color = "night"

draw.rectangle([x1, y1, x2, y2], fill=colors[color])

return enc_image_base64(image)

def enc_image_base64(image):
buffer = io.BytesIO()
image.save(buffer, format="PNG")
imgb64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return imgb64

def get_sunset_sunrise_info(now_minute, sunrise_minute, sunset_minute, HOURS, MINUTES, cold, coln):
sunrise_in = norm_mod(sunrise_minute - now_minute, MINUTES)
sunset_in  = norm_mod(sunset_minute  - now_minute, MINUTES)

sunrise_info = cold("sunrise in %s" % format_hours_mins(timedelta(minutes=sunrise_in)))
sunset_info  = coln("sunset  in %s" % format_hours_mins(timedelta(minutes=sunset_in)))

percent_done_info = None
set_rise_info = None
if sunrise_minute <= now_minute <= sunset_minute:
set_rise_info = [sunset_info, sunrise_info]

total = norm_mod(sunset_minute - sunrise_minute, MINUTES)
done = norm_mod(now_minute - sunrise_minute, MINUTES)
night_done, *_rest = get_percents(done, total, total_count=total)
percent_done_info = cold("day %d%% done" % night_done)
else:
set_rise_info = [sunrise_info, sunset_info]

total = norm_mod(sunrise_minute - sunset_minute, MINUTES)
done = norm_mod(now_minute - sunset_minute, MINUTES)
night_done, *_rest = get_percents(done, total, total_count=total)
percent_done_info = coln("night %d%% done" % night_done)

return set_rise_info, percent_done_info, sunrise_in, sunset_in

def format_hours_mins(timedelta):
dt = datetime(1,1,1) + timedelta
return dt.strftime("%Hh %Mm")

def parse_hours_mins(timedelta):
dt = datetime(1,1,1) + timedelta
h = int(dt.strftime("%H"))
m = int(dt.strftime("%M"))
return h, m

h, m = parse_hours_mins(timedelta)
hh = f"{h}"
mm = f"0{m}" if padmins and m <= 9 else m
return f"{hh}h{mm}m"

def format_hours_mins_colon(dt):
return dt.strftime("%H") + ":" + dt.strftime("%M")

def striplines(x):
return "\n".join(
map(
lambda y: y.strip(),
x.strip().split("\n")
)
)

if __name__ == '__main__':
main()
``````