Periodic tasks with TimerSwitch

·

6 min read

Every now and then in automation systems there will be a problem where something needs to happen at a specified time and for some predetermined duration. To make things more interesting, that something should happen only on certain weekday. Like triggering the mixing of the irrigation solution every workday, at 17:00 that runs for 15 minutes, except on weekends.

Obviously my first idea on solving this was using the TON, TOFF and TP function blocks (turn on after a period, turn off after a period and pulse timer). Until I realized that having more than one bit to drive and more than one period to take care of makes this approach quite unfeasible. So after digging through the library documentation of Codesys I found a really nice construct TimerSwitch.

On the first look, it looks really promising. It can drive the output of DWORD (32) bits, supports scheduling using the idea of first on time and last on time, setting the days (including weekdays/weekends), can accommodate the time-zone switching and so forth. But getting it to work actually has been quite a ride, so I thought I'll document the nuances up here, for the reference.

TimerSwitch aslSchedule input parameter

So first key thing is that contrary to the documentation IT IS NOT pointer to anything. The input is actually ARRAY [*] OF UTIL.Schedule. Yes. This is pretty wild, because the documentation clearly states it should be pointer to schedule. So fear not and make it an array and pass it directly to the function block.

A sample of that array can be seen here, where a declaration of 32 schedule items is made and first 2 are initialised.

// Ofc. it would be nice to have ST syntax colouring here as well :P
(* Create an array with 32 schedule items, e.g. for every bit
   and initialize the first two items there *)
aslMySwitchingSchedule: ARRAY[0..31] OF UTIL.Schedule :=
    [(usiSwitch  := 1, 
      todFirstOn := TOD#08:00:00, todLastOn  := TOD#09:00:00, 
      byDayFlags := DAY_FLAGS.EVERY_DAY),
     (usiSwitch  := 1,
      todFirstOn := TOD#20:00:00, todLastOn  := TOD#21:00:00,
      byDayFlags := DAY_FLAGS.WEEKEND)];

I have not tested out, if the array has any fixed maximum size limits, so far the schedule with automatically created 512 items seemed to work correctly, but rather not make assumption on this. The documentation has no clear information on this neither.

Continuous array with usiSwitch > 0

Second very key thing is that the schedule items have to be on sequential indices. To make it more clear and understandable let me make a quick example, which would not work as expected (at least, as I was expecting).

aslMySwitchingSchedule: ARRAY [0..2] OF Util.Schedule :=
    [(usiSwitch := 1, 
      todFirstOn := TOD#20:00:00, todLastOn := TOD#21:00:00, 
      byDayFlags := DAY_FLAGS.EVERY_DAY),
      (* NBNB! THE FOLLOWING ITEM BREAKS WHOLE SCHEDULE
         AND THE ITEM(S) AFTER WILL NOT BE EXECUTED *)
     (usiSwitch := 0, 
      todFirstOn := TOD#00:00:00, todLastOn := TOD#00:00:00,
      byDayFlags := DAY_FLAGS.EVERY_DAY),
     (usiSwitch := 4,
      todFirstOn := TOD#20:00:00, todLastOn := TOD#21:00:00,
      byDayFlags := DAY_FLAGS.EVERY_DAY)];

Based on this schedule the educated assumption would be that between 20:00 and 21:00 the bits set in the dwSwitches output are 0 and 4 (B#1001 or making the value H#09). Well it turns out that my educated guess was not really that educated. It seems that the internal logic uses the fact that usiSwitch = 0 and after that does not process any following array items.

In general this means that one can't have any kind of fancy logic where the certain indexes are allocated/modified so that the usiSwitch might be eventually 0. E.g. you update some parts of the array to contain structures where the usiSwitch for any reason is set to 0. This will render the whole schedule after this item as non-triggered.

No Rollovers

Another thing which I ran into and ... probably depends on your viewpoint ... didn't make any sense to me (might make to you). This is the case where the schedule kicks off at some point before midnight and runs into some other point during next day. To get it working two schedule items are needed (ordering is irrelevant). Again an example

(* Will always output 0 in the dwSwitches *)
aslMySwitchingSchedule: ARRAY [0..1] OF Util.Schedule :=
    [(usiSwitch := 1, 
      todFirstOn := TOD#23:00:00, todLastOn := TOD#01:00:00, 
      byDayFlags := DAY_FLAGS.EVERY_DAY)];

And what didn't make any sense to me as well, was that if there is an idea that the rollover should not happen, then at least it could run until T#23:59:59.999. But it won't. The correct way to get it working would be to create a following array structure which runs until midnight and then runs from midnight to the specified time.

(* Will output 1 in the dwSwitches between 23:00 and 01:00 *)
aslMySwitchingSchedule: ARRAY [0..1] OF Util.Schedule :=
    [(usiSwitch := 1, 
      todFirstOn := TOD#23:00:00, todLastOn := TOD#23:59:59.999, 
      byDayFlags := DAY_FLAGS.EVERY_DAY),
     (usiSwitch := 1, 
      todFirstOn := TOD#00:00:00, todLastOn := TOD#01:00:00, 
      byDayFlags := DAY_FLAGS.EVERY_DAY)];

In general, if there is any kind of automation involved in the schedule calculations, this makes it a bit annoying. In general there is a need for additional check that the duration doesn't roll over to the next day, and if it does, additional schedule item needs to be created.

Time and duration calculations

The final a most annoying thing which triggered me to create this whole blog are the time and duration calculations. Codesys help/reference states that:

The data types DATE, DATE_AND_TIME (DT), and TIME_OF_DAY (TOD) are handled internally like a DWORD (32-bit value).

...

Data TypeLower LimitUpper LimitMemoryResolution
TIME_OF_DAYTIME_OF_DAY#0:0:0TIME_OF_DAY#23:59:59.99932-bitMilliseconds

So obviously if you need to translate a business logic into schedule item (e.g. based on some criteria), which says to start at 20:00 and run for 6 hours, then a sensible calculation that one would do is to take TOD#20:00:00 + T#06:00:00 and according to all Codesys UI logic one will get TOD#04:00:00 as result.

But it clearly did not work. So even if I created a new schedule item and added it to array, it resulted in trigger not turning off at all. Which was a bit weird, as it clearly should've had done so. After a small investigation and using TOD_TO_DWORD() it became clear that although the TOD field is showing the correct TOD, the actual DWORD value used, is not as expected. And honestly at this point, I will not make any assumptions as how the whole system interprets this value.

Again an example and how I solved it, keeping in mind that I'm an average developer.

aslMySchedule: ARRAY[0..1] OF UTIL.Schedule;
todStart: TIME_OF_DAY := TOD#20:00:00;
tDuration: TIME := T#6h;
todEnd: TIME_OF_DAY;

(* Codesys tooling shows (correctly|incorrectly)? that the result
   is T#04:00:00, but no idea how this really is interpreted *)
todEnd := todStart + tDuration;

(* So to solve it, and get correct result I check if the
   value is higher than TOD#23:59:59.999 and if it is then
   subtract 24h from it, which seems to give the right result *)
todEnd := todStart + tDuration;
IF todEnd > TOD_TO_DWORD(TOD#23:59:59.999) THEN
    todEnd := todEnd - TIME_TO_DWORD(T#24h);
END_IF