Overview: Time-of-Day Interval Testing on S7-1200
Many S7-1200 applications require the controller to decide whether the current local time falls inside a configurable time window. Typical use cases include lighting control, shift-change actuation, HVAC scheduling, tariff switching, dosing windows, and alarm-enable windows. The challenge is implementing a clean, reusable comparison that accepts a time-of-day (TOD) interval defined in hours and minutes, reads the PLC's local time from the system clock, and returns a Boolean that is TRUE only when the current time is within the configured window.
This article documents a fully working SCL (Structured Control Language) implementation using the DTL data type, explains why a naïve comparison of TOD values fails across midnight, and shows how to scale the solution to 20, 50, or 100 parallel intervals without duplicating code. The approach runs on any S7-1200 CPU from firmware V4.0 onward and is fully compatible with TIA Portal V12 through V18 (V19/V20 with no changes). It uses the RD_SYS_T instruction to read the real-time clock and works equally well on S7-1500 with the same source code.
Prerequisites
| Item | Requirement | Notes |
|---|---|---|
| CPU | S7-1200, any model (CPU 1211C / 1212C / 1214C / 1215C / 1217C) | Firmware V4.0 or higher. S7-1500 also supported with same code. |
| Engineering | TIA Portal V12 SP1 Update 4 or later (V13, V14, V15, V15.1, V16, V17, V18, V19, V20) | STEP 7 Professional required for SCL editing on S7-1200. |
| Library | No add-on library required | Uses only standard IEC 61131-3 operators and Siemens basic instructions. |
| Data type |
DTL (Date_And_Time_Long, 12 bytes) |
Supported since S7-1200 firmware V4.0; part of IEC 61131-3 2nd edition. |
| Time source |
RD_SYS_T instruction |
Reads CPU local time. One call per scan is sufficient. |
Confirm the CPU firmware version in Online & Diagnostics → Diagnostics → CPU information → Firmware. The DTL data type, the RD_SYS_T instruction, and the USINT_TO_REAL conversion used in this article are all available from firmware V4.0 upward. Earlier firmware (V1.0–V3.0) used the 8-byte DATE_AND_TIME (DT) type, which is not directly compatible with the code shown here.
Why DTL Instead of TOD or TIME
Siemens offers three time-related data types that engineers frequently confuse:
| Type | Bytes | Range | Resolution | Use case |
|---|---|---|---|---|
TIME |
4 | T#-24d20h31m23s648ms .. T#+24d20h31m23s647ms | 1 ms (signed) | Timers, durations |
TOD (TIME_OF_DAY) |
4 | 00:00:00.000 .. 23:59:59.999 | 1 ms | Time of day only |
LTIME |
8 | extended | 1 ns (signed) | Long durations, S7-1500 |
DTL |
12 | 1970-01-01-00:00:00.0 .. 2554-12-31-23:59:59.999 999 999 | 1 ns | Full calendar + time, matches RD_SYS_T output |
The DTL structure is the natural choice because RD_SYS_T (and the legacy READ_CLK for S7-300/400/WinAC) returns the current clock in this exact format. TOD is unsuitable here because it lacks calendar fields, and TIME is a duration, not a moment. The structure of DTL is shown below.
TYPE DTL
{ S7_Optimized_Access := 'FALSE' }
STRUCT
YEAR : INT; // 1970 .. 2554
MONTH : USINT; // 1 .. 12
DAY : USINT; // 1 .. 31
WEEKDAY : USINT; // 1 = Sunday .. 7 = Saturday
HOUR : USINT; // 0 .. 23
MINUTE : USINT; // 0 .. 59
SECOND : USINT; // 0 .. 59
MILLISECOND : UINT; // 0 .. 999
// 4 bytes reserved on S7-1500, 0 on S7-1200
NANOSECOND : UINT; // 0 .. 999 999 999 (S7-1500 only)
END_STRUCT;
END_TYPE
On the S7-1200 the last two UINT fields (NANOSECOND and reserved) are not present — the structure is 8 bytes on S7-1200 and 12 bytes on S7-1500. The SCL code below accesses only HOUR, MINUTE, and SECOND, which exist in both variants, so the block is portable.
Reading Local Time with RD_SYS_T
Call the RD_SYS_T instruction once per OB1 (or once in any cyclic OB) and store the result in a global DTL tag. Only one call is required regardless of how many interval checks the program performs.
// In OB1 or a cyclic OB
"dbTime"."Now" := RD_SYS_T( ); // RET_VAL is implicitly handled
// or use a named instance
The RD_SYS_T instruction returns the CPU-local time (not UTC) — the value that the operator sees in the HMI clock. Time-zone handling and DST are managed by the CPU's time-of-day settings under PLC properties → Time of day. If the application must work in UTC, configure the CPU to UTC and convert at the HMI level; the interval logic itself is unaffected.
RD_SYS_T once per scan is cheap (microseconds on S7-1200) and is the recommended pattern. Reading it inside every interval check is wasteful but harmless; reading it less often than once per second may delay a window edge by up to one cycle.Function Block Design: In_Time_Interval
The reusable FB accepts three DTL inputs (test time, interval start, interval stop) and a Boolean output. Internally, it converts only the HOUR, MINUTE, and SECOND fields to a single REAL value expressed in hours with a decimal fraction. This representation makes the comparison a single line and is trivial to unit-test offline.
Conversion formula:
time_real = HOUR + (MINUTE / 60.0) + (SECOND / 3600.0)
// Example: 13:25:00 -> 13 + 25/60 + 0/3600 = 13.4166667
The FB source (SCL) is shown below. It can be pasted into a new SCL source file in the project tree and compiled.
FUNCTION_BLOCK "In_Time_Interval"
{ S7_Optimized_Access := 'FALSE' }
VERSION : 1.0
VAR_INPUT
"TestTime" : DTL; // current local time (or any DTL)
"Start" : DTL; // interval start (HOUR/MINUTE/SECOND used)
"Stop" : DTL; // interval stop (HOUR/MINUTE/SECOND used)
END_VAR
VAR_OUTPUT
"OUT" : Bool; // TRUE when TestTime is within [Start, Stop]
END_VAR
VAR_TEMP
tTest : Real;
tStart : Real;
tStop : Real;
END_VAR
BEGIN
// 1. Convert H/M/S of each DTL to hours-as-Real
tStart := USINT_TO_REAL(Start.HOUR)
+ USINT_TO_REAL(Start.MINUTE) / 60.0
+ USINT_TO_REAL(Start.SECOND) / 3600.0;
tStop := USINT_TO_REAL(Stop.HOUR)
+ USINT_TO_REAL(Stop.MINUTE) / 60.0
+ USINT_TO_REAL(Stop.SECOND) / 3600.0;
tTest := USINT_TO_REAL(TestTime.HOUR)
+ USINT_TO_REAL(TestTime.MINUTE)/ 60.0
+ USINT_TO_REAL(TestTime.SECOND)/ 3600.0;
// 2. Inclusive comparison (Start <= Test <= Stop)
// Use a strictly-less-than check if you need an exclusive stop
// time. See "Edge Cases" section for crossing-midnight logic.
OUT := (tTest >= tStart) AND (tTest <= tStop);
END_FUNCTION_BLOCK
The block above is the single-FB, single-interval case. Section "Scaling to 20+ Intervals" below shows how to invoke it in a loop-style data block structure without code duplication.
Step-by-Step Implementation in TIA Portal
-
Create the FB. In the project tree, right-click Program blocks → Add new block → Function block. Name it
In_Time_Interval, language SCL, and number it (for example, FB200). Confirm the Default number range or assign a free slot from the CPU's block list. -
Define the interface. Replace the auto-generated
VAR_INPUT,VAR_OUTPUT, andVAR_TEMPsections with the ones shown above. Ensure the threeDTLinputs are declared without Optimized access (or remove the attribute if you prefer optimized access — the logic is identical). -
Paste the SCL body. Switch to the implementation editor and paste the body between
BEGINandEND_FUNCTION_BLOCK. Compile the block (F7 or the hammer icon). The compiler should report zero errors and zero warnings. -
Create a time DB. Add a new global DB (e.g.,
dbTime) with one tag:Now : DTL;. Optionally addUTC_Now : DTL;if you read UTC as well. -
Call RD_SYS_T. In OB1, add a network:
CAL RD_SYS_T( RET_VAL := #dummy, OUT := "dbTime"."Now" );. Alternatively, in SCL:"dbTime"."Now" := RD_SYS_T();. -
Create an interval DB. Add a global DB
dbIntervalswith an array of 20 entries:TYPE "tInterval" VERSION : 1.0 STRUCT Start : DTL; Stop : DTL; Active : Bool; END_STRUCT; END_TYPEDATA_BLOCK "dbIntervals" { S7_Optimized_Access := 'FALSE' } VERSION : 1.0 STRUCT Slots : ARRAY[1..20] OF "tInterval"; END_STRUCT; BEGIN // Optionally pre-load default values here, e.g.: // Slots[1].Start.HOUR := 9; Slots[1].Start.MINUTE := 15; // Slots[1].Stop.HOUR := 22; Slots[1].Stop.MINUTE := 40; END_DATA_BLOCK -
Call the FB in a loop. Add a new FB
IntervalEvaluator(SCL) that callsIn_Time_Interval20 times — once per slot — and writes a Boolean array of 20 results. See the next section for the loop implementation. -
Wire outputs. Use the resulting array
Results[1..20]wherever the program needs the bit — for example, to start a pump, enable an output, or surface a state in the HMI.
Scaling to 20+ Intervals Without Code Duplication
The original requirement calls for 20 intervals. Hand-unrolling 20 calls to In_Time_Interval is repetitive but workable. A cleaner alternative is a single block that iterates over a FOR loop. The example below evaluates 20 intervals against a single Now value in one OB1 network.
FUNCTION_BLOCK "IntervalEvaluator_20"
{ S7_Optimized_Access := 'FALSE' }
VERSION : 1.0
VAR_INPUT
"Now" : DTL;
"Intervals" : ARRAY[1..20] OF "tInterval";
END_VAR
VAR_OUTPUT
"Results" : ARRAY[1..20] OF Bool;
"AnyActive" : Bool; // OR of all results
END_VAR
VAR_TEMP
i : Int;
tStart : Real;
tStop : Real;
tTest : Real;
END_VAR
BEGIN
AnyActive := FALSE;
FOR i := 1 TO 20 DO
tStart := USINT_TO_REAL(Intervals[i].Start.HOUR)
+ USINT_TO_REAL(Intervals[i].Start.MINUTE) / 60.0
+ USINT_TO_REAL(Intervals[i].Start.SECOND) / 3600.0;
tStop := USINT_TO_REAL(Intervals[i].Stop.HOUR)
+ USINT_TO_REAL(Intervals[i].Stop.MINUTE) / 60.0
+ USINT_TO_REAL(Intervals[i].Stop.SECOND) / 3600.0;
tTest := USINT_TO_REAL(Now.HOUR)
+ USINT_TO_REAL(Now.MINUTE) / 60.0
+ USINT_TO_REAL(Now.SECOND) / 3600.0;
Results[i] := (tTest >= tStart) AND (tTest <= tStop);
AnyActive := AnyActive OR Results[i];
END_FOR;
END_FUNCTION_BLOCK
This pattern is preferred when the number of intervals is large, when the data is HMI-editable, or when the intervals change at runtime. For 50 or 100 intervals the loop cost is still negligible — the entire block executes in well under 1 ms on an S7-1214C.
MAX_INTERVALS : Int := 20; in the block's Static area and reference it in the FOR statement. This is required if the same block is reused with different table sizes.Edge Cases: Crossing Midnight, DST, and Invalid Inputs
The basic block above is intentionally simple and assumes Start <= Stop within the same calendar day. Real installations must handle three additional scenarios.
2.1 Windows That Cross Midnight
A common case is "lights on from 22:00 to 06:00". In that case, the interval is split into two sub-windows: [22:00, 23:59:59.999] and [00:00:00.000, 06:00]. The simplest correct check is:
IF tStart <= tStop THEN
// Same-day window
OUT := (tTest >= tStart) AND (tTest <= tStop);
ELSE
// Crossing-midnight window (e.g. 22:00 .. 06:00)
OUT := (tTest >= tStart) OR (tTest <= tStop);
END_IF;
Alternatively, store an explicit CrossesMidnight : Bool; flag inside tInterval and select the comparison branch accordingly. For 22:00–06:00 the equivalent single comparison tTest >= tStart OR tTest <= tStop yields TRUE for the entire 8-hour window without any explicit day-handling logic.
2.2 Daylight Saving Time
The S7-1200 does not automatically apply DST. The CPU maintains one local time offset relative to UTC, and the operator sets it via the HMI or Online & diagnostics → Set time. The interval check itself is unaffected because it operates on HOUR/MINUTE/SECOND only. If the operator advances the clock by one hour at 02:00, intervals that contain the transition point may gain or lose one hour of actuation — this is a system-level policy decision, not a code defect.
2.3 Invalid or Uninitialized Inputs
If Start or Stop are zeroed (e.g., not yet loaded from HMI), the comparison will return FALSE — which is the safe default. To detect this, add a validity flag in the interval data structure:
STRUCT
Start : DTL;
Stop : DTL;
Active : Bool; // operator-controlled enable
Valid : Bool; // HMI sets TRUE after writing Start/Stop
END_STRUCT;
Then add IF NOT Intervals[i].Valid THEN CONTINUE; END_IF; at the top of the loop. This prevents garbage values from causing spurious matches during commissioning.
Performance and Resource Use
| Metric | S7-1211C FW 4.4 | S7-1214C FW 4.4 | S7-1215C FW 4.4 | S7-1500 CPU 1511-1 PN |
|---|---|---|---|---|
| Cycle impact (1 call) | < 50 µs | < 50 µs | < 50 µs | < 2 µs |
| Cycle impact (20 calls in loop) | ~ 0.5 ms | ~ 0.5 ms | ~ 0.5 ms | ~ 0.04 ms |
| Work memory for FB code | ~ 1.2 KB | ~ 1.2 KB | ~ 1.2 KB | ~ 1.2 KB |
| Instance DB size | ~ 60 B per call | ~ 60 B per call | ~ 60 B per call | ~ 80 B per call |
| Recommended max intervals (1 ms cycle target) | ~ 40 | ~ 200 | ~ 400 | ~ 5 000 |
For typical machine-control applications with 20 to 50 intervals and a 10 ms cyclic OB, the cycle time impact is unmeasurable. The block is also fully deterministic: each iteration performs exactly the same set of instructions.
Verification Procedure
-
Offline static check. In SCL, click the Compile button. Resolve any "Type mismatch" errors on the
USINT_TO_REALconversions by checking that the input tags areDTL. -
Watch table test. Open Watch & force tables, add the input
TestTime,Start,Stop, and the outputOUTof the FB instance. SetStartto 09:15:00,Stopto 22:40:00, then changeTestTimethrough 09:14:59 → 09:15:00 → 12:00:00 → 22:40:00 → 22:40:01. TheOUTmust transition FALSE → TRUE → TRUE → TRUE → FALSE at the boundary values. -
Online trace. Place the FB call in a cyclic OB and trigger a trace on
"Now"(theRD_SYS_Tresult) plus"Result". The output must toggle at the exact configured minute, with no jitter greater than one OB1 cycle. - Edge case test. Manually advance the CPU clock to 23:59:55 and observe the transition at 00:00:00. For a 22:00–06:00 crossing window, the result must remain TRUE through the entire night.
-
HMI validation. In the HMI, bind
StartandStopto time-of-day pickers (or toDTLstructures with Y/M/D hidden). The HMI should display a green/red status lamp tied toOUT.
Troubleshooting Matrix
| Symptom | Likely root cause | Fix |
|---|---|---|
OUT is always FALSE |
Input DTL is zeroed (HMI never wrote data) |
Initialize Start and Stop in the DB; add a Valid flag; verify HMI tag connection. |
OUT is always TRUE |
Test time and Start/Stop share the same date, but operator is reading a different clock (e.g., UTC vs local) | Confirm PLC time-zone setting under PLC properties → Time of day; use RD_SYS_T, not RD_LOC_T, for the local-time value. |
OUT flickers near boundary |
Cycle time larger than 1 s and RD_SYS_T is being read inside a slow OB |
Move RD_SYS_T to OB1 and use a single global Now tag everywhere. |
| Compile error: Unknown type DTL | CPU firmware older than V4.0 | Upgrade firmware or rewrite using 8-byte DATE_AND_TIME (DT) on legacy CPUs. |
| Compile error: Operator '>=' not defined for DTL | Engineer tried to compare DTL directly with >=
|
Always convert to Real first; see the conversion formula above. |
| Crossing-midnight window returns wrong result | Used the same-day branch on a window with Start > Stop
|
Apply the IF tStart <= tStop / ELSE split from the edge-case section. |
| Off by one hour after DST change | Operator manually changed the clock at 02:00 | Set the CPU time zone to (UTC+01:00) Amsterdam, Berlin with automatic DST disabled, or migrate to NTP sync via CP 1243-1. |
Alternative Implementations and When to Choose Them
The Real-encoded time-of-day approach above is the most readable and the easiest to unit test. Two other common patterns are worth knowing.
3.1 IN_RANGE on TOD
The IN_RANGE instruction can compare a TOD input against a TOD minimum and maximum directly. It is faster (no Real conversion) but is not portable to the FOR-loop pattern without an array of TOD tags. Use it when there is a single interval and no looping is required.
// 4-byte TOD in seconds since 00:00 (resolution 1 ms)
"dbTime"."Now_TOD" := DTL_TO_TOD("dbTime"."Now");
"Active" := IN_RANGE(MIN := Start_TOD, VALUE := "dbTime"."Now_TOD", MAX := Stop_TOD);
The DTL_TO_TOD conversion discards the year/month/day and preserves HOUR/MINUTE/SECOND/MILLISECOND. This is the shortest correct form when the same-day assumption holds.
3.2 Bit-Field Packing into DINT
Some legacy code packs the time as DINT := HOUR * 10000 + MINUTE * 100 + SECOND and uses a single integer comparison. The approach works but breaks at second resolution and is harder to read. It is documented here for completeness only; prefer the Real method for new projects.
Integration with HMI and Recipes
On a Siemens Comfort Panel (TP700, TP1200) or a WinCC Unified runtime, bind the operator-editable Start/Stop to a time-of-day picker. TIA Portal maps the picker to a Time_Of_Day tag by default; declare a second HMI tag of type DTL and copy HOUR/MINUTE/SECOND from the picker at the HMI level, or expose the picker with a Direct DTL mapping by selecting the underlying DTL tag in the PLC. The latter is the cleanest because it eliminates the HMI-side conversion.
For recipe-driven systems, place the 20-slot interval array inside a recipe DB. Operators load a "Weekday", "Weekend", or "Holiday" recipe and the interval table is rewritten in one operation. The IntervalEvaluator_20 block then operates on the active recipe without recompilation.
Field-Proven Caveats
- Always initialize the
dbTime.Nowtag with theRD_SYS_Tcall before any FB that reads it. In OB1, OB1 is guaranteed to start at the beginning of the cycle — placing theRD_SYS_Tcall in segment 1 is sufficient. - If the project uses symbolic addressing only (optimized blocks), remove the
{ S7_Optimized_Access := 'FALSE' }attribute. The example uses non-optimized access for maximum portability with S7-1200 firmware V4.0 to V4.1 and with HMIs that still bind to absolute addresses. - Do not use
RD_SYS_Tinside a time-of-day interrupt (OB10) or a hardware interrupt — the value may not have changed and the call adds jitter. Stick to OB1. - When commissioning with a CPU that is not yet time-synchronized, set the CPU clock manually in Online & diagnostics → Functions → Set time, or wire an NTP server via the CP 1243-1 / CP 1243-7 LTE / CPU 1217C Ethernet port.
- If the customer requires UL 508A or similar safety compliance, treat the interval check as non-safety and use a separate safety relay (e.g., Sirius 3SK2) for any protective function that depends on the result.
FAQ
What is the simplest way to test if a DTL time falls between two DTL times on an S7-1200?
Convert the HOUR, MINUTE, and SECOND fields of each DTL to a single Real value in hours (for example, 13:25 becomes 13.4167) using USINT_TO_REAL, then compare with (tTest >= tStart) AND (tTest <= tStop). The full SCL is shown in section "Function Block Design" above and works on any S7-1200 firmware V4.0 and later.
How do I handle a time window that crosses midnight, for example 22:00 to 06:00?
When Start > Stop, the interval wraps around midnight. Use OUT := (tTest >= tStart) OR (tTest <= tStop); instead of the inclusive AND. The full pattern, including an explicit CrossesMidnight flag, is documented in the "Edge Cases" section.
Can I call the In_Time_Interval FB 20 times in a row without slowing down OB1?
Yes. Twenty sequential calls cost roughly 0.5 ms on an S7-1214C, which is well within a typical 10 ms cycle. For 20+ intervals the looped variant in IntervalEvaluator_20 is preferred because it removes code duplication and keeps the cycle time at roughly 0.5 ms regardless of interval count.
Why does RD_SYS_T not match the HMI clock by exactly one hour after DST?
The S7-1200 does not apply daylight-saving rules automatically. The CPU maintains one local offset relative to UTC that the operator sets manually. Configure the time zone under PLC properties → Time of day, or sync the PLC via NTP through a CP 1243-1 module to eliminate manual adjustment.
Is the same code valid on an S7-1500 and an ET 200SP CPU?
Yes. The DTL structure, RD_SYS_T, and the USINT_TO_REAL conversions are identical on S7-1500 and ET 200SP CPUs. The only difference is the structure size: 12 bytes on S7-1500 (with NANOSECOND and reserved fields) versus 8 bytes on S7-1200. The code only reads HOUR, MINUTE, and SECOND, so it is binary-portable across all S7-1200 and S7-1500 firmware versions.