Chapter 28 / 32 · Part 7 · Advanced language

Dates, clocks and rarer types

Work with DATE, TOD and DT, the real-time clock and time stamps, and meet WSTRING, LREAL, subranges, unions and aliases.

Three calendar types and what each stores

A TIME is a duration: 20 seconds of mixing, 3 minutes of drain. It does not know what day it is, and production needs to. A batch record needs the date and time the batch started; an alarm list needs the time each alarm came in; a shift report needs to know when 06:00 is; a clean-in-place skid needs to start at 02:30 every night. For that IEC 61131-3 gives you three calendar types, which chapter 2 introduced in passing:

TypeLong nameLiteralMeans
DATEDATED#2026-10-24a calendar day
TODTIME_OF_DAYTOD#06:00:00 or TOD#22:15:30.5a clock reading within a day
DTDATE_AND_TIMEDT#2026-10-24-06:00:00a point in time: day and clock reading together

The literal forms are standard. What the controller stores behind them is not, and you find out the moment you send one of these values to another system or do arithmetic on it.

CODESYS and TwinCAT store DATE and DT as an unsigned 32-bit count of seconds since 1970-01-01 (for a DATE the time part is zero), and TOD as milliseconds since midnight. Unsigned 32-bit seconds run out in February 2106. The 64-bit LDATE, LTOD and LDT, counting nanoseconds, cover the gap where your version supports them.

The Siemens S7-1500 stores DATE as a 16-bit count of days since 1990-01-01, TIME_OF_DAY as milliseconds since midnight, and the old DATE_AND_TIME as 8 bytes of BCD limited to 1990–2089. The type you actually use on an S7-1500 is DTL, a 12-byte structure with YEAR, MONTH, DAY, WEEKDAY, HOUR, MINUTE, SECOND and NANOSECOND members. There is also LDT, 64-bit nanoseconds since 1970.

Rockwell Logix has no calendar types at all. The clock comes out as a DINT[7] array (year, month, day, hour, minute, second, microsecond) or as a LINT count of microseconds since 1970 in UTC, and you handle it as integers.

So a DT copied byte for byte from a TwinCAT controller to an S7-1500 is garbage, and a Logix LINT timestamp means nothing to a CODESYS DT until someone divides by a million. When calendar values cross a boundary (to SCADA, MES, a historian or another PLC), send them in a format both sides define explicitly: Unix seconds in UTC as an integer, or an ISO 8601 string such as 2026-10-24T06:00:00Z. Chapter 29 comes back to interface design.

The declarations and literals work the same way everywhere, and the engine on this page runs them, along with comparisons and conversion to text:

PROGRAM Main
VAR
    Now             : TOD := TOD#09:41:20;   (* input: type a time such as TOD#05:30:00 *)
    ShiftStart      : TOD := TOD#06:00:00;
    BatchStart      : DT := DT#2026-10-24-06:12:00;
    BatchDate       : DATE := D#2026-10-24;
    AfterShiftStart : BOOL;
    NowText         : STRING;
END_VAR

AfterShiftStart := Now >= ShiftStart;
NowText := TOD_TO_STRING(Now);

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Now.

Type TOD#05:30:00 into Now and AfterShiftStart drops. Comparisons on calendar values work as you would expect: later is greater. This engine does not run arithmetic on them (Now + T#2h is rejected at build time), so later sections show the IEC date functions as static listings and use plain integers to simulate a clock.

Reading the controller clock

Every PLC has a real-time clock, and every vendor reads it differently. None of these calls runs on this page; they are what you will type on the real platforms.

(* TIA Portal SCL, S7-1500, static listing *)
VAR_TEMP
    NowLocal : DTL;
    NowUtc   : DTL;
    Ret      : Int;
END_VAR

#Ret := RD_SYS_T(OUT => #NowUtc);    (* system time = UTC *)
#Ret := RD_LOC_T(OUT => #NowLocal);  (* local time: time zone and DST from the CPU properties *)
IF #NowLocal.HOUR = 6 AND #NowLocal.MINUTE = 0 THEN
    ;  (* ... *)
END_IF;

On an S7-1500 the CPU keeps UTC internally. RD_SYS_T returns it; RD_LOC_T applies the time zone and daylight-saving rules you set in the CPU's hardware configuration. WR_SYS_T sets the clock, though on a networked plant you should let NTP do that; the S7-1500 can synchronise from up to four NTP servers.

(* Studio 5000 Logix ST, static listing *)
GSV(WallClockTime, , LocalDateTime, NowLocal[0]);   (* DINT[7]: Y, M, D, h, m, s, µs *)
GSV(WallClockTime, , CurrentValue, NowUtc_us);      (* LINT: µs since 1970, UTC *)

Logix reads system objects with GSV (Get System Value). The WallClockTime object holds UTC as CurrentValue, and local time as LocalDateTime once the time zone and DST settings are filled in. Time synchronisation on Logix normally comes from CIP Sync (IEEE 1588 PTP) over the backplane and EtherNet/IP.

In CODESYS the usual route is the SysTimeRtc system library: SysTimeRtcGet returns the clock as seconds since 1970 in UTC, and companion functions convert to local time and to a broken-down date. In TwinCAT 3 the PLC runs beside Windows, and the clock comes from there: F_GetSystemTime() returns a 64-bit UTC value in 100 ns ticks since 1601 (the Windows FILETIME format), and the FB_LocalSystemTime block in Tc2_Utilities delivers local time as a TIMESTRUCT and resynchronises it periodically. Function names and libraries shift between versions; check the library manager for the one installed.

Whatever the platform, three things are true of the clock.

Reading it can cost something. Some calls are cheap, some go through the operating system, and a few (like TwinCAT's NT_GetTime) are asynchronous with START and BUSY pins. The pattern is the same everywhere: read the clock once per scan at the top of the program into a variable, and let everything else use that variable. Every timestamp in that scan then agrees.

It drifts. A typical RTC crystal is good to about ±20 ppm, which is 1.7 seconds a day or about 10 minutes a year. Without synchronisation, two controllers on the same line disagree by minutes within months, and a sequence-of-events report that interleaves their alarms is fiction.

It jumps. NTP corrections, a technician setting the time from a laptop, a CPU battery that died over a long shutdown and restarted the clock in 1990 or 2012: all of these happen. Code that compares clock readings must survive a reading that goes backwards.

The live examples below simulate the clock with a counter: a DINT holding seconds since midnight, advanced by a chosen amount each scan. I would do the same on a real controller. Converting the vendor clock into a plain "seconds of day" and "day number" once per scan gives the rest of the program simple integers to compare, and makes the logic testable without waiting for midnight.

FUNCTION TwoDigits : STRING
VAR_INPUT
    N : DINT;
END_VAR

IF N < 10 THEN
    TwoDigits := CONCAT('0', DINT_TO_STRING(N));
ELSE
    TwoDigits := DINT_TO_STRING(N);
END_IF;

END_FUNCTION

PROGRAM Main
VAR
    Rate_s   : DINT := 600;      (* input: simulated seconds per scan *)
    SecOfDay : DINT := 82800;    (* starts at 23:00:00 *)
    DayNo    : DINT := 1;
    Hour     : DINT;
    Minute   : DINT;
    Clock    : STRING;
END_VAR

SecOfDay := SecOfDay + Rate_s;
IF SecOfDay >= 86400 THEN
    SecOfDay := SecOfDay - 86400;    (* keep the remainder, not zero *)
    DayNo := DayNo + 1;
END_IF;

Hour   := SecOfDay / 3600;
Minute := (SecOfDay MOD 3600) / 60;
Clock  := CONCAT(CONCAT(TwoDigits(Hour), ':'), TwoDigits(Minute));

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Rate_s.

Each scan stands for ten minutes, so midnight arrives after six scans: Clock goes from 23:50 to 00:00 and DayNo becomes 2. Change Rate_s to 1 for a clock that runs at ten times real speed. The rollover subtracts 86,400 instead of setting the counter to zero. With a rate that does not divide a day evenly, setting it to zero would lose a few seconds every midnight, the integer version of a clock that runs slow.

Time stamps for alarms and batch records

A time stamp answers "when did this happen?", and the answer must be the moment it started. That sounds obvious until you see how often alarm code gets it wrong. Take the stamp on the rising edge of the event, exactly once, and then leave it alone:

FUNCTION TwoDigits : STRING
VAR_INPUT
    N : DINT;
END_VAR

IF N < 10 THEN
    TwoDigits := CONCAT('0', DINT_TO_STRING(N));
ELSE
    TwoDigits := DINT_TO_STRING(N);
END_IF;

END_FUNCTION

PROGRAM Main
VAR
    HighTemp    : BOOL;            (* input: delayed alarm condition *)
    SecOfDay    : DINT := 50400;   (* simulated clock, starts 14:00:00 *)
    Clock       : STRING;
    AlarmEdge   : R_TRIG;
    AlarmAt     : STRING;          (* when the latest occurrence started *)
    AlarmText   : STRING;
    Occurrences : DINT;
END_VAR

(* Simulated clock: one scan = one minute *)
SecOfDay := (SecOfDay + 60) MOD 86400;
Clock := CONCAT(CONCAT(TwoDigits(SecOfDay / 3600), ':'), TwoDigits((SecOfDay MOD 3600) / 60));

(* Stamp on the edge, once per occurrence *)
AlarmEdge(CLK := HighTemp);
IF AlarmEdge.Q THEN
    AlarmAt := Clock;
    AlarmText := CONCAT('Oven 2 high temperature since ', Clock);
    Occurrences := Occurrences + 1;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: HighTemp.

Switch HighTemp on: the alarm text records the minute it came in, and the clock carries on without dragging the stamp along. Switch it off and on again and you get a new stamp and a new occurrence. On a real controller the stamp would be a DT, LDT or DTL read from the clock at the top of the scan, and the HMI would build the text from the stored value (chapter 16 explains why text belongs there). The stamp is still taken on the edge.

Batch records need a little more discipline than alarms, because they become quality documents. A good batch record in ST is a structure with a start stamp, an end stamp, the recipe and the totals, filled at defined points of the sequence and handed to the MES or historian when the batch closes:

(* Static listing: a batch record on an S7-1500; DTL is Siemens' date-time structure *)
TYPE ST_BatchRecord :
STRUCT
    BatchNo     : DINT;
    RecipeNo    : INT;
    StartUtc    : DTL;      (* read with RD_SYS_T, stored in UTC *)
    EndUtc      : DTL;
    Flour_kg    : REAL;
    Water_kg    : REAL;
    MixTime     : TIME;     (* measured with a timer, not by subtracting stamps *)
    Complete    : BOOL;
END_STRUCT
END_TYPE

Two details in that structure come from experience. The stamps are UTC, for reasons the daylight-saving section makes clear. And the mix duration is measured with a timer and stored as a TIME instead of being computed by subtracting StartUtc from EndUtc. The stamps say when and the timer says how long, so if the clock was corrected during the batch, only one of them is wrong.

Shifts, days and midnight

Most plants run on shifts that do not respect midnight. A three-shift pattern of 06:00–14:00, 14:00–22:00 and 22:00–06:00 puts the night shift on two calendar days. Code that works out "which shift is it now?" with clock comparisons meets the midnight problem immediately:

The second midnight problem is subtraction. A line stops at 23:50 and restarts at 00:20. In seconds of day that is 85,800 and 1,200, and 1200 - 85800 is −84,600: the stop lasted minus 23.5 hours. The fix is modular arithmetic: add a day before taking the remainder, (End - Start + 86400) MOD 86400, which gives 1,800 seconds, 30 minutes. It works for any stop shorter than a day. For longer stops you need the day number or a full DT, and for any duration the machine acts on, a timer.

The third is the production day. A plant that reports by shift wants the pallet packed at 03:15 on 25 October booked to the night shift that started on 24 October. Most MES systems handle that by defining the production day as starting at 06:00 rather than at midnight. In ST it is one comparison: anything before the first shift starts belongs to the previous production day.

UTC, local time and daylight saving

Twice a year, most of Europe and North America change their clocks. In the EU the change happens at 01:00 UTC on the last Sunday of March (local clocks jump from 02:00 to 03:00) and the last Sunday of October (03:00 back to 02:00). In the US and Canada it is 02:00 local time on the second Sunday of March and the first Sunday of November. A plant that runs through the night lives through both.

In spring, the hour from 02:00 to 03:00 local does not exist, and anything scheduled at 02:30 local never sees 02:30. In autumn, the hour from 02:00 to 03:00 happens twice. A batch that starts at 02:40 local and ends at 02:10 local, both stamped in local time, appears to have taken minus 30 minutes. An alarm list sorted by local time puts a trip before its cause. Any site that stamps in local time has a few of these in its records.

The cure is a rule I would not argue about: keep UTC in the controller and show local time to people. UTC never jumps for daylight saving. Stamps stored in UTC sort correctly, subtract correctly and match the historian and MES, which almost always run in UTC as well. The HMI, which knows where it is, converts to local for display. The S7-1500 does this for you if you read RD_SYS_T; Logix keeps CurrentValue in UTC; CODESYS and TwinCAT give you both.

Local time still has a place: things that people schedule by the wall clock. A CIP that must run "at 02:30" runs at 02:30 local because that is when the night shift expects it. Converting is an offset plus a DST correction:

FUNCTION HhMm : STRING
VAR_INPUT
    Sec : DINT;
END_VAR
VAR
    H : DINT;
    M : DINT;
    Hs : STRING;
    Ms : STRING;
END_VAR

H := Sec / 3600;
M := (Sec MOD 3600) / 60;
Hs := DINT_TO_STRING(H);
Ms := DINT_TO_STRING(M);
IF H < 10 THEN Hs := CONCAT('0', Hs); END_IF;
IF M < 10 THEN Ms := CONCAT('0', Ms); END_IF;
HhMm := CONCAT(CONCAT(Hs, ':'), Ms);

END_FUNCTION

PROGRAM Main
VAR
    DstActive  : BOOL;              (* input: summer time in force *)
    UtcSec     : DINT := 79200;     (* simulated UTC clock, starts 22:00 *)
    UtcDay     : DINT := 1;
    Offset_s   : DINT;
    LocalSec   : DINT;
    LocalDay   : DINT;
    UtcClock   : STRING;
    LocalClock : STRING;
END_VAR

(* UTC: one scan = five minutes *)
UtcSec := UtcSec + 300;
IF UtcSec >= 86400 THEN
    UtcSec := UtcSec - 86400;
    UtcDay := UtcDay + 1;
END_IF;

(* Central European Time: UTC+1, or UTC+2 in summer *)
Offset_s := 3600;
IF DstActive THEN
    Offset_s := 7200;
END_IF;

LocalSec := UtcSec + Offset_s;
LocalDay := UtcDay;
IF LocalSec >= 86400 THEN
    LocalSec := LocalSec - 86400;
    LocalDay := LocalDay + 1;       (* local midnight comes before UTC midnight *)
END_IF;

UtcClock   := HhMm(UtcSec);
LocalClock := HhMm(LocalSec);

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: DstActive.

Run it and watch local midnight arrive an hour before UTC midnight, with LocalDay moving on first. Flip DstActive and the local clock jumps an hour while the UTC clock carries on untouched. Every local-time stamp in the plant takes that jump twice a year. Real controllers take the DST rule from configuration (the S7-1500 CPU properties, the Logix WallClockTime settings, the operating system under CODESYS and TwinCAT), so you rarely write the rule yourself. You do write the logic that must survive it.

Day of the week, and the 2038 limit

Weekly jobs need the day of the week: a filter change every Monday at 06:00, a CIP that runs only on weekdays, a weekend tariff. Vendors hand it to you in different places and with different numbering. A Siemens DTL has a WEEKDAY member that counts 1 for Sunday through 7 for Saturday. ISO 8601, which many MES and IT systems use, counts 1 for Monday through 7 for Sunday. The Logix DINT[7] has no weekday at all.

From a count of days since 1970 you can work it out yourself, because 1 January 1970 was a Thursday:

PROGRAM Main
VAR
    UnixSec  : LINT := 1793577600;   (* input: UTC seconds since 1970-01-01 *)
    LocalOffset_s : LINT := 3600;    (* input: UTC+1, Central European winter time *)
    LocalSec : LINT;
    DayNo    : LINT;                 (* whole local days since 1970-01-01 *)
    SecOfDay : LINT;
    IsoDay   : INT;                  (* ISO 8601: 1 = Monday ... 7 = Sunday *)
    DtlDay   : INT;                  (* Siemens DTL.WEEKDAY: 1 = Sunday ... 7 = Saturday *)
    Weekend  : BOOL;
END_VAR

LocalSec := UnixSec + LocalOffset_s;
DayNo    := LocalSec / 86400;
SecOfDay := LocalSec MOD 86400;
IsoDay   := LINT_TO_INT((DayNo + 3) MOD 7) + 1;   (* 1970-01-01 was a Thursday *)
DtlDay   := IsoDay MOD 7 + 1;
Weekend  := IsoDay >= 6;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: UnixSec, LocalOffset_s.

1793577600 is midnight UTC on Monday 2 November 2026, so with the one-hour offset it is 01:00 local on Monday: IsoDay 1, DtlDay 2. Now type 1793574000, an hour earlier. In UTC that is 23:00 on Sunday, but locally it is exactly midnight on Monday, and IsoDay still reads 1. Set LocalOffset_s to 0 and it drops to 7, with Weekend TRUE. The weekday belongs to a time zone, just as the hour does. Work it out from local time for anything people schedule, and from UTC for anything you store.

Notice the declarations. Unix seconds in a signed 32-bit DINT run out at 2,147,483,647, which is 03:14:07 UTC on 19 January 2038. A controller commissioned today can easily still be running then: plenty of PLCs installed in the 1990s still run production lines. A DINT time stamp, a DINT "seconds since" counter in a historian interface or a protocol field defined as signed 32-bit seconds all wrap to a date in December 1901 at that moment. Use LINT (or the vendor's 64-bit calendar types) for seconds since 1970, and when an interface document says "Unix time, 32 bit", ask whether it is signed.

The IEC date functions, and what vendors do instead

IEC 61131-3 defines a family of functions for calendar arithmetic. Their names say exactly what they do:

FunctionComputesExample
ADD_TOD_TIMETOD + TIME → TOD06:00 + 8 h → 14:00
ADD_DT_TIMEDT + TIME → DTbatch start + recipe time → expected end
SUB_TOD_TIME, SUB_DT_TIMEsubtract a durationshift end − 8 h → shift start
SUB_TOD_TODTOD − TOD → TIME14:00 − 06:00 → 8 h
SUB_DT_DTDT − DT → TIMEend − start → duration
SUB_DATE_DATEDATE − DATE → TIMEdays since last calibration
CONCAT_DATE_TODDATE and TOD → DTtoday's date with 06:00 → shift start
DT_TO_DATE, DT_TO_TODsplit a DTa batch stamp's date, for the report header
(* IEC 61131-3 date functions, static listing, not run on this page *)
ShiftEnd   := ADD_TOD_TIME(ShiftStart, T#8h);
BatchTime  := SUB_DT_DT(BatchEnd, BatchStart);
ShiftStamp := CONCAT_DATE_TOD(DT_TO_DATE(NowDt), TOD#06:00:00);
NextCal    := SUB_DATE_DATE(D#2027-04-01, DT_TO_DATE(NowDt));

CODESYS and TwinCAT accept these and, in most versions, also the plain + and - operators on calendar types. The 3rd edition adds SPLIT_DATE, SPLIT_TOD, SPLIT_DT and DAY_OF_WEEK, which are more useful in practice than most of the table. TIA Portal uses its own instructions instead: T_ADD and T_SUB add or subtract a duration from a DTL, DT or TOD, T_DIFF gives the difference of two as a TIME, and T_COMBINE joins a DATE and a TOD. On an S7-1500 you will more often just read the members of a DTL directly. Logix has none of this: you take two LINT microsecond values from WallClockTime and subtract them as integers.

Two cautions apply whichever form you use. A difference returned as a 32-bit TIME runs out at 24 or 49 days (signed or unsigned, by vendor, as chapter 16 explained), so "days since last calibration" overflows in under two months; use LTIME, or work in days. And a difference between two local-time stamps is wrong across a DST change by exactly one hour. Store UTC and the problem disappears.

LREAL: when seven digits run out

Chapter 2 showed the 32-bit REAL totaliser that stalls at 16,777,216 because the gap between neighbouring values grows past the increment. Clock values hit the same wall sooner than people expect. Unix time today is about 1.79 billion seconds, and a 32-bit REAL near that value can only represent every 128th second, so a time stamp stored as REAL is wrong by up to a minute. Any value that combines a large magnitude with a small resolution (a totaliser, a time stamp, an encoder position on a long axis in micrometres) needs more than seven digits.

LREAL is the 64-bit IEEE 754 double: about 15 to 16 significant digits. That moves the stall point from 16.7 million to about 9,007,199,254,740,992 (2⁵³). It does not remove it. A note on this page's engine: it computes REAL in 64 bits too, so it cannot show you the 32-bit stall, but its LREAL behaves exactly like a PLC's:

The totaliser pattern I trust does not depend on the float type at all. Keep the whole units in an integer, and only the fraction, which never exceeds 1.0, in a float. The float never gets large, so its seven digits are always enough:

PROGRAM Main
VAR
    Flow_lps : REAL := 3.7;   (* input: flow, litres per second *)
    Whole_l  : LINT;          (* whole litres, exact forever *)
    Frac_l   : REAL;          (* the part-litre not yet counted, 0..1 *)
    Carry    : DINT;
END_VAR

Frac_l := Frac_l + Flow_lps * 0.1;     (* this scan's volume, 100 ms *)
Carry  := TRUNC(Frac_l);               (* whole litres in the fraction *)
Whole_l := Whole_l + Carry;
Frac_l := Frac_l - DINT_TO_REAL(Carry);

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Flow_lps.

At 3.7 L/s the count goes up by 0.37 L per scan; every third scan or so a whole litre moves from Frac_l into Whole_l. Change Flow_lps to 250 for a big filler and the carry becomes 25 per scan. The fraction shows tiny binary noise such as 0.1100000000000001 here (and 0.7000001 or similar on a 32-bit PLC); because it never grows, the noise never accumulates into the total. Flow meters and energy meters do essentially this internally, which is why their totals agree with the invoice and a float totaliser in a PLC usually does not.

Use LREAL for calculations that need the digits: an axis position in a 40 m gantry with micrometre resolution, a long-run mass balance, coordinate transforms. Remember its costs: 8 bytes per value, not every HMI or Modbus device can read it, and Logix supports it only on its newer controllers with a reduced instruction set. Do not make it the default for every analog value; a 4–20 mA transmitter has about four significant digits of real information, and REAL holds that with room to spare.

LINT and ULINT for energy totals

Energy is the totaliser that most often outgrows DINT. A medium-sized factory drawing 2 MW consumes 2,000,000 Wh every hour, which is 48 million Wh a day. A DINT counting watt-hours tops out at 2,147,483,647, which that site reaches in under 45 days. Then it wraps to −2,147,483,648, and the energy report shows the factory generating power. A UDINT lasts 89 days. A 64-bit LINT lasts about 500 million years.

This example runs one scan per minute of plant time and starts both counters a few hundred thousand watt-hours short of the DINT limit:

PROGRAM Main
VAR
    Load_kW        : REAL := 2000.0;          (* input: site load *)
    Step_Wh        : DINT;                    (* energy in one simulated minute *)
    Energy_Wh_DINT : DINT := 2147000000;
    Energy_Wh_LINT : LINT := 2147000000;
    Energy_MWh     : LREAL;                   (* for display *)
END_VAR

Step_Wh := REAL_TO_DINT(Load_kW * 1000.0 / 60.0);    (* kW for one minute, in Wh *)
Energy_Wh_DINT := Energy_Wh_DINT + Step_Wh;
Energy_Wh_LINT := Energy_Wh_LINT + Step_Wh;
Energy_MWh := LINT_TO_LREAL(Energy_Wh_LINT) / 1.0E6;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Load_kW.

Fifteen scans in, the DINT total turns negative and the LINT total carries on past 2.147 billion. Nothing faulted. Integer overflow in IEC 61131-3 wraps silently, as chapter 2 showed with INT, and a wrong energy figure goes straight into an invoice or a carbon report. Change Load_kW to 10,000 for a steelworks and the wrap comes five times sooner.

Choose the unit together with the type. Watt-hours in a LINT is exact and enormous; kilowatt-hours in a DINT lasts 122 years at 2 MW but cannot record a 0.4 kWh pump run. Count in the smallest unit the meter delivers (pulses usually represent 1 Wh, 10 Wh or 1 kWh), store it in LINT or ULINT, and convert for display. On an S7-1500, LInt and ULInt are available; the S7-1200 does not support 64-bit integers, so there the split pattern from the previous section (MWh in one DINT, the remainder in Wh in another) is the way. Older Logix controllers support LINT for only a handful of instructions, such as moves and the clock; the 5380 and 5580 families do arithmetic on it.

WSTRING, subranges, unions and aliases

Four more types appear in larger projects. You can go years without declaring one, but you need to read them.

WSTRING

WSTRING holds wide characters: two bytes each (UTF-16 or UCS-2, depending on the vendor), enough for Polish, Turkish, Greek, Chinese or Arabic operator texts. Literals use double quotes, "Ölpumpe 3", where a STRING uses single quotes. CODESYS and TwinCAT provide W-prefixed string functions such as WLEN and WCONCAT in their standard libraries; TIA Portal's WString (up to 16,382 characters) works with the ordinary LEN and CONCAT; Logix has no wide string at all. Converting with WSTRING_TO_STRING cannot keep characters outside the single-byte range; depending on the vendor they become a placeholder such as ? or are lost, so test with real texts.

(* Static listing: WSTRING length, not run here because this engine counts it wrongly *)
VAR
    Label : WSTRING[40] := "Ölpumpe 3";
    n     : INT;
END_VAR
n := LEN(Label);      (* 9 characters: Ö counts as one, though it takes 2 bytes *)

I would keep multilingual text out of the controller. The HMI has text lists per language, and the PLC sends a message number. A WSTRING in the PLC is justified when the controller itself must produce text for a printer, a label or a line in a batch report.

Subranges

A subrange type restricts an integer to a range: TYPE T_Percent : INT(0..100); END_TYPE. CODESYS and TwinCAT reject an out-of-range constant at compile time, but at run time the range is only enforced if you add the implicit check functions CheckRangeSigned and CheckRangeUnsigned, the cousins of the CheckBounds function in chapter 27. TIA Portal and Studio 5000 have no subranges. Treat a subrange as documentation plus an optional run-time check, and still LIMIT values that come from outside.

(* CODESYS / TwinCAT, static listing *)
TYPE T_Percent : INT(0..100); END_TYPE
VAR
    ValvePos : T_Percent := 50;
END_VAR
ValvePos := 150;          (* compile error: constant out of range *)
ValvePos := HmiValue;     (* compiles; only CheckRangeSigned catches it at run time *)

Unions

A UNION lets several members share the same memory. Writing one member changes the others, because they are the same bytes viewed as different types. The classic use is sending a REAL over Modbus, which only knows 16-bit registers:

(* CODESYS / TwinCAT, static listing *)
TYPE U_Real32 :
UNION
    r : REAL;                      (* 4 bytes as a float *)
    w : ARRAY[0..1] OF WORD;       (* the same 4 bytes as two registers *)
END_UNION
END_TYPE

VAR
    Conv : U_Real32;
END_VAR
Conv.r := Flow_m3h;
Reg[100] := Conv.w[1];      (* word order: check the device manual *)
Reg[101] := Conv.w[0];

Which word goes first is the eternal Modbus argument; devices disagree, and the manual (or a test with a known value such as 1.0 = 16#3F80_0000) settles it. TIA Portal does the same job with an AT overlay declaration, with restrictions in optimized blocks; Logix uses COP to copy a REAL into an INT[2] byte for byte. This page's engine models a UNION as an ordinary structure whose members do not overlap, so overlay code cannot run here. Unions reinterpret bytes as another type. Use them at a protocol boundary and nowhere else.

Alias types

An alias type gives an existing type a new name: TYPE T_Pressure_bar : REAL; END_TYPE. Aliases document intent (a parameter of type T_Pressure_bar says its unit in the declaration) and give you one place to change if a type must grow, such as a count that moves from DINT to LINT. They do not create a new type the compiler keeps apart. In CODESYS and TwinCAT an alias is fully compatible with its base type, so the compiler will happily add bar to rpm:

TYPE T_Pressure_bar : REAL; END_TYPE
TYPE T_Speed_rpm : REAL; END_TYPE
TYPE T_Count : DINT; END_TYPE

PROGRAM Main
VAR
    Suction_bar : T_Pressure_bar := 1.8;   (* input *)
    Speed_rpm   : T_Speed_rpm := 1480.0;   (* input *)
    Starts      : T_Count;
    Nonsense    : REAL;
    Label       : WSTRING := "Ölpumpe 3";
    StartEdge   : R_TRIG;
END_VAR

StartEdge(CLK := Speed_rpm > 100.0);
IF StartEdge.Q THEN
    Starts := Starts + 1;
END_IF;

(* Compiles without complaint: aliases do not keep units apart *)
Nonsense := Suction_bar + Speed_rpm;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Suction_bar, Speed_rpm.

Nonsense reads 1481.8, bar plus rpm. The alias documented the units and checked nothing. If you want the compiler to keep quantities apart, the tool is a STRUCT with one member (a ST_Pressure cannot be added to a ST_Speed), at the cost of writing .Value everywhere. Most teams settle for aliases plus unit suffixes in names, which is what this course has used throughout. TIA Portal and Studio 5000 have no alias declaration; there the naming convention does the whole job.

From types to interfaces

Several lessons in this chapter already pointed outward: time stamps that MES must read, UNIONs that exist to talk Modbus, weekday numbers that differ between Siemens and ISO, text that belongs on the HMI. The next chapter is about that boundary. It covers command and status words, heartbeats and handshakes, so that an HMI, a SCADA system or another PLC can talk to your program without either side guessing.

Practise this chapter