Skip to content

Commit

Permalink
Improve normalization/calculation of event times (#18)
Browse files Browse the repository at this point in the history
- Resolve issue with using UTC to normalize event times
- Handle high latitude locations where sunrise/sunset may not occur at certain times
- Show the time for events that don't occur as "-" (previously the whole label would disappear)
- Add interactive dev mode with predefined locations/dates to test things in a visual manner + python script to generate new test data if needed
- More tests
  • Loading branch information
avataar authored Mar 22, 2023
1 parent 985c7d8 commit 23f473d
Show file tree
Hide file tree
Showing 18 changed files with 1,339 additions and 148 deletions.
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ trim_trailing_whitespace = false

[*.snap]
trim_trailing_whitespace = false

[*.py]
indent_size = 4
13 changes: 13 additions & 0 deletions dev/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {
"env": {
"browser": true
},
"rules": {
"semi": ["warn", "always"],
"space-before-function-paren": ["warn", "never"],
"no-console": "warn",
"no-multiple-empty-lines": "warn",
"prefer-const": "warn",
"@typescript-eslint/no-floating-promises": "off",
}
}
62 changes: 62 additions & 0 deletions dev/dev.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;200;300;400;500;700;900&display=swap');

body {
font-family: 'Roboto', sans-serif;
background: #111111;
}

body.light {
background: #fafafa;
}

.card {
background-color: rgb(28, 28, 28);
border-radius: 4px;
box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12);
padding: 1rem;
max-width: 1000px;
margin: auto;
}

body.light .card {
background-color: #fff;
}

body:not(.light) #dev-panel {
color: #fafafa;
}

#dev-panel > div {
max-width: 1000px;
margin: auto;
}

#time {
text-align: center;
padding-top: 1em;
font-size: 120%;
}

#buttons {
display: flex;
}

#langs {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
}

#buttons > div {
flex-grow: 1;
}

.radio {
display: flex;
flex-flow: column wrap;
max-height: 150px;
}

#buttons div {
padding-bottom: 10px;
}
180 changes: 180 additions & 0 deletions dev/dev.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
const settings = {
darkMode: false,
intervalUpdateMs: 200,
stepMinutes: 20,
date: null,
place: null,
lang: "en"
};

fetch("/test-data.json")
.then((response) => response.json())
.then((json) => init(json));

function init(testData) {
const test = document.querySelector("#test");
const dates = Object.keys(testData);
const places = Object.keys(testData[dates[0]]);
settings.date = dates[0];
settings.place = places[0];

createRadioButtons("Date", "date", dates, settings.date,
(date) => {
settings.date = date;
update();
});

createRadioButtons("Place", "place", places, settings.place,
(place) => {
settings.place = place;
update();
});

createRadioButtons("Step (minutes)", "step", [20, 10, 5], settings.stepMinutes, (min) => {
settings.stepMinutes = min;
update();
});

createRadioButtons("Interval (ms)", "interval", [500, 200, 100], settings.intervalUpdateMs,
(ms) => {
settings.intervalUpdateMs = ms;
resetInterval();
});

createRadioButtons("Theme", "theme", ["Light", "Dark"], settings.darkMode ? "Dark" : "Light",
(theme) => {
settings.darkMode = theme !== "Light";
update();
});

createLanguageButtons((lang) => {
settings.lang = lang;
update();
});

let fixedOffset = 6 * 60 * 60 * 1000; // 06:00:00
let interval = null;

update();

resetInterval();

function update() {
const hours = Math.floor(fixedOffset / (60 * 60 * 1000));
const remainingMillis = fixedOffset % (60 * 60 * 1000);
const minutes = Math.floor(remainingMillis / (60 * 1000));
const fixedTime = String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0");
test.setFixedNow(new Date(settings.date + "T" + fixedTime + ":00"));
document.querySelector("#time").innerText = fixedTime;

if (settings.darkMode) {
document.body.classList.remove("light");
} else {
document.body.classList.add("light");
}
test.setConfig({
title: "Sunrise & Sunset",
showAzimuth: true,
showElevation: true,
darkMode: settings.darkMode
});

const tzOffset = testData[settings.date][settings.place]["tzOffset"];
const sunData = Object.assign({}, testData[settings.date][settings.place]["sun"]);
fixAllTimesTz(sunData, tzOffset);

test.hass = {
language: settings.lang,
locale: {
language: settings.lang
},
themes: {
darkMode: settings.darkMode
},
states: {
"sun.sun": {
state: "above_horizon",
attributes: sunData
}
}
};
}

function resetInterval() {
if (interval) {
clearInterval(interval);
}
interval = setInterval(function() {
fixedOffset += settings.stepMinutes * 60 * 1000;
if (fixedOffset >= 24 * 60 * 60 * 1000) {
fixedOffset = 0;
}
update();
}, settings.intervalUpdateMs);
}

function fixAllTimesTz(sunData, tzOffset) {
Object.keys(sunData).forEach(key => {
if (key.startsWith("next_")) {
sunData[key] = fixTz(sunData[key], tzOffset);
}
});
}

function fixTz(date, tzOffset) {
const original = new Date(date);
const localTzOffset = original.getTimezoneOffset() * 60;
return new Date(original.getTime() + localTzOffset * 1000 + tzOffset * 1000).toISOString();
}
}

function createRadioButtons(desc, id, values, defaultValue, callback) {
const buttons = document.querySelector("#buttons");
const container = document.createElement("div");
container.id = id;
if (values.length > 5) {
// I couldn't figure how to make the div grow on its own when the items wrap :(
container.style.flexGrow = "2.4";
}
buttons.appendChild(container);

const label = document.createElement("div");
label.innerText = desc;
container.appendChild(label);

const radios = document.createElement("div");
radios.classList.add("radio");
container.appendChild(radios);

values.forEach((value, i) => {
const div = document.createElement("div");
radios.appendChild(div);

const input = document.createElement("input");
input.type = "radio";
input.id = id + i;
input.name = id;
input.value = value;
if (value === defaultValue) {
input.setAttribute("checked", "checked");
}
input.onclick = () => callback(value);
div.appendChild(input);

const label = document.createElement("label");
label.setAttribute("for", input.id);
label.innerText = value;
div.appendChild(label);
});
}

function createLanguageButtons(callback) {
const langs = document.querySelector("#langs");
Object.keys(window.Constants.LOCALIZATION_LANGUAGES).forEach(lang => {
const langButton = document.createElement("button");
langButton.innerText = lang;
langButton.onclick = () => callback(lang);
langs.appendChild(langButton);
});
}

103 changes: 103 additions & 0 deletions dev/generate-test-data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Generates test data (sun event times) for some places and dates.
The test data will be saved to test-data.json in the same directory.
The values are generated with the assumption that the current moment is either
before noon (but after sunrise) or after noon (but before dusk) to mimic
how Home Assistant provides those values:
- next_dawn will be tomorrow's value
- next_dusk will be today's value
- next_midnight will be today's value
- next_noon will be today or tomorrow's value
- next_sunrise will be tomorrow's value
- next_sunset will be today's value
All times will be serialized with TZ +00:00 to mimic Home Assistant.
The generated values for azimuth and elevation correspond to the moment of solar noon.
The elevation is used to detect certain weirdness at high latitudes when the sun is
below the horizon -- since the generated elevation is the maximum for the day it
does the job fine even though it doesn't change as it would in a real environment.
"""
import datetime
import json

from astral import LocationInfo
from astral.sun import azimuth, elevation, dawn, dusk, noon, sunrise, sunset, midnight

# Places to generate data for
PLACES = [
LocationInfo("Sofia", "BG", "Europe/Sofia", 42.7, 23.3),
LocationInfo("Berlin", "DE", "Europe/Berlin", 52.5, 13.4),
LocationInfo("Karasjok", "NO", "Europe/Oslo", 69.5, 25.5),
LocationInfo("Quito", "EC", "America/Lima", -0.15, -78.5),
LocationInfo("Sao Paulo", "BR", "America/Sao_Paulo", -23.5, -46.6)
]

# Dates to generate data for with a boolean that indicates if next_noon is the same day
DATES = [
# Quirky in Karasjok - sunrise for next day, no sunrise/sunset on actual day
(datetime.date(2023, 1, 19), False),
(datetime.date(2023, 3, 17), True),
# Quirky in Karasjok - sunrise is right after sunset before midnight
(datetime.date(2023, 5, 18), False),
# Summer solstice
(datetime.date(2023, 6, 21), True),
(datetime.date(2023, 9, 21), False),
(datetime.date(2023, 8, 8), True),
(datetime.date(2023, 11, 1), False),
# Winter solstice
(datetime.date(2023, 12, 22), True)
]


def next_event(place, date, event_fun):
mod = 0
while True:
# Loop until we find the next event, this mimics Home Assistant's Sun integration
try:
check_date = date + datetime.timedelta(days=mod)
return event_fun(place.observer, date=check_date, tzinfo="UTC").isoformat()
except ValueError:
mod += 1


def generate_data(place, date_today, tomorrow_noon):
date_tomorrow = date_today + datetime.timedelta(days=1)

_noon = noon(place, date_today, place.tzinfo)
_azimuth = round(azimuth(place, _noon), 2)
_elevation = round(elevation(place, _noon), 2)

# tz and tzOffset are used by the dev code
return {
"tz": place.tzinfo.zone,
"tzOffset": noon(place, date_today, place.tzinfo).utcoffset().total_seconds(),
"sun": {
'next_dawn': next_event(place, date_tomorrow, dawn),
'next_dusk': next_event(place, date_today, dusk),
'next_midnight': next_event(place, date_tomorrow, midnight),
'next_noon': next_event(place,
date_tomorrow if tomorrow_noon else date_today,
noon),
'next_rising': next_event(place, date_tomorrow, sunrise),
'next_setting': next_event(place, date_today, sunset),
'elevation': _elevation,
'azimuth': _azimuth,
'rising': False
},
}


data = {}
for date, tomorrow_noon in DATES:
place_data = {}
data[str(date)] = place_data
for place in PLACES:
place_name = f"{place.name} {place.region} ({place.latitude} {place.longitude})"
place_data[place_name] = generate_data(place, date, tomorrow_noon)

with open('test-data.json', 'w') as fp:
json.dump(data, fp, indent=2)
print(json.dumps(data, indent=2))
Loading

0 comments on commit 23f473d

Please sign in to comment.