There is no simple alternative to $moment() in JSONata, so I may have another look at this again myself. I am just (I believe rightly) concerned as to how it is implemented!
Daylight Saving is probably the most complicated bit of coding I have ever done, and I have been chipping away at this problem for about two years. As well as reading in my electricity tariff and meter consumption, I am also reading in solar forecasts in arrays, and have to deal with ‘time’. My most recent project was to call Solcast by API, and this produces all the returned half-hour periods with an end-time, which is in UTC. Solcast, quite rightly, say that if you want the time in local time you have to do it yourself. So I have.
Living in the UK, I can (and have) used the ‘01:00 UTC on the last Sunday in March and October’ rule to find the DST switching times, but decided to make my code universal. I am using the moment-timezone library, as an extension to the moment library. This does not work in JSONata - I just tried $moment().tz - and since JSONata is not exactly efficient, I have written code to obtain local DST details using JavaScript. This sits in a function node, and the moment library is loaded using the node setup module load feature.
This is the JS code
// guess timezone but use NR TZ env variable and work in local time
// for Home Assistant addon, TZ should be set, otherwise set in flow properties
let NRzone = env.get("TZ");
let LCzone = moment.tz.guess(true);
let mzon = moment.tz(NRzone);
let mnow = moment();
let utcoff = mzon.tz(NRzone).utcOffset();
// look in January and June to see if UTC offset different => DST necessary
let testjan = moment.tz({month:0, day:1}, NRzone);
let testjun = moment.tz({month:5, day:1}, NRzone);
let dst_jan = {
"test": testjan.format(),
"offset": testjan.utcOffset(),
"timezone": testjan.format('Z z') };
let dst_jun = {
"test": testjun.format(),
"offset": testjun.utcOffset(),
"timezone": testjun.format('Z z') };
// fixed dates for year and spring & autumn defaults
let year_start = moment.tz(NRzone).startOf('year')
let year_end = moment.tz(NRzone).endOf('year');
let springDST = moment.tz({month: 2, day: 20, hour: 3, minute: 0}, NRzone);
let autumnDST = moment.tz({month: 8, day: 22, hour: 3, minute: 0}, NRzone);
let dstarea = dst_jan.offset != dst_jun.offset; // does DST apply here
let dstnrth = dst_jun.offset > dst_jan.offset; // north or south hemisphere
let dstison = (dstarea && utcoff == Math.max(dst_jan.offset, dst_jun.offset)); // is DST currently on
let dstmodes = dstarea == false ? ["OFF", "OFF", "OFF"] : dstnrth ? ["OFF", "ON", "OFF"] : ["ON", "OFF","ON"];
// function to scan for DST change day then quarter hour - start moment and days to scan
function dst(event, lookdays){
let testhour = event.hour();
let x=0;
while (x<=lookdays){
event.add(24, 'hours');
if (event.hour() != testhour) {
testhour = event.hour();
x = lookdays;
};
x++;
};
event.hour(0);
testhour = event.hour();
let testmins = event.minute();
while (event.hour() == testhour && event.minutes() == testmins && testhour < 6) {
event.add(15, 'minute');
testmins = testmins+15;
if (testmins>45) {
testmins = 0;
testhour++;
};
};
return event;
};
// scan from 1st March to end April, and 1st September to end November
if (dstarea) {
springDST = dst(moment.tz({month:2, day:1, hour:12 }, NRzone), 61);
autumnDST = dst(moment.tz({month:8, day:1, hour:12 }, NRzone), 93);
};
// for DST set spring and autumn change to 1 second before exact time
// this makes from-upto times inclusive for each period
let xspring = springDST.clone().subtract(1, 'second').unix();
let xautumn = autumnDST.clone().subtract(1, 'second').unix();
// for both DST and non-DST, all three periods are contiguous
// period 'A' from January to DST (or 'spring')
let dst_a = {
"period": "to spring",
"from": {"local": year_start.format(),
"utc": year_start.utc(),
"seconds": year_start.unix()},
"upto": {"local": springDST.format(),
"utc": springDST.utc(),
"seconds": xspring },
"offset": dst_jan.offset,
"timezone": dst_jan.timezone,
"mode": dstmodes[0]
};
// period 'B' from DST/spring to DST (or 'autumn')
let dst_b = {
"period": "spring to autumn",
"from": {"local": springDST.format(),
"utc": springDST.utc(),
"seconds": springDST.unix()},
"upto": {"local": autumnDST.format(),
"utc": autumnDST.utc(),
"seconds": xautumn },
"offset": dst_jun.offset,
"timezone": dst_jun.timezone,
"mode": dstmodes[1]
};
// period 'C' from DST/autumn to end of the year
let dst_c = {
"period": "from autumn",
"from": {"local": autumnDST.format(),
"utc": autumnDST.utc(),
"seconds": autumnDST.unix()},
"upto": {"local": year_end.format(),
"utc": year_end.utc(),
"seconds": year_end.unix() },
"offset": dst_jan.offset,
"timezone": dst_jan.timezone,
"mode": dstmodes[2]
};
// final result
msg.dst =
{
"MYzone": LCzone,
"NRzone": NRzone,
"now_loc": mnow.format(),
"now_utc": mnow.utc(),
"zone_loc": mzon.format(),
"zone_utc": mzon.utc(),
"for_year": mzon.year(),
"utc_offset": utcoff,
"dst_aware": dstarea,
"dst_north": dstnrth,
"dst_is_on": dstison,
"dst_details": [dst_a, dst_b, dst_c]
};
return msg;
and this it what it produces, updated every time I make an API call to Solcast
{
"MYzone": "Europe/London",
"NRzone": "Europe/London",
"now_loc": "2024-10-24T06:05:20+01:00",
"now_utc": "2024-10-24T05:05:20.977Z",
"zone_loc": "2024-10-24T06:05:20+01:00",
"zone_utc": "2024-10-24T05:05:20.976Z",
"for_year": 2024,
"utc_offset": 60,
"dst_aware": true,
"dst_north": true,
"dst_is_on": true,
"dst_details": [
{
"period": "to spring",
"from": {
"local": "2024-01-01T00:00:00Z",
"utc": "2024-01-01T00:00:00.000Z",
"seconds": 1704067200
},
"upto": {
"local": "2024-03-31T02:00:00+01:00",
"utc": "2024-03-31T01:00:00.000Z",
"seconds": 1711846799
},
"offset": 0,
"timezone": "+00:00 GMT",
"mode": "OFF"
},
{
"period": "spring to autumn",
"from": {
"local": "2024-03-31T01:00:00Z",
"utc": "2024-03-31T01:00:00.000Z",
"seconds": 1711846800
},
"upto": {
"local": "2024-10-27T01:00:00Z",
"utc": "2024-10-27T01:00:00.000Z",
"seconds": 1729990799
},
"offset": 60,
"timezone": "+01:00 BST",
"mode": "ON"
},
{
"period": "from autumn",
"from": {
"local": "2024-10-27T01:00:00Z",
"utc": "2024-10-27T01:00:00.000Z",
"seconds": 1729990800
},
"upto": {
"local": "2024-12-31T23:59:59Z",
"utc": "2024-12-31T23:59:59.999Z",
"seconds": 1735689599
},
"offset": 0,
"timezone": "+00:00 GMT",
"mode": "OFF"
}
]
}
By pulling in the Node-RED (add-on) timezone from the environment variable, I can easily override this for testing, and have checked my code for all the strange places around the world where it does not change, changes by 15 minutes, is in the southern hemisphere and so on. Seems to work.
This DST object is stored in NR context. You will notice that the dst_details array is three periods with a from and upto milliseconds time, and these are contiguous for the full year. Given a full array of ‘times’ (from a tariff array, solar forecast array etc) it is easy to filter the DST details for the one period or two periods that include the times within the array. Thus, a DST time change will occur within the array of times if and only if there are two DST periods found.
I use the following bit of JSONata code to
- identify the first and last time points (UTC) in the array of time periods
- pull the covering DST details for this period
- compile the DST offset and zone for the DST current / before a change
- compile the DST offset and zone for the DST after change if a change is within the span of the array time period
Then I am in a position to transform the array of times. Here I am removing 30 minutes from the ‘end time’ to get a ‘start time’ as Solcast give an end time for all forecast periods, and personally I want to know when a period starts from, not when it ends. After that I pull the +00:00 or +01:00 bit of the timezone I need, depending on DST, and then create a new ‘period_start’ which is now 30 minutes before the end time, and in local time, and correct for DST.
I have tested this, and as we (here) are about to put our clocks back from BST to GMT this very Sunday, I will have the once-a-year opportunity to check the code again for correct DST change.
For solar forecasts, the timezone change at DST is pointless, since all time changes take place at night, and at night the sun does not shine (duh…) but this is particularly important for translating UTC time to local time in energy pricing tariffs.
Of course, it does lead to the issue of only 23 hours in one day in March, and 25 hours in one day in October…
(
/* April 2024 */
/* read first and last times in payload and get covering DST change info */
/* calculate period start (-30 mins from period_end) and set to local time */
$times:=(payload.**.period_end).($toMillis()/1000 -1800);
$first:=$min($times); $last:=$max($times);
$tz:=dst.dst_details[($first>=from.seconds and $first<=upto.seconds) or ($last>=from.seconds and $last<=upto.seconds)];
$x:=$tz{
"offset_a": $[0].offset,
"zone_a": $substringAfter($[0].timezone," "),
"hhmm_a": $substringBefore($[0].timezone, " ")~>$replace(/[: ]/, ""),
"uptosec": $[0].upto.seconds*1000,
"change": $exists($[1]),
"offset_b": $[1].offset,
"zone_b": $substringAfter($[1].timezone," "),
"hhmm_b": $substringBefore($[1].timezone, " ")~>$replace(/[: ]/, "")
};
/* if each start time past DST change, use post change details */
/* use offset as 'hhmm' in $fromMillis to force time to local */
/* tranform on 3rd layer object to add period_start and zone */
/* also to delete period and period_end as no longer required */
payload~>|*.*|(
$start:=$toMillis(period_end) -1800000;
$hhmm:= $start<$x.uptosec ? $x.hhmm_a : $x.hhmm_b;
{"period_start": $fromMillis($start,'[Y]-[M01]-[D01]T[H01]:[m01]:[s][Z]', $hhmm),
"zone": $start<$x.uptosec ? $x.zone_a : $x.zone_b}),
["period", "period_end"] |;
)