S7-1200 DTL Time Interval Comparison SCL Function Block for TIA

David Krause16 min read
S7-1200SiemensTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

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.

Note on naming. The example interval notation "9-15 to 22-40" in the original requirement means 09:15 (start) and 22:40 (stop). The same notation is used throughout this article for the start hour-minute and stop hour-minute values.

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.

Note on cyclic read frequency. Reading 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

  1. 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.
  2. Define the interface. Replace the auto-generated VAR_INPUT, VAR_OUTPUT, and VAR_TEMP sections with the ones shown above. Ensure the three DTL inputs are declared without Optimized access (or remove the attribute if you prefer optimized access — the logic is identical).
  3. Paste the SCL body. Switch to the implementation editor and paste the body between BEGIN and END_FUNCTION_BLOCK. Compile the block (F7 or the hammer icon). The compiler should report zero errors and zero warnings.
  4. Create a time DB. Add a new global DB (e.g., dbTime) with one tag: Now : DTL;. Optionally add UTC_Now : DTL; if you read UTC as well.
  5. 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();.
  6. Create an interval DB. Add a global DB dbIntervals with an array of 20 entries:
    TYPE "tInterval"
    VERSION : 1.0
       STRUCT
          Start : DTL;
          Stop  : DTL;
          Active : Bool;
       END_STRUCT;
    END_TYPE
    DATA_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
  7. Call the FB in a loop. Add a new FB IntervalEvaluator (SCL) that calls In_Time_Interval 20 times — once per slot — and writes a Boolean array of 20 results. See the next section for the loop implementation.
  8. 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.

Note on array bounds. The fixed array bound of 20 is encoded directly. To make the block fully parametric, change the array upper bound to a constant 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

  1. Offline static check. In SCL, click the Compile button. Resolve any "Type mismatch" errors on the USINT_TO_REAL conversions by checking that the input tags are DTL.
  2. Watch table test. Open Watch & force tables, add the input TestTime, Start, Stop, and the output OUT of the FB instance. Set Start to 09:15:00, Stop to 22:40:00, then change TestTime through 09:14:59 → 09:15:00 → 12:00:00 → 22:40:00 → 22:40:01. The OUT must transition FALSE → TRUE → TRUE → TRUE → FALSE at the boundary values.
  3. Online trace. Place the FB call in a cyclic OB and trigger a trace on "Now" (the RD_SYS_T result) plus "Result". The output must toggle at the exact configured minute, with no jitter greater than one OB1 cycle.
  4. 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.
  5. HMI validation. In the HMI, bind Start and Stop to time-of-day pickers (or to DTL structures with Y/M/D hidden). The HMI should display a green/red status lamp tied to OUT.

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.Now tag with the RD_SYS_T call before any FB that reads it. In OB1, OB1 is guaranteed to start at the beginning of the cycle — placing the RD_SYS_T call 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_T inside 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.

Back to blog